diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f237ad8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: Nextbillion Maps Flutter CI + +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.0" + + - name: Install dependencies + run: flutter pub get + + - name: Run analyzer + run: flutter analyze lib + + - name: Run tests with coverage + run: | + flutter test --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + files: coverage/lcov.info + flags: unittests + name: codecov-report + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da9c827 --- /dev/null +++ b/.gitignore @@ -0,0 +1,121 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +.fvm/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ +build/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ + +# Flutter repo-specific +/bin/cache/ +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/Flutter-Debug.xcconfig +**/macos/Flutter/Flutter-Release.xcconfig +**/macos/Flutter/Flutter-Profile.xcconfig +__MACOSX/ + +# Coverage +coverage/ + +# Binaries +google-java-format-1.13.0-all-deps.jar +swiftformat + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cfe3c73 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,106 @@ +## v1.2.0, Dec 16, 2024 +* Adapting to Android Gradle Plugin 8.0 Without Using the AGP Upgrading Assistant + +## v1.1.0, Nov 28, 2024 +* Update Android NB Maps SDK to 1.1.5 +* Adapt to Android Gradle Plugin 8.0 +* Upgrade the compile SDK version to 34 to support Flutter SDK 3.24.0+ + +## v1.0.0, Sept 5, 2024 +* Pinned `NextBillionMap` dependency to version `1.1.5`. + +## v0.4.3, Sep 4, 2024 +* Update Android NB Maps SDK to 1.1.4 and Update iOS NB Maps framework to 1.1.5 + * Modify user agent for Android and iOS + * Add cross-platform info into the native user agent + +## v0.4.2, June 6, 2024 +* Update Android NB Maps SDK to 1.1.3 and Update iOS NB Maps framework to 1.1.4 + +## v0.4.1, June 5, 2024 +* Add setUserId method to NextBillion +* Add getUserId method to NextBillion +* Add getNbId method to NextBillion +## v0.4.0, May 29, 2024 +* Remove state check exception when calling methods of NextbillionMapController after the controller is disposed + +## v0.3.5, May 7, 2024 +* Add result for NextBillion methods + +## v0.3.4, May 7, 2024 +* Add await for some methods in controller +* Support obtaining the disposed status from the controller + +## v0.3.2, Apr 29, 2024 +* Update Android NB Maps SDK to 1.1.0 +* Update iOS NB Maps framework to 1.1.0 + +## v0.3.1, Apr 24, 2024 +* Throw an exception when calling methods of NextbillionMapController after the controller is disposed + +## v0.3.0, Nov 8, 2023 +* Update Android NB Maps SDK to 1.0.3 + +## v0.2.0, Sept 26, 2023 +* Update iOS NB Maps framework to 1.0.3 +* Update Android NB Maps SDK to 1.0.2 +* Support to fit camera into bounds with multi points + +## v0.1.6, Sept 15, 2023 +* Fix the animateCamera issue + * When calling controller.animateCamera() within onStyleLoadedCallback + +## v0.1.5, Aug 17, 2023 +* Update Android NB Maps SDK to 1.0.0 +* Update the default map style + +## v0.1.4, Aug 16, 2023 +* Update iOS NB Maps framework to 1.0.2 +* Support to change the base url of map style url +* Update the default map style + +## v0.1.0, July 20, 2023 +* Display MapView + * Camera position + * Map Click Callback + * OnMapLongClickCallback + * MapView Created callback + * Map Style loaded callback +* Map Options + * Map Style String + * Enable Map Compass + * Enable zoom/scroll/tilt/rotate gestures + * Enable User Location + * Config Location Tracking Mode + * Config Location Render Mode +* Location Component + * Tracking Current location + * Get Current location + * OnLocationUpdate Callback +* Camera API + * Animate Camera + * Move Camera + * OnCameraTrackingDismissedCallback + * OnCameraTrackingChangedCallback + * OnCameraIdleCallback +* Annotations View + * Symbol annotation + * Line annotation + * Fill annotation + * Circle annotation + * Add Asset Image Symbol +* Query Features +* Customize source & layers + * GeoJson Source + * Image Source + * Raster Source + * Vector Source + * Hillshade Layer + * Fill Layer + * Line Layer + * Circle Layer + * Symbol Layer + * Raster Layer + +## v0.1.4, August 16, 2023 +* Update iOS native framework version from 1.0.1 to 1.0.2 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2e552d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) , NextBillion.ai + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c94b6e8 --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +# Nextbillion Maps Flutter +[![codecov](https://codecov.io/github/nextbillion-ai/nb-maps-flutter/graph/badge.svg?token=3S22ZLBW7O)](https://codecov.io/github/nextbillion-ai/nb-maps-flutter) + +## Instroduction +![Nextbillion_Maps_IMG](https://github.com/nextbillion-ai/nb-maps-flutter/assets/100656364/5b8cedb6-a839-45b4-bc04-a760ba562329) + + +## Prerequisites +* Access Key +* Android minSdkVersion 16+ +* iOS 11+ +* Flutter 3.10+ +* Pod 1.11.3+ +* Ensure that Build Libraries for Distribution (available under build settings) is set to No. + 截屏2023-07-20 上午11 22 01 + +## Installation +### Dependency +Add the following dependency to your project pubspec.yaml file to use the NB Maps Flutter Plugin add the dependency to the pubspec.yaml: +``` +dependencies: + nb_maps_flutter: version +``` + +### Import +Import the maps plugin in your code +``` +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +``` + +### Initialization +To run the Maps Flutter Plugin you will need to configure the NB Maps Token at the beginning of your flutter app using `NextBillion.initNextBillion(YOUR_ACCESS_KEY)`. +``` + class _MapsDemoState extends State { + @override + void initState() { + super.initState(); + NextBillion.initNextBillion(YOUR_ACCESS_KEY); + } +} +``` + +## NBMap Widget +Create a NBMap Widget with initial camera position +``` +NBMap( + onMapCreated: onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(0.0, 0.0), + zoom: 11.0, + ), +) +``` +## Location Component +### Configuration permissions +You need to grant location permission in order to use the location component of the NB Maps Flutter Plugin, declare the permission for both platforms: +### Android +Add the following permissions to the manifest: +``` + + +``` + +### iOS +Add the following to the Runner/Info.plist to explain why you need access to the location data: +``` + NSLocationWhenInUseUsageDescription + [Your explanation here] +``` + +### Enable Location Tracking +* trackCameraPosition: true +* myLocationEnabled: true +* myLocationTrackingMode: MyLocationTrackingMode.Tracking + +### Observe and Tracking User Location +* add the callback onUserLocationUpdated(UserLocation location) +* animate camera to user location within `onStyleLoadedCallback` +``` +void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + } + +_onUserLocationUpdate(UserLocation location) { + currentLocation = location; + } + +_onStyleLoadedCallback() { + if (currentLocation != null) { + controller?.animateCamera(CameraUpdate.newLatLngZoom(currentLocation!.position, 14), duration: Duration(milliseconds: 400)); + } + } + +NBMap( + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoadedCallback, + initialCameraPosition: const CameraPosition( + target: LatLng(0, 0), + zoom: 14.0, + ), + trackCameraPosition: true, + myLocationEnabled: true, + myLocationTrackingMode: MyLocationTrackingMode.Tracking, + onUserLocationUpdated: _onUserLocationUpdate, +) +``` + +## Annotations +To operate the mapview, you need to get the map controller in the `onMapReady(NextbillionMapController controller) callback`. +### Symbol Annotation +The Symbol annotation adds a symbol to the map. It is configured by the specified custom SymbolOptions. If you need to add an image symbol, you need to add the image source to the map style. +#### Add image source +``` +final ByteData bytes = await rootBundle.load("assets/image.png"); +final Uint8List list = bytes.buffer.asUint8List(); +await controller?.addImage("ic_marker", list); +``` +``` +var symbol = controller.addSymbol( + SymbolOptions( + geometry: LatLng(-33.894372606072309, 151.17576679759523), + iconImage: "ic_marker", + iconSize: 2), + ); + +//remove annotation +controller!.removeSymbol(symbol); + +//update annotation +controller!.updateSymbol(symbol, updatedOptions) +``` +#### Line Annotation +The Line annotation adds a line to the map. It is configured by the specified custom LineOptions. +``` +var line = controller.addLine( + LineOptions( + geometry: [ + LatLng(-33.874867744475786, 151.170627211986584), + LatLng(-33.881979408447314, 151.171361438502117), + LatLng(-33.887058805548882, 151.175032571079726), + ], + lineColor: "#0000FF", + lineWidth: 20, + ), + ); + +//remove annotation +controller!.removeLine(line); + +//update annotation +controller!.updateLine(line, updatedOptions) +``` +#### Fill Annotation +The Fill annotation adds a fill to the map. It is configured using the specified custom FillOptions. +``` +var fill = controller!.addFill( + FillOptions( + geometry: [ + [ + LatLng(-33.901517742631846, 151.178099204457737), + LatLng(-33.872845324482071, 151.179025547977773), + LatLng(-33.868230472039514, 151.147000529140399), + LatLng(-33.883172899638311, 151.150838238009328), + LatLng(-33.901517742631846, 151.178099204457737), + ], + ], + fillColor: "#FF0000", + fillOutlineColor: "#000000", + ), + ) + +//remove annotation +controller!.removeFill(fill); + +//update annotation +controller!.updateFill(fill, updatedOptions) +``` +#### Circle Annotation +The Circle annotation adds a circle to the map. It is configured using the specified custom CircleOptions. +``` +var addCircle = controller!.addCircle( + CircleOptions( + geometry: LatLng(-33.894372606072309, 151.17576679759523), + circleStrokeColor: "#00FF00", + circleStrokeWidth: 2, + circleRadius: 30, + ), + ); +``` +#### Annotation onTap Callbacks +The following code snippet shows how to add Callbacks to receive tap events, and how to place annotations on this map, and how to remove these callbacks in dispose() function. +``` +void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + controller.onFillTapped.add(_onFillTapped); + controller.onCircleTapped.add(_onCircleTapped); + controller.onLineTapped.add(_onLineTapped); + controller.onSymbolTapped.add(_onSymbolTapped); + } + + @override + void dispose() { + controller.onFillTapped.remove(_onFillTapped); + controller.onCircleTapped.remove(_onCircleTapped); + controller.onLineTapped.remove(_onLineTapped); + controller.onSymbolTapped.remove(_onSymbolTapped); + super.dispose(); + } +``` + +## Camera +Defines a camera move, supports absolute moves as well as moving relatively to a specified position. +### CameraUpdate +A Camera update moves the camera to the specified CameraPosition with the camera's [target] geographical location, its [zoom] level, [tilt] angle and [bearing]. +``` +CameraUpdate.newCameraPosition( + const CameraPosition( + bearing: 270.0, + target: LatLng(51.5160895, -0.1294527), + tilt: 30.0, + zoom: 17.0, + ), + ) +controller.animateCamera(cameraUpdate) +``` +The following code snippet shows how to move the camera target to a specified geographical location. +``` +CameraUpdate.newLatLng(const LatLng(56.1725505, 10.1850512)) +``` +### Move Camera +The animateCamera() function starts an animated change of the map camera position +``` +controller.animateCamera(cameraUpdate, duration) +``` +The moveCamera function will move the camera quickly, which can be visually jarring for a user. In real usage we should strongly consider using the animateCamera() methods instead of moveCamera() because it's less abrupt. +``` +controller.moveCamera(cameraUpdate) +``` +### More +* scrollBy() +* zoomBy() +* zoomIn() +* zoomOut() +* zoomTo(double zoom) +* bearingTo(double bearing) +* tiltTo(double tilt) diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..090919c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +gradlew +gradlew.bat \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..bef1edd --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,55 @@ +group 'ai.nextbillion.maps_flutter' +version '1.2.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.2.0' + } +} + + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 34 + ndkVersion "20.1.5948944" + namespace 'ai.nextbillion.maps_flutter' + + defaultConfig { + minSdkVersion 16 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + buildConfigField "String", "GIT_REVISION_SHORT", String.format("\"%s\"", getGitRevision()) + buildConfigField "String", "NBMAP_FLUTTER_VERSION", String.format("\"%s\"", project.version) + } + lintOptions { + disable 'InvalidPackage' + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + dependencies { + implementation 'ai.nextbillion:nb-maps-android:1.1.5' + implementation 'com.squareup.okhttp3:okhttp:4.9.0' + } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + + buildFeatures { + buildConfig = true + } +} +def static getGitRevision() { + def cmd = "git rev-parse --short HEAD" + def process = cmd.execute() + def ref = process.text.trim() + return ref +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f98a4cc --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx1536M \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..39735c1 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.1-bin.zip \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..43254cc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,10 @@ +rootProject.name = 'nb_maps_flutter' +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..88026fc --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/BitmapUtils.java b/android/src/main/java/ai/nextbillion/maps_flutter/BitmapUtils.java new file mode 100644 index 0000000..2498b8a --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/BitmapUtils.java @@ -0,0 +1,57 @@ +package ai.nextbillion.maps_flutter; + +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; +import android.util.Base64; +import android.util.Log; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** Created by nickitaliano on 10/9/17. */ +public class BitmapUtils { + private static final String LOG_TAG = "BitmapUtils"; + + public static String createTempFile(Context context, Bitmap bitmap) { + File tempFile = null; + FileOutputStream outputStream = null; + + try { + tempFile = File.createTempFile(LOG_TAG, ".jpeg", context.getCacheDir()); + outputStream = new FileOutputStream(tempFile); + } catch (IOException e) { + Log.w(LOG_TAG, e.getLocalizedMessage()); + } + + if (tempFile == null) { + return null; + } + + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream); + closeSnapshotOutputStream(outputStream); + return Uri.fromFile(tempFile).toString(); + } + + public static String createBase64(Bitmap bitmap) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream); + byte[] bitmapBytes = outputStream.toByteArray(); + closeSnapshotOutputStream(outputStream); + String base64Prefix = "data:image/jpeg;base64,"; + return base64Prefix + Base64.encodeToString(bitmapBytes, Base64.NO_WRAP); + } + + private static void closeSnapshotOutputStream(OutputStream outputStream) { + if (outputStream == null) { + return; + } + try { + outputStream.close(); + } catch (IOException e) { + Log.w(LOG_TAG, e.getLocalizedMessage()); + } + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/Convert.java b/android/src/main/java/ai/nextbillion/maps_flutter/Convert.java new file mode 100644 index 0000000..2f67563 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/Convert.java @@ -0,0 +1,289 @@ + +package ai.nextbillion.maps_flutter; + +import android.content.Context; +import android.util.DisplayMetrics; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ai.nextbillion.kits.geojson.Point; +import ai.nextbillion.kits.geojson.Polygon; +import ai.nextbillion.maps.camera.CameraPosition; +import ai.nextbillion.maps.camera.CameraUpdate; +import ai.nextbillion.maps.camera.CameraUpdateFactory; +import ai.nextbillion.maps.core.NextbillionMap; +import ai.nextbillion.maps.geometry.LatLng; +import ai.nextbillion.maps.geometry.LatLngBounds; + +/** Conversions between JSON-like values and NbMaps data types. */ +class Convert { + + private static final String TAG = "Convert"; + + static boolean toBoolean(Object o) { + return (Boolean) o; + } + + static CameraPosition toCameraPosition(Object o) { + final Map data = toMap(o); + final CameraPosition.Builder builder = new CameraPosition.Builder(); + builder.bearing(toFloat(data.get("bearing"))); + builder.target(toLatLng(data.get("target"))); + builder.tilt(toFloat(data.get("tilt"))); + builder.zoom(toFloat(data.get("zoom"))); + return builder.build(); + } + + static boolean isScrollByCameraUpdate(Object o) { + return toString(toList(o).get(0)).equals("scrollBy"); + } + + static CameraUpdate toCameraUpdate(Object o, NextbillionMap nextbillionMap, float density) { + final List data = toList(o); + switch (toString(data.get(0))) { + case "newCameraPosition": + return CameraUpdateFactory.newCameraPosition(toCameraPosition(data.get(1))); + case "newLatLng": + return CameraUpdateFactory.newLatLng(toLatLng(data.get(1))); + case "newLatLngBounds": + return CameraUpdateFactory.newLatLngBounds( + toLatLngBounds(data.get(1)), + toPixels(data.get(2), density), + toPixels(data.get(3), density), + toPixels(data.get(4), density), + toPixels(data.get(5), density)); + case "newLatLngZoom": + return CameraUpdateFactory.newLatLngZoom(toLatLng(data.get(1)), toFloat(data.get(2))); + case "scrollBy": + nextbillionMap.scrollBy( + toFractionalPixels(data.get(1), density), toFractionalPixels(data.get(2), density)); + return null; + case "zoozmBy": + if (data.size() == 2) { + return CameraUpdateFactory.zoomBy(toFloat(data.get(1))); + } else { + return CameraUpdateFactory.zoomBy(toFloat(data.get(1)), toPoint(data.get(2), density)); + } + case "zoomIn": + return CameraUpdateFactory.zoomIn(); + case "zoomOut": + return CameraUpdateFactory.zoomOut(); + case "zoomTo": + return CameraUpdateFactory.zoomTo(toFloat(data.get(1))); + case "bearingTo": + return CameraUpdateFactory.bearingTo(toFloat(data.get(1))); + case "tiltTo": + return CameraUpdateFactory.tiltTo(toFloat(data.get(1))); + default: + throw new IllegalArgumentException("Cannot interpret " + o + " as CameraUpdate"); + } + } + + static double toDouble(Object o) { + return ((Number) o).doubleValue(); + } + + static float toFloat(Object o) { + return ((Number) o).floatValue(); + } + + static Float toFloatWrapper(Object o) { + return (o == null) ? null : toFloat(o); + } + + static int toInt(Object o) { + return ((Number) o).intValue(); + } + + static Object toJson(CameraPosition position) { + if (position == null) { + return null; + } + final Map data = new HashMap<>(); + data.put("bearing", position.bearing); + data.put("target", toJson(position.target)); + data.put("tilt", position.tilt); + data.put("zoom", position.zoom); + return data; + } + + private static Object toJson(LatLng latLng) { + return Arrays.asList(latLng.getLatitude(), latLng.getLongitude()); + } + + static LatLng toLatLng(Object o) { + final List data = toList(o); + return new LatLng(toDouble(data.get(0)), toDouble(data.get(1))); + } + + static LatLngBounds toLatLngBounds(Object o) { + if (o == null) { + return null; + } + final List data = toList(o); + LatLng[] boundsArray = new LatLng[] {toLatLng(data.get(0)), toLatLng(data.get(1))}; + List bounds = Arrays.asList(boundsArray); + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + builder.includes(bounds); + return builder.build(); + } + + static List toLatLngList(Object o, boolean flippedOrder) { + if (o == null) { + return null; + } + final List data = toList(o); + List latLngList = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + final List coords = toList(data.get(i)); + if (flippedOrder) { + latLngList.add(new LatLng(toDouble(coords.get(1)), toDouble(coords.get(0)))); + } else { + latLngList.add(new LatLng(toDouble(coords.get(0)), toDouble(coords.get(1)))); + } + } + return latLngList; + } + + private static List> toLatLngListList(Object o) { + if (o == null) { + return null; + } + final List data = toList(o); + List> latLngListList = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + List latLngList = toLatLngList(data.get(i), false); + latLngListList.add(latLngList); + } + return latLngListList; + } + + static Polygon interpretListLatLng(List> geometry) { + List> points = new ArrayList<>(geometry.size()); + for (List innerGeometry : geometry) { + List innerPoints = new ArrayList<>(innerGeometry.size()); + for (LatLng latLng : innerGeometry) { + innerPoints.add( + Point.fromLngLat(latLng.getLongitude(), latLng.getLatitude())); + } + points.add(innerPoints); + } + return Polygon.fromLngLats(points); + } + + static List toList(Object o) { + return (List) o; + } + + static long toLong(Object o) { + return ((Number) o).longValue(); + } + + static Map toMap(Object o) { + return (Map) o; + } + + private static float toFractionalPixels(Object o, float density) { + return toFloat(o) * density; + } + + static int toPixels(Object o, float density) { + return (int) toFractionalPixels(o, density); + } + + private static android.graphics.Point toPoint(Object o, float density) { + final List data = toList(o); + return new android.graphics.Point(toPixels(data.get(0), density), toPixels(data.get(1), density)); + } + + static String toString(Object o) { + return (String) o; + } + + static void interpretNextbillionMapOptions(Object o, ai.nextbillion.maps_flutter.NextbillionMapOptionsSink sink, Context context) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final Map data = toMap(o); + final Object cameraTargetBounds = data.get("cameraTargetBounds"); + if (cameraTargetBounds != null) { + final List targetData = toList(cameraTargetBounds); + sink.setCameraTargetBounds(toLatLngBounds(targetData.get(0))); + } + final Object compassEnabled = data.get("compassEnabled"); + if (compassEnabled != null) { + sink.setCompassEnabled(toBoolean(compassEnabled)); + } + final Object styleString = data.get("styleString"); + if (styleString != null) { + sink.setStyleString(toString(styleString)); + } + final Object minMaxZoomPreference = data.get("minMaxZoomPreference"); + if (minMaxZoomPreference != null) { + final List zoomPreferenceData = toList(minMaxZoomPreference); + sink.setMinMaxZoomPreference( // + toFloatWrapper(zoomPreferenceData.get(0)), // + toFloatWrapper(zoomPreferenceData.get(1))); + } + final Object rotateGesturesEnabled = data.get("rotateGesturesEnabled"); + if (rotateGesturesEnabled != null) { + sink.setRotateGesturesEnabled(toBoolean(rotateGesturesEnabled)); + } + final Object scrollGesturesEnabled = data.get("scrollGesturesEnabled"); + if (scrollGesturesEnabled != null) { + sink.setScrollGesturesEnabled(toBoolean(scrollGesturesEnabled)); + } + final Object tiltGesturesEnabled = data.get("tiltGesturesEnabled"); + if (tiltGesturesEnabled != null) { + sink.setTiltGesturesEnabled(toBoolean(tiltGesturesEnabled)); + } + final Object trackCameraPosition = data.get("trackCameraPosition"); + if (trackCameraPosition != null) { + sink.setTrackCameraPosition(toBoolean(trackCameraPosition)); + } + final Object zoomGesturesEnabled = data.get("zoomGesturesEnabled"); + if (zoomGesturesEnabled != null) { + sink.setZoomGesturesEnabled(toBoolean(zoomGesturesEnabled)); + } + final Object myLocationEnabled = data.get("myLocationEnabled"); + if (myLocationEnabled != null) { + sink.setMyLocationEnabled(toBoolean(myLocationEnabled)); + } + final Object myLocationTrackingMode = data.get("myLocationTrackingMode"); + if (myLocationTrackingMode != null) { + sink.setMyLocationTrackingMode(toInt(myLocationTrackingMode)); + } + final Object myLocationRenderMode = data.get("myLocationRenderMode"); + if (myLocationRenderMode != null) { + sink.setMyLocationRenderMode(toInt(myLocationRenderMode)); + } + final Object logoViewMargins = data.get("logoViewMargins"); + if (logoViewMargins != null) { + final List logoViewMarginsData = toList(logoViewMargins); + final android.graphics.Point point = toPoint(logoViewMarginsData, metrics.density); + sink.setLogoViewMargins(point.x, point.y); + } + final Object compassGravity = data.get("compassViewPosition"); + if (compassGravity != null) { + sink.setCompassGravity(toInt(compassGravity)); + } + final Object compassViewMargins = data.get("compassViewMargins"); + if (compassViewMargins != null) { + final List compassViewMarginsData = toList(compassViewMargins); + final android.graphics.Point point = toPoint(compassViewMarginsData, metrics.density); + sink.setCompassViewMargins(point.x, point.y); + } + final Object attributionButtonGravity = data.get("attributionButtonPosition"); + if (attributionButtonGravity != null) { + sink.setAttributionButtonGravity(toInt(attributionButtonGravity)); + } + final Object attributionButtonMargins = data.get("attributionButtonMargins"); + if (attributionButtonMargins != null) { + final List attributionButtonMarginsData = toList(attributionButtonMargins); + final android.graphics.Point point = toPoint(attributionButtonMarginsData, metrics.density); + sink.setAttributionButtonMargins(point.x, point.y); + } + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/GeoJSONUtils.java b/android/src/main/java/ai/nextbillion/maps_flutter/GeoJSONUtils.java new file mode 100644 index 0000000..56a753c --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/GeoJSONUtils.java @@ -0,0 +1,39 @@ +package ai.nextbillion.maps_flutter; + +import java.util.ArrayList; +import java.util.List; + +import ai.nextbillion.kits.geojson.Feature; +import ai.nextbillion.kits.geojson.FeatureCollection; +import ai.nextbillion.kits.geojson.Geometry; +import ai.nextbillion.kits.geojson.GeometryCollection; +import ai.nextbillion.kits.geojson.Point; +import ai.nextbillion.kits.turf.TurfMeasurement; +import ai.nextbillion.maps.geometry.LatLng; +import ai.nextbillion.maps.geometry.LatLngBounds; + +public class GeoJSONUtils { + public static LatLng toLatLng(Point point) { + if (point == null) { + return null; + } + return new LatLng(point.latitude(), point.longitude()); + } + + private static GeometryCollection toGeometryCollection(List features) { + ArrayList geometries = new ArrayList<>(); + geometries.ensureCapacity(features.size()); + for (Feature feature : features) { + geometries.add(feature.geometry()); + } + return GeometryCollection.fromGeometries(geometries); + } + + public static LatLngBounds toLatLngBounds(FeatureCollection featureCollection) { + List features = featureCollection.features(); + + double[] bbox = TurfMeasurement.bbox(toGeometryCollection(features)); + + return LatLngBounds.from(bbox[3], bbox[2], bbox[1], bbox[0]); + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/GlobalMethodHandler.java b/android/src/main/java/ai/nextbillion/maps_flutter/GlobalMethodHandler.java new file mode 100644 index 0000000..5ccccf1 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/GlobalMethodHandler.java @@ -0,0 +1,157 @@ +package ai.nextbillion.maps_flutter; + +import android.content.Context; +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +import ai.nextbillion.maps.net.ConnectivityReceiver; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; + +class GlobalMethodHandler implements MethodChannel.MethodCallHandler { + private static final String TAG = GlobalMethodHandler.class.getSimpleName(); + private static final String DATABASE_NAME = "mbgl-offline.db"; + private static final int BUFFER_SIZE = 1024 * 2; + @NonNull private final Context context; + @NonNull private final BinaryMessenger messenger; + @Nullable private PluginRegistry.Registrar registrar; + @Nullable private FlutterPlugin.FlutterAssets flutterAssets; + + GlobalMethodHandler(@NonNull PluginRegistry.Registrar registrar) { + this.registrar = registrar; + this.context = registrar.activeContext(); + this.messenger = registrar.messenger(); + } + + GlobalMethodHandler(@NonNull FlutterPlugin.FlutterPluginBinding binding) { + this.context = binding.getApplicationContext(); + this.flutterAssets = binding.getFlutterAssets(); + this.messenger = binding.getBinaryMessenger(); + } + + private static void copy(InputStream input, OutputStream output) throws IOException { + final byte[] buffer = new byte[BUFFER_SIZE]; + final BufferedInputStream in = new BufferedInputStream(input, BUFFER_SIZE); + final BufferedOutputStream out = new BufferedOutputStream(output, BUFFER_SIZE); + int count = 0; + int n = 0; + try { + while ((n = in.read(buffer, 0, BUFFER_SIZE)) != -1) { + out.write(buffer, 0, n); + count += n; + } + out.flush(); + } finally { + try { + out.close(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + try { + in.close(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + } + } + + @Override + public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) { + String accessToken = methodCall.argument("accessToken"); + NbMapUtils.getNextbillion(context, accessToken); + + switch (methodCall.method) { + case "installOfflineMapTiles": + String tilesDb = methodCall.argument("tilesdb"); + installOfflineMapTiles(tilesDb); + result.success(null); + break; + case "setOffline": + boolean offline = methodCall.argument("offline"); + ConnectivityReceiver.instance(context).setConnected(offline ? false : null); + result.success(null); + break; + case "mergeOfflineRegions": + OfflineManagerUtils.mergeRegions(result, context, methodCall.argument("path")); + break; + case "setOfflineTileCountLimit": + OfflineManagerUtils.setOfflineTileCountLimit( + result, context, methodCall.argument("limit").longValue()); + break; + case "setHttpHeaders": + Map headers = (Map) methodCall.argument("headers"); + NbMapHttpRequestUtil.setHttpHeaders(headers, result); + break; + case "downloadOfflineRegion": + // Get args from caller + Map definitionMap = (Map) methodCall.argument("definition"); + Map metadataMap = (Map) methodCall.argument("metadata"); + String channelName = methodCall.argument("channelName"); + + // Prepare args + OfflineChannelHandlerImpl channelHandler = + new OfflineChannelHandlerImpl(messenger, channelName); + + // Start downloading + OfflineManagerUtils.downloadRegion( + result, context, definitionMap, metadataMap, channelHandler); + break; + case "getListOfRegions": + OfflineManagerUtils.regionsList(result, context); + break; + case "updateOfflineRegionMetadata": + // Get download region arguments from caller + Map metadata = (Map) methodCall.argument("metadata"); + OfflineManagerUtils.updateRegionMetadata( + result, context, methodCall.argument("id").longValue(), metadata); + break; + case "deleteOfflineRegion": + OfflineManagerUtils.deleteRegion( + result, context, methodCall.argument("id").longValue()); + break; + default: + result.notImplemented(); + break; + } + } + + private void installOfflineMapTiles(String tilesDb) { + final File dest = new File(context.getFilesDir(), DATABASE_NAME); + try (InputStream input = openTilesDbFile(tilesDb); + OutputStream output = new FileOutputStream(dest)) { + copy(input, output); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private InputStream openTilesDbFile(String tilesDb) throws IOException { + if (tilesDb.startsWith("/")) { // Absolute path. + return new FileInputStream(new File(tilesDb)); + } else { + String assetKey; + if (registrar != null) { + assetKey = registrar.lookupKeyForAsset(tilesDb); + } else if (flutterAssets != null) { + assetKey = flutterAssets.getAssetFilePathByName(tilesDb); + } else { + throw new IllegalStateException(); + } + return context.getAssets().open(assetKey); + } + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/LayerPropertyConverter.java b/android/src/main/java/ai/nextbillion/maps_flutter/LayerPropertyConverter.java new file mode 100644 index 0000000..f8cdb41 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/LayerPropertyConverter.java @@ -0,0 +1,536 @@ + + +package ai.nextbillion.maps_flutter; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import ai.nextbillion.maps.style.expressions.Expression; +import ai.nextbillion.maps.style.layers.PropertyFactory; +import ai.nextbillion.maps.style.layers.PropertyValue; + +import static ai.nextbillion.maps_flutter.Convert.toMap; + +class LayerPropertyConverter { + static PropertyValue[] interpretSymbolLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "icon-opacity": + properties.add(PropertyFactory.iconOpacity(expression)); + break; + case "icon-color": + properties.add(PropertyFactory.iconColor(expression)); + break; + case "icon-halo-color": + properties.add(PropertyFactory.iconHaloColor(expression)); + break; + case "icon-halo-width": + properties.add(PropertyFactory.iconHaloWidth(expression)); + break; + case "icon-halo-blur": + properties.add(PropertyFactory.iconHaloBlur(expression)); + break; + case "icon-translate": + properties.add(PropertyFactory.iconTranslate(expression)); + break; + case "icon-translate-anchor": + properties.add(PropertyFactory.iconTranslateAnchor(expression)); + break; + case "text-opacity": + properties.add(PropertyFactory.textOpacity(expression)); + break; + case "text-color": + properties.add(PropertyFactory.textColor(expression)); + break; + case "text-halo-color": + properties.add(PropertyFactory.textHaloColor(expression)); + break; + case "text-halo-width": + properties.add(PropertyFactory.textHaloWidth(expression)); + break; + case "text-halo-blur": + properties.add(PropertyFactory.textHaloBlur(expression)); + break; + case "text-translate": + properties.add(PropertyFactory.textTranslate(expression)); + break; + case "text-translate-anchor": + properties.add(PropertyFactory.textTranslateAnchor(expression)); + break; + case "symbol-placement": + properties.add(PropertyFactory.symbolPlacement(expression)); + break; + case "symbol-spacing": + properties.add(PropertyFactory.symbolSpacing(expression)); + break; + case "symbol-avoid-edges": + properties.add(PropertyFactory.symbolAvoidEdges(expression)); + break; + case "symbol-sort-key": + properties.add(PropertyFactory.symbolSortKey(expression)); + break; + case "symbol-z-order": + properties.add(PropertyFactory.symbolZOrder(expression)); + break; + case "icon-allow-overlap": + properties.add(PropertyFactory.iconAllowOverlap(expression)); + break; + case "icon-ignore-placement": + properties.add(PropertyFactory.iconIgnorePlacement(expression)); + break; + case "icon-optional": + properties.add(PropertyFactory.iconOptional(expression)); + break; + case "icon-rotation-alignment": + properties.add(PropertyFactory.iconRotationAlignment(expression)); + break; + case "icon-size": + properties.add(PropertyFactory.iconSize(expression)); + break; + case "icon-text-fit": + properties.add(PropertyFactory.iconTextFit(expression)); + break; + case "icon-text-fit-padding": + properties.add(PropertyFactory.iconTextFitPadding(expression)); + break; + case "icon-image": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.iconImage(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.iconImage(expression)); + } + break; + case "icon-rotate": + properties.add(PropertyFactory.iconRotate(expression)); + break; + case "icon-padding": + properties.add(PropertyFactory.iconPadding(expression)); + break; + case "icon-keep-upright": + properties.add(PropertyFactory.iconKeepUpright(expression)); + break; + case "icon-offset": + properties.add(PropertyFactory.iconOffset(expression)); + break; + case "icon-anchor": + properties.add(PropertyFactory.iconAnchor(expression)); + break; + case "icon-pitch-alignment": + properties.add(PropertyFactory.iconPitchAlignment(expression)); + break; + case "text-pitch-alignment": + properties.add(PropertyFactory.textPitchAlignment(expression)); + break; + case "text-rotation-alignment": + properties.add(PropertyFactory.textRotationAlignment(expression)); + break; + case "text-field": + properties.add(PropertyFactory.textField(expression)); + break; + case "text-font": + properties.add(PropertyFactory.textFont(expression)); + break; + case "text-size": + properties.add(PropertyFactory.textSize(expression)); + break; + case "text-max-width": + properties.add(PropertyFactory.textMaxWidth(expression)); + break; + case "text-line-height": + properties.add(PropertyFactory.textLineHeight(expression)); + break; + case "text-letter-spacing": + properties.add(PropertyFactory.textLetterSpacing(expression)); + break; + case "text-justify": + properties.add(PropertyFactory.textJustify(expression)); + break; + case "text-radial-offset": + properties.add(PropertyFactory.textRadialOffset(expression)); + break; + case "text-variable-anchor": + properties.add(PropertyFactory.textVariableAnchor(expression)); + break; + case "text-anchor": + properties.add(PropertyFactory.textAnchor(expression)); + break; + case "text-max-angle": + properties.add(PropertyFactory.textMaxAngle(expression)); + break; + case "text-writing-mode": + properties.add(PropertyFactory.textWritingMode(expression)); + break; + case "text-rotate": + properties.add(PropertyFactory.textRotate(expression)); + break; + case "text-padding": + properties.add(PropertyFactory.textPadding(expression)); + break; + case "text-keep-upright": + properties.add(PropertyFactory.textKeepUpright(expression)); + break; + case "text-transform": + properties.add(PropertyFactory.textTransform(expression)); + break; + case "text-offset": + properties.add(PropertyFactory.textOffset(expression)); + break; + case "text-allow-overlap": + properties.add(PropertyFactory.textAllowOverlap(expression)); + break; + case "text-ignore-placement": + properties.add(PropertyFactory.textIgnorePlacement(expression)); + break; + case "text-optional": + properties.add(PropertyFactory.textOptional(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretCircleLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "circle-radius": + properties.add(PropertyFactory.circleRadius(expression)); + break; + case "circle-color": + properties.add(PropertyFactory.circleColor(expression)); + break; + case "circle-blur": + properties.add(PropertyFactory.circleBlur(expression)); + break; + case "circle-opacity": + properties.add(PropertyFactory.circleOpacity(expression)); + break; + case "circle-translate": + properties.add(PropertyFactory.circleTranslate(expression)); + break; + case "circle-translate-anchor": + properties.add(PropertyFactory.circleTranslateAnchor(expression)); + break; + case "circle-pitch-scale": + properties.add(PropertyFactory.circlePitchScale(expression)); + break; + case "circle-pitch-alignment": + properties.add(PropertyFactory.circlePitchAlignment(expression)); + break; + case "circle-stroke-width": + properties.add(PropertyFactory.circleStrokeWidth(expression)); + break; + case "circle-stroke-color": + properties.add(PropertyFactory.circleStrokeColor(expression)); + break; + case "circle-stroke-opacity": + properties.add(PropertyFactory.circleStrokeOpacity(expression)); + break; + case "circle-sort-key": + properties.add(PropertyFactory.circleSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretLineLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "line-opacity": + properties.add(PropertyFactory.lineOpacity(expression)); + break; + case "line-color": + properties.add(PropertyFactory.lineColor(expression)); + break; + case "line-translate": + properties.add(PropertyFactory.lineTranslate(expression)); + break; + case "line-translate-anchor": + properties.add(PropertyFactory.lineTranslateAnchor(expression)); + break; + case "line-width": + properties.add(PropertyFactory.lineWidth(expression)); + break; + case "line-gap-width": + properties.add(PropertyFactory.lineGapWidth(expression)); + break; + case "line-offset": + properties.add(PropertyFactory.lineOffset(expression)); + break; + case "line-blur": + properties.add(PropertyFactory.lineBlur(expression)); + break; + case "line-dasharray": + properties.add(PropertyFactory.lineDasharray(expression)); + break; + case "line-pattern": + properties.add(PropertyFactory.linePattern(expression)); + break; + case "line-gradient": + properties.add(PropertyFactory.lineGradient(expression)); + break; + case "line-cap": + properties.add(PropertyFactory.lineCap(expression)); + break; + case "line-join": + properties.add(PropertyFactory.lineJoin(expression)); + break; + case "line-miter-limit": + properties.add(PropertyFactory.lineMiterLimit(expression)); + break; + case "line-round-limit": + properties.add(PropertyFactory.lineRoundLimit(expression)); + break; + case "line-sort-key": + properties.add(PropertyFactory.lineSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretFillLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "fill-antialias": + properties.add(PropertyFactory.fillAntialias(expression)); + break; + case "fill-opacity": + properties.add(PropertyFactory.fillOpacity(expression)); + break; + case "fill-color": + properties.add(PropertyFactory.fillColor(expression)); + break; + case "fill-outline-color": + properties.add(PropertyFactory.fillOutlineColor(expression)); + break; + case "fill-translate": + properties.add(PropertyFactory.fillTranslate(expression)); + break; + case "fill-translate-anchor": + properties.add(PropertyFactory.fillTranslateAnchor(expression)); + break; + case "fill-pattern": + properties.add(PropertyFactory.fillPattern(expression)); + break; + case "fill-sort-key": + properties.add(PropertyFactory.fillSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretFillExtrusionLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "fill-extrusion-opacity": + properties.add(PropertyFactory.fillExtrusionOpacity(expression)); + break; + case "fill-extrusion-color": + properties.add(PropertyFactory.fillExtrusionColor(expression)); + break; + case "fill-extrusion-translate": + properties.add(PropertyFactory.fillExtrusionTranslate(expression)); + break; + case "fill-extrusion-translate-anchor": + properties.add(PropertyFactory.fillExtrusionTranslateAnchor(expression)); + break; + case "fill-extrusion-pattern": + properties.add(PropertyFactory.fillExtrusionPattern(expression)); + break; + case "fill-extrusion-height": + properties.add(PropertyFactory.fillExtrusionHeight(expression)); + break; + case "fill-extrusion-base": + properties.add(PropertyFactory.fillExtrusionBase(expression)); + break; + case "fill-extrusion-vertical-gradient": + properties.add(PropertyFactory.fillExtrusionVerticalGradient(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretRasterLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "raster-opacity": + properties.add(PropertyFactory.rasterOpacity(expression)); + break; + case "raster-hue-rotate": + properties.add(PropertyFactory.rasterHueRotate(expression)); + break; + case "raster-brightness-min": + properties.add(PropertyFactory.rasterBrightnessMin(expression)); + break; + case "raster-brightness-max": + properties.add(PropertyFactory.rasterBrightnessMax(expression)); + break; + case "raster-saturation": + properties.add(PropertyFactory.rasterSaturation(expression)); + break; + case "raster-contrast": + properties.add(PropertyFactory.rasterContrast(expression)); + break; + case "raster-resampling": + properties.add(PropertyFactory.rasterResampling(expression)); + break; + case "raster-fade-duration": + properties.add(PropertyFactory.rasterFadeDuration(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretHillshadeLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "hillshade-illumination-direction": + properties.add(PropertyFactory.hillshadeIlluminationDirection(expression)); + break; + case "hillshade-illumination-anchor": + properties.add(PropertyFactory.hillshadeIlluminationAnchor(expression)); + break; + case "hillshade-exaggeration": + properties.add(PropertyFactory.hillshadeExaggeration(expression)); + break; + case "hillshade-shadow-color": + properties.add(PropertyFactory.hillshadeShadowColor(expression)); + break; + case "hillshade-highlight-color": + properties.add(PropertyFactory.hillshadeHighlightColor(expression)); + break; + case "hillshade-accent-color": + properties.add(PropertyFactory.hillshadeAccentColor(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretHeatmapLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "heatmap-radius": + properties.add(PropertyFactory.heatmapRadius(expression)); + break; + case "heatmap-weight": + properties.add(PropertyFactory.heatmapWeight(expression)); + break; + case "heatmap-intensity": + properties.add(PropertyFactory.heatmapIntensity(expression)); + break; + case "heatmap-color": + properties.add(PropertyFactory.heatmapColor(expression)); + break; + case "heatmap-opacity": + properties.add(PropertyFactory.heatmapOpacity(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue())); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NbMapBuilder.java b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapBuilder.java new file mode 100644 index 0000000..8a5163f --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapBuilder.java @@ -0,0 +1,211 @@ + +package ai.nextbillion.maps_flutter; + +import android.content.Context; +import android.view.Gravity; + +import ai.nextbillion.maps.camera.CameraPosition; +import ai.nextbillion.maps.core.NextbillionMapOptions; +import ai.nextbillion.maps.geometry.LatLngBounds; +import io.flutter.plugin.common.BinaryMessenger; + +class NbMapBuilder implements NextbillionMapOptionsSink { + public final String TAG = getClass().getSimpleName(); + private final NextbillionMapOptions options = new NextbillionMapOptions().attributionEnabled(false); + private boolean trackCameraPosition = false; + private boolean myLocationEnabled = false; + private boolean dragEnabled = true; + private int myLocationTrackingMode = 0; + private int myLocationRenderMode = 0; + private String styleString = "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light"; + private LatLngBounds bounds = null; + + NextbillionMapController build( + int id, + Context context, + BinaryMessenger messenger, + NbMapsPlugin.LifecycleProvider lifecycleProvider) { + final NextbillionMapController controller = + new NextbillionMapController( + id, + context, + messenger, + lifecycleProvider, + options, + styleString, + dragEnabled); + controller.init(); + controller.setMyLocationEnabled(myLocationEnabled); + controller.setMyLocationTrackingMode(myLocationTrackingMode); + controller.setMyLocationRenderMode(myLocationRenderMode); + controller.setTrackCameraPosition(trackCameraPosition); + + if (null != bounds) { + controller.setCameraTargetBounds(bounds); + } + + return controller; + } + + public void setInitialCameraPosition(CameraPosition position) { + options.camera(position); + } + + @Override + public void setCompassEnabled(boolean compassEnabled) { + options.compassEnabled(compassEnabled); + } + + @Override + public void setCameraTargetBounds(LatLngBounds bounds) { + this.bounds = bounds; + } + + @Override + public void setStyleString(String styleString) { + this.styleString = styleString; + // options. styleString(styleString); + } + + @Override + public void setMinMaxZoomPreference(Float min, Float max) { + if (min != null) { + options.minZoomPreference(min); + } + if (max != null) { + options.maxZoomPreference(max); + } + } + + @Override + public void setTrackCameraPosition(boolean trackCameraPosition) { + this.trackCameraPosition = trackCameraPosition; + } + + @Override + public void setRotateGesturesEnabled(boolean rotateGesturesEnabled) { + options.rotateGesturesEnabled(rotateGesturesEnabled); + } + + @Override + public void setScrollGesturesEnabled(boolean scrollGesturesEnabled) { + options.scrollGesturesEnabled(scrollGesturesEnabled); + } + + @Override + public void setTiltGesturesEnabled(boolean tiltGesturesEnabled) { + options.tiltGesturesEnabled(tiltGesturesEnabled); + } + + @Override + public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { + options.zoomGesturesEnabled(zoomGesturesEnabled); + } + + @Override + public void setMyLocationEnabled(boolean myLocationEnabled) { + this.myLocationEnabled = myLocationEnabled; + } + + @Override + public void setMyLocationTrackingMode(int myLocationTrackingMode) { + this.myLocationTrackingMode = myLocationTrackingMode; + } + + @Override + public void setMyLocationRenderMode(int myLocationRenderMode) { + this.myLocationRenderMode = myLocationRenderMode; + } + + public void setLogoViewMargins(int x, int y) { + options.logoMargins( + new int[] { + (int) x, // left + (int) 0, // top + (int) 0, // right + (int) y, // bottom + }); + } + + @Override + public void setCompassGravity(int gravity) { + switch (gravity) { + case 0: + options.compassGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.compassGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.compassGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.compassGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setCompassViewMargins(int x, int y) { + switch (options.getCompassGravity()) { + case Gravity.TOP | Gravity.START: + options.compassMargins(new int[] {(int) x, (int) y, 0, 0}); + break; + // If the application code has not specified gravity, assume the platform + // default for the compass which is top-right + default: + case Gravity.TOP | Gravity.END: + options.compassMargins(new int[] {0, (int) y, (int) x, 0}); + break; + case Gravity.BOTTOM | Gravity.START: + options.compassMargins(new int[] {(int) x, 0, 0, (int) y}); + break; + case Gravity.BOTTOM | Gravity.END: + options.compassMargins(new int[] {0, 0, (int) x, (int) y}); + break; + } + } + + @Override + public void setAttributionButtonGravity(int gravity) { + switch (gravity) { + case 0: + options.attributionGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.attributionGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.attributionGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.attributionGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setAttributionButtonMargins(int x, int y) { + switch (options.getAttributionGravity()) { + case Gravity.TOP | Gravity.START: + options.attributionMargins(new int[] {(int) x, (int) y, 0, 0}); + break; + case Gravity.TOP | Gravity.END: + options.attributionMargins(new int[] {0, (int) y, (int) x, 0}); + break; + // If the application code has not specified gravity, assume the platform + // default for the attribution button which is bottom left + default: + case Gravity.BOTTOM | Gravity.START: + options.attributionMargins(new int[] {(int) x, 0, 0, (int) y}); + break; + case Gravity.BOTTOM | Gravity.END: + options.attributionMargins(new int[] {0, 0, (int) x, (int) y}); + break; + } + } + + public void setDragEnabled(boolean enabled) { + this.dragEnabled = enabled; + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NbMapFactory.java b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapFactory.java new file mode 100644 index 0000000..b186676 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapFactory.java @@ -0,0 +1,45 @@ +package ai.nextbillion.maps_flutter; + +import android.content.Context; + +import java.util.Map; + +import ai.nextbillion.maps.camera.CameraPosition; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; + +import static ai.nextbillion.maps_flutter.Convert.interpretNextbillionMapOptions; + +public class NbMapFactory extends PlatformViewFactory { + + private final BinaryMessenger messenger; + private final NbMapsPlugin.LifecycleProvider lifecycleProvider; + + public NbMapFactory( + BinaryMessenger messenger, NbMapsPlugin.LifecycleProvider lifecycleProvider) { + super(StandardMessageCodec.INSTANCE); + this.messenger = messenger; + this.lifecycleProvider = lifecycleProvider; + } + + @Override + public PlatformView create(Context context, int id, Object args) { + Map params = (Map) args; + final NbMapBuilder builder = new NbMapBuilder(); + + interpretNextbillionMapOptions(params.get("options"), builder, context); + if (params.containsKey("initialCameraPosition")) { + CameraPosition position = Convert.toCameraPosition(params.get("initialCameraPosition")); + builder.setInitialCameraPosition(position); + } + if (params.containsKey("dragEnabled")) { + boolean dragEnabled = Convert.toBoolean(params.get("dragEnabled")); + builder.setDragEnabled(dragEnabled); + } + + return builder.build( + id, context, messenger, lifecycleProvider); + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NbMapHttpRequestUtil.java b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapHttpRequestUtil.java new file mode 100644 index 0000000..6e6b535 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapHttpRequestUtil.java @@ -0,0 +1,44 @@ +package ai.nextbillion.maps_flutter; + +import java.util.Map; + +import ai.nextbillion.maps.module.http.HttpRequestUtil; +import io.flutter.plugin.common.MethodChannel; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +abstract class NbMapHttpRequestUtil { + + public static void setHttpHeaders(Map headers, MethodChannel.Result result) { + HttpRequestUtil.setOkHttpClient(getOkHttpClient(headers, result).build()); + result.success(null); + } + + private static OkHttpClient.Builder getOkHttpClient( + Map headers, MethodChannel.Result result) { + try { + return new OkHttpClient.Builder() + .addNetworkInterceptor( + chain -> { + Request.Builder builder = chain.request().newBuilder(); + for (Map.Entry header : headers.entrySet()) { + if (header.getKey() == null || header.getKey().trim().isEmpty()) { + continue; + } + if (header.getValue() == null || header.getValue().trim().isEmpty()) { + builder.removeHeader(header.getKey()); + } else { + builder.header(header.getKey(), header.getValue()); + } + } + return chain.proceed(builder.build()); + }); + } catch (Exception e) { + result.error( + "OK_HTTP_CLIENT_ERROR", + "An unexcepted error happened during creating http " + "client" + e.getMessage(), + null); + throw new RuntimeException(e); + } + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NbMapUtils.java b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapUtils.java new file mode 100644 index 0000000..d815f08 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapUtils.java @@ -0,0 +1,77 @@ +package ai.nextbillion.maps_flutter; + +import android.content.Context; + +import ai.nextbillion.maps.Nextbillion; +import ai.nextbillion.maps.exceptions.NbmapConfigurationException; + +abstract class NbMapUtils { + private static final String TAG = "NbMapController"; + + static Nextbillion getNextbillion(Context context, String accessToken) { + if (accessToken == null || accessToken.isEmpty()) { + throw new NbmapConfigurationException("\nUsing MapView requires calling Nextbillion.initNextbillion(String accessKey) before inflating or creating NBMap Widget. The accessKey parameter is required when using a NBMap Widget."); + } + return Nextbillion.getInstance(context.getApplicationContext(), accessToken); + } + + static String getAccessKey() { + String accessToken = Nextbillion.getAccessKey(); + if (accessToken == null || accessToken.isEmpty()) { + throw new NbmapConfigurationException("\n Access Key is not set or Access Key is empty"); + } + return accessToken; + } + + static void setAccessKey(String accessToken) { + if (accessToken == null || accessToken.isEmpty()) { + throw new NbmapConfigurationException("\n Access Key should not be empty"); + } + Nextbillion.setAccessKey(accessToken); + } + + static String getBaseUri() { + String baseUri = Nextbillion.getBaseUri(); + if (baseUri == null || baseUri.isEmpty()) { + throw new NbmapConfigurationException("\n BaseUri is not set or BaseUri is empty"); + } + return baseUri; + } + + static void setBaseUri(String baseUri) { + if (baseUri == null || baseUri.isEmpty()) { + throw new NbmapConfigurationException("\n BaseUri should not be empty"); + } + Nextbillion.setBaseUri(baseUri); + } + + static void setApiKeyHeaderName(String apiKeyHeaderName) { + if (apiKeyHeaderName == null || apiKeyHeaderName.isEmpty()) { + throw new NbmapConfigurationException("\n Api Key Header Name should not be empty"); + } + Nextbillion.setApiKeyHeaderName(apiKeyHeaderName); + } + + static String getApiKeyHeaderName() { + return Nextbillion.getApiKeyHeaderName(); + } + + static String getUserid() { + return Nextbillion.getUserId(); + } + + static void setUserid(String userid) { + Nextbillion.setUserId(userid); + } + + static String getNbId() { + return Nextbillion.getNBId(); + } + + static void setCrossPlatformInfo() { + String crossPlatformName = String.format("Flutter-%s-%s", BuildConfig.NBMAP_FLUTTER_VERSION, BuildConfig.GIT_REVISION_SHORT); + Nextbillion.setCrossPlatformInfo(crossPlatformName); + } + + +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NbMapsPlugin.java b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapsPlugin.java new file mode 100644 index 0000000..7d70659 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NbMapsPlugin.java @@ -0,0 +1,219 @@ + +package ai.nextbillion.maps_flutter; + +import android.app.Activity; +import android.app.Application.ActivityLifecycleCallbacks; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LifecycleRegistry; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.plugins.lifecycle.HiddenLifecycleReference; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry.Registrar; + +/** + * Plugin for controlling a set of NBMap views to be shown as overlays on top of the Flutter + * view. The overlay should be hidden during transformations or while Flutter is rendering on top of + * the map. A Texture drawn using NBMap bitmap snapshots can then be shown instead of the + * overlay. + */ +public class NbMapsPlugin implements FlutterPlugin, ActivityAware { + + private static final String VIEW_TYPE = "plugins.flutter.io/nb_maps_flutter"; + + static FlutterAssets flutterAssets; + private Lifecycle lifecycle; + + public NbMapsPlugin() { + // no-op + } + + // New Plugin APIs + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + flutterAssets = binding.getFlutterAssets(); + + MethodChannel methodChannel = + new MethodChannel(binding.getBinaryMessenger(), "plugins.flutter.io/nb_maps_flutter"); + methodChannel.setMethodCallHandler(new GlobalMethodHandler(binding)); + + MethodChannel nextBillionChannel = + new MethodChannel(binding.getBinaryMessenger(), "plugins.flutter.io/nextbillion_init"); + nextBillionChannel.setMethodCallHandler(new NextBillionMethodHandler(binding)); + + binding + .getPlatformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/nb_maps_flutter", + new NbMapFactory( + binding.getBinaryMessenger(), + new LifecycleProvider() { + @Nullable + @Override + public Lifecycle getLifecycle() { + return lifecycle; + } + })); + + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + // no-op + } + + @Override + public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { + lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { + onAttachedToActivity(binding); + } + + @Override + public void onDetachedFromActivity() { + lifecycle = null; + } + + // Old Plugin APIs + + public static void registerWith(Registrar registrar) { + final Activity activity = registrar.activity(); + if (activity == null) { + // When a background flutter view tries to register the plugin, the registrar has no activity. + // We stop the registration process as this plugin is foreground only. + return; + } + if (activity instanceof LifecycleOwner) { + registrar + .platformViewRegistry() + .registerViewFactory( + VIEW_TYPE, + new NbMapFactory( + registrar.messenger(), + new LifecycleProvider() { + @Override + public Lifecycle getLifecycle() { + return ((LifecycleOwner) activity).getLifecycle(); + } + })); + } else { + registrar + .platformViewRegistry() + .registerViewFactory( + VIEW_TYPE, + new NbMapFactory(registrar.messenger(), new ProxyLifecycleProvider(activity))); + } + + MethodChannel methodChannel = + new MethodChannel(registrar.messenger(), "plugins.flutter.io/nb_maps_flutter"); + methodChannel.setMethodCallHandler(new GlobalMethodHandler(registrar)); + } + + private static final class ProxyLifecycleProvider + implements ActivityLifecycleCallbacks, LifecycleOwner, LifecycleProvider { + + private final LifecycleRegistry lifecycle = new LifecycleRegistry(this); + private final int registrarActivityHashCode; + + private ProxyLifecycleProvider(Activity activity) { + this.registrarActivityHashCode = activity.hashCode(); + activity.getApplication().registerActivityLifecycleCallbacks(this); + } + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + if (activity.hashCode() != registrarActivityHashCode) { + return; + } + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); + } + + @Override + public void onActivityStarted(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; + } + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START); + } + + @Override + public void onActivityResumed(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; + } + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME); + } + + @Override + public void onActivityPaused(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; + } + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE); + } + + @Override + public void onActivityStopped(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; + } + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP); + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + + @Override + public void onActivityDestroyed(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; + } + activity.getApplication().unregisterActivityLifecycleCallbacks(this); + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); + } + + @NonNull + @Override + public Lifecycle getLifecycle() { + return lifecycle; + } + } + + interface LifecycleProvider { + @Nullable + Lifecycle getLifecycle(); + } + + /** Provides a static method for extracting lifecycle objects from Flutter plugin bindings. */ + public static class FlutterLifecycleAdapter { + + /** + * Returns the lifecycle object for the activity a plugin is bound to. + * + *

Returns null if the Flutter engine version does not include the lifecycle extraction code. + * (this probably means the Flutter engine version is too old). + */ + @NonNull + public static Lifecycle getActivityLifecycle( + @NonNull ActivityPluginBinding activityPluginBinding) { + HiddenLifecycleReference reference = + (HiddenLifecycleReference) activityPluginBinding.getLifecycle(); + return reference.getLifecycle(); + } + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NextBillionMethodHandler.java b/android/src/main/java/ai/nextbillion/maps_flutter/NextBillionMethodHandler.java new file mode 100644 index 0000000..40f7c6b --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NextBillionMethodHandler.java @@ -0,0 +1,80 @@ +package ai.nextbillion.maps_flutter; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import ai.nextbillion.maps.Nextbillion; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +/** + * @author qiuyu + * @Date 2023/7/13 + **/ +public class NextBillionMethodHandler implements MethodChannel.MethodCallHandler { + + @NonNull + private final Context context; + + NextBillionMethodHandler(@NonNull FlutterPlugin.FlutterPluginBinding binding) { + this.context = binding.getApplicationContext(); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "nextbillion/init_nextbillion": + String accessToken = call.argument("accessKey"); + NbMapUtils.getNextbillion(context, accessToken); + NbMapUtils.setCrossPlatformInfo(); + result.success(null); + break; + case "nextbillion/get_access_key": + String accessTokenResult = NbMapUtils.getAccessKey(); + result.success(accessTokenResult); + break; + case "nextbillion/set_access_key": + String accessTokenToSet = call.argument("accessKey"); + NbMapUtils.setAccessKey(accessTokenToSet); + result.success(null); + break; + case "nextbillion/get_base_uri": + String baseUri = NbMapUtils.getBaseUri(); + result.success(baseUri); + break; + case "nextbillion/set_base_uri": + String baseUriToSet = call.argument("baseUri"); + NbMapUtils.setBaseUri(baseUriToSet); + result.success(null); + break; + case "nextbillion/set_key_header_name": + String apiKeyHeaderName = call.argument("apiKeyHeaderName"); + NbMapUtils.setApiKeyHeaderName(apiKeyHeaderName); + result.success(null); + break; + case "nextbillion/get_key_header_name": + String keyHeaderName = NbMapUtils.getApiKeyHeaderName(); + result.success(keyHeaderName); + break; + case "nextbillion/get_nb_id": + String nbId = NbMapUtils.getNbId(); + result.success(nbId); + break; + case "nextbillion/set_user_id": + String id = call.argument("userId"); + NbMapUtils.setUserid(id); + result.success(null); + break; + case "nextbillion/get_user_id": + String userId = NbMapUtils.getUserid(); + result.success(userId); + break; + + default: + result.notImplemented(); + } + + } +} \ No newline at end of file diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NextbillionMapController.java b/android/src/main/java/ai/nextbillion/maps_flutter/NextbillionMapController.java new file mode 100644 index 0000000..f316c7d --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NextbillionMapController.java @@ -0,0 +1,2093 @@ + +package ai.nextbillion.maps_flutter; + + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.AssetFileDescriptor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.PointF; +import android.graphics.RectF; +import android.location.Location; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import ai.nextbillion.gestures.AndroidGesturesManager; +import ai.nextbillion.gestures.MoveGestureDetector; +import ai.nextbillion.kits.geojson.Feature; +import ai.nextbillion.kits.geojson.FeatureCollection; +import ai.nextbillion.kits.geojson.Point; +import ai.nextbillion.maps.camera.CameraPosition; +import ai.nextbillion.maps.camera.CameraUpdate; +import ai.nextbillion.maps.camera.CameraUpdateFactory; +import ai.nextbillion.maps.core.MapView; +import ai.nextbillion.maps.core.NextbillionMap; +import ai.nextbillion.maps.core.NextbillionMapOptions; +import ai.nextbillion.maps.core.OnMapReadyCallback; +import ai.nextbillion.maps.core.Style; +import ai.nextbillion.maps.geometry.LatLng; +import ai.nextbillion.maps.geometry.LatLngBounds; +import ai.nextbillion.maps.geometry.LatLngQuad; +import ai.nextbillion.maps.geometry.VisibleRegion; +import ai.nextbillion.maps.location.LocationComponent; +import ai.nextbillion.maps.location.LocationComponentOptions; +import ai.nextbillion.maps.location.OnCameraTrackingChangedListener; +import ai.nextbillion.maps.location.engine.LocationEngine; +import ai.nextbillion.maps.location.engine.LocationEngineCallback; +import ai.nextbillion.maps.location.engine.LocationEngineProvider; +import ai.nextbillion.maps.location.engine.LocationEngineResult; +import ai.nextbillion.maps.location.modes.CameraMode; +import ai.nextbillion.maps.location.modes.RenderMode; +import ai.nextbillion.maps.offline.OfflineManager; +import ai.nextbillion.maps.snapshotter.MapSnapshotter; +import ai.nextbillion.maps.storage.FileSource; +import ai.nextbillion.maps.style.expressions.Expression; +import ai.nextbillion.maps.style.layers.CircleLayer; +import ai.nextbillion.maps.style.layers.FillExtrusionLayer; +import ai.nextbillion.maps.style.layers.FillLayer; +import ai.nextbillion.maps.style.layers.HeatmapLayer; +import ai.nextbillion.maps.style.layers.HillshadeLayer; +import ai.nextbillion.maps.style.layers.Layer; +import ai.nextbillion.maps.style.layers.LineLayer; +import ai.nextbillion.maps.style.layers.PropertyValue; +import ai.nextbillion.maps.style.layers.RasterLayer; +import ai.nextbillion.maps.style.layers.SymbolLayer; +import ai.nextbillion.maps.style.sources.GeoJsonSource; +import ai.nextbillion.maps.style.sources.ImageSource; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.platform.PlatformView; + +import static ai.nextbillion.maps.style.layers.Property.NONE; +import static ai.nextbillion.maps.style.layers.Property.VISIBLE; +import static ai.nextbillion.maps.style.layers.PropertyFactory.visibility; +import static ai.nextbillion.maps_flutter.Convert.interpretNextbillionMapOptions; + +/** Controller of a single NbMaps MapView instance. */ +@SuppressLint("MissingPermission") +final class NextbillionMapController + implements DefaultLifecycleObserver, + NextbillionMap.OnCameraIdleListener, + NextbillionMap.OnCameraMoveListener, + NextbillionMap.OnCameraMoveStartedListener, + MapView.OnDidBecomeIdleListener, + NextbillionMap.OnMapClickListener, + NextbillionMap.OnMapLongClickListener, + NextbillionMapOptionsSink, + MethodChannel.MethodCallHandler, + OnMapReadyCallback, + OnCameraTrackingChangedListener, + PlatformView { + + private static final String TAG = "NbMapController"; + private final int id; + private final MethodChannel methodChannel; + private final NbMapsPlugin.LifecycleProvider lifecycleProvider; + private final float density; + private final Context context; + private final String styleStringInitial; + private final Set interactiveFeatureLayerIds; + private final Map addedFeaturesByLayer; + private final Map mSnapshotterMap; + private MapView mapView; + private NextbillionMap nextbillionMap; + private boolean trackCameraPosition = false; + private boolean myLocationEnabled = false; + private int myLocationTrackingMode = 0; + private int myLocationRenderMode = 0; + private boolean disposed = false; + private boolean dragEnabled = true; + private MethodChannel.Result mapReadyResult; + private LocationComponent locationComponent = null; + private LocationEngine locationEngine = null; + private LocationEngineCallback locationEngineCallback = null; +// private LocalizationPlugin localizationPlugin; + private Style style; + private Feature draggedFeature; + private AndroidGesturesManager androidGesturesManager; + private LatLng dragOrigin; + private LatLng dragPrevious; + private LatLngBounds bounds = null; + Style.OnStyleLoaded onStyleLoadedCallback = + new Style.OnStyleLoaded() { + @Override + public void onStyleLoaded(@NonNull Style style) { + NextbillionMapController.this.style = style; + + updateMyLocationEnabled(); + + if (null != bounds) { + nextbillionMap.setLatLngBoundsForCameraTarget(bounds); + } + + nextbillionMap.addOnMapClickListener(NextbillionMapController.this); + nextbillionMap.addOnMapLongClickListener(NextbillionMapController.this); +// localizationPlugin = new LocalizationPlugin(mapView, nextbillionMap, style); + + methodChannel.invokeMethod("map#onStyleLoaded", null); + } + }; + + NextbillionMapController( + int id, + Context context, + BinaryMessenger messenger, + NbMapsPlugin.LifecycleProvider lifecycleProvider, + NextbillionMapOptions options, + String styleStringInitial, + boolean dragEnabled) { + this.id = id; + this.context = context; + this.dragEnabled = dragEnabled; + this.styleStringInitial = styleStringInitial; + this.mapView = new MapView(context, options); + this.interactiveFeatureLayerIds = new HashSet<>(); + this.addedFeaturesByLayer = new HashMap(); + this.density = context.getResources().getDisplayMetrics().density; + this.lifecycleProvider = lifecycleProvider; + if (dragEnabled) { + this.androidGesturesManager = new AndroidGesturesManager(this.mapView.getContext(), false); + } + this.mSnapshotterMap = new HashMap<>(); + methodChannel = new MethodChannel(messenger, "plugins.flutter.io/nbmaps_maps_" + id); + methodChannel.setMethodCallHandler(this); + } + + @Override + public View getView() { + return mapView; + } + + void init() { + lifecycleProvider.getLifecycle().addObserver(this); + mapView.getMapAsync(this); + } + + private void moveCamera(CameraUpdate cameraUpdate) { + nextbillionMap.moveCamera(cameraUpdate); + } + + private void animateCamera(CameraUpdate cameraUpdate) { + nextbillionMap.animateCamera(cameraUpdate); + } + + private CameraPosition getCameraPosition() { + return trackCameraPosition ? nextbillionMap.getCameraPosition() : null; + } + + @Override + public void onMapReady(NextbillionMap nextbillionMap) { + this.nextbillionMap = nextbillionMap; + if (mapReadyResult != null) { + mapReadyResult.success(null); + mapReadyResult = null; + } + nextbillionMap.addOnCameraMoveStartedListener(this); + nextbillionMap.addOnCameraMoveListener(this); + nextbillionMap.addOnCameraIdleListener(this); + + if (androidGesturesManager != null) { + androidGesturesManager.setMoveGestureListener(new MoveGestureListener()); + mapView.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + androidGesturesManager.onTouchEvent(event); + + return draggedFeature != null; + } + }); + } + + mapView.addOnStyleImageMissingListener( + (id) -> { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + final Bitmap bitmap = getScaledImage(id, displayMetrics.density); + if (bitmap != null) { + nextbillionMap.getStyle().addImage(id, bitmap); + } + }); + + mapView.addOnDidBecomeIdleListener(this); + + setStyleString(styleStringInitial); + } + + @Override + public void setStyleString(String styleString) { + // clear old layer id from the location Component + clearLocationComponentLayer(); + + // Check if json, url, absolute path or asset path: + if (styleString == null || styleString.isEmpty()) { + Log.e(TAG, "setStyleString - string empty or null"); + } else if (styleString.startsWith("{") || styleString.startsWith("[")) { + nextbillionMap.setStyle(new Style.Builder().fromJson(styleString), onStyleLoadedCallback); + } else if (styleString.startsWith("/")) { + // Absolute path + nextbillionMap.setStyle( + new Style.Builder().fromUri("file://" + styleString), onStyleLoadedCallback); + } else if (!styleString.startsWith("http://") + && !styleString.startsWith("https://")) { + // We are assuming that the style will be loaded from an asset here. + String key = NbMapsPlugin.flutterAssets.getAssetFilePathByName(styleString); + nextbillionMap.setStyle(new Style.Builder().fromUri("asset://" + key), onStyleLoadedCallback); + } else { + nextbillionMap.setStyle(new Style.Builder().fromUri(styleString), onStyleLoadedCallback); + } + } + + @SuppressWarnings({"MissingPermission"}) + private void enableLocationComponent(@NonNull Style style) { + if (hasLocationPermission()) { + locationEngine = LocationEngineProvider.getBestLocationEngine(context); + locationComponent = nextbillionMap.getLocationComponent(); + locationComponent.activateLocationComponent( + context, style, buildLocationComponentOptions(style)); + locationComponent.setLocationComponentEnabled(true); + // locationComponent.setRenderMode(RenderMode.COMPASS); // remove or keep default? + locationComponent.setLocationEngine(locationEngine); + locationComponent.setMaxAnimationFps(30); + updateMyLocationTrackingMode(); + updateMyLocationRenderMode(); + locationComponent.addOnCameraTrackingChangedListener(this); + } else { + Log.e(TAG, "missing location permissions"); + } + } + + private void updateLocationComponentLayer() { + if (locationComponent != null && locationComponentRequiresUpdate()) { + locationComponent.applyStyle(buildLocationComponentOptions(style)); + } + } + + private void clearLocationComponentLayer() { + if (locationComponent != null) { + locationComponent.applyStyle(buildLocationComponentOptions(null)); + } + } + + String getLastLayerOnStyle(Style style) { + if (style != null) { + final List layers = style.getLayers(); + + if (layers.size() > 0) { + return layers.get(layers.size() - 1).getId(); + } + } + return null; + } + + boolean locationComponentRequiresUpdate() { + final String lastLayerId = getLastLayerOnStyle(style); + return lastLayerId != null && !lastLayerId.equals("nbmap-location-bearing-layer"); + } + + private LocationComponentOptions buildLocationComponentOptions(Style style) { + final LocationComponentOptions.Builder optionsBuilder = + LocationComponentOptions.builder(context); + optionsBuilder.trackingGesturesManagement(true); + + final String lastLayerId = getLastLayerOnStyle(style); + if (lastLayerId != null) { + optionsBuilder.layerAbove(lastLayerId); + } + return optionsBuilder.build(); + } + + private void onUserLocationUpdate(Location location) { + if (location == null) { + return; + } + + final Map userLocation = new HashMap<>(6); + userLocation.put("position", new double[] {location.getLatitude(), location.getLongitude()}); + userLocation.put("speed", location.getSpeed()); + userLocation.put("altitude", location.getAltitude()); + userLocation.put("bearing", location.getBearing()); + userLocation.put("horizontalAccuracy", location.getAccuracy()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + userLocation.put( + "verticalAccuracy", + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + ? location.getVerticalAccuracyMeters() + : null); + } + userLocation.put("timestamp", location.getTime()); + + final Map arguments = new HashMap<>(1); + arguments.put("userLocation", userLocation); + methodChannel.invokeMethod("map#onUserLocationUpdated", arguments); + } + + private void addGeoJsonSource(String sourceName, String source) { + FeatureCollection featureCollection = FeatureCollection.fromJson(source); + GeoJsonSource geoJsonSource = new GeoJsonSource(sourceName, featureCollection); + addedFeaturesByLayer.put(sourceName, featureCollection); + + style.addSource(geoJsonSource); + } + + private void setGeoJsonSource(String sourceName, String geojson) { + FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + GeoJsonSource geoJsonSource = style.getSourceAs(sourceName); + addedFeaturesByLayer.put(sourceName, featureCollection); + + geoJsonSource.setGeoJson(featureCollection); + } + + private void setGeoJsonFeature(String sourceName, String geojsonFeature) { + Feature feature = Feature.fromJson(geojsonFeature); + FeatureCollection featureCollection = addedFeaturesByLayer.get(sourceName); + GeoJsonSource geoJsonSource = style.getSourceAs(sourceName); + if (featureCollection != null && geoJsonSource != null) { + final List features = featureCollection.features(); + for (int i = 0; i < features.size(); i++) { + final String id = features.get(i).id(); + if (id.equals(feature.id())) { + features.set(i, feature); + break; + } + } + + geoJsonSource.setGeoJson(featureCollection); + } + } + + private void addSymbolLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + + SymbolLayer symbolLayer = new SymbolLayer(layerName, sourceName); + symbolLayer.setProperties(properties); + if (sourceLayer != null) { + symbolLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + symbolLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + symbolLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + symbolLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(symbolLayer, belowLayerId); + } else { + style.addLayer(symbolLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addLineLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + LineLayer lineLayer = new LineLayer(layerName, sourceName); + lineLayer.setProperties(properties); + if (sourceLayer != null) { + lineLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + lineLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + lineLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + lineLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(lineLayer, belowLayerId); + } else { + style.addLayer(lineLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addFillLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + FillLayer fillLayer = new FillLayer(layerName, sourceName); + fillLayer.setProperties(properties); + if (sourceLayer != null) { + fillLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + fillLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + fillLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + fillLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(fillLayer, belowLayerId); + } else { + style.addLayer(fillLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addFillExtrusionLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + FillExtrusionLayer fillLayer = new FillExtrusionLayer(layerName, sourceName); + fillLayer.setProperties(properties); + if (sourceLayer != null) { + fillLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + fillLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + fillLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + fillLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(fillLayer, belowLayerId); + } else { + style.addLayer(fillLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addCircleLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + CircleLayer circleLayer = new CircleLayer(layerName, sourceName); + circleLayer.setProperties(properties); + if (sourceLayer != null) { + circleLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + circleLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + circleLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + circleLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(circleLayer, belowLayerId); + } else { + style.addLayer(circleLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private Expression parseFilter(String filter) { + JsonParser parser = new JsonParser(); + JsonElement filterJsonElement = parser.parse(filter); + return filterJsonElement.isJsonNull() ? null : Expression.Converter.convert(filterJsonElement); + } + + private void addRasterLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + RasterLayer layer = new RasterLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private void addHillshadeLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + HillshadeLayer layer = new HillshadeLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private void addHeatmapLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + HeatmapLayer layer = new HeatmapLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private Feature firstFeatureOnLayers(RectF in) { + if (style != null) { + final List layers = style.getLayers(); + final List layersInOrder = new ArrayList(); + for (Layer layer : layers) { + String id = layer.getId(); + if (interactiveFeatureLayerIds.contains(id)) { + layersInOrder.add(id); + } + } + Collections.reverse(layersInOrder); + + for (String id : layersInOrder) { + List features = nextbillionMap.queryRenderedFeatures(in, id); + if (!features.isEmpty()) { + return features.get(0); + } + } + } + return null; + } + + @Override + public void onMethodCall(MethodCall call, MethodChannel.Result result) { + + switch (call.method) { + case "map#waitForMap": + if (nextbillionMap != null) { + result.success(null); + return; + } + mapReadyResult = result; + break; + case "map#update": + { + interpretNextbillionMapOptions(call.argument("options"), this, context); + result.success(Convert.toJson(getCameraPosition())); + break; + } + case "map#updateMyLocationTrackingMode": + { + int myLocationTrackingMode = call.argument("mode"); + setMyLocationTrackingMode(myLocationTrackingMode); + result.success(null); + break; + } + case "map#matchMapLanguageWithDeviceDefault": + { + try { +// localizationPlugin.matchMapLanguageWithDeviceDefault(); + result.success(null); + } catch (RuntimeException exception) { + Log.d(TAG, exception.toString()); + result.error("NbMaps LOCALIZATION PLUGIN ERROR", exception.toString(), null); + } + break; + } + case "map#updateContentInsets": + { + HashMap insets = call.argument("bounds"); + final CameraUpdate cameraUpdate = + CameraUpdateFactory.paddingTo( + Convert.toPixels(insets.get("left"), density), + Convert.toPixels(insets.get("top"), density), + Convert.toPixels(insets.get("right"), density), + Convert.toPixels(insets.get("bottom"), density)); + + if (call.argument("animated")) { + animateCamera(cameraUpdate, null, result); + } else { + moveCamera(cameraUpdate, result); + } + break; + } + case "map#setMapLanguage": + { + final String language = call.argument("language"); + try { +// localizationPlugin.setMapLanguage(language); + result.success(null); + } catch (RuntimeException exception) { + Log.d(TAG, exception.toString()); + result.error("NbMaps LOCALIZATION PLUGIN ERROR", exception.toString(), null); + } + break; + } + case "map#getVisibleRegion": + { + Map reply = new HashMap<>(); + VisibleRegion visibleRegion = nextbillionMap.getProjection().getVisibleRegion(); + reply.put( + "sw", + Arrays.asList( + visibleRegion.nearLeft.getLatitude(), visibleRegion.nearLeft.getLongitude())); + reply.put( + "ne", + Arrays.asList( + visibleRegion.farRight.getLatitude(), visibleRegion.farRight.getLongitude())); + result.success(reply); + break; + } + case "map#toScreenLocation": + { + Map reply = new HashMap<>(); + PointF pointf = + nextbillionMap + .getProjection() + .toScreenLocation( + new LatLng(call.argument("latitude"), call.argument("longitude"))); + reply.put("x", pointf.x); + reply.put("y", pointf.y); + result.success(reply); + break; + } + case "map#toScreenLocationBatch": + { + double[] param = (double[]) call.argument("coordinates"); + double[] reply = new double[param.length]; + + for (int i = 0; i < param.length; i += 2) { + PointF pointf = + nextbillionMap.getProjection().toScreenLocation(new LatLng(param[i], param[i + 1])); + reply[i] = pointf.x; + reply[i + 1] = pointf.y; + } + + result.success(reply); + break; + } + case "map#toLatLng": + { + Map reply = new HashMap<>(); + LatLng latlng = + nextbillionMap + .getProjection() + .fromScreenLocation( + new PointF( + ((Double) call.argument("x")).floatValue(), + ((Double) call.argument("y")).floatValue())); + reply.put("latitude", latlng.getLatitude()); + reply.put("longitude", latlng.getLongitude()); + result.success(reply); + break; + } + case "map#getMetersPerPixelAtLatitude": + { + Map reply = new HashMap<>(); + Double retVal = + nextbillionMap + .getProjection() + .getMetersPerPixelAtLatitude((Double) call.argument("latitude")); + reply.put("metersperpixel", retVal); + result.success(reply); + break; + } + case "camera#move": + { + final CameraUpdate cameraUpdate = + Convert.toCameraUpdate(call.argument("cameraUpdate"), nextbillionMap, density); + moveCamera(cameraUpdate, result); + break; + } + case "camera#animate": + { + final CameraUpdate cameraUpdate = + Convert.toCameraUpdate(call.argument("cameraUpdate"), nextbillionMap, density); + final Integer duration = call.argument("duration"); + + animateCamera(cameraUpdate, duration, result); + break; + } + case "map#queryRenderedFeatures": + { + Map reply = new HashMap<>(); + List features; + + String[] layerIds = ((List) call.argument("layerIds")).toArray(new String[0]); + + List filter = call.argument("filter"); + JsonElement jsonElement = filter == null ? null : new Gson().toJsonTree(filter); + JsonArray jsonArray = null; + if (jsonElement != null && jsonElement.isJsonArray()) { + jsonArray = jsonElement.getAsJsonArray(); + } + Expression filterExpression = + jsonArray == null ? null : Expression.Converter.convert(jsonArray); + if (call.hasArgument("x")) { + Double x = call.argument("x"); + Double y = call.argument("y"); + PointF pixel = new PointF(x.floatValue(), y.floatValue()); + features = nextbillionMap.queryRenderedFeatures(pixel, filterExpression, layerIds); + } else { + Double left = call.argument("left"); + Double top = call.argument("top"); + Double right = call.argument("right"); + Double bottom = call.argument("bottom"); + RectF rectF = + new RectF( + left.floatValue(), top.floatValue(), right.floatValue(), bottom.floatValue()); + features = nextbillionMap.queryRenderedFeatures(rectF, filterExpression, layerIds); + } + List featuresJson = new ArrayList<>(); + for (Feature feature : features) { + featuresJson.add(feature.toJson()); + } + reply.put("features", featuresJson); + result.success(reply); + break; + } + case "map#setTelemetryEnabled": + { + result.success(null); + break; + } + case "map#getTelemetryEnabled": + { +// final TelemetryEnabler.State telemetryState = +// TelemetryEnabler.retrieveTelemetryStateFromPreferences(); +// result.success(telemetryState == TelemetryEnabler.State.ENABLED); + break; + } + case "map#invalidateAmbientCache": + { + OfflineManager fileSource = OfflineManager.getInstance(context); + + fileSource.invalidateAmbientCache( + new OfflineManager.FileSourceCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onError(@NonNull String message) { + result.error("NbMaps CACHE ERROR", message, null); + } + }); + break; + } + case "source#addGeoJson": + { + final String sourceId = call.argument("sourceId"); + final String geojson = call.argument("geojson"); + addGeoJsonSource(sourceId, geojson); + result.success(null); + break; + } + case "source#setGeoJson": + { + final String sourceId = call.argument("sourceId"); + final String geojson = call.argument("geojson"); + setGeoJsonSource(sourceId, geojson); + result.success(null); + break; + } + case "source#setFeature": + { + final String sourceId = call.argument("sourceId"); + final String geojsonFeature = call.argument("geojsonFeature"); + setGeoJsonFeature(sourceId, geojsonFeature); + result.success(null); + break; + } + case "symbolLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretSymbolLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + removeLayer(layerId); + addSymbolLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "lineLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretLineLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + removeLayer(layerId); + addLineLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "fillLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretFillLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + removeLayer(layerId); + addFillLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "fillExtrusionLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretFillExtrusionLayerProperties( + call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + removeLayer(layerId); + addFillExtrusionLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "circleLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretCircleLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + removeLayer(layerId); + addCircleLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "rasterLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretRasterLayerProperties(call.argument("properties")); + + removeLayer(layerId); + addRasterLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "hillshadeLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretHillshadeLayerProperties(call.argument("properties")); + addHillshadeLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "heatmapLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretHeatmapLayerProperties(call.argument("properties")); + addHeatmapLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "locationComponent#getLastLocation": + { + Log.e(TAG, "location component: getLastLocation"); + if (this.myLocationEnabled && locationComponent != null && locationEngine != null) { + Map reply = new HashMap<>(); + locationEngine.getLastLocation( + new LocationEngineCallback() { + @Override + public void onSuccess(LocationEngineResult locationEngineResult) { + Location lastLocation = locationEngineResult.getLastLocation(); + if (lastLocation != null) { + reply.put("latitude", lastLocation.getLatitude()); + reply.put("longitude", lastLocation.getLongitude()); + reply.put("altitude", lastLocation.getAltitude()); + result.success(reply); + } else { + result.error("", "", null); // ??? + } + } + + @Override + public void onFailure(@NonNull Exception exception) { + result.error("", "", null); // ??? + } + }); + } + break; + } + case "style#setStyleString": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String styleString = call.argument("styleString"); + setStyleString(styleString); + result.success(null); + break; + } + case "style#addImage": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + style.addImage( + call.argument("name"), + BitmapFactory.decodeByteArray(call.argument("bytes"), 0, call.argument("length")), + call.argument("sdf")); + result.success(null); + break; + } + case "style#addImageSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + List coordinates = Convert.toLatLngList(call.argument("coordinates"), false); + style.addSource( + new ImageSource( + call.argument("imageSourceId"), + new LatLngQuad( + coordinates.get(0), + coordinates.get(1), + coordinates.get(2), + coordinates.get(3)), + BitmapFactory.decodeByteArray( + call.argument("bytes"), 0, call.argument("length")))); + result.success(null); + break; + } + case "style#updateImageSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + ImageSource imageSource = style.getSourceAs(call.argument("imageSourceId")); + List coordinates = Convert.toLatLngList(call.argument("coordinates"), false); + if (coordinates != null) { + imageSource.setCoordinates( + new LatLngQuad( + coordinates.get(0), + coordinates.get(1), + coordinates.get(2), + coordinates.get(3))); + } + byte[] bytes = call.argument("bytes"); + if (bytes != null) { + imageSource.setImage(BitmapFactory.decodeByteArray(bytes, 0, call.argument("length"))); + } + result.success(null); + break; + } + case "style#addSource": + { + final String id = Convert.toString(call.argument("sourceId")); + final Map properties = (Map) call.argument("properties"); + SourcePropertyConverter.addSource(id, properties, style); + result.success(null); + break; + } + + case "style#removeSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + style.removeSource((String) call.argument("sourceId")); + result.success(null); + break; + } + case "style#addLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + addRasterLayer( + call.argument("imageLayerId"), + call.argument("imageSourceId"), + call.argument("minzoom") != null + ? ((Double) call.argument("minzoom")).floatValue() + : null, + call.argument("maxzoom") != null + ? ((Double) call.argument("maxzoom")).floatValue() + : null, + null, + new PropertyValue[] {}, + null); + result.success(null); + break; + } + case "style#addLayerBelow": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + addRasterLayer( + call.argument("imageLayerId"), + call.argument("imageSourceId"), + call.argument("minzoom") != null + ? ((Double) call.argument("minzoom")).floatValue() + : null, + call.argument("maxzoom") != null + ? ((Double) call.argument("maxzoom")).floatValue() + : null, + call.argument("belowLayerId"), + new PropertyValue[] {}, + null); + result.success(null); + break; + } + case "style#findBelowLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String belowLayer = ""; + List styleLayers = style.getLayers(); + List belowAt = call.argument("belowAt") != null ? call.argument("belowAt") : new ArrayList<>(); + if (!styleLayers.isEmpty()) { + for (int i = styleLayers.size() - 1; i >= 0; i--) { + if (!(styleLayers.get(i) instanceof SymbolLayer) + && !(styleLayers.get(i) instanceof FillExtrusionLayer) + // Avoid placing the route on top of the user location layer + && !checkContainLayer(styleLayers.get(i).getId(), belowAt)) { + if (i == styleLayers.size() - 1) { + // avoid index out of range + belowLayer = styleLayers.get(i).getId(); + } else { + // current index refer to top line layer, and the route line layer should be above it + belowLayer = styleLayers.get(i + 1).getId(); + } + break; + } + } + } + result.success(belowLayer); + break; + } + case "style#removeLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + removeLayer(layerId); + + result.success(null); + break; + } + case "style#setFilter": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + String filter = call.argument("filter"); + + Layer layer = style.getLayer(layerId); + + JsonParser parser = new JsonParser(); + JsonElement jsonElement = parser.parse(filter); + Expression expression = Expression.Converter.convert(jsonElement); + + if (layer instanceof CircleLayer) { + ((CircleLayer) layer).setFilter(expression); + } else if (layer instanceof FillExtrusionLayer) { + ((FillExtrusionLayer) layer).setFilter(expression); + } else if (layer instanceof FillLayer) { + ((FillLayer) layer).setFilter(expression); + } else if (layer instanceof HeatmapLayer) { + ((HeatmapLayer) layer).setFilter(expression); + } else if (layer instanceof LineLayer) { + ((LineLayer) layer).setFilter(expression); + } else if (layer instanceof SymbolLayer) { + ((SymbolLayer) layer).setFilter(expression); + } else { + result.error( + "INVALID LAYER TYPE", + String.format("Layer '%s' does not support filtering.", layerId), + null); + break; + } + + result.success(null); + break; + } + case "style#setVisibility": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + boolean isVisible = call.argument("isVisible"); + Layer layer = style.getLayer(layerId); + if (layer != null) { + layer.setProperties(isVisible ? visibility(VISIBLE) : visibility(NONE)); + } + + result.success(null); + break; + } + case "snapshot#takeSnapshot": + { + FileSource.getInstance(context).activate(); + MapSnapshotter.Options snapShotOptions = + new MapSnapshotter.Options( + (int) call.argument("width"), (int) call.argument("height")); + + snapShotOptions.withLogo((boolean) call.argument("withLogo")); + Style.Builder styleBuilder = new Style.Builder(); + if (call.hasArgument("styleUri")) { + styleBuilder.fromUri((String) call.argument("styleUri")); + } else if (call.hasArgument("styleJson")) { + styleBuilder.fromJson((String) call.argument("styleJson")); + } else { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + styleBuilder.fromUri(style.getUri()); + } + snapShotOptions.withStyleBuilder(styleBuilder); + if (call.hasArgument("bounds")) { + FeatureCollection bounds = FeatureCollection.fromJson((String) call.argument("bounds")); + snapShotOptions.withRegion(GeoJSONUtils.toLatLngBounds(bounds)); + } else if (call.hasArgument("centerCoordinate")) { + Feature centerPoint = Feature.fromJson((String) call.argument("centerCoordinate")); + CameraPosition cameraPosition = + new CameraPosition.Builder() + .target(GeoJSONUtils.toLatLng((Point) centerPoint.geometry())) + .tilt((double) call.argument("pitch")) + .bearing((double) call.argument("heading")) + .zoom((double) call.argument("zoomLevel")) + .build(); + snapShotOptions.withCameraPosition(cameraPosition); + } else { + snapShotOptions.withRegion(nextbillionMap.getProjection().getVisibleRegion().latLngBounds); + } + + final MapSnapshotter snapshotter = new MapSnapshotter(context, snapShotOptions); + final String snapshotterID = UUID.randomUUID().toString(); + mSnapshotterMap.put(snapshotterID, snapshotter); + + snapshotter.start( + snapshot -> { + Bitmap bitmap = snapshot.getBitmap(); + + String result1; + if ((boolean) call.argument("writeToDisk")) { + result1 = BitmapUtils.createTempFile(context, bitmap); + } else { + result1 = BitmapUtils.createBase64(bitmap); + } + + if (result1 == null) { + result.error( + "NO_RESULT", + "Could not generate snapshot, please check Android logs for more info.", + null); + return; + } + + result.success(result1); + mSnapshotterMap.remove(snapshotterID); + }, + new MapSnapshotter.ErrorHandler() { + @Override + public void onError(String error) { + result.error("SNAPSHOT_ERROR", error, null); + mSnapshotterMap.remove(snapshotterID); + } + }); + break; + } + default: + result.notImplemented(); + } + } + + private boolean checkContainLayer(String layerID, List belowAt) { + boolean containsItem = false; + for (String item : belowAt) { + if (layerID.contains(item)) { + containsItem = true; + break; + } + } + return containsItem; + } + + @Override + public void onCameraMoveStarted(int reason) { + final Map arguments = new HashMap<>(2); + boolean isGesture = reason == NextbillionMap.OnCameraMoveStartedListener.REASON_API_GESTURE; + arguments.put("isGesture", isGesture); + methodChannel.invokeMethod("camera#onMoveStarted", arguments); + } + + @Override + public void onCameraMove() { + if (!trackCameraPosition) { + return; + } + final Map arguments = new HashMap<>(2); + arguments.put("position", Convert.toJson(nextbillionMap.getCameraPosition())); + methodChannel.invokeMethod("camera#onMove", arguments); + } + + @Override + public void onCameraIdle() { + final Map arguments = new HashMap<>(2); + if (trackCameraPosition) { + arguments.put("position", Convert.toJson(nextbillionMap.getCameraPosition())); + } + methodChannel.invokeMethod("camera#onIdle", arguments); + } + + @Override + public void onCameraTrackingChanged(int currentMode) { + final Map arguments = new HashMap<>(2); + arguments.put("mode", currentMode); + methodChannel.invokeMethod("map#onCameraTrackingChanged", arguments); + } + + @Override + public void onCameraTrackingDismissed() { + this.myLocationTrackingMode = 0; + methodChannel.invokeMethod("map#onCameraTrackingDismissed", new HashMap<>()); + } + + @Override + public void onDidBecomeIdle() { + methodChannel.invokeMethod("map#onIdle", new HashMap<>()); + } + + @Override + public boolean onMapClick(@NonNull LatLng point) { + PointF pointf = nextbillionMap.getProjection().toScreenLocation(point); + RectF rectF = new RectF(pointf.x - 10, pointf.y - 10, pointf.x + 10, pointf.y + 10); + Feature feature = firstFeatureOnLayers(rectF); + final Map arguments = new HashMap<>(); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("lng", point.getLongitude()); + arguments.put("lat", point.getLatitude()); + if (feature != null) { + arguments.put("id", feature.id()); + methodChannel.invokeMethod("feature#onTap", arguments); + } else { + methodChannel.invokeMethod("map#onMapClick", arguments); + } + return true; + } + + @Override + public boolean onMapLongClick(@NonNull LatLng point) { + PointF pointf = nextbillionMap.getProjection().toScreenLocation(point); + final Map arguments = new HashMap<>(5); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("lng", point.getLongitude()); + arguments.put("lat", point.getLatitude()); + methodChannel.invokeMethod("map#onMapLongClick", arguments); + return true; + } + + @Override + public void dispose() { + if (disposed) { + return; + } + disposed = true; + methodChannel.setMethodCallHandler(null); + destroyMapViewIfNecessary(); + Lifecycle lifecycle = lifecycleProvider.getLifecycle(); + if (lifecycle != null) { + lifecycle.removeObserver(this); + } + } + + private void moveCamera(CameraUpdate cameraUpdate, MethodChannel.Result result) { + if (cameraUpdate != null) { + // camera transformation not handled yet + nextbillionMap.moveCamera( + cameraUpdate, + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }); + + // moveCamera(cameraUpdate); + } else { + result.success(false); + } + } + + private void animateCamera( + CameraUpdate cameraUpdate, Integer duration, MethodChannel.Result result) { + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + if (cameraUpdate != null && duration != null) { + // camera transformation not handled yet + nextbillionMap.animateCamera(cameraUpdate, duration, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + nextbillionMap.animateCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + } + + private void destroyMapViewIfNecessary() { + if (mapView == null) { + return; + } + + if (locationComponent != null) { + locationComponent.setLocationComponentEnabled(false); + } + stopListeningForLocationUpdates(); + + mapView.onStop(); + mapView.onDestroy(); + mapView = null; + } + + @Override + public void onCreate(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onCreate(null); + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onStart(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onResume(); + if (myLocationEnabled) { + startListeningForLocationUpdates(); + } + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onPause(); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onStop(); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + owner.getLifecycle().removeObserver(this); + if (disposed) { + return; + } + destroyMapViewIfNecessary(); + } + + // nextbillionMapOptionsSink methods + + @Override + public void setCameraTargetBounds(LatLngBounds bounds) { + this.bounds = bounds; + } + + @Override + public void setCompassEnabled(boolean compassEnabled) { + nextbillionMap.getUiSettings().setCompassEnabled(compassEnabled); + } + + @Override + public void setTrackCameraPosition(boolean trackCameraPosition) { + this.trackCameraPosition = trackCameraPosition; + } + + @Override + public void setRotateGesturesEnabled(boolean rotateGesturesEnabled) { + nextbillionMap.getUiSettings().setRotateGesturesEnabled(rotateGesturesEnabled); + } + + @Override + public void setScrollGesturesEnabled(boolean scrollGesturesEnabled) { + nextbillionMap.getUiSettings().setScrollGesturesEnabled(scrollGesturesEnabled); + } + + @Override + public void setTiltGesturesEnabled(boolean tiltGesturesEnabled) { + nextbillionMap.getUiSettings().setTiltGesturesEnabled(tiltGesturesEnabled); + } + + @Override + public void setMinMaxZoomPreference(Float min, Float max) { + nextbillionMap.setMinZoomPreference(min != null ? min : 12); + nextbillionMap.setMaxZoomPreference(max != null ? max : 18); + } + + @Override + public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { + nextbillionMap.getUiSettings().setZoomGesturesEnabled(zoomGesturesEnabled); + } + + @Override + public void setMyLocationEnabled(boolean myLocationEnabled) { + if (this.myLocationEnabled == myLocationEnabled) { + return; + } + this.myLocationEnabled = myLocationEnabled; + if (nextbillionMap != null) { + updateMyLocationEnabled(); + } + } + + @Override + public void setMyLocationTrackingMode(int myLocationTrackingMode) { + if (nextbillionMap != null) { + // ensure that location is trackable + updateMyLocationEnabled(); + } + if (this.myLocationTrackingMode == myLocationTrackingMode) { + return; + } + this.myLocationTrackingMode = myLocationTrackingMode; + if (nextbillionMap != null && locationComponent != null) { + updateMyLocationTrackingMode(); + } + } + + @Override + public void setMyLocationRenderMode(int myLocationRenderMode) { + if (this.myLocationRenderMode == myLocationRenderMode) { + return; + } + this.myLocationRenderMode = myLocationRenderMode; + if (nextbillionMap != null && locationComponent != null) { + updateMyLocationRenderMode(); + } + } + + public void setLogoViewMargins(int x, int y) { + nextbillionMap.getUiSettings().setLogoMargins(x, 0, 0, y); + } + + @Override + public void setCompassGravity(int gravity) { + switch (gravity) { + case 0: + nextbillionMap.getUiSettings().setCompassGravity(Gravity.TOP | Gravity.START); + break; + default: + case 1: + nextbillionMap.getUiSettings().setCompassGravity(Gravity.TOP | Gravity.END); + break; + case 2: + nextbillionMap.getUiSettings().setCompassGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + nextbillionMap.getUiSettings().setCompassGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setCompassViewMargins(int x, int y) { + switch (nextbillionMap.getUiSettings().getCompassGravity()) { + case Gravity.TOP | Gravity.START: + nextbillionMap.getUiSettings().setCompassMargins(x, y, 0, 0); + break; + default: + case Gravity.TOP | Gravity.END: + nextbillionMap.getUiSettings().setCompassMargins(0, y, x, 0); + break; + case Gravity.BOTTOM | Gravity.START: + nextbillionMap.getUiSettings().setCompassMargins(x, 0, 0, y); + break; + case Gravity.BOTTOM | Gravity.END: + nextbillionMap.getUiSettings().setCompassMargins(0, 0, x, y); + break; + } + } + + @Override + public void setAttributionButtonGravity(int gravity) { + switch (gravity) { + case 0: + nextbillionMap.getUiSettings().setAttributionGravity(Gravity.TOP | Gravity.START); + break; + default: + case 1: + nextbillionMap.getUiSettings().setAttributionGravity(Gravity.TOP | Gravity.END); + break; + case 2: + nextbillionMap.getUiSettings().setAttributionGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + nextbillionMap.getUiSettings().setAttributionGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setAttributionButtonMargins(int x, int y) { + switch (nextbillionMap.getUiSettings().getAttributionGravity()) { + case Gravity.TOP | Gravity.START: + nextbillionMap.getUiSettings().setAttributionMargins(x, y, 0, 0); + break; + default: + case Gravity.TOP | Gravity.END: + nextbillionMap.getUiSettings().setAttributionMargins(0, y, x, 0); + break; + case Gravity.BOTTOM | Gravity.START: + nextbillionMap.getUiSettings().setAttributionMargins(x, 0, 0, y); + break; + case Gravity.BOTTOM | Gravity.END: + nextbillionMap.getUiSettings().setAttributionMargins(0, 0, x, y); + break; + } + } + + private void updateMyLocationEnabled() { + if (this.locationComponent == null && myLocationEnabled) { + enableLocationComponent(nextbillionMap.getStyle()); + } + + if (myLocationEnabled) { + startListeningForLocationUpdates(); + } else { + stopListeningForLocationUpdates(); + } + + if (locationComponent != null) { + locationComponent.setLocationComponentEnabled(myLocationEnabled); + } + } + + private void startListeningForLocationUpdates() { + if (locationEngineCallback == null + && locationComponent != null + && locationComponent.getLocationEngine() != null) { + locationEngineCallback = + new LocationEngineCallback() { + @Override + public void onSuccess(LocationEngineResult result) { + onUserLocationUpdate(result.getLastLocation()); + } + + @Override + public void onFailure(@NonNull Exception exception) {} + }; + locationComponent + .getLocationEngine() + .requestLocationUpdates( + locationComponent.getLocationEngineRequest(), locationEngineCallback, null); + } + } + + private void stopListeningForLocationUpdates() { + if (locationEngineCallback != null + && locationComponent != null + && locationComponent.getLocationEngine() != null) { + locationComponent.getLocationEngine().removeLocationUpdates(locationEngineCallback); + locationEngineCallback = null; + } + } + + private void updateMyLocationTrackingMode() { + int[] nbMapTrackingModes = + new int[] { + CameraMode.NONE, CameraMode.TRACKING, CameraMode.TRACKING_COMPASS, CameraMode.TRACKING_GPS + }; + locationComponent.setCameraMode(nbMapTrackingModes[this.myLocationTrackingMode]); + } + + private void updateMyLocationRenderMode() { + int[] nbMapRenderModes = new int[] {RenderMode.NORMAL, RenderMode.COMPASS, RenderMode.GPS}; + locationComponent.setRenderMode(nbMapRenderModes[this.myLocationRenderMode]); + } + + private boolean hasLocationPermission() { + return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + private int checkSelfPermission(String permission) { + if (permission == null) { + throw new IllegalArgumentException("permission is null"); + } + return context.checkPermission( + permission, android.os.Process.myPid(), android.os.Process.myUid()); + } + + /** + * Tries to find highest scale image for display type + * + * @param imageId + * @param density + * @return + */ + private Bitmap getScaledImage(String imageId, float density) { + AssetFileDescriptor assetFileDescriptor; + + // Split image path into parts. + List imagePathList = Arrays.asList(imageId.split("/")); + List assetPathList = new ArrayList<>(); + + // "On devices with a device pixel ratio of 1.8, the asset .../2.0x/my_icon.png would be chosen. + // For a device pixel ratio of 2.7, the asset .../3.0x/my_icon.png would be chosen." + // Source: https://flutter.dev/docs/development/ui/assets-and-images#resolution-aware + for (int i = (int) Math.ceil(density); i > 0; i--) { + String assetPath; + if (i == 1) { + // If density is 1.0x then simply take the default asset path + assetPath = NbMapsPlugin.flutterAssets.getAssetFilePathByName(imageId); + } else { + // Build a resolution aware asset path as follows: + // // + // where ratio is 1.0x, 2.0x or 3.0x. + StringBuilder stringBuilder = new StringBuilder(); + for (int j = 0; j < imagePathList.size() - 1; j++) { + stringBuilder.append(imagePathList.get(j)); + stringBuilder.append("/"); + } + stringBuilder.append(((float) i) + "x"); + stringBuilder.append("/"); + stringBuilder.append(imagePathList.get(imagePathList.size() - 1)); + assetPath = NbMapsPlugin.flutterAssets.getAssetFilePathByName(stringBuilder.toString()); + } + // Build up a list of resolution aware asset paths. + assetPathList.add(assetPath); + } + + // Iterate over asset paths and get the highest scaled asset (as a bitmap). + Bitmap bitmap = null; + for (String assetPath : assetPathList) { + try { + // Read path (throws exception if doesn't exist). + assetFileDescriptor = mapView.getContext().getAssets().openFd(assetPath); + InputStream assetStream = assetFileDescriptor.createInputStream(); + bitmap = BitmapFactory.decodeStream(assetStream); + assetFileDescriptor.close(); // Close for memory + break; // If exists, break + } catch (IOException e) { + // Skip + } + } + return bitmap; + } + + boolean onMoveBegin(MoveGestureDetector detector) { + // onMoveBegin gets called even during a move - move end is also not called unless this function + // returns + // true at least once. To avoid redundant queries only check for feature if the previous event + // was ACTION_DOWN + if (detector.getPreviousEvent().getActionMasked() == MotionEvent.ACTION_DOWN + && detector.getPointersCount() == 1) { + PointF pointf = detector.getFocalPoint(); + LatLng origin = nextbillionMap.getProjection().fromScreenLocation(pointf); + RectF rectF = new RectF(pointf.x - 10, pointf.y - 10, pointf.x + 10, pointf.y + 10); + Feature feature = firstFeatureOnLayers(rectF); + if (feature != null && startDragging(feature, origin)) { + invokeFeatureDrag(pointf, "start"); + return true; + } + } + return false; + } + + private void invokeFeatureDrag(PointF pointf, String eventType) { + LatLng current = nextbillionMap.getProjection().fromScreenLocation(pointf); + + final Map arguments = new HashMap<>(9); + arguments.put("id", draggedFeature.id()); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("originLng", dragOrigin.getLongitude()); + arguments.put("originLat", dragOrigin.getLatitude()); + arguments.put("currentLng", current.getLongitude()); + arguments.put("currentLat", current.getLatitude()); + arguments.put("eventType", eventType); + arguments.put("deltaLng", current.getLongitude() - dragPrevious.getLongitude()); + arguments.put("deltaLat", current.getLatitude() - dragPrevious.getLatitude()); + dragPrevious = current; + methodChannel.invokeMethod("feature#onDrag", arguments); + } + + boolean onMove(MoveGestureDetector detector) { + if (draggedFeature != null) { + if (detector.getPointersCount() > 1) { + stopDragging(); + return true; + } + PointF pointf = detector.getFocalPoint(); + invokeFeatureDrag(pointf, "drag"); + return false; + } + return true; + } + + void removeLayer(String layerId) { + if (style != null && layerId != null) { + style.removeLayer(layerId); + interactiveFeatureLayerIds.remove(layerId); + } + } + + void onMoveEnd(MoveGestureDetector detector) { + PointF pointf = detector.getFocalPoint(); + invokeFeatureDrag(pointf, "end"); + stopDragging(); + } + + boolean startDragging(@NonNull Feature feature, @NonNull LatLng origin) { + final boolean draggable = + feature.hasNonNullValueForProperty("draggable") + ? feature.getBooleanProperty("draggable") + : false; + if (draggable) { + draggedFeature = feature; + dragPrevious = origin; + dragOrigin = origin; + return true; + } + return false; + } + + void stopDragging() { + draggedFeature = null; + dragOrigin = null; + dragPrevious = null; + } + + /** Simple Listener to listen for the status of camera movements. */ + public class OnCameraMoveFinishedListener implements NextbillionMap.CancelableCallback { + @Override + public void onFinish() {} + + @Override + public void onCancel() {} + } + + private class MoveGestureListener implements MoveGestureDetector.OnMoveGestureListener { + + @Override + public boolean onMoveBegin(MoveGestureDetector detector) { + return NextbillionMapController.this.onMoveBegin(detector); + } + + @Override + public boolean onMove(MoveGestureDetector detector, float distanceX, float distanceY) { + return NextbillionMapController.this.onMove(detector); + } + + @Override + public void onMoveEnd(MoveGestureDetector detector, float velocityX, float velocityY) { + NextbillionMapController.this.onMoveEnd(detector); + } + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/NextbillionMapOptionsSink.java b/android/src/main/java/ai/nextbillion/maps_flutter/NextbillionMapOptionsSink.java new file mode 100644 index 0000000..8a7c17a --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/NextbillionMapOptionsSink.java @@ -0,0 +1,44 @@ + +package ai.nextbillion.maps_flutter; + + +import ai.nextbillion.maps.geometry.LatLngBounds; + +/** Receiver of NBMap configuration options. */ +interface NextbillionMapOptionsSink { + void setCameraTargetBounds( + LatLngBounds bounds); // todo: dddd replace with CameraPosition.Builder target + + void setCompassEnabled(boolean compassEnabled); + + // TODO: styleString is not actually a part of options. consider moving + void setStyleString(String styleString); + + void setMinMaxZoomPreference(Float min, Float max); + + void setRotateGesturesEnabled(boolean rotateGesturesEnabled); + + void setScrollGesturesEnabled(boolean scrollGesturesEnabled); + + void setTiltGesturesEnabled(boolean tiltGesturesEnabled); + + void setTrackCameraPosition(boolean trackCameraPosition); + + void setZoomGesturesEnabled(boolean zoomGesturesEnabled); + + void setMyLocationEnabled(boolean myLocationEnabled); + + void setMyLocationTrackingMode(int myLocationTrackingMode); + + void setMyLocationRenderMode(int myLocationRenderMode); + + void setLogoViewMargins(int x, int y); + + void setCompassGravity(int gravity); + + void setCompassViewMargins(int x, int y); + + void setAttributionButtonGravity(int gravity); + + void setAttributionButtonMargins(int x, int y); +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/OfflineChannelHandlerImpl.java b/android/src/main/java/ai/nextbillion/maps_flutter/OfflineChannelHandlerImpl.java new file mode 100644 index 0000000..2bfbbb0 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/OfflineChannelHandlerImpl.java @@ -0,0 +1,55 @@ +package ai.nextbillion.maps_flutter; + +import androidx.annotation.Nullable; +import com.google.gson.Gson; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import java.util.HashMap; +import java.util.Map; + +public class OfflineChannelHandlerImpl implements EventChannel.StreamHandler { + private EventChannel.EventSink sink; + private Gson gson = new Gson(); + + OfflineChannelHandlerImpl(BinaryMessenger messenger, String channelName) { + EventChannel eventChannel = new EventChannel(messenger, channelName); + eventChannel.setStreamHandler(this); + } + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + sink = events; + } + + @Override + public void onCancel(Object arguments) { + sink = null; + } + + void onError(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { + if (sink == null) return; + sink.error(errorCode, errorMessage, errorDetails); + } + + void onSuccess() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "success"); + sink.success(gson.toJson(body)); + } + + void onStart() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "start"); + sink.success(gson.toJson(body)); + } + + void onProgress(double progress) { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "progress"); + body.put("progress", progress); + sink.success(gson.toJson(body)); + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/OfflineManagerUtils.java b/android/src/main/java/ai/nextbillion/maps_flutter/OfflineManagerUtils.java new file mode 100644 index 0000000..f8fe901 --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/OfflineManagerUtils.java @@ -0,0 +1,339 @@ +package ai.nextbillion.maps_flutter; + + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import ai.nextbillion.maps.geometry.LatLng; +import ai.nextbillion.maps.geometry.LatLngBounds; +import ai.nextbillion.maps.offline.OfflineManager; +import ai.nextbillion.maps.offline.OfflineRegion; +import ai.nextbillion.maps.offline.OfflineRegionDefinition; +import ai.nextbillion.maps.offline.OfflineRegionError; +import ai.nextbillion.maps.offline.OfflineRegionStatus; +import ai.nextbillion.maps.offline.OfflineTilePyramidRegionDefinition; +import io.flutter.plugin.common.MethodChannel; + +abstract class OfflineManagerUtils { + private static final String TAG = "OfflineManagerUtils"; + + static void mergeRegions(MethodChannel.Result result, Context context, String path) { + OfflineManager.getInstance(context) + .mergeOfflineRegions( + path, + new OfflineManager.MergeOfflineRegionsCallback() { + public void onMerge(OfflineRegion[] offlineRegions) { + if (result == null) return; + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + String json = new Gson().toJson(regionsArgs); + result.success(json); + } + + public void onError(String error) { + if (result == null) return; + result.error("mergeOfflineRegions Error", error, null); + } + }); + } + + static void setOfflineTileCountLimit(MethodChannel.Result result, Context context, long limit) { + OfflineManager.getInstance(context).setOfflineNbmapTileCountLimit(limit); + result.success(null); + } + + static void downloadRegion( + MethodChannel.Result result, + Context context, + Map definitionMap, + Map metadataMap, + OfflineChannelHandlerImpl channelHandler) { + float pixelDensity = context.getResources().getDisplayMetrics().density; + OfflineRegionDefinition definition = mapToRegionDefinition(definitionMap, pixelDensity); + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + AtomicBoolean isComplete = new AtomicBoolean(false); + // Download region + OfflineManager.getInstance(context) + .createOfflineRegion( + definition, + metadata.getBytes(), + new OfflineManager.CreateOfflineRegionCallback() { + private OfflineRegion _offlineRegion; + + @Override + public void onCreate(OfflineRegion offlineRegion) { + Map regionData = offlineRegionToMap(offlineRegion); + result.success(new Gson().toJson(regionData)); + + _offlineRegion = offlineRegion; + // Start downloading region + _offlineRegion.setDownloadState(OfflineRegion.STATE_ACTIVE); + channelHandler.onStart(); + // Observe downloading state + OfflineRegion.OfflineRegionObserver observer = + new OfflineRegion.OfflineRegionObserver() { + @Override + public void onStatusChanged(OfflineRegionStatus status) { + // Calculate progress of + // downloading + double progress = + calculateDownloadingProgress( + status.getRequiredResourceCount(), + status.getCompletedResourceCount()); + // Check if downloading is + // complete + if (status.isComplete()) { + Log.i(TAG, "Region " + "downloaded " + "successfully."); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + // This can be called + // multiple times, and + // result can be called + // only once, + // so there is need to + // prevent it + if (isComplete.get()) return; + isComplete.set(true); + channelHandler.onSuccess(); + } else { + Log.i(TAG, "Region " + "download " + "progress = " + progress); + channelHandler.onProgress(progress); + } + } + + @Override + public void onError(OfflineRegionError error) { + Log.e(TAG, "onError reason: " + error.getReason()); + Log.e(TAG, "onError message: " + error.getMessage()); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "Downloading error", error.getMessage(), error.getReason()); + } + + @Override + public void nbmapTileCountLimitExceeded(long limit) { + Log.e(TAG, "NBMap tile count" + " limit exceeded: " + limit); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "nbMapTileCountLimitExceeded", + "nbMap tile count " + "limit " + "exceeded: " + limit, + null); + // Nbmaps even after crash + // and not downloading fully + // region still keeps part + // of it in database, so we + // have to remove it + deleteRegion(null, context, _offlineRegion.getID()); + } + }; + _offlineRegion.setObserver(observer); + } + + /** + * This will be call if given region definition is invalid + * + * @param error + */ + @Override + public void onError(String error) { + Log.e(TAG, "Error: " + error); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + channelHandler.onError("nbMapInvalidRegionDefinition", error, null); + result.error("nbMapInvalidRegionDefinition", error, null); + } + }); + } + + static void regionsList(MethodChannel.Result result, Context context) { + OfflineManager.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + result.success(new Gson().toJson(regionsArgs)); + } + + @Override + public void onError(String error) { + result.error("RegionListError", error, null); + } + }); + } + + static void updateRegionMetadata( + MethodChannel.Result result, Context context, long id, Map metadataMap) { + OfflineManager.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getID() != id) continue; + + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + offlineRegion.updateMetadata( + metadata.getBytes(), + new OfflineRegion.OfflineRegionUpdateMetadataCallback() { + @Override + public void onUpdate(byte[] metadataBytes) { + Map regionData = offlineRegionToMap(offlineRegion); + regionData.put("metadata", metadataBytesToMap(metadataBytes)); + + if (result == null) return; + result.success(new Gson().toJson(regionData)); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("UpdateMetadataError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "UpdateMetadataError", + "There is no " + "region with given id to " + "update.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + static void deleteRegion(MethodChannel.Result result, Context context, long id) { + OfflineManager.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getID() != id) continue; + + offlineRegion.delete( + new OfflineRegion.OfflineRegionDeleteCallback() { + @Override + public void onDelete() { + if (result == null) return; + result.success(null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("DeleteRegionError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "DeleteRegionError", + "There is no " + "region with given id to " + "delete.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + private static double calculateDownloadingProgress( + long requiredResourceCount, long completedResourceCount) { + return requiredResourceCount > 0 + ? (100.0 * completedResourceCount / requiredResourceCount) + : 0.0; + } + + private static OfflineRegionDefinition mapToRegionDefinition( + Map map, float pixelDensity) { + for (Map.Entry entry : map.entrySet()) { + Log.d(TAG, entry.getKey()); + Log.d(TAG, entry.getValue().toString()); + } + // Create a bounding box for the offline region + return new OfflineTilePyramidRegionDefinition( + (String) map.get("mapStyleUrl"), + listToBounds((List>) map.get("bounds")), + ((Number) map.get("minZoom")).doubleValue(), + ((Number) map.get("maxZoom")).doubleValue(), + pixelDensity, + (Boolean) map.get("includeIdeographs")); + } + + private static LatLngBounds listToBounds(List> bounds) { + return new LatLngBounds.Builder() + .include(new LatLng(bounds.get(1).get(0), bounds.get(1).get(1))) // Northeast + .include(new LatLng(bounds.get(0).get(0), bounds.get(0).get(1))) // Southwest + .build(); + } + + private static Map offlineRegionToMap(OfflineRegion region) { + Map result = new HashMap(); + result.put("id", region.getID()); + result.put("definition", offlineRegionDefinitionToMap(region.getDefinition())); + result.put("metadata", metadataBytesToMap(region.getMetadata())); + return result; + } + + private static Map offlineRegionDefinitionToMap( + OfflineRegionDefinition definition) { + Map result = new HashMap(); + result.put("mapStyleUrl", definition.getStyleURL()); + result.put("bounds", boundsToList(definition.getBounds())); + result.put("minZoom", definition.getMinZoom()); + result.put("maxZoom", definition.getMaxZoom()); + result.put("includeIdeographs", definition.getIncludeIdeographs()); + return result; + } + + private static List> boundsToList(LatLngBounds bounds) { + List> boundsList = new ArrayList<>(); + List northeast = Arrays.asList(bounds.getLatNorth(), bounds.getLonEast()); + List southwest = Arrays.asList(bounds.getLatSouth(), bounds.getLonWest()); + boundsList.add(southwest); + boundsList.add(northeast); + return boundsList; + } + + private static Map metadataBytesToMap(byte[] metadataBytes) { + if (metadataBytes != null) { + return new Gson().fromJson(new String(metadataBytes), HashMap.class); + } + return new HashMap(); + } +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/OnCameraMoveListener.java b/android/src/main/java/ai/nextbillion/maps_flutter/OnCameraMoveListener.java new file mode 100644 index 0000000..c53d32f --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/OnCameraMoveListener.java @@ -0,0 +1,13 @@ + +package ai.nextbillion.maps_flutter; + + +import ai.nextbillion.maps.camera.CameraPosition; + +interface OnCameraMoveListener { + void onCameraMoveStarted(boolean isGesture); + + void onCameraMove(CameraPosition newPosition); + + void onCameraIdle(); +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/OnInfoWindowTappedListener.java b/android/src/main/java/ai/nextbillion/maps_flutter/OnInfoWindowTappedListener.java new file mode 100644 index 0000000..1b24a8f --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/OnInfoWindowTappedListener.java @@ -0,0 +1,9 @@ + +package ai.nextbillion.maps_flutter; + + +import ai.nextbillion.maps.annotations.Marker; + +public interface OnInfoWindowTappedListener { + void onInfoWindowTapped(Marker marker); +} diff --git a/android/src/main/java/ai/nextbillion/maps_flutter/SourcePropertyConverter.java b/android/src/main/java/ai/nextbillion/maps_flutter/SourcePropertyConverter.java new file mode 100644 index 0000000..50acdbe --- /dev/null +++ b/android/src/main/java/ai/nextbillion/maps_flutter/SourcePropertyConverter.java @@ -0,0 +1,223 @@ +package ai.nextbillion.maps_flutter; + +import android.net.Uri; + +import com.google.gson.Gson; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import ai.nextbillion.kits.geojson.FeatureCollection; +import ai.nextbillion.maps.core.Style; +import ai.nextbillion.maps.geometry.LatLng; +import ai.nextbillion.maps.geometry.LatLngQuad; +import ai.nextbillion.maps.style.sources.GeoJsonOptions; +import ai.nextbillion.maps.style.sources.GeoJsonSource; +import ai.nextbillion.maps.style.sources.ImageSource; +import ai.nextbillion.maps.style.sources.RasterDemSource; +import ai.nextbillion.maps.style.sources.RasterSource; +import ai.nextbillion.maps.style.sources.Source; +import ai.nextbillion.maps.style.sources.TileSet; +import ai.nextbillion.maps.style.sources.VectorSource; + +class SourcePropertyConverter { + private static final String TAG = "SourcePropertyConverter"; + + static TileSet buildTileset(Map data) { + final Object tiles = data.get("tiles"); + + // options are only valid with tiles + if (tiles == null) { + return null; + } + + final TileSet tileSet = + new TileSet("2.1.0", (String[]) Convert.toList(tiles).toArray(new String[0])); + + final Object bounds = data.get("bounds"); + if (bounds != null) { + List boundsFloat = new ArrayList(); + for (Object item : Convert.toList(bounds)) { + boundsFloat.add(Convert.toFloat(item)); + } + tileSet.setBounds(boundsFloat.toArray(new Float[0])); + } + + final Object scheme = data.get("scheme"); + if (scheme != null) { + tileSet.setScheme(Convert.toString(scheme)); + } + + final Object minzoom = data.get("minzoom"); + if (minzoom != null) { + tileSet.setMinZoom(Convert.toFloat(minzoom)); + } + + final Object maxzoom = data.get("maxzoom"); + if (maxzoom != null) { + tileSet.setMaxZoom(Convert.toFloat(maxzoom)); + } + + final Object attribution = data.get("attribution"); + if (attribution != null) { + tileSet.setAttribution(Convert.toString(attribution)); + } + return tileSet; + } + + static GeoJsonOptions buildGeojsonOptions(Map data) { + GeoJsonOptions options = new GeoJsonOptions(); + + final Object buffer = data.get("buffer"); + if (buffer != null) { + options = options.withBuffer(Convert.toInt(buffer)); + } + + final Object cluster = data.get("cluster"); + if (cluster != null) { + options = options.withCluster(Convert.toBoolean(cluster)); + } + + final Object clusterMaxZoom = data.get("clusterMaxZoom"); + if (clusterMaxZoom != null) { + options = options.withClusterMaxZoom(Convert.toInt(clusterMaxZoom)); + } + + final Object clusterRadius = data.get("clusterRadius"); + if (clusterRadius != null) { + options = options.withClusterRadius(Convert.toInt(clusterRadius)); + } + + final Object lineMetrics = data.get("lineMetrics"); + if (lineMetrics != null) { + options = options.withLineMetrics(Convert.toBoolean(lineMetrics)); + } + + final Object maxZoom = data.get("maxZoom"); + if (maxZoom != null) { + options = options.withMaxZoom(Convert.toInt(maxZoom)); + } + + final Object minZoom = data.get("minZoom"); + if (minZoom != null) { + options = options.withMinZoom(Convert.toInt(minZoom)); + } + + final Object tolerance = data.get("tolerance"); + if (tolerance != null) { + options = options.withTolerance(Convert.toFloat(tolerance)); + } + return options; + } + + static GeoJsonSource buildGeojsonSource(String id, Map properties) { + final Object data = properties.get("data"); + final GeoJsonOptions options = buildGeojsonOptions(properties); + if (data != null) { + if (data instanceof String) { + try { + final URI uri = new URI(Convert.toString(data)); + return new GeoJsonSource(id, uri, options); + } catch (URISyntaxException e) { + } + } else { + Gson gson = new Gson(); + String geojson = gson.toJson(data); + final FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + return new GeoJsonSource(id, featureCollection, options); + } + } + return null; + } + + static ImageSource buildImageSource(String id, Map properties) { + final Object url = properties.get("url"); + List coordinates = Convert.toLatLngList(properties.get("coordinates"), true); + final LatLngQuad quad = + new LatLngQuad( + coordinates.get(0), coordinates.get(1), coordinates.get(2), coordinates.get(3)); + try { + final URI uri = new URI(Convert.toString(url)); + return new ImageSource(id, quad, uri); + } catch (URISyntaxException e) { + } + return null; + } + + static VectorSource buildVectorSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + final Uri uri = Uri.parse(Convert.toString(url)); + + if (uri != null) { + return new VectorSource(id, uri); + } + return null; + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new VectorSource(id, tileSet) : null; + } + + static RasterSource buildRasterSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + try { + final URI uri = new URI(Convert.toString(url)); + return new RasterSource(id, uri); + } catch (URISyntaxException e) { + } + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new RasterSource(id, tileSet) : null; + } + + static RasterDemSource buildRasterDemSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + try { + final URI uri = new URI(Convert.toString(url)); + return new RasterDemSource(id, uri); + } catch (URISyntaxException e) { + } + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new RasterDemSource(id, tileSet) : null; + } + + static void addSource(String id, Map properties, Style style) { + final Object type = properties.get("type"); + Source source = null; + + if (type != null) { + switch (Convert.toString(type)) { + case "vector": + source = buildVectorSource(id, properties); + break; + case "raster": + source = buildRasterSource(id, properties); + break; + case "raster-dem": + source = buildRasterDemSource(id, properties); + break; + case "image": + source = buildImageSource(id, properties); + break; + case "geojson": + source = buildGeojsonSource(id, properties); + break; + default: + // unsupported source type + } + } + + if (source != null) { + style.addSource(source); + } + } +} diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..92b28e8 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,72 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +.fvm/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..3b7a05a --- /dev/null +++ b/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 2e2f19d3e9dd9bbc4e97e6b79bd573ab67c65109 + channel: master + +project_type: app diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..3bebd61 --- /dev/null +++ b/example/README.md @@ -0,0 +1,16 @@ +# nb_maps_flutter_example + +Demonstrates how to use the nb_maps_flutter plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.io/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.io/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.io/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..ee44ebd --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,11 @@ +*.iml +*.class +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +GeneratedPluginRegistrant.java +app/src/main/res/values/developer-config.xml \ No newline at end of file diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..e08be9e --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,73 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + namespace 'ai.nextbillion.mapsglexample' + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "ai.nextbillion.example.maps_flutter" + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + + // To fix UnsatisfiedLinkError + ndk { + abiFilters 'armeabi-v7a','arm64-v8a','x86_64', 'x86' + } + } + } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } +} + +flutter { + source '../..' +} + +dependencies { + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.1.0-alpha4' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0-alpha4' +} diff --git a/example/android/app/proguard-rules.pro b/example/android/app/proguard-rules.pro new file mode 100644 index 0000000..3c10c03 --- /dev/null +++ b/example/android/app/proguard-rules.pro @@ -0,0 +1,22 @@ +# Nbmap ProGuard configuration is handled in the SDK, +# This file contains test app specific configuration + +# Kotlin +-dontnote kotlin.** + +# LeakCanary +-dontnote com.squareup.leakcanary.internal.** +-dontnote gnu.trove.THashMap +# Please add these rules to your existing keep rules in order to suppress warnings. +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +# GMS +-dontnote com.google.android.gms.** \ No newline at end of file diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ccf1ff6 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/java/ai/nextbillion/mapsglexample/MainActivity.java b/example/android/app/src/main/java/ai/nextbillion/mapsglexample/MainActivity.java new file mode 100644 index 0000000..c3b66a0 --- /dev/null +++ b/example/android/app/src/main/java/ai/nextbillion/mapsglexample/MainActivity.java @@ -0,0 +1,5 @@ +package ai.nextbillion.mapsglexample; + +import io.flutter.embedding.android.FlutterActivity; + +public class MainActivity extends FlutterActivity {} diff --git a/example/android/app/src/main/kotlin/ai/nextbillion/example/MainActivity.kt b/example/android/app/src/main/kotlin/ai/nextbillion/example/MainActivity.kt new file mode 100644 index 0000000..a1d3fd2 --- /dev/null +++ b/example/android/app/src/main/kotlin/ai/nextbillion/example/MainActivity.kt @@ -0,0 +1,12 @@ +package ai.nextbillion.example + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugins.GeneratedPluginRegistrant + +class MainActivity: FlutterActivity() { + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + GeneratedPluginRegistrant.registerWith(flutterEngine); + } +} diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..a63ab46 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..f880684 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..bc157bd --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..1d24159 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,7 @@ +android.enableJetifier=true +android.useAndroidX=true +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..91bf32e --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip \ No newline at end of file diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..73edba6 --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version '8.1.0' apply false + id "org.jetbrains.kotlin.android" version "1.7.20" apply false +} + +include ":app" \ No newline at end of file diff --git a/example/android/settings_aar.gradle b/example/android/settings_aar.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/example/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/example/assets/fill-extrusion/indoor_3d_map.json b/example/assets/fill-extrusion/indoor_3d_map.json new file mode 100644 index 0000000..ac81fd8 --- /dev/null +++ b/example/assets/fill-extrusion/indoor_3d_map.json @@ -0,0 +1,685 @@ +{ + "features": [ + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Bird Exhibit", + "height": 0, + "base_height": 0, + "color": "orange" + }, + "geometry": { + "coordinates": [ + [ + [-87.618312, 41.866282], + [-87.61832, 41.866674], + [-87.618087, 41.866676], + [-87.618087, 41.866661], + [-87.617423, 41.86667], + [-87.617416, 41.8663], + [-87.618312, 41.866282] + ] + ], + "type": "Polygon" + }, + "id": "06e8fa0b3f851e3ae0e1da5fc17e111e" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Bird Exhibit", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.617808, 41.866266], + [-87.617806, 41.866293], + [-87.617415, 41.866298], + [-87.617424, 41.866671], + [-87.617382, 41.866669], + [-87.617371, 41.866274], + [-87.617808, 41.866266] + ] + ], + "type": "Polygon" + }, + "id": "08a10ab2bf15c4d14669b588062f7f08" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "East Hallway", + "base_height": 0, + "height": 0, + "color": "indigo" + }, + "geometry": { + "coordinates": [ + [ + [-87.616704, 41.866141], + [-87.616707, 41.866338], + [-87.61572, 41.866355], + [-87.61572, 41.866148], + [-87.616704, 41.866141] + ] + ], + "type": "Polygon" + }, + "id": "09ead44537d94ece576c7bc001c33e53" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "East Entrance", + "base_height": 0, + "height": 0, + "color": "violet" + }, + "geometry": { + "coordinates": [ + [ + [-87.61544, 41.866149], + [-87.615449, 41.866358], + [-87.615721, 41.866355], + [-87.61572, 41.866143], + [-87.61544, 41.866149] + ] + ], + "type": "Polygon" + }, + "id": "12251ebf764b36cf3b8c5ad42e2deb29" + }, + { + "type": "Feature", + "properties": { + "height": 0, + "base_height": 0, + "level": 1, + "name": "Under the Earth", + "color": "red" + }, + "geometry": { + "coordinates": [ + [ + [-87.616701, 41.865816], + [-87.616705, 41.866115], + [-87.615712, 41.866125], + [-87.615706, 41.865802], + [-87.615936, 41.865801], + [-87.615938, 41.865824], + [-87.616701, 41.865816] + ] + ], + "type": "Polygon" + }, + "id": "1ce4f8c186a40b9927006644e27bfd69" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Atrium", + "base_height": 0, + "height": 0, + "color": "yellow" + }, + "geometry": { + "coordinates": [ + [ + [-87.617365, 41.865799], + [-87.6167, 41.865815], + [-87.616718, 41.866672], + [-87.617384, 41.86667], + [-87.617365, 41.865799] + ] + ], + "type": "Polygon" + }, + "id": "369f5d8865e677120b7576b1de6082eb" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Ancient Egypt", + "height": 0, + "base_height": 0, + "color": "blue" + }, + "geometry": { + "coordinates": [ + [ + [-87.61807, 41.865761], + [-87.618299, 41.865758], + [-87.618307, 41.866139], + [-87.617407, 41.86615], + [-87.617399, 41.865799], + [-87.61807, 41.865789], + [-87.61807, 41.865761] + ] + ], + "type": "Polygon" + }, + "id": "3e9f198afe6d7dff01ac81c6eaa511fb" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "West Arch", + "height": 40, + "base_height": 30, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.617424, 41.86667], + [-87.617384, 41.86667], + [-87.617365, 41.865799], + [-87.617405, 41.865799], + [-87.617424, 41.86667] + ] + ], + "type": "Polygon" + }, + "id": "402706f28b793d27c78d9615fff747f2" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Bird Exhibit", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.618312, 41.866283], + [-87.61797, 41.866288], + [-87.617972, 41.866265], + [-87.618312, 41.866259], + [-87.618312, 41.866283] + ] + ], + "type": "Polygon" + }, + "id": "43e1e2fc8399dee075ad59764f2a2878" + }, + { + "type": "Feature", + "properties": { + "name": "Arch", + "level": 1, + "color": "grey", + "base_height": 30, + "height": 40 + }, + "geometry": { + "coordinates": [ + [ + [-87.617971, 41.866291], + [-87.617973, 41.866265], + [-87.617805, 41.866267], + [-87.617806, 41.866294], + [-87.617971, 41.866291] + ] + ], + "type": "Polygon" + }, + "id": "4528ad9b9264cbec65bb2e55ac0012c1" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "color": "grey", + "base_height": 30, + "height": 40, + "name": "Arch" + }, + "geometry": { + "coordinates": [ + [ + [-87.617979, 41.866167], + [-87.617797, 41.866168], + [-87.617799, 41.866145], + [-87.617976, 41.866144], + [-87.617979, 41.866167] + ] + ], + "type": "Polygon" + }, + "id": "4be6817c3b595042c8d971eebd0c4fd3" + }, + { + "type": "Feature", + "properties": { + "name": "Kids Zone", + "level": 1, + "base_height": 0, + "height": 0, + "color": "green" + }, + "geometry": { + "coordinates": [ + [ + [-87.616718, 41.866675], + [-87.616709, 41.866371], + [-87.615725, 41.866381], + [-87.615732, 41.866711], + [-87.61596, 41.866711], + [-87.61596, 41.866688], + [-87.616718, 41.866675] + ] + ], + "type": "Polygon" + }, + "id": "5775eba03674ea1cb3550ffb38321432" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "color": "grey", + "name": "Arch", + "height": 40, + "base_height": 30 + }, + "geometry": { + "coordinates": [ + [ + [-87.616286, 41.866119], + [-87.616286, 41.866144], + [-87.616089, 41.866149], + [-87.616086, 41.86612], + [-87.616286, 41.866119] + ] + ], + "type": "Polygon" + }, + "id": "598832b1512dc9facc855c5367251531" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Arch", + "color": "grey", + "height": 40, + "base_height": 30 + }, + "geometry": { + "coordinates": [ + [ + [-87.616287, 41.866343], + [-87.616288, 41.866374], + [-87.616076, 41.866378], + [-87.616077, 41.866347], + [-87.616287, 41.866343] + ] + ], + "type": "Polygon" + }, + "id": "59ed13d4411ff5f88714d9af539788d2" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "color": "grey", + "name": "center_arch", + "base_height": 30, + "height": 40 + }, + "geometry": { + "coordinates": [ + [ + [-87.61737, 41.866198], + [-87.617372, 41.86624], + [-87.616708, 41.866243], + [-87.616708, 41.866203], + [-87.61737, 41.866198] + ] + ], + "type": "Polygon" + }, + "id": "67f8952a18dfe21ee0744a3e86bc41d8" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Kids Zone", + "height": 0, + "base_height": 40, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.615719, 41.866354], + [-87.615718, 41.866381], + [-87.616077, 41.866378], + [-87.616077, 41.866347], + [-87.615719, 41.866354] + ] + ], + "type": "Polygon" + }, + "id": "6bb2c92386bcf4678113d6b3e400ae3b" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Under the Earth", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.616089, 41.866149], + [-87.616089, 41.866119], + [-87.615711, 41.866124], + [-87.615713, 41.866147], + [-87.616089, 41.866149] + ] + ], + "type": "Polygon" + }, + "id": "844c87987089df6b9db3923f6a00e4d6" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Under the Earth", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.616707, 41.866115], + [-87.616286, 41.866119], + [-87.616285, 41.866144], + [-87.616705, 41.866141], + [-87.616707, 41.866115] + ] + ], + "type": "Polygon" + }, + "id": "85547a1ecdbd2ca1fdc6324aea3c6b70" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "North Entrance", + "height": 0, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.617386, 41.86667], + [-87.617388, 41.866786], + [-87.617228, 41.866786], + [-87.617226, 41.866848], + [-87.616867, 41.866849], + [-87.616868, 41.866791], + [-87.616718, 41.866791], + [-87.616718, 41.866672], + [-87.617386, 41.86667] + ] + ], + "type": "Polygon" + }, + "id": "91ab1ee01729c33568c7b57eb258e699" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Ancient Egypt", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.617394, 41.865799], + [-87.617405, 41.86615], + [-87.617802, 41.866147], + [-87.6178, 41.866167], + [-87.617369, 41.866172], + [-87.617364, 41.865799], + [-87.617394, 41.865799] + ] + ], + "type": "Polygon" + }, + "id": "943171d55d207024791e15294def7e8f" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Ancient Egypt", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.617976, 41.866166], + [-87.617976, 41.866143], + [-87.618309, 41.866139], + [-87.618309, 41.866161], + [-87.617976, 41.866166] + ] + ], + "type": "Polygon" + }, + "id": "a37230898905988cab9b72927dc99258" + }, + { + "type": "Feature", + "properties": { + "name": "West Hallway", + "level": 1, + "base_height": 0, + "height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.618309, 41.866161], + [-87.618312, 41.86626], + [-87.61737, 41.866272], + [-87.61737, 41.86617], + [-87.618309, 41.866161] + ] + ], + "type": "Polygon" + }, + "id": "c7cc79da8711d746985f23a9b22c1d5e" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "Kids Zone", + "height": 40, + "base_height": 0, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.616286, 41.866343], + [-87.616286, 41.866374], + [-87.61671, 41.866371], + [-87.616708, 41.866338], + [-87.616286, 41.866343] + ] + ], + "type": "Polygon" + }, + "id": "cfbf2aee6a73a45c12e7fc7432d6009e" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "South Entrance", + "height": 0, + "base_height": 0, + "color": "teal" + }, + "geometry": { + "coordinates": [ + [ + [-87.617359, 41.865801], + [-87.617355, 41.865674], + [-87.617221, 41.865677], + [-87.617219, 41.86559], + [-87.616824, 41.865595], + [-87.616826, 41.86568], + [-87.616695, 41.865683], + [-87.6167, 41.865813], + [-87.617359, 41.865801] + ] + ], + "type": "Polygon" + }, + "id": "d71ab89467076ad023161c37f4ff0d5f" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "East Arch", + "height": 40, + "base_height": 30, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.616688, 41.866675], + [-87.616716, 41.866675], + [-87.616699, 41.865814], + [-87.616665, 41.865814], + [-87.616688, 41.866675] + ] + ], + "type": "Polygon" + }, + "id": "dd2baec57e4079eb65dcae5be495da62" + }, + { + "type": "Feature", + "properties": { + "level": 1, + "name": "outer-walls", + "base_height": 0, + "height": 40, + "color": "grey" + }, + "geometry": { + "coordinates": [ + [ + [-87.618087, 41.86666], + [-87.618087, 41.866674], + [-87.618319, 41.866674], + [-87.618298, 41.865757], + [-87.618326, 41.865756], + [-87.618347, 41.866693], + [-87.618068, 41.866696], + [-87.618067, 41.866675], + [-87.61741, 41.866684], + [-87.617416, 41.866802], + [-87.617247, 41.866803], + [-87.617246, 41.866866], + [-87.616846, 41.866868], + [-87.616848, 41.866807], + [-87.61669, 41.866811], + [-87.616693, 41.866693], + [-87.615992, 41.866701], + [-87.615992, 41.866729], + [-87.615703, 41.866733], + [-87.615689, 41.866377], + [-87.615412, 41.866382], + [-87.615411, 41.866122], + [-87.615689, 41.866119], + [-87.615682, 41.865781], + [-87.615966, 41.865779], + [-87.615969, 41.865798], + [-87.616669, 41.865794], + [-87.616663, 41.865662], + [-87.6168, 41.865661], + [-87.616796, 41.865571], + [-87.61726, 41.865559], + [-87.617258, 41.865654], + [-87.617388, 41.865652], + [-87.617395, 41.865778], + [-87.618045, 41.865773], + [-87.618044, 41.865742], + [-87.618325, 41.865739], + [-87.618326, 41.865758], + [-87.61807, 41.865761], + [-87.61807, 41.865789], + [-87.617356, 41.8658], + [-87.617356, 41.865672], + [-87.617218, 41.865677], + [-87.617215, 41.86559], + [-87.616822, 41.865595], + [-87.616827, 41.86568], + [-87.616694, 41.865681], + [-87.616697, 41.865813], + [-87.615939, 41.865824], + [-87.615937, 41.8658], + [-87.615706, 41.865802], + [-87.615713, 41.866143], + [-87.615441, 41.86615], + [-87.61545, 41.866357], + [-87.615724, 41.866353], + [-87.615733, 41.866712], + [-87.615959, 41.86671], + [-87.615959, 41.866688], + [-87.616719, 41.866672], + [-87.61672, 41.866791], + [-87.616869, 41.86679], + [-87.616868, 41.86685], + [-87.617223, 41.866847], + [-87.617227, 41.866786], + [-87.617387, 41.866785], + [-87.617383, 41.86667], + [-87.618087, 41.86666] + ] + ], + "type": "Polygon" + }, + "id": "ef6512f46485e27963c248bcc945c3db" + } + ], + "type": "FeatureCollection" +} diff --git a/example/assets/fill/cat_silhouette_pattern.png b/example/assets/fill/cat_silhouette_pattern.png new file mode 100644 index 0000000..6f5ece8 Binary files /dev/null and b/example/assets/fill/cat_silhouette_pattern.png differ diff --git a/example/assets/style.json b/example/assets/style.json new file mode 100644 index 0000000..17315b5 --- /dev/null +++ b/example/assets/style.json @@ -0,0 +1,36 @@ +{ + "version": 8, + "name": "Example taken", + "sources": { + "mapillary": { + "type": "vector", + "tiles": [ + "https://d25uarhxywzl1j.cloudfront.net/v0.1/{z}/{x}/{y}.mvt" + ], + "attribution": "© Mapillary, CC BY", + "maxzoom": 14 + } + }, + "layers": [{ + "id": "background", + "type": "background", + "paint": { + "background-color": "#485E77" + } + }, + { + "id": "mapillary-sequences", + "type": "line", + "source": "mapillary", + "source-layer": "mapillary-sequences", + "filter": [ + "==", + "$type", + "LineString" + ], + "paint": { + "line-color": "#F56745" + } + } + ] +} \ No newline at end of file diff --git a/example/assets/symbols/2.0x/custom-icon.png b/example/assets/symbols/2.0x/custom-icon.png new file mode 100644 index 0000000..592b852 Binary files /dev/null and b/example/assets/symbols/2.0x/custom-icon.png differ diff --git a/example/assets/symbols/3.0x/custom-icon.png b/example/assets/symbols/3.0x/custom-icon.png new file mode 100644 index 0000000..592b852 Binary files /dev/null and b/example/assets/symbols/3.0x/custom-icon.png differ diff --git a/example/assets/symbols/custom-icon.png b/example/assets/symbols/custom-icon.png new file mode 100644 index 0000000..592b852 Binary files /dev/null and b/example/assets/symbols/custom-icon.png differ diff --git a/example/assets/symbols/location_off.png b/example/assets/symbols/location_off.png new file mode 100644 index 0000000..044ed0a Binary files /dev/null and b/example/assets/symbols/location_off.png differ diff --git a/example/assets/symbols/location_on.png b/example/assets/symbols/location_on.png new file mode 100644 index 0000000..ec3bd21 Binary files /dev/null and b/example/assets/symbols/location_on.png differ diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..e96ef60 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/.last_build_id b/example/ios/Flutter/.last_build_id new file mode 100644 index 0000000..1345a40 --- /dev/null +++ b/example/ios/Flutter/.last_build_id @@ -0,0 +1 @@ +03fcf93a7807ed5ca7e1593e013555e0 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..e8efba1 --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..399e934 --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..279576f --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4bc9afc --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,601 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1C318FD9FE81A3CF826CB6E0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9C618A260D4CE68F2F89632 /* Pods_Runner.framework */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; + 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 33EB4B753D90FC406A268B9A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 647A9CC8EAD456F68D57F590 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 77F62DAA39FA47F19A7FF5D8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E9C618A260D4CE68F2F89632 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1C318FD9FE81A3CF826CB6E0 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 6B19A70E8F48794F8B06590A /* Frameworks */ = { + isa = PBXGroup; + children = ( + E9C618A260D4CE68F2F89632 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + BF9D592592C87D7867B07AED /* Pods */, + 6B19A70E8F48794F8B06590A /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 97C146F21CF9000F007C117D /* main.m */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + BF9D592592C87D7867B07AED /* Pods */ = { + isa = PBXGroup; + children = ( + 77F62DAA39FA47F19A7FF5D8 /* Pods-Runner.debug.xcconfig */, + 33EB4B753D90FC406A268B9A /* Pods-Runner.release.xcconfig */, + 647A9CC8EAD456F68D57F590 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 61A6A5795B0A22D55417D672 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 889EE4D7842958F998B4C2E7 /* [CP] Embed Pods Frameworks */, + F186E3F73253D3C2DC89D728 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = "The Chromium Authors"; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + English, + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 61A6A5795B0A22D55417D672 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 889EE4D7842958F998B4C2E7 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", + "${BUILT_PRODUCTS_DIR}/nb_maps_flutter/nb_maps_flutter.framework", + "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/NextBillionMap/Nbmap.framework/Nbmap", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nb_maps_flutter.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Nbmap.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + F186E3F73253D3C2DC89D728 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/permission_handler_apple/permission_handler_apple_privacy.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/permission_handler_apple_privacy.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */, + 97C146F31CF9000F007C117D /* main.m in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.nextbillion.nbmapsGlExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.nextbillion.nbmapsGlExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.nextbillion.nbmapsGlExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e67b280 --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner/AppDelegate.h b/example/ios/Runner/AppDelegate.h new file mode 100644 index 0000000..36e21bb --- /dev/null +++ b/example/ios/Runner/AppDelegate.h @@ -0,0 +1,6 @@ +#import +#import + +@interface AppDelegate : FlutterAppDelegate + +@end diff --git a/example/ios/Runner/AppDelegate.m b/example/ios/Runner/AppDelegate.m new file mode 100644 index 0000000..59a72e9 --- /dev/null +++ b/example/ios/Runner/AppDelegate.m @@ -0,0 +1,13 @@ +#include "AppDelegate.h" +#include "GeneratedPluginRegistrant.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [GeneratedPluginRegistrant registerWithRegistry:self]; + // Override point for customization after application launch. + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..1756297 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..3d43d11 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..28c6bf0 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..f091b6b Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cde121 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..d0ef06e Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..dcdc230 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c8f9ed8 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..75b2d16 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..c4df70d Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..6a84f41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..d0e1f58 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..4ed69b1 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,55 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nb_maps_flutter_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSLocationAlwaysUsageDescription + Shows your location on the map and helps improve the map + NSLocationWhenInUseUsageDescription + Shows your location on the map and helps improve the map + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/Runner/main.m b/example/ios/Runner/main.m new file mode 100644 index 0000000..dff6597 --- /dev/null +++ b/example/ios/Runner/main.m @@ -0,0 +1,9 @@ +#import +#import +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/example/lib/animate_camera.dart b/example/lib/animate_camera.dart new file mode 100644 index 0000000..e4119f9 --- /dev/null +++ b/example/lib/animate_camera.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class AnimateCameraPage extends ExamplePage { + AnimateCameraPage() + : super(const Icon(Icons.map), 'Camera control, animated'); + + @override + Widget build(BuildContext context) { + return const AnimateCamera(); + } +} + +class AnimateCamera extends StatefulWidget { + const AnimateCamera(); + @override + State createState() => AnimateCameraState(); +} + +class AnimateCameraState extends State { + late NextbillionMapController mapController; + + void _onMapCreated(NextbillionMapController controller) { + mapController = controller; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 300.0, + height: 200.0, + child: NBMap( + onMapCreated: _onMapCreated, + initialCameraPosition: + const CameraPosition(target: LatLng(0.0, 0.0)), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + TextButton( + onPressed: () { + mapController + .animateCamera( + CameraUpdate.newCameraPosition( + const CameraPosition( + bearing: 270.0, + target: LatLng(51.5160895, -0.1294527), + tilt: 30.0, + zoom: 17.0, + ), + ), + ) + .then((result) => print( + "mapController.animateCamera() returned $result")); + }, + child: const Text('newCameraPosition'), + ), + TextButton( + onPressed: () { + mapController + .animateCamera( + CameraUpdate.newLatLng( + const LatLng(56.1725505, 10.1850512), + ), + duration: Duration(seconds: 5), + ) + .then((result) => print( + "mapController.animateCamera() returned $result")); + }, + child: const Text('newLatLng'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: const LatLng(-38.483935, 113.248673), + northeast: const LatLng(-8.982446, 153.823821), + ), + left: 10, + top: 5, + bottom: 25, + ), + ); + }, + child: const Text('newLatLngBounds'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.newLatLngZoom( + const LatLng(37.4231613, -122.087159), + 11.0, + ), + ); + }, + child: const Text('newLatLngZoom'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.scrollBy(150.0, -225.0), + ); + }, + child: const Text('scrollBy'), + ), + ], + ), + Column( + children: [ + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.zoomBy( + -0.5, + const Offset(30.0, 20.0), + ), + ); + }, + child: const Text('zoomBy with focus'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.zoomBy(-0.5), + ); + }, + child: const Text('zoomBy'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.zoomIn(), + ); + }, + child: const Text('zoomIn'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.zoomOut(), + ); + }, + child: const Text('zoomOut'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.zoomTo(16.0), + ); + }, + child: const Text('zoomTo'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.bearingTo(45.0), + ); + }, + child: const Text('bearingTo'), + ), + TextButton( + onPressed: () { + mapController.animateCamera( + CameraUpdate.tiltTo(30.0), + ); + }, + child: const Text('tiltTo'), + ), + ], + ), + ], + ) + ], + ); + } +} diff --git a/example/lib/annotation_order_maps.dart b/example/lib/annotation_order_maps.dart new file mode 100644 index 0000000..839f157 --- /dev/null +++ b/example/lib/annotation_order_maps.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; // ignore: unnecessary_import +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class AnnotationOrderPage extends ExamplePage { + AnnotationOrderPage() + : super(const Icon(Icons.layers), 'Annotation order maps'); + + @override + Widget build(BuildContext context) => AnnotationOrderBody(); +} + +class AnnotationOrderBody extends StatefulWidget { + AnnotationOrderBody(); + + @override + _AnnotationOrderBodyState createState() => _AnnotationOrderBodyState(); +} + +class _AnnotationOrderBodyState extends State { + late NextbillionMapController controllerOne; + late NextbillionMapController controllerTwo; + + final LatLng center = const LatLng(36.580664, 32.5563837); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 0.0), + child: Column( + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 5.0), + child: Text( + 'This map has polygones (fill) above all other anotations (default behavior)'), + ), + Center( + child: SizedBox( + width: 250.0, + height: 250.0, + child: NBMap( + onMapCreated: onMapCreatedOne, + onStyleLoadedCallback: () => onStyleLoaded(controllerOne), + initialCameraPosition: CameraPosition( + target: center, + zoom: 5.0, + ), + annotationOrder: const [ + AnnotationType.line, + AnnotationType.symbol, + AnnotationType.circle, + AnnotationType.fill, + ], + ), + ), + ), + ], + ), + ), + ), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 0.0), + child: Column( + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 5.0, top: 5.0), + child: Text( + 'This map has polygones (fill) under all other anotations'), + ), + Center( + child: SizedBox( + width: 250.0, + height: 250.0, + child: NBMap( + onMapCreated: onMapCreatedTwo, + onStyleLoadedCallback: () => onStyleLoaded(controllerTwo), + initialCameraPosition: CameraPosition( + target: center, + zoom: 5.0, + ), + annotationOrder: const [ + AnnotationType.fill, + AnnotationType.line, + AnnotationType.symbol, + AnnotationType.circle, + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + void onMapCreatedOne(NextbillionMapController controller) { + this.controllerOne = controller; + } + + void onMapCreatedTwo(NextbillionMapController controller) { + this.controllerTwo = controller; + } + + void onStyleLoaded(NextbillionMapController controller) { + controller.addSymbol( + SymbolOptions( + geometry: LatLng( + center.latitude, + center.longitude, + ), + iconImage: "airport-15", + ), + ); + controller.addLine( + LineOptions( + draggable: false, + lineColor: "#ff0000", + lineWidth: 7.0, + lineOpacity: 1, + geometry: [ + LatLng(35.3649902, 32.0593003), + LatLng(34.9475098, 31.1187944), + LatLng(36.7108154, 30.7040582), + LatLng(37.6995850, 33.6512083), + LatLng(35.8648682, 33.6969227), + LatLng(35.3814697, 32.0546447), + ], + ), + ); + controller.addFill( + FillOptions( + draggable: false, + fillColor: "#008888", + fillOpacity: 0.3, + geometry: [ + [ + LatLng(35.3649902, 32.0593003), + LatLng(34.9475098, 31.1187944), + LatLng(36.7108154, 30.7040582), + LatLng(37.6995850, 33.6512083), + LatLng(35.8648682, 33.6969227), + LatLng(35.3814697, 32.0546447), + ] + ], + ), + ); + } +} diff --git a/example/lib/click_annotations.dart b/example/lib/click_annotations.dart new file mode 100644 index 0000000..e862455 --- /dev/null +++ b/example/lib/click_annotations.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class ClickAnnotationPage extends ExamplePage { + ClickAnnotationPage() + : super(const Icon(Icons.check_circle), 'Annotation tap'); + + @override + Widget build(BuildContext context) { + return const ClickAnnotationBody(); + } +} + +class ClickAnnotationBody extends StatefulWidget { + const ClickAnnotationBody(); + + @override + State createState() => ClickAnnotationBodyState(); +} + +class ClickAnnotationBodyState extends State { + ClickAnnotationBodyState(); + + static const LatLng center = const LatLng(-33.88, 151.16); + bool overlapping = false; + + NextbillionMapController? controller; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + controller.onFillTapped.add(_onFillTapped); + controller.onCircleTapped.add(_onCircleTapped); + controller.onLineTapped.add(_onLineTapped); + controller.onSymbolTapped.add(_onSymbolTapped); + } + + @override + void dispose() { + controller?.onFillTapped.remove(_onFillTapped); + controller?.onCircleTapped.remove(_onCircleTapped); + controller?.onLineTapped.remove(_onLineTapped); + controller?.onSymbolTapped.remove(_onSymbolTapped); + super.dispose(); + } + + _showSnackBar(String type, String id) { + final snackBar = SnackBar( + content: Text('Tapped $type $id', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + backgroundColor: Theme.of(context).primaryColor); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void _onFillTapped(Fill fill) { + _showSnackBar('fill', fill.id); + } + + void _onCircleTapped(Circle circle) { + _showSnackBar('circle', circle.id); + } + + void _onLineTapped(Line line) { + _showSnackBar('line', line.id); + } + + void _onSymbolTapped(Symbol symbol) { + _showSnackBar('symbol', symbol.id); + } + + void _onStyleLoaded() { + controller!.addCircle( + CircleOptions( + geometry: LatLng(-33.881979408447314, 151.171361438502117), + circleStrokeColor: "#00FF00", + circleStrokeWidth: 2, + circleRadius: 16, + ), + ); + controller!.addCircle( + CircleOptions( + geometry: LatLng(-33.894372606072309, 151.17576679759523), + circleStrokeColor: "#00FF00", + circleStrokeWidth: 2, + circleRadius: 30, + ), + ); + controller!.addSymbol( + SymbolOptions( + geometry: LatLng(-33.894372606072309, 151.17576679759523), + iconImage: "fast-food-15", + iconSize: 2), + ); + controller!.addLine( + LineOptions( + geometry: [ + LatLng(-33.874867744475786, 151.170627211986584), + LatLng(-33.881979408447314, 151.171361438502117), + LatLng(-33.887058805548882, 151.175032571079726), + LatLng(-33.894372606072309, 151.17576679759523), + LatLng(-33.900060683994681, 151.15765587687909), + ], + lineColor: "#0000FF", + lineWidth: 20, + ), + ); + + controller!.addFill( + FillOptions( + geometry: [ + [ + LatLng(-33.901517742631846, 151.178099204457737), + LatLng(-33.872845324482071, 151.179025547977773), + LatLng(-33.868230472039514, 151.147000529140399), + LatLng(-33.883172899638311, 151.150838238009328), + LatLng(-33.894158309528244, 151.14223647675135), + LatLng(-33.904812805307806, 151.155999294764086), + LatLng(-33.901517742631846, 151.178099204457737), + ], + ], + fillColor: "#FF0000", + fillOutlineColor: "#000000", + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: NBMap( + annotationOrder: [ + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + AnnotationType.symbol, + ], + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: center, + zoom: 12.0, + ), + ), + floatingActionButton: ElevatedButton( + onPressed: () { + setState(() { + overlapping = !overlapping; + }); + controller!.setSymbolIconAllowOverlap(overlapping); + controller!.setSymbolIconIgnorePlacement(overlapping); + + controller!.setSymbolTextAllowOverlap(overlapping); + controller!.setSymbolTextIgnorePlacement(overlapping); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text("Toggle overlapping"), + )), + ); + } +} diff --git a/example/lib/custom_marker.dart b/example/lib/custom_marker.dart new file mode 100644 index 0000000..16efda4 --- /dev/null +++ b/example/lib/custom_marker.dart @@ -0,0 +1,242 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; // ignore: unnecessary_import +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +const randomMarkerNum = 10; + +class CustomMarkerPage extends ExamplePage { + CustomMarkerPage() : super(const Icon(Icons.place), 'Custom marker'); + + @override + Widget build(BuildContext context) { + return CustomMarker(); + } +} + +class CustomMarker extends StatefulWidget { + const CustomMarker(); + + @override + State createState() => CustomMarkerState(); +} + +class CustomMarkerState extends State { + final Random _rnd = new Random(); + + late NextbillionMapController _mapController; + List _markers = []; + List<_MarkerState> _markerStates = []; + + void _addMarkerStates(_MarkerState markerState) { + _markerStates.add(markerState); + } + + void _onMapCreated(NextbillionMapController controller) { + _mapController = controller; + controller.addListener(() { + if (controller.isCameraMoving) { + _updateMarkerPosition(); + } + }); + } + + void _onStyleLoadedCallback() { + print('onStyleLoadedCallback'); + } + + void _onMapLongClickCallback(Point point, LatLng coordinates) { + _addMarker(point, coordinates); + } + + void _onCameraIdleCallback() { + _updateMarkerPosition(); + } + + void _updateMarkerPosition() { + final coordinates = []; + + for (final markerState in _markerStates) { + coordinates.add(markerState.getCoordinate()); + } + + _mapController.toScreenLocationBatch(coordinates).then((points) { + if (points == null) { + return; + } + _markerStates.asMap().forEach((i, value) { + _markerStates[i].updatePosition(points[i]); + }); + }); + } + + void _addMarker(Point point, LatLng coordinates) { + setState(() { + _markers.add(Marker(_rnd.nextInt(100000).toString(), coordinates, point, + _addMarkerStates)); + }); + } + + @override + Widget build(BuildContext context) { + return new Container( + child: Stack(children: [ + NBMap( + trackCameraPosition: true, + onMapCreated: _onMapCreated, + onMapLongClick: _onMapLongClickCallback, + onCameraIdle: _onCameraIdleCallback, + onStyleLoadedCallback: _onStyleLoadedCallback, + initialCameraPosition: + const CameraPosition(target: LatLng(35.0, 135.0), zoom: 5), + ), + IgnorePointer( + ignoring: true, + child: Stack( + children: _markers, + )), + FloatingActionButton( + onPressed: () { + // Generate random markers + var param = []; + for (var i = 0; i < randomMarkerNum; i++) { + final lat = _rnd.nextDouble() * 20 + 30; + final lng = _rnd.nextDouble() * 20 + 125; + param.add(LatLng(lat, lng)); + } + + _mapController.toScreenLocationBatch(param).then((value) { + if (value == null) { + return; + } + for (var i = 0; i < randomMarkerNum; i++) { + var point = + Point(value[i].x as double, value[i].y as double); + _addMarker(point, param[i]); + } + }); + }, + child: Icon(Icons.add), + ), + ]), + ); + } + + // ignore: unused_element + void _measurePerformance() { + final trial = 10; + final batches = [500, 1000, 1500, 2000, 2500, 3000]; + var results = Map>(); + for (final batch in batches) { + results[batch] = [0.0, 0.0]; + } + + _mapController.toScreenLocation(LatLng(0, 0)); + Stopwatch sw = Stopwatch(); + + for (final batch in batches) { + // + // primitive + // + for (var i = 0; i < trial; i++) { + sw.start(); + var list = >>[]; + for (var j = 0; j < batch; j++) { + var p = _mapController + .toScreenLocation(LatLng(j.toDouble() % 80, j.toDouble() % 300)); + + list.add(p); + } + Future.wait(list); + sw.stop(); + results[batch]![0] += sw.elapsedMilliseconds; + sw.reset(); + } + + // + // batch + // + for (var i = 0; i < trial; i++) { + sw.start(); + var param = []; + for (var j = 0; j < batch; j++) { + param.add(LatLng(j.toDouble() % 80, j.toDouble() % 300)); + } + Future.wait([_mapController.toScreenLocationBatch(param)]); + sw.stop(); + results[batch]![1] += sw.elapsedMilliseconds; + sw.reset(); + } + + print( + 'batch=$batch,primitive=${results[batch]![0] / trial}ms, batch=${results[batch]![1] / trial}ms'); + } + } +} + +class Marker extends StatefulWidget { + final Point _initialPosition; + final LatLng _coordinate; + final void Function(_MarkerState) _addMarkerState; + + Marker( + String key, this._coordinate, this._initialPosition, this._addMarkerState) + : super(key: Key(key)); + + @override + State createState() { + final state = _MarkerState(_initialPosition); + _addMarkerState(state); + return state; + } +} + +class _MarkerState extends State with TickerProviderStateMixin { + final _iconSize = 20.0; + + Point _position; + + _MarkerState(this._position); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var ratio = 1.0; + + // web does not support Platform._operatingSystem + if (!kIsWeb) { + // iOS returns logical pixel while Android returns screen pixel + ratio = Platform.isIOS ? 1.0 : MediaQuery.of(context).devicePixelRatio; + } + + return Positioned( + left: _position.x / ratio - _iconSize / 2, + top: _position.y / ratio - _iconSize / 2, + child: Image.asset('assets/symbols/2.0x/custom-icon.png', + height: _iconSize)); + } + + void updatePosition(Point point) { + setState(() { + _position = point; + }); + } + + LatLng getCoordinate() { + return (widget as Marker)._coordinate; + } +} diff --git a/example/lib/full_map.dart b/example/lib/full_map.dart new file mode 100644 index 0000000..1e9c1dd --- /dev/null +++ b/example/lib/full_map.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class FullMapPage extends ExamplePage { + FullMapPage() : super(const Icon(Icons.map), 'Full screen map'); + + @override + Widget build(BuildContext context) { + return const FullMap(); + } +} + +class FullMap extends StatefulWidget { + const FullMap(); + + @override + State createState() => FullMapState(); +} + +class FullMapState extends State { + NextbillionMapController? mapController; + var isLight = true; + + _onMapCreated(NextbillionMapController controller) { + mapController = controller; + } + + _onStyleLoadedCallback() { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text("Style loaded :)"), + backgroundColor: Theme.of(context).primaryColor, + duration: Duration(seconds: 1), + )); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + floatingActionButton: Padding( + padding: const EdgeInsets.all(32.0), + child: FloatingActionButton( + child: Icon(Icons.swap_horiz), + onPressed: () => setState( + () => isLight = !isLight, + ), + ), + ), + body: NBMap( + styleString: isLight ? NbMapStyles.LIGHT : NbMapStyles.DARK, + onMapCreated: _onMapCreated, + initialCameraPosition: + const CameraPosition(target: LatLng(0.0, 0.0), zoom: 14), + onStyleLoadedCallback: _onStyleLoadedCallback, + myLocationTrackingMode: MyLocationTrackingMode.Tracking, + myLocationEnabled: true, + trackCameraPosition: true, + )); + } +} diff --git a/example/lib/layer.dart b/example/lib/layer.dart new file mode 100644 index 0000000..bd39979 --- /dev/null +++ b/example/lib/layer.dart @@ -0,0 +1,294 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:nb_maps_flutter_example/page.dart'; + +class LayerPage extends ExamplePage { + LayerPage() : super(const Icon(Icons.share), 'Layer'); + + @override + Widget build(BuildContext context) => LayerBody(); +} + +class LayerBody extends StatefulWidget { + @override + State createState() => LayerState(); +} + +class LayerState extends State { + static final LatLng center = const LatLng(-33.86711, 151.1947171); + + late NextbillionMapController controller; + Timer? bikeTimer; + Timer? filterTimer; + Timer? visibilityTimer; + int filteredId = 0; + bool isVisible = true; + + @override + Widget build(BuildContext context) { + return NBMap( + dragEnabled: false, + myLocationEnabled: true, + onMapCreated: _onMapCreated, + onMapClick: (point, latLong) => + print(point.toString() + latLong.toString()), + onStyleLoadedCallback: _onStyleLoadedCallback, + initialCameraPosition: CameraPosition( + target: center, + zoom: 11.0, + ), + annotationOrder: const [], + ); + } + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + + controller.onFeatureTapped.add(onFeatureTap); + } + + void onFeatureTap(dynamic featureId, Point point, LatLng latLng) { + final snackBar = SnackBar( + content: Text( + 'Tapped feature with id $featureId', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + backgroundColor: Theme.of(context).primaryColor, + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void _onStyleLoadedCallback() async { + await controller.addGeoJsonSource("points", _points); + await controller.addGeoJsonSource("moving", _movingFeature(0)); + + //new style of adding sources + await controller.addSource("fills", GeojsonSourceProperties(data: _fills)); + + await controller.addFillLayer( + "fills", + "fills", + FillLayerProperties(fillColor: [ + Expressions.interpolate, + ['exponential', 0.5], + [Expressions.zoom], + 11, + 'red', + 18, + 'green' + ], fillOpacity: 0.4), + belowLayerId: "water", + filter: ['==', 'id', filteredId], + ); + + await controller.addLineLayer( + "fills", + "lines", + LineLayerProperties( + lineColor: Colors.lightBlue.toHexStringRGB(), + lineWidth: [ + Expressions.interpolate, + ["linear"], + [Expressions.zoom], + 11.0, + 2.0, + 20.0, + 10.0 + ]), + ); + + await controller.addCircleLayer( + "fills", + "circles", + CircleLayerProperties( + circleRadius: 4, + circleColor: Colors.blue.toHexStringRGB(), + ), + ); + + await controller.addSymbolLayer( + "points", + "symbols", + SymbolLayerProperties( + iconImage: "{type}-15", + iconSize: 2, + iconAllowOverlap: true, + ), + ); + + await controller.addSymbolLayer( + "moving", + "moving", + SymbolLayerProperties( + textField: [Expressions.get, "name"], + textHaloWidth: 1, + textSize: 10, + textHaloColor: Colors.white.toHexStringRGB(), + textOffset: [ + Expressions.literal, + [0, 2] + ], + iconImage: "bicycle-15", + iconSize: 2, + iconAllowOverlap: true, + textAllowOverlap: true, + ), + minzoom: 11, + ); + + bikeTimer = Timer.periodic(Duration(milliseconds: 10), (t) { + controller.setGeoJsonSource("moving", _movingFeature(t.tick / 2000)); + }); + + filterTimer = Timer.periodic(Duration(seconds: 3), (t) { + filteredId = filteredId == 0 ? 1 : 0; + controller.setFilter('fills', ['==', 'id', filteredId]); + }); + + visibilityTimer = Timer.periodic(Duration(seconds: 5), (t) { + isVisible = !isVisible; + controller.setVisibility('water', isVisible); + }); + } + + @override + void dispose() { + bikeTimer?.cancel(); + filterTimer?.cancel(); + visibilityTimer?.cancel(); + super.dispose(); + } +} + +Map _movingFeature(double t) { + List makeLatLong(double t) { + final angle = t * 2 * pi; + const r = 0.025; + const center_x = 151.1849; + const center_y = -33.8748; + return [ + center_x + r * sin(angle), + center_y + r * cos(angle), + ]; + } + + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": "POGAČAR Tadej"}, + "id": 10, + "geometry": {"type": "Point", "coordinates": makeLatLong(t)} + }, + { + "type": "Feature", + "properties": {"name": "VAN AERT Wout"}, + "id": 11, + "geometry": {"type": "Point", "coordinates": makeLatLong(t + 0.15)} + }, + ] + }; +} + +final _fills = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": 0, // web currently only supports number ids + "properties": {'id': 0}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [151.178099204457737, -33.901517742631846], + [151.179025547977773, -33.872845324482071], + [151.147000529140399, -33.868230472039514], + [151.150838238009328, -33.883172899638311], + [151.14223647675135, -33.894158309528244], + [151.155999294764086, -33.904812805307806], + [151.178099204457737, -33.901517742631846] + ], + [ + [151.162657925954278, -33.879168932438581], + [151.155323416087612, -33.890737666431583], + [151.173659690754278, -33.897637567778119], + [151.162657925954278, -33.879168932438581] + ] + ] + } + }, + { + "type": "Feature", + "id": 1, + "properties": {'id': 1}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [151.18735077583878, -33.891143558434102], + [151.197374605989864, -33.878357032551868], + [151.213021560372084, -33.886475683791488], + [151.204953599518745, -33.899463918807818], + [151.18735077583878, -33.891143558434102] + ] + ] + } + } + ] +}; + +const _points = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": 2, + "properties": { + "type": "restaurant", + }, + "geometry": { + "type": "Point", + "coordinates": [151.184913929732943, -33.874874486427181] + } + }, + { + "type": "Feature", + "id": 3, + "properties": { + "type": "airport", + }, + "geometry": { + "type": "Point", + "coordinates": [151.215730044667879, -33.874616048776858] + } + }, + { + "type": "Feature", + "id": 4, + "properties": { + "type": "bakery", + }, + "geometry": { + "type": "Point", + "coordinates": [151.228803547973598, -33.892188026142584] + } + }, + { + "type": "Feature", + "id": 5, + "properties": { + "type": "college", + }, + "geometry": { + "type": "Point", + "coordinates": [151.186470299174118, -33.902781145804774] + } + } + ] +}; diff --git a/example/lib/line.dart b/example/lib/line.dart new file mode 100644 index 0000000..aa22643 --- /dev/null +++ b/example/lib/line.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class LinePage extends ExamplePage { + LinePage() : super(const Icon(Icons.share), 'Line'); + + @override + Widget build(BuildContext context) { + return const LineBody(); + } +} + +class LineBody extends StatefulWidget { + const LineBody(); + + @override + State createState() => LineBodyState(); +} + +class LineBodyState extends State { + LineBodyState(); + + static final LatLng center = const LatLng(-33.86711, 151.1947171); + + NextbillionMapController? controller; + int _lineCount = 0; + Line? _selectedLine; + final String _linePatternImage = "assets/fill/cat_silhouette_pattern.png"; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + controller.onLineTapped.add(_onLineTapped); + } + + @override + void dispose() { + controller?.onLineTapped.remove(_onLineTapped); + super.dispose(); + } + + /// Adds an asset image to the currently displayed style + Future addImageFromAsset(String name, String assetName) async { + final ByteData bytes = await rootBundle.load(assetName); + final Uint8List list = bytes.buffer.asUint8List(); + return controller!.addImage(name, list); + } + + _onLineTapped(Line line) async { + await _updateSelectedLine( + LineOptions(lineColor: "#ff0000"), + ); + setState(() { + _selectedLine = line; + }); + await _updateSelectedLine( + LineOptions(lineColor: "#ffe100"), + ); + } + + _updateSelectedLine(LineOptions changes) async { + if (_selectedLine != null) controller!.updateLine(_selectedLine!, changes); + } + + void _add() { + controller!.addLine( + LineOptions( + geometry: [ + LatLng(-33.86711, 151.1947171), + LatLng(-33.86711, 151.1947171), + LatLng(-32.86711, 151.1947171), + LatLng(-33.86711, 152.1947171), + ], + lineColor: "#ff0000", + lineWidth: 14.0, + lineOpacity: 0.5, + draggable: true), + ); + setState(() { + _lineCount += 1; + }); + } + + _move() async { + final currentStart = _selectedLine!.options.geometry![0]; + final currentEnd = _selectedLine!.options.geometry![1]; + final end = + LatLng(currentEnd.latitude + 0.001, currentEnd.longitude + 0.001); + final start = + LatLng(currentStart.latitude - 0.001, currentStart.longitude - 0.001); + await controller! + .updateLine(_selectedLine!, LineOptions(geometry: [start, end])); + } + + void _remove() { + controller!.removeLine(_selectedLine!); + setState(() { + _selectedLine = null; + _lineCount -= 1; + }); + } + + Future _changeLinePattern() async { + String? current = + _selectedLine!.options.linePattern == null ? "assetImage" : null; + await _updateSelectedLine( + LineOptions(linePattern: current), + ); + } + + Future _changeAlpha() async { + double? current = _selectedLine!.options.lineOpacity; + if (current == null) { + // default value + current = 1.0; + } + + await _updateSelectedLine( + LineOptions(lineOpacity: current < 0.1 ? 1.0 : current * 0.75), + ); + } + + Future _toggleVisible() async { + double? current = _selectedLine!.options.lineOpacity; + if (current == null) { + // default value + current = 1.0; + } + await _updateSelectedLine( + LineOptions(lineOpacity: current == 0.0 ? 1.0 : 0.0), + ); + } + + _onStyleLoadedCallback() async { + addImageFromAsset("assetImage", _linePatternImage); + await controller!.addLine( + LineOptions( + geometry: [LatLng(37.4220, -122.0841), LatLng(37.4240, -122.0941)], + lineColor: "#ff0000", + lineWidth: 14.0, + lineOpacity: 0.5, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + height: 400.0, + child: NBMap( + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoadedCallback, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + Row( + children: [ + TextButton( + child: const Text('add'), + onPressed: (_lineCount == 12) ? null : _add, + ), + TextButton( + child: const Text('remove'), + onPressed: (_selectedLine == null) ? null : _remove, + ), + TextButton( + child: const Text('move'), + onPressed: (_selectedLine == null) + ? null + : () async { + await _move(); + }, + ), + TextButton( + child: const Text('change line-pattern'), + onPressed: (_selectedLine == null) + ? null + : _changeLinePattern, + ), + ], + ), + Row( + children: [ + TextButton( + child: const Text('change alpha'), + onPressed: + (_selectedLine == null) ? null : _changeAlpha, + ), + TextButton( + child: const Text('toggle visible'), + onPressed: + (_selectedLine == null) ? null : _toggleVisible, + ), + TextButton( + child: const Text('print current LatLng'), + onPressed: (_selectedLine == null) + ? null + : () async { + var latLngs = await controller! + .getLineLatLngs(_selectedLine!); + if (latLngs != null) { + for (var latLng in latLngs) { + print(latLng.toString()); + } + } + }, + ), + ], + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/local_style.dart b/example/lib/local_style.dart new file mode 100644 index 0000000..36f6689 --- /dev/null +++ b/example/lib/local_style.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'page.dart'; + +class LocalStylePage extends ExamplePage { + LocalStylePage() : super(const Icon(Icons.map), 'Local style'); + + @override + Widget build(BuildContext context) { + return const LocalStyle(); + } +} + +class LocalStyle extends StatefulWidget { + const LocalStyle(); + + @override + State createState() => LocalStyleState(); +} + +class LocalStyleState extends State { + NextbillionMapController? mapController; + String? styleAbsoluteFilePath; + + @override + initState() { + super.initState(); + + getApplicationDocumentsDirectory().then((dir) async { + String documentDir = dir.path; + String stylesDir = '$documentDir/styles'; + String styleJSON = + '{"version":8,"name":"Basic","constants":{},"sources":{"mapillary":{"type":"vector","tiles":["https://d25uarhxywzl1j.cloudfront.net/v0.1/{z}/{x}/{y}.mvt"],"attribution":"© Mapillary, CC BY","maxzoom":14}},"sprite":"","glyphs":"","layers":[{"id":"background","type":"background","paint":{"background-color":"rgba(135, 149, 154, 1)"}},{"id":"water","type":"fill","source":"nbmap","source-layer":"water","paint":{"fill-color":"rgba(108, 148, 120, 1)"}}]}'; + + await new Directory(stylesDir).create(recursive: true); + + File styleFile = new File('$stylesDir/style.json'); + + await styleFile.writeAsString(styleJSON); + + setState(() { + styleAbsoluteFilePath = styleFile.path; + }); + }); + } + + void _onMapCreated(NextbillionMapController controller) { + mapController = controller; + } + + @override + Widget build(BuildContext context) { + if (styleAbsoluteFilePath == null) { + return Scaffold( + body: Center(child: Text('Creating local style file...')), + ); + } + + return new Scaffold( + body: NBMap( + styleString: styleAbsoluteFilePath, + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition(target: LatLng(0.0, 0.0)), + onStyleLoadedCallback: onStyleLoadedCallback, + )); + } + + void onStyleLoadedCallback() {} +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..9bbb991 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:nb_maps_flutter_example/track_current_location.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'animate_camera.dart'; +import 'annotation_order_maps.dart'; +import 'click_annotations.dart'; +import 'custom_marker.dart'; +import 'full_map.dart'; +import 'layer.dart'; +import 'line.dart'; +import 'local_style.dart'; +import 'map_ui.dart'; +import 'move_camera.dart'; +import 'offline_regions.dart'; +import 'page.dart'; +import 'place_batch.dart'; +import 'place_circle.dart'; +import 'place_fill.dart'; +import 'place_source.dart'; +import 'place_symbol.dart'; +import 'scrolling_map.dart'; +import 'sources.dart'; +import 'take_snapshot.dart'; + +final List _allPages = [ + MapUiPage(), + FullMapPage(), + AnimateCameraPage(), + MoveCameraPage(), + PlaceSymbolPage(), + PlaceSourcePage(), + LinePage(), + LocalStylePage(), + LayerPage(), + PlaceCirclePage(), + PlaceFillPage(), + ScrollingMapPage(), + OfflineRegionsPage(), + AnnotationOrderPage(), + CustomMarkerPage(), + BatchAddPage(), + TakeSnapPage(), + ClickAnnotationPage(), + Sources(), + TrackCurrentLocationPage() +]; + +class MapsDemo extends StatefulWidget { + static const String ACCESS_KEY = String.fromEnvironment("ACCESS_KEY"); + + @override + State createState() => _MapsDemoState(); +} + +class _MapsDemoState extends State { + @override + void initState() { + super.initState(); + NextBillion.initNextBillion(MapsDemo.ACCESS_KEY); + + NextBillion.getUserId().then((value) { + print("User id: $value"); + }); + NextBillion.setUserId("1234"); + NextBillion.getNbId().then((value) { + print("NB id: $value"); + }); + + NextBillion.getUserId().then((value) { + print("User id: $value"); + }); + } + + /// Determine the android version of the phone and turn off HybridComposition + /// on older sdk versions to improve performance for these + /// + /// !!! Hybrid composition is currently broken do no use !!! + Future initHybridComposition() async { + if (!kIsWeb && Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + final sdkVersion = androidInfo.version.sdkInt; + if (sdkVersion != null && sdkVersion >= 29) { + NBMap.useHybridComposition = true; + } else { + NBMap.useHybridComposition = false; + } + } + } + + void _pushPage(BuildContext context, ExamplePage page) async { + var status = await Permission.location.status; + if (status.isDenied) { + await [Permission.location].request(); + } + + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => Scaffold( + appBar: AppBar(title: Text(page.title)), + body: page, + ))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('NbMaps examples')), + body: MapsDemo.ACCESS_KEY.isEmpty || + MapsDemo.ACCESS_KEY.contains("YOUR_TOKEN") + ? buildAccessTokenWarning() + : ListView.separated( + itemCount: _allPages.length, + separatorBuilder: (BuildContext context, int index) => + const Divider(height: 1), + itemBuilder: (_, int index) => ListTile( + leading: _allPages[index].leading, + title: Text(_allPages[index].title), + onTap: () => _pushPage(context, _allPages[index]), + ), + ), + ); + } + + Widget buildAccessTokenWarning() { + return Container( + color: Colors.red[900], + child: SizedBox.expand( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + "Using MapView requires calling Nextbillion.initNextbillion(String accessKey) " + "before inflating or creating NBMap Widget. ", + ] + .map((text) => Padding( + padding: EdgeInsets.all(8), + child: Text(text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white)), + )) + .toList(), + ), + ), + ); + } +} + +void main() { + runApp(MaterialApp(home: MapsDemo())); +} diff --git a/example/lib/map_ui.dart b/example/lib/map_ui.dart new file mode 100644 index 0000000..64562ed --- /dev/null +++ b/example/lib/map_ui.dart @@ -0,0 +1,440 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +final LatLngBounds sydneyBounds = LatLngBounds( + southwest: const LatLng(-34.022631, 150.620685), + northeast: const LatLng(-33.571835, 151.325952), +); + +class MapUiPage extends ExamplePage { + MapUiPage() : super(const Icon(Icons.map), 'User interface'); + + @override + Widget build(BuildContext context) { + return const MapUiBody(); + } +} + +class MapUiBody extends StatefulWidget { + const MapUiBody(); + + @override + State createState() => MapUiBodyState(); +} + +class MapUiBodyState extends State { + MapUiBodyState(); + + static final CameraPosition _kInitialPosition = const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ); + + NextbillionMapController? mapController; + CameraPosition _position = _kInitialPosition; + bool _isMoving = false; + bool _compassEnabled = true; + bool _mapExpanded = true; + CameraTargetBounds _cameraTargetBounds = CameraTargetBounds.unbounded; + MinMaxZoomPreference _minMaxZoomPreference = MinMaxZoomPreference.unbounded; + int _styleStringIndex = 0; + // Style string can a reference to a local or remote resources. + // On Android the raw JSON can also be passed via a styleString, on iOS this is not supported. + List _styleStrings = [ + NbMapStyles.NBMAP_STREETS, + NbMapStyles.SATELLITE, + NbMapStyles.DARK + ]; + List _styleStringLabels = [ + "NBMAP_STREETS", + "SATELLITE", + "LOCAL_ASSET" + ]; + bool _rotateGesturesEnabled = true; + bool _scrollGesturesEnabled = true; + bool? _doubleClickToZoomEnabled; + bool _tiltGesturesEnabled = true; + bool _zoomGesturesEnabled = true; + bool _myLocationEnabled = true; + bool _telemetryEnabled = true; + MyLocationTrackingMode _myLocationTrackingMode = MyLocationTrackingMode.None; + List? _featureQueryFilter; + Fill? _selectedFill; + + @override + void initState() { + super.initState(); + } + + void _onMapChanged() { + setState(() { + _extractMapInfo(); + }); + } + + void _extractMapInfo() { + final position = mapController!.cameraPosition; + if (position != null) _position = position; + _isMoving = mapController!.isCameraMoving; + } + + @override + void dispose() { + mapController?.removeListener(_onMapChanged); + super.dispose(); + } + + Widget _myLocationTrackingModeCycler() { + final MyLocationTrackingMode nextType = MyLocationTrackingMode.values[ + (_myLocationTrackingMode.index + 1) % + MyLocationTrackingMode.values.length]; + return TextButton( + child: Text('change to $nextType'), + onPressed: () { + setState(() { + _myLocationTrackingMode = nextType; + }); + }, + ); + } + + Widget _queryFilterToggler() { + return TextButton( + child: Text( + 'filter zoo on click ${_featureQueryFilter == null ? 'disabled' : 'enabled'}'), + onPressed: () { + setState(() { + if (_featureQueryFilter == null) { + _featureQueryFilter = [ + "==", + ["get", "type"], + "zoo" + ]; + } else { + _featureQueryFilter = null; + } + }); + }, + ); + } + + Widget _mapSizeToggler() { + return TextButton( + child: Text('${_mapExpanded ? 'shrink' : 'expand'} map'), + onPressed: () { + setState(() { + _mapExpanded = !_mapExpanded; + }); + }, + ); + } + + Widget _compassToggler() { + return TextButton( + child: Text('${_compassEnabled ? 'disable' : 'enable'} compasss'), + onPressed: () { + setState(() { + _compassEnabled = !_compassEnabled; + }); + }, + ); + } + + Widget _latLngBoundsToggler() { + return TextButton( + child: Text( + _cameraTargetBounds.bounds == null + ? 'bound camera target' + : 'release camera target', + ), + onPressed: () { + setState(() { + _cameraTargetBounds = _cameraTargetBounds.bounds == null + ? CameraTargetBounds(sydneyBounds) + : CameraTargetBounds.unbounded; + }); + }, + ); + } + + Widget _zoomBoundsToggler() { + return TextButton( + child: Text(_minMaxZoomPreference.minZoom == null + ? 'bound zoom' + : 'release zoom'), + onPressed: () { + setState(() { + _minMaxZoomPreference = _minMaxZoomPreference.minZoom == null + ? const MinMaxZoomPreference(12.0, 16.0) + : MinMaxZoomPreference.unbounded; + }); + }, + ); + } + + Widget _setStyleToSatellite() { + return TextButton( + child: Text( + 'change map style to ${_styleStringLabels[(_styleStringIndex + 1) % _styleStringLabels.length]}'), + onPressed: () { + setState(() { + _styleStringIndex = (_styleStringIndex + 1) % _styleStrings.length; + }); + }, + ); + } + + Widget _rotateToggler() { + return TextButton( + child: Text('${_rotateGesturesEnabled ? 'disable' : 'enable'} rotate'), + onPressed: () { + setState(() { + _rotateGesturesEnabled = !_rotateGesturesEnabled; + }); + }, + ); + } + + Widget _scrollToggler() { + return TextButton( + child: Text('${_scrollGesturesEnabled ? 'disable' : 'enable'} scroll'), + onPressed: () { + setState(() { + _scrollGesturesEnabled = !_scrollGesturesEnabled; + }); + }, + ); + } + + Widget _doubleClickToZoomToggler() { + final stateInfo = _doubleClickToZoomEnabled == null + ? "disable" + : _doubleClickToZoomEnabled! + ? 'unset' + : 'enable'; + return TextButton( + child: Text('$stateInfo double click to zoom'), + onPressed: () { + setState(() { + if (_doubleClickToZoomEnabled == null) { + _doubleClickToZoomEnabled = false; + } else if (!_doubleClickToZoomEnabled!) { + _doubleClickToZoomEnabled = true; + } else { + _doubleClickToZoomEnabled = null; + } + }); + }, + ); + } + + Widget _tiltToggler() { + return TextButton( + child: Text('${_tiltGesturesEnabled ? 'disable' : 'enable'} tilt'), + onPressed: () { + setState(() { + _tiltGesturesEnabled = !_tiltGesturesEnabled; + }); + }, + ); + } + + Widget _zoomToggler() { + return TextButton( + child: Text('${_zoomGesturesEnabled ? 'disable' : 'enable'} zoom'), + onPressed: () { + setState(() { + _zoomGesturesEnabled = !_zoomGesturesEnabled; + }); + }, + ); + } + + Widget _myLocationToggler() { + return TextButton( + child: Text('${_myLocationEnabled ? 'disable' : 'enable'} my location'), + onPressed: () { + setState(() { + _myLocationEnabled = !_myLocationEnabled; + }); + }, + ); + } + + Widget _telemetryToggler() { + return TextButton( + child: Text('${_telemetryEnabled ? 'disable' : 'enable'} telemetry'), + onPressed: () { + setState(() { + _telemetryEnabled = !_telemetryEnabled; + }); + mapController?.setTelemetryEnabled(_telemetryEnabled); + }, + ); + } + + Widget _visibleRegionGetter() { + return TextButton( + child: Text('get currently visible region'), + onPressed: () async { + var result = await mapController!.getVisibleRegion(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + "SW: ${result?.southwest.toString()} NE: ${result?.northeast.toString()}"), + )); + }, + ); + } + + _clearFill() { + if (_selectedFill != null) { + mapController!.removeFill(_selectedFill!); + setState(() { + _selectedFill = null; + }); + } + } + + _drawFill(List features) async { + Map? feature = + features.firstWhereOrNull((f) => f['geometry']['type'] == 'Polygon'); + + if (feature != null) { + List> geometry = feature['geometry']['coordinates'] + .map( + (ll) => ll.map((l) => LatLng(l[1], l[0])).toList().cast()) + .toList() + .cast>(); + Fill? fill = await mapController!.addFill(FillOptions( + geometry: geometry, + fillColor: "#FF0000", + fillOutlineColor: "#FF0000", + fillOpacity: 0.6, + )); + setState(() { + _selectedFill = fill; + }); + } + } + + @override + Widget build(BuildContext context) { + final NBMap nbMap = NBMap( + onMapCreated: onMapCreated, + initialCameraPosition: _kInitialPosition, + trackCameraPosition: true, + compassEnabled: _compassEnabled, + cameraTargetBounds: _cameraTargetBounds, + minMaxZoomPreference: _minMaxZoomPreference, + styleString: _styleStrings[_styleStringIndex], + rotateGesturesEnabled: _rotateGesturesEnabled, + scrollGesturesEnabled: _scrollGesturesEnabled, + tiltGesturesEnabled: _tiltGesturesEnabled, + zoomGesturesEnabled: _zoomGesturesEnabled, + doubleClickZoomEnabled: _doubleClickToZoomEnabled, + myLocationEnabled: _myLocationEnabled, + myLocationTrackingMode: _myLocationTrackingMode, + myLocationRenderMode: MyLocationRenderMode.GPS, + onMapClick: (point, latLng) async { + print( + "Map click: ${point.x},${point.y} ${latLng.latitude}/${latLng.longitude}"); + print("Filter $_featureQueryFilter"); + List features = await mapController! + .queryRenderedFeatures(point, ["landuse"], _featureQueryFilter); + print('# features: ${features.length}'); + _clearFill(); + if (features.isEmpty && _featureQueryFilter != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('QueryRenderedFeatures: No features found!'))); + } else if (features.isNotEmpty) { + _drawFill(features); + } + }, + onMapLongClick: (point, latLng) async { + print( + "Map long press: ${point.x},${point.y} ${latLng.latitude}/${latLng.longitude}"); + + double? metersPerPixel = + await mapController!.getMetersPerPixelAtLatitude(latLng.latitude); + + print( + "Map long press The distance measured in meters at latitude ${latLng.latitude} is $metersPerPixel m"); + + List features = + await mapController!.queryRenderedFeatures(point, [], null); + if (features.length > 0) { + print(features[0]); + } + }, + onCameraTrackingDismissed: () { + this.setState(() { + _myLocationTrackingMode = MyLocationTrackingMode.None; + }); + }, + onUserLocationUpdated: (location) { + print( + "new location: ${location.position}, alt.: ${location.altitude}, bearing: ${location.bearing}, speed: ${location.speed}, horiz. accuracy: ${location.horizontalAccuracy}, vert. accuracy: ${location.verticalAccuracy}"); + }, + ); + + final List listViewChildren = []; + + if (mapController != null) { + listViewChildren.addAll( + [ + Text('camera bearing: ${_position.bearing}'), + Text('camera target: ${_position.target.latitude.toStringAsFixed(4)},' + '${_position.target.longitude.toStringAsFixed(4)}'), + Text('camera zoom: ${_position.zoom}'), + Text('camera tilt: ${_position.tilt}'), + Text(_isMoving ? '(Camera moving)' : '(Camera idle)'), + _mapSizeToggler(), + _queryFilterToggler(), + _compassToggler(), + _myLocationTrackingModeCycler(), + _latLngBoundsToggler(), + _setStyleToSatellite(), + _zoomBoundsToggler(), + _rotateToggler(), + _scrollToggler(), + _doubleClickToZoomToggler(), + _tiltToggler(), + _zoomToggler(), + _myLocationToggler(), + _telemetryToggler(), + _visibleRegionGetter(), + ], + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: SizedBox( + width: _mapExpanded ? null : 300.0, + height: 200.0, + child: nbMap, + ), + ), + Expanded( + child: ListView( + children: listViewChildren, + ), + ) + ], + ); + } + + void onMapCreated(NextbillionMapController controller) { + mapController = controller; + mapController!.addListener(_onMapChanged); + _extractMapInfo(); + + mapController!.getTelemetryEnabled().then((isEnabled) => setState(() { + _telemetryEnabled = isEnabled; + })); + } +} diff --git a/example/lib/move_camera.dart b/example/lib/move_camera.dart new file mode 100644 index 0000000..68cac6f --- /dev/null +++ b/example/lib/move_camera.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class MoveCameraPage extends ExamplePage { + MoveCameraPage() : super(const Icon(Icons.map), 'Camera control'); + + @override + Widget build(BuildContext context) { + return const MoveCamera(); + } +} + +class MoveCamera extends StatefulWidget { + const MoveCamera(); + @override + State createState() => MoveCameraState(); +} + +class MoveCameraState extends State { + late NextbillionMapController mapController; + + void _onMapCreated(NextbillionMapController controller) { + mapController = controller; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 300.0, + height: 200.0, + child: NBMap( + onMapCreated: _onMapCreated, + onCameraIdle: () => print("onCameraIdle"), + initialCameraPosition: + const CameraPosition(target: LatLng(0.0, 0.0)), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.newCameraPosition( + const CameraPosition( + bearing: 270.0, + target: LatLng(51.5160895, -0.1294527), + tilt: 30.0, + zoom: 17.0, + ), + ), + ); + }, + child: const Text('newCameraPosition'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.newLatLng( + const LatLng(56.1725505, 10.1850512), + ), + ); + }, + child: const Text('newLatLng'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: const LatLng(-38.483935, 113.248673), + northeast: const LatLng(-8.982446, 153.823821), + ), + left: 10, + top: 5, + bottom: 25, + ), + ); + }, + child: const Text('newLatLngBounds'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.newLatLngZoom( + const LatLng(37.4231613, -122.087159), + 11.0, + ), + ); + }, + child: const Text('newLatLngZoom'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.scrollBy(150.0, -225.0), + ); + }, + child: const Text('scrollBy'), + ), + ], + ), + Column( + children: [ + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.zoomBy( + -0.5, + const Offset(30.0, 20.0), + ), + ); + }, + child: const Text('zoomBy with focus'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.zoomBy(-0.5), + ); + }, + child: const Text('zoomBy'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.zoomIn(), + ); + }, + child: const Text('zoomIn'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.zoomOut(), + ); + }, + child: const Text('zoomOut'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.zoomTo(16.0), + ); + }, + child: const Text('zoomTo'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.bearingTo(45.0), + ); + }, + child: const Text('bearingTo'), + ), + TextButton( + onPressed: () { + mapController.moveCamera( + CameraUpdate.tiltTo(30.0), + ); + }, + child: const Text('tiltTo'), + ), + ], + ), + ], + ) + ], + ); + } +} diff --git a/example/lib/offline_region_map.dart b/example/lib/offline_region_map.dart new file mode 100644 index 0000000..730d376 --- /dev/null +++ b/example/lib/offline_region_map.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'offline_regions.dart'; + +class OfflineRegionMap extends StatefulWidget { + OfflineRegionMap(this.item); + + final OfflineRegionListItem item; + + @override + _OfflineRegionMapState createState() => _OfflineRegionMapState(); +} + +class _OfflineRegionMapState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Offline Region: ${widget.item.name}'), + ), + body: NBMap( + initialCameraPosition: CameraPosition( + target: _center, + zoom: widget.item.offlineRegionDefinition.minZoom, + ), + minMaxZoomPreference: MinMaxZoomPreference( + widget.item.offlineRegionDefinition.minZoom, + widget.item.offlineRegionDefinition.maxZoom, + ), + styleString: widget.item.offlineRegionDefinition.mapStyleUrl, + cameraTargetBounds: CameraTargetBounds( + widget.item.offlineRegionDefinition.bounds, + ), + ), + ); + } + + LatLng get _center { + final bounds = widget.item.offlineRegionDefinition.bounds; + final lat = (bounds.southwest.latitude + bounds.northeast.latitude) / 2; + final lng = (bounds.southwest.longitude + bounds.northeast.longitude) / 2; + return LatLng(lat, lng); + } +} diff --git a/example/lib/offline_regions.dart b/example/lib/offline_regions.dart new file mode 100644 index 0000000..70b53e5 --- /dev/null +++ b/example/lib/offline_regions.dart @@ -0,0 +1,268 @@ +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:nb_maps_flutter_example/main.dart'; + +import 'offline_region_map.dart'; +import 'page.dart'; + +final LatLngBounds hawaiiBounds = LatLngBounds( + southwest: const LatLng(17.26672, -161.14746), + northeast: const LatLng(23.76523, -153.74267), +); + +final LatLngBounds santiagoBounds = LatLngBounds( + southwest: const LatLng(-33.5597, -70.49102), + northeast: const LatLng(-33.33282, -153.74267), +); + +final LatLngBounds aucklandBounds = LatLngBounds( + southwest: const LatLng(-36.87838, 174.73205), + northeast: const LatLng(-36.82838, 174.79745), +); + +final List regionDefinitions = [ + OfflineRegionDefinition( + bounds: hawaiiBounds, + minZoom: 3.0, + maxZoom: 8.0, + mapStyleUrl: NbMapStyles.NBMAP_STREETS, + ), + OfflineRegionDefinition( + bounds: santiagoBounds, + minZoom: 10.0, + maxZoom: 16.0, + mapStyleUrl: NbMapStyles.NBMAP_STREETS, + ), + OfflineRegionDefinition( + bounds: aucklandBounds, + minZoom: 13.0, + maxZoom: 16.0, + mapStyleUrl: NbMapStyles.NBMAP_STREETS, + ), +]; + +final List regionNames = ['Hawaii', 'Santiago', 'Auckland']; + +class OfflineRegionListItem { + OfflineRegionListItem({ + required this.offlineRegionDefinition, + required this.downloadedId, + required this.isDownloading, + required this.name, + required this.estimatedTiles, + }); + + final OfflineRegionDefinition offlineRegionDefinition; + final int? downloadedId; + final bool isDownloading; + final String name; + final int estimatedTiles; + + OfflineRegionListItem copyWith({ + int? downloadedId, + bool? isDownloading, + }) => + OfflineRegionListItem( + offlineRegionDefinition: offlineRegionDefinition, + name: name, + estimatedTiles: estimatedTiles, + downloadedId: downloadedId, + isDownloading: isDownloading ?? this.isDownloading, + ); + + bool get isDownloaded => downloadedId != null; +} + +final List allRegions = [ + OfflineRegionListItem( + offlineRegionDefinition: regionDefinitions[0], + downloadedId: null, + isDownloading: false, + name: regionNames[0], + estimatedTiles: 61, + ), + OfflineRegionListItem( + offlineRegionDefinition: regionDefinitions[1], + downloadedId: null, + isDownloading: false, + name: regionNames[1], + estimatedTiles: 3580, + ), + OfflineRegionListItem( + offlineRegionDefinition: regionDefinitions[2], + downloadedId: null, + isDownloading: false, + name: regionNames[2], + estimatedTiles: 202, + ), +]; + +class OfflineRegionsPage extends ExamplePage { + OfflineRegionsPage() : super(const Icon(Icons.map), 'Offline Regions'); + + @override + Widget build(BuildContext context) { + return const OfflineRegionBody(); + } +} + +class OfflineRegionBody extends StatefulWidget { + const OfflineRegionBody(); + + @override + _OfflineRegionsBodyState createState() => _OfflineRegionsBodyState(); +} + +class _OfflineRegionsBodyState extends State { + List _items = []; + + @override + void initState() { + super.initState(); + _updateListOfRegions(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), + itemCount: _items.length, + itemBuilder: (context, index) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.map), + onPressed: () => _goToMap(_items[index]), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _items[index].name, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + Text( + 'Est. tiles: ${_items[index].estimatedTiles}', + style: TextStyle( + fontSize: 16, + ), + ), + ], + ), + const Spacer(), + _items[index].isDownloading + ? Container( + child: CircularProgressIndicator(), + height: 16, + width: 16, + ) + : IconButton( + icon: Icon( + _items[index].isDownloaded + ? Icons.delete + : Icons.file_download, + ), + onPressed: _items[index].isDownloaded + ? () => _deleteRegion(_items[index], index) + : () => _downloadRegion(_items[index], index), + ), + ], + ), + ), + ], + ); + } + + void _updateListOfRegions() async { + List offlineRegions = + await getListOfRegions(accessToken: MapsDemo.ACCESS_KEY); + List regionItems = []; + for (var item in allRegions) { + final offlineRegion = offlineRegions.firstWhereOrNull( + (offlineRegion) => offlineRegion.metadata['name'] == item.name); + if (offlineRegion != null) { + regionItems.add(item.copyWith(downloadedId: offlineRegion.id)); + } else { + regionItems.add(item); + } + } + setState(() { + _items.clear(); + _items.addAll(regionItems); + }); + } + + void _downloadRegion(OfflineRegionListItem item, int index) async { + setState(() { + _items.removeAt(index); + _items.insert(index, item.copyWith(isDownloading: true)); + }); + + try { + final downloadingRegion = await downloadOfflineRegion( + item.offlineRegionDefinition, + metadata: { + 'name': regionNames[index], + }, + accessToken: MapsDemo.ACCESS_KEY, + ); + setState(() { + _items.removeAt(index); + _items.insert( + index, + item.copyWith( + isDownloading: false, + downloadedId: downloadingRegion.id, + )); + }); + } on Exception catch (_) { + setState(() { + _items.removeAt(index); + _items.insert( + index, + item.copyWith( + isDownloading: false, + downloadedId: null, + )); + }); + return; + } + } + + void _deleteRegion(OfflineRegionListItem item, int index) async { + setState(() { + _items.removeAt(index); + _items.insert(index, item.copyWith(isDownloading: true)); + }); + + await deleteOfflineRegion( + item.downloadedId!, + accessToken: MapsDemo.ACCESS_KEY, + ); + + setState(() { + _items.removeAt(index); + _items.insert( + index, + item.copyWith( + isDownloading: false, + downloadedId: null, + )); + }); + } + + _goToMap(OfflineRegionListItem item) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OfflineRegionMap(item), + ), + ); + } +} diff --git a/example/lib/page.dart b/example/lib/page.dart new file mode 100644 index 0000000..6ff6bb9 --- /dev/null +++ b/example/lib/page.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +abstract class ExamplePage extends StatelessWidget { + const ExamplePage(this.leading, this.title); + + final Widget leading; + final String title; +} diff --git a/example/lib/place_batch.dart b/example/lib/place_batch.dart new file mode 100644 index 0000000..87c2fa1 --- /dev/null +++ b/example/lib/place_batch.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +const fillOptions = [ + FillOptions( + geometry: [ + [ + LatLng(-33.719, 151.150), + LatLng(-33.858, 151.150), + LatLng(-33.866, 151.401), + LatLng(-33.747, 151.328), + LatLng(-33.719, 151.150), + ], + [ + LatLng(-33.762, 151.250), + LatLng(-33.827, 151.250), + LatLng(-33.833, 151.347), + LatLng(-33.762, 151.250), + ] + ], + fillColor: "#FF0000", + ), + FillOptions(geometry: [ + [ + LatLng(-33.719, 151.550), + LatLng(-33.858, 151.550), + LatLng(-33.866, 151.801), + LatLng(-33.747, 151.728), + LatLng(-33.719, 151.550), + ], + [ + LatLng(-33.762, 151.650), + LatLng(-33.827, 151.650), + LatLng(-33.833, 151.747), + LatLng(-33.762, 151.650), + ] + ], fillColor: "#FF0000"), +]; + +class BatchAddPage extends ExamplePage { + BatchAddPage() : super(const Icon(Icons.check_circle), 'Batch add/remove'); + + @override + Widget build(BuildContext context) { + return const BatchAddBody(); + } +} + +class BatchAddBody extends StatefulWidget { + const BatchAddBody(); + + @override + State createState() => BatchAddBodyState(); +} + +class BatchAddBodyState extends State { + BatchAddBodyState(); + List _fills = []; + List? _circles = []; + List? _lines = []; + List? _symbols = []; + + static final LatLng center = const LatLng(-33.86711, 151.1947171); + + late NextbillionMapController controller; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + } + + List makeLinesOptionsForFillOptions( + Iterable options) { + final listOptions = []; + for (final option in options) { + for (final geom in option.geometry!) { + listOptions.add(LineOptions(geometry: geom, lineColor: "#00FF00")); + } + } + return listOptions; + } + + List makeCircleOptionsForFillOptions( + Iterable options) { + final circleOptions = []; + for (final option in options) { + // put circles only on the outside + for (final latLng in option.geometry!.first) { + circleOptions + .add(CircleOptions(geometry: latLng, circleColor: "#00FF00")); + } + } + return circleOptions; + } + + List makeSymbolOptionsForFillOptions( + Iterable options) { + final symbolOptions = []; + for (final option in options) { + // put symbols only on the inner most ring if it exists + if (option.geometry!.length > 1) + for (final latLng in option.geometry!.last) { + symbolOptions + .add(SymbolOptions(iconImage: 'hospital-11', geometry: latLng)); + } + } + return symbolOptions; + } + + void _add() async { + if (_fills.isEmpty) { + _fills = await controller.addFills(fillOptions); + _lines = await controller + .addLines(makeLinesOptionsForFillOptions(fillOptions)); + _circles = await controller + .addCircles(makeCircleOptionsForFillOptions(fillOptions)); + _symbols = await controller + .addSymbols(makeSymbolOptionsForFillOptions(fillOptions)); + } + } + + void _remove() { + controller.removeFills(_fills); + if (_lines != null) { + controller.removeLines(_lines!); + } + if (_circles != null) { + controller.removeCircles(_circles!); + } + if (_symbols != null) { + controller.removeSymbols(_symbols!); + } + _fills.clear(); + _lines?.clear(); + _circles?.clear(); + _symbols?.clear(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + height: 200.0, + child: NBMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.8, 151.511), + zoom: 8.2, + ), + annotationOrder: const [ + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + AnnotationType.symbol, + ], + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Column( + children: [ + TextButton( + child: const Text('batch add'), onPressed: _add), + TextButton( + child: const Text('batch remove'), + onPressed: _remove), + ], + ), + ], + ) + ], + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/place_circle.dart b/example/lib/place_circle.dart new file mode 100644 index 0000000..1f37779 --- /dev/null +++ b/example/lib/place_circle.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class PlaceCirclePage extends ExamplePage { + PlaceCirclePage() : super(const Icon(Icons.check_circle), 'Place circle'); + + @override + Widget build(BuildContext context) { + return const PlaceCircleBody(); + } +} + +class PlaceCircleBody extends StatefulWidget { + const PlaceCircleBody(); + + @override + State createState() => PlaceCircleBodyState(); +} + +class PlaceCircleBodyState extends State { + PlaceCircleBodyState(); + + static final LatLng center = const LatLng(-33.86711, 151.1947171); + + NextbillionMapController? controller; + int _circleCount = 0; + Circle? _selectedCircle; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + controller.onCircleTapped.add(_onCircleTapped); + } + + @override + void dispose() { + controller?.onCircleTapped.remove(_onCircleTapped); + super.dispose(); + } + + void _onCircleTapped(Circle circle) { + if (_selectedCircle != null) { + _updateSelectedCircle( + const CircleOptions(circleRadius: 60), + ); + } + setState(() { + _selectedCircle = circle; + }); + _updateSelectedCircle( + CircleOptions( + circleRadius: 30, + ), + ); + } + + void _updateSelectedCircle(CircleOptions changes) { + controller!.updateCircle(_selectedCircle!, changes); + } + + void _add() { + controller!.addCircle( + CircleOptions( + geometry: LatLng( + center.latitude + sin(_circleCount * pi / 6.0) / 20.0, + center.longitude + cos(_circleCount * pi / 6.0) / 20.0, + ), + circleColor: "#FF0000"), + ); + setState(() { + _circleCount += 1; + }); + } + + void _remove() { + controller!.removeCircle(_selectedCircle!); + setState(() { + _selectedCircle = null; + _circleCount -= 1; + }); + } + + void _changePosition() { + final LatLng current = _selectedCircle!.options.geometry!; + final Offset offset = Offset( + center.latitude - current.latitude, + center.longitude - current.longitude, + ); + _updateSelectedCircle( + CircleOptions( + geometry: LatLng( + center.latitude + offset.dy, + center.longitude + offset.dx, + ), + ), + ); + } + + void _changeDraggable() { + bool? draggable = _selectedCircle!.options.draggable; + if (draggable == null) { + // default value + draggable = false; + } + _updateSelectedCircle( + CircleOptions( + draggable: !draggable, + ), + ); + } + + void _getLatLng() async { + LatLng? latLng = await controller!.getCircleLatLng(_selectedCircle!); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(latLng.toString()), + ), + ); + } + + void _changeCircleStrokeOpacity() { + double? current = _selectedCircle!.options.circleStrokeOpacity; + if (current == null) { + // default value + current = 1.0; + } + + _updateSelectedCircle( + CircleOptions(circleStrokeOpacity: current < 0.1 ? 1.0 : current * 0.75), + ); + } + + void _changeCircleStrokeWidth() { + double? current = _selectedCircle!.options.circleStrokeWidth; + if (current == null) { + // default value + current = 0; + } + _updateSelectedCircle( + CircleOptions(circleStrokeWidth: current == 0 ? 5.0 : 0)); + } + + Future _changeCircleStrokeColor() async { + String? current = _selectedCircle!.options.circleStrokeColor; + if (current == null) { + // default value + current = "#FFFFFF"; + } + + _updateSelectedCircle( + CircleOptions( + circleStrokeColor: current == "#FFFFFF" ? "#FF0000" : "#FFFFFF"), + ); + } + + Future _changeCircleOpacity() async { + double? current = _selectedCircle!.options.circleOpacity; + if (current == null) { + // default value + current = 1.0; + } + + _updateSelectedCircle( + CircleOptions(circleOpacity: current < 0.1 ? 1.0 : current * 0.75), + ); + } + + Future _changeCircleRadius() async { + double? current = _selectedCircle!.options.circleRadius; + if (current == null) { + // default value + current = 0; + } + _updateSelectedCircle( + CircleOptions(circleRadius: current == 120.0 ? 30.0 : current + 30.0), + ); + } + + Future _changeCircleColor() async { + String? current = _selectedCircle!.options.circleColor; + if (current == null) { + // default value + current = "#FF0000"; + } + + _updateSelectedCircle( + CircleOptions(circleColor: "#FFFF00"), + ); + } + + Future _changeCircleBlur() async { + double? current = _selectedCircle!.options.circleBlur; + if (current == null) { + // default value + current = 0; + } + _updateSelectedCircle( + CircleOptions(circleBlur: current == 0.75 ? 0 : 0.75), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 300.0, + height: 200.0, + child: NBMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Column( + children: [ + TextButton( + child: const Text('add'), + onPressed: (_circleCount == 12) ? null : _add, + ), + TextButton( + child: const Text('remove'), + onPressed: (_selectedCircle == null) ? null : _remove, + ), + ], + ), + Column( + children: [ + TextButton( + child: const Text('change circle-opacity'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleOpacity, + ), + TextButton( + child: const Text('change circle-radius'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleRadius, + ), + TextButton( + child: const Text('change circle-color'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleColor, + ), + TextButton( + child: const Text('change circle-blur'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleBlur, + ), + TextButton( + child: const Text('change circle-stroke-width'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleStrokeWidth, + ), + TextButton( + child: const Text('change circle-stroke-color'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleStrokeColor, + ), + TextButton( + child: const Text('change circle-stroke-opacity'), + onPressed: (_selectedCircle == null) + ? null + : _changeCircleStrokeOpacity, + ), + TextButton( + child: const Text('change position'), + onPressed: (_selectedCircle == null) + ? null + : _changePosition, + ), + TextButton( + child: const Text('toggle draggable'), + onPressed: (_selectedCircle == null) + ? null + : _changeDraggable, + ), + TextButton( + child: const Text('get current LatLng'), + onPressed: + (_selectedCircle == null) ? null : _getLatLng, + ), + ], + ), + ], + ) + ], + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/place_fill.dart b/example/lib/place_fill.dart new file mode 100644 index 0000000..2bc2dc7 --- /dev/null +++ b/example/lib/place_fill.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class PlaceFillPage extends ExamplePage { + PlaceFillPage() : super(const Icon(Icons.check_circle), 'Place fill'); + + @override + Widget build(BuildContext context) { + return const PlaceFillBody(); + } +} + +class PlaceFillBody extends StatefulWidget { + const PlaceFillBody(); + + @override + State createState() => PlaceFillBodyState(); +} + +class PlaceFillBodyState extends State { + PlaceFillBodyState(); + + static final LatLng center = const LatLng(-33.86711, 151.1947171); + final String _fillPatternImage = "assets/fill/cat_silhouette_pattern.png"; + + final List> _defaultGeometry = [ + [ + LatLng(-33.719, 151.150), + LatLng(-33.858, 151.150), + LatLng(-33.866, 151.401), + LatLng(-33.747, 151.328), + LatLng(-33.719, 151.150), + ], + [ + LatLng(-33.762, 151.250), + LatLng(-33.827, 151.250), + LatLng(-33.833, 151.347), + LatLng(-33.762, 151.250), + ] + ]; + + NextbillionMapController? controller; + int _fillCount = 0; + Fill? _selectedFill; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + controller.onFillTapped.add(_onFillTapped); + this.controller!.onFeatureDrag.add(_onFeatureDrag); + } + + void _onFeatureDrag(id, + {required current, + required delta, + required origin, + required point, + required eventType}) { + DragEventType type = eventType; + switch (type) { + case DragEventType.start: + // TODO: Handle this case. + break; + case DragEventType.drag: + // TODO: Handle this case. + break; + case DragEventType.end: + // TODO: Handle this case. + break; + } + } + + void _onStyleLoaded() { + addImageFromAsset("assetImage", _fillPatternImage); + } + + /// Adds an asset image to the currently displayed style + Future addImageFromAsset(String name, String assetName) async { + final ByteData bytes = await rootBundle.load(assetName); + final Uint8List list = bytes.buffer.asUint8List(); + return controller!.addImage(name, list); + } + + @override + void dispose() { + controller?.onFillTapped.remove(_onFillTapped); + super.dispose(); + } + + void _onFillTapped(Fill fill) { + setState(() { + _selectedFill = fill; + }); + } + + void _updateSelectedFill(FillOptions changes) { + controller!.updateFill(_selectedFill!, changes); + } + + void _add() { + controller!.addFill( + FillOptions( + geometry: _defaultGeometry, + fillColor: "#FF0000", + fillOutlineColor: "#FF0000"), + ); + setState(() { + _fillCount += 1; + }); + } + + void _remove() { + controller!.removeFill(_selectedFill!); + setState(() { + _selectedFill = null; + _fillCount -= 1; + }); + } + + void _changePosition() { + List>? geometry = _selectedFill!.options.geometry; + + if (geometry == null) { + geometry = _defaultGeometry; + } + + _updateSelectedFill(FillOptions( + geometry: geometry + .map((list) => list + .map( + // Move to right with 0.1 degree on longitude + (latLng) => LatLng(latLng.latitude, latLng.longitude + 0.1)) + .toList()) + .toList())); + } + + void _changeDraggable() { + bool? draggable = _selectedFill!.options.draggable; + if (draggable == null) { + // default value + draggable = false; + } + _updateSelectedFill( + FillOptions(draggable: !draggable), + ); + } + + Future _changeFillOpacity() async { + double? current = _selectedFill!.options.fillOpacity; + if (current == null) { + // default value + current = 1.0; + } + + _updateSelectedFill( + FillOptions(fillOpacity: current < 0.1 ? 1.0 : current * 0.75), + ); + } + + Future _changeFillColor() async { + String? current = _selectedFill!.options.fillColor; + if (current == null) { + // default value + current = "#FF0000"; + } + + _updateSelectedFill( + FillOptions(fillColor: "#FFFF00"), + ); + } + + Future _changeFillOutlineColor() async { + String? current = _selectedFill!.options.fillOutlineColor; + if (current == null) { + // default value + current = "#FF0000"; + } + + _updateSelectedFill( + FillOptions(fillOutlineColor: "#FFFF00"), + ); + } + + Future _changeFillPattern() async { + String? current = + _selectedFill!.options.fillPattern == null ? "assetImage" : null; + _updateSelectedFill( + FillOptions(fillPattern: current), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 300.0, + height: 200.0, + child: NBMap( + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 7.0, + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Column( + children: [ + TextButton( + child: const Text('add'), + onPressed: (_fillCount == 12) ? null : _add, + ), + TextButton( + child: const Text('remove'), + onPressed: (_selectedFill == null) ? null : _remove, + ), + ], + ), + Column( + children: [ + TextButton( + child: const Text('change fill-opacity'), + onPressed: (_selectedFill == null) + ? null + : _changeFillOpacity, + ), + TextButton( + child: const Text('change fill-color'), + onPressed: + (_selectedFill == null) ? null : _changeFillColor, + ), + TextButton( + child: const Text('change fill-outline-color'), + onPressed: (_selectedFill == null) + ? null + : _changeFillOutlineColor, + ), + TextButton( + child: const Text('change fill-pattern'), + onPressed: (_selectedFill == null) + ? null + : _changeFillPattern, + ), + TextButton( + child: const Text('change position'), + onPressed: + (_selectedFill == null) ? null : _changePosition, + ), + TextButton( + child: const Text('toggle draggable'), + onPressed: + (_selectedFill == null) ? null : _changeDraggable, + ), + ], + ), + ], + ) + ], + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/place_source.dart b/example/lib/place_source.dart new file mode 100644 index 0000000..2531b8a --- /dev/null +++ b/example/lib/place_source.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class PlaceSourcePage extends ExamplePage { + PlaceSourcePage() : super(const Icon(Icons.place), 'Place source'); + + @override + Widget build(BuildContext context) { + return const PlaceSymbolBody(); + } +} + +class PlaceSymbolBody extends StatefulWidget { + const PlaceSymbolBody(); + + @override + State createState() => PlaceSymbolBodyState(); +} + +class PlaceSymbolBodyState extends State { + PlaceSymbolBodyState(); + + static const SOURCE_ID = 'sydney_source'; + static const LAYER_ID = 'sydney_layer'; + + bool sourceAdded = false; + bool layerAdded = false; + late NextbillionMapController controller; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + } + + @override + void dispose() { + super.dispose(); + } + + /// Adds an asset image as a source to the currently displayed style + Future addImageSourceFromAsset( + String imageSourceId, String assetName) async { + final ByteData bytes = await rootBundle.load(assetName); + final Uint8List list = bytes.buffer.asUint8List(); + return controller.addImageSource( + imageSourceId, + list, + const LatLngQuad( + bottomRight: LatLng(-33.86264728692581, 151.19916915893555), + bottomLeft: LatLng(-33.86264728692581, 151.2288236618042), + topLeft: LatLng(-33.84322353475214, 151.2288236618042), + topRight: LatLng(-33.84322353475214, 151.19916915893555), + ), + ); + } + + /// Update an asset image as a source to the currently displayed style + Future updateImageSourceFromAsset( + String imageSourceId, String assetName) async { + final ByteData bytes = await rootBundle.load(assetName); + final Uint8List list = bytes.buffer.asUint8List(); + return controller.updateImageSource( + imageSourceId, + list, + const LatLngQuad( + bottomRight: LatLng(-33.89884564291081, 151.25229835510254), + bottomLeft: LatLng(-33.89884564291081, 151.20131492614746), + topLeft: LatLng(-33.934601369931634, 151.20131492614746), + topRight: LatLng(-33.934601369931634, 151.25229835510254), + ), + ); + } + + Future removeImageSource(String imageSourceId) { + return controller.removeSource(imageSourceId); + } + + Future addLayer(String imageLayerId, String imageSourceId) { + if (layerAdded) { + removeLayer(imageLayerId); + } + setState(() => layerAdded = true); + return controller.addImageLayer(imageLayerId, imageSourceId); + } + + Future addLayerBelow( + String imageLayerId, String imageSourceId, String belowLayerId) { + if (layerAdded) { + removeLayer(imageLayerId); + } + setState(() => layerAdded = true); + return controller.addImageLayerBelow( + imageLayerId, imageSourceId, belowLayerId); + } + + Future removeLayer(String imageLayerId) { + setState(() => layerAdded = false); + return controller.removeLayer(imageLayerId); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 300.0, + child: NBMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 10.0, + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + TextButton( + child: const Text('Add source (asset image)'), + onPressed: sourceAdded + ? null + : () { + addImageSourceFromAsset(SOURCE_ID, + 'assets/symbols/custom-icon.png') + .then((value) { + setState(() => sourceAdded = true); + }); + }, + ), + TextButton( + child: const Text('Update source (asset image)'), + onPressed: !sourceAdded + ? null + : () { + updateImageSourceFromAsset(SOURCE_ID, + 'assets/symbols/custom-icon.png') + .then((value) { + setState(() => sourceAdded = true); + }); + }, + ), + TextButton( + child: const Text('Remove source (asset image)'), + onPressed: sourceAdded + ? () async { + await removeLayer(LAYER_ID); + removeImageSource(SOURCE_ID).then((value) { + setState(() => sourceAdded = false); + }); + } + : null, + ), + TextButton( + child: const Text('Show layer'), + onPressed: sourceAdded + ? () => addLayer(LAYER_ID, SOURCE_ID) + : null, + ), + TextButton( + child: const Text('Show layer below water'), + onPressed: sourceAdded + ? () => addLayerBelow(LAYER_ID, SOURCE_ID, 'water') + : null, + ), + TextButton( + child: const Text('Hide layer'), + onPressed: + sourceAdded ? () => removeLayer(LAYER_ID) : null, + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/place_symbol.dart b/example/lib/place_symbol.dart new file mode 100644 index 0000000..a7ff315 --- /dev/null +++ b/example/lib/place_symbol.dart @@ -0,0 +1,419 @@ +import 'dart:async'; // ignore: unnecessary_import +import 'dart:core'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'page.dart'; + +class PlaceSymbolPage extends ExamplePage { + PlaceSymbolPage() : super(const Icon(Icons.place), 'Place symbol'); + + @override + Widget build(BuildContext context) { + return const PlaceSymbolBody(); + } +} + +class PlaceSymbolBody extends StatefulWidget { + const PlaceSymbolBody(); + + @override + State createState() => PlaceSymbolBodyState(); +} + +class PlaceSymbolBodyState extends State { + PlaceSymbolBodyState(); + + static final LatLng center = const LatLng(-33.86711, 151.1947171); + + NextbillionMapController? controller; + int _symbolCount = 0; + Symbol? _selectedSymbol; + bool _iconAllowOverlap = false; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + controller.onSymbolTapped.add(_onSymbolTapped); + } + + void _onStyleLoaded() { + addImageFromAsset("assetImage", "assets/symbols/custom-icon.png"); + addImageFromUrl( + "networkImage", Uri.parse("https://via.placeholder.com/50")); + } + + @override + void dispose() { + controller?.onSymbolTapped.remove(_onSymbolTapped); + super.dispose(); + } + + /// Adds an asset image to the currently displayed style + Future addImageFromAsset(String name, String assetName) async { + final ByteData bytes = await rootBundle.load(assetName); + final Uint8List list = bytes.buffer.asUint8List(); + return controller!.addImage(name, list); + } + + /// Adds a network image to the currently displayed style + Future addImageFromUrl(String name, Uri uri) async { + var response = await http.get(uri); + return controller!.addImage(name, response.bodyBytes); + } + + void _onSymbolTapped(Symbol symbol) { + if (_selectedSymbol != null) { + _updateSelectedSymbol( + const SymbolOptions(iconSize: 1.0), + ); + } + setState(() { + _selectedSymbol = symbol; + }); + _updateSelectedSymbol( + SymbolOptions( + iconSize: 1.4, + ), + ); + } + + void _updateSelectedSymbol(SymbolOptions changes) async { + await controller!.updateSymbol(_selectedSymbol!, changes); + } + + void _add(String iconImage) { + List availableNumbers = Iterable.generate(12).toList(); + controller!.symbols.forEach( + (s) => availableNumbers.removeWhere((i) => i == s.data!['count'])); + print("========add==${availableNumbers}"); + if (availableNumbers.isNotEmpty) { + controller!.addSymbol( + _getSymbolOptions(iconImage, availableNumbers.first), + {'count': availableNumbers.first}); + setState(() { + _symbolCount += 1; + }); + } + } + + SymbolOptions _getSymbolOptions(String iconImage, int symbolCount) { + LatLng geometry = LatLng( + center.latitude + sin(symbolCount * pi / 6.0) / 20.0, + center.longitude + cos(symbolCount * pi / 6.0) / 20.0, + ); + return iconImage == 'customFont' + ? SymbolOptions( + geometry: geometry, + iconImage: 'airport-15', + fontNames: ['DIN Offc Pro Bold', 'Arial Unicode MS Regular'], + textField: 'Airport', + textSize: 12.5, + textOffset: Offset(0, 0.8), + textAnchor: 'top', + textColor: '#000000', + textHaloBlur: 1, + textHaloColor: '#ffffff', + textHaloWidth: 0.8, + ) + : SymbolOptions( + geometry: geometry, + textField: 'Airport', + textOffset: Offset(0, 0.8), + iconImage: iconImage, + ); + } + + Future _addAll(String iconImage) async { + List symbolsToAddNumbers = Iterable.generate(12).toList(); + controller!.symbols.forEach( + (s) => symbolsToAddNumbers.removeWhere((i) => i == s.data!['count'])); + + if (symbolsToAddNumbers.isNotEmpty) { + final List symbolOptionsList = symbolsToAddNumbers + .map((i) => _getSymbolOptions(iconImage, i)) + .toList(); + controller!.addSymbols(symbolOptionsList, + symbolsToAddNumbers.map((i) => {'count': i}).toList()); + + setState(() { + _symbolCount += symbolOptionsList.length; + }); + } + } + + void _remove() { + controller!.removeSymbol(_selectedSymbol!); + setState(() { + _selectedSymbol = null; + _symbolCount -= 1; + }); + } + + void _removeAll() { + controller!.removeSymbols(controller!.symbols); + setState(() { + _selectedSymbol = null; + _symbolCount = 0; + }); + } + + void _changePosition() { + final LatLng current = _selectedSymbol!.options.geometry!; + final Offset offset = Offset( + center.latitude - current.latitude, + center.longitude - current.longitude, + ); + _updateSelectedSymbol( + SymbolOptions( + geometry: LatLng( + center.latitude + offset.dy, + center.longitude + offset.dx, + ), + ), + ); + } + + void _changeIconOffset() { + Offset? currentAnchor = _selectedSymbol!.options.iconOffset; + if (currentAnchor == null) { + // default value + currentAnchor = Offset(0.0, 0.0); + } + final Offset newAnchor = Offset(1.0 - currentAnchor.dy, currentAnchor.dx); + _updateSelectedSymbol(SymbolOptions(iconOffset: newAnchor)); + } + + Future _changeIconAnchor() async { + String? current = _selectedSymbol!.options.iconAnchor; + if (current == null || current == 'center') { + current = 'bottom'; + } else { + current = 'center'; + } + _updateSelectedSymbol( + SymbolOptions(iconAnchor: current), + ); + } + + Future _toggleDraggable() async { + bool? draggable = _selectedSymbol!.options.draggable; + if (draggable == null) { + // default value + draggable = false; + } + + _updateSelectedSymbol( + SymbolOptions(draggable: !draggable), + ); + } + + Future _changeAlpha() async { + double? current = _selectedSymbol!.options.iconOpacity; + if (current == null) { + // default value + current = 1.0; + } + + _updateSelectedSymbol( + SymbolOptions(iconOpacity: current < 0.1 ? 1.0 : current * 0.75), + ); + } + + Future _changeRotation() async { + double? current = _selectedSymbol!.options.iconRotate; + if (current == null) { + // default value + current = 0; + } + _updateSelectedSymbol( + SymbolOptions(iconRotate: current == 330.0 ? 0.0 : current + 30.0), + ); + } + + Future _toggleVisible() async { + double? current = _selectedSymbol!.options.iconOpacity; + if (current == null) { + // default value + current = 1.0; + } + + _updateSelectedSymbol( + SymbolOptions(iconOpacity: current == 0.0 ? 1.0 : 0.0), + ); + } + + Future _changeZIndex() async { + int? current = _selectedSymbol!.options.zIndex; + if (current == null) { + // default value + current = 0; + } + _updateSelectedSymbol( + SymbolOptions(zIndex: current == 12 ? 0 : current + 1), + ); + } + + void _getLatLng() async { + LatLng? latLng = await controller!.getSymbolLatLng(_selectedSymbol!); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(latLng.toString()), + ), + ); + } + + Future _changeIconOverlap() async { + setState(() { + _iconAllowOverlap = !_iconAllowOverlap; + }); + await controller!.setSymbolIconAllowOverlap(_iconAllowOverlap); + await controller!.setSymbolTextAllowOverlap(_iconAllowOverlap); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + height: 300.0, + child: NBMap( + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + styleString: "https://api.nextbillion.io/maps/streets/style.json", + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Column( + children: [ + TextButton( + child: const Text('add'), + onPressed: () => + (_symbolCount == 12) ? null : _add("airport-15"), + ), + TextButton( + child: const Text('add all'), + onPressed: () => (_symbolCount == 12) + ? null + : _addAll("airport-15"), + ), + TextButton( + child: const Text('add (custom icon)'), + onPressed: () => (_symbolCount == 12) + ? null + : _add("assets/symbols/custom-icon.png"), + ), + TextButton( + child: const Text('remove'), + onPressed: (_selectedSymbol == null) ? null : _remove, + ), + TextButton( + child: Text( + '${_iconAllowOverlap ? 'disable' : 'enable'} icon overlap'), + onPressed: _changeIconOverlap, + ), + TextButton( + child: const Text('remove all'), + onPressed: (_symbolCount == 0) ? null : _removeAll, + ), + TextButton( + child: const Text('add (asset image)'), + onPressed: () => (_symbolCount == 12) + ? null + : _add( + "assetImage"), //assetImage added to the style in _onStyleLoaded + ), + TextButton( + child: const Text('add (network image)'), + onPressed: () => (_symbolCount == 12) + ? null + : _add( + "networkImage"), //networkImage added to the style in _onStyleLoaded + ), + TextButton( + child: const Text('add (custom font)'), + onPressed: () => + (_symbolCount == 12) ? null : _add("customFont"), + ) + ], + ), + Column( + children: [ + TextButton( + child: const Text('change alpha'), + onPressed: + (_selectedSymbol == null) ? null : _changeAlpha, + ), + TextButton( + child: const Text('change icon offset'), + onPressed: (_selectedSymbol == null) + ? null + : _changeIconOffset, + ), + TextButton( + child: const Text('change icon anchor'), + onPressed: (_selectedSymbol == null) + ? null + : _changeIconAnchor, + ), + TextButton( + child: const Text('toggle draggable'), + onPressed: (_selectedSymbol == null) + ? null + : _toggleDraggable, + ), + TextButton( + child: const Text('change position'), + onPressed: (_selectedSymbol == null) + ? null + : _changePosition, + ), + TextButton( + child: const Text('change rotation'), + onPressed: (_selectedSymbol == null) + ? null + : _changeRotation, + ), + TextButton( + child: const Text('toggle visible'), + onPressed: + (_selectedSymbol == null) ? null : _toggleVisible, + ), + TextButton( + child: const Text('change zIndex'), + onPressed: + (_selectedSymbol == null) ? null : _changeZIndex, + ), + TextButton( + child: const Text('get current LatLng'), + onPressed: + (_selectedSymbol == null) ? null : _getLatLng, + ), + ], + ), + ], + ) + ], + ), + ), + ), + ], + ); + } +} diff --git a/example/lib/scrolling_map.dart b/example/lib/scrolling_map.dart new file mode 100644 index 0000000..a31f772 --- /dev/null +++ b/example/lib/scrolling_map.dart @@ -0,0 +1,136 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; // ignore: unnecessary_import +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class ScrollingMapPage extends ExamplePage { + ScrollingMapPage() : super(const Icon(Icons.map), 'Scrolling map'); + + @override + Widget build(BuildContext context) { + return ScrollingMapBody(); + } +} + +class ScrollingMapBody extends StatefulWidget { + ScrollingMapBody(); + + @override + _ScrollingMapBodyState createState() => _ScrollingMapBodyState(); +} + +class _ScrollingMapBodyState extends State { + late NextbillionMapController controllerOne; + late NextbillionMapController controllerTwo; + + final LatLng center = const LatLng(32.080664, 34.9563837); + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 30.0), + child: Column( + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 12.0), + child: Text('This map consumes all touch events.'), + ), + Center( + child: SizedBox( + width: 300.0, + height: 300.0, + child: NBMap( + onMapCreated: onMapCreatedOne, + onStyleLoadedCallback: () => onStyleLoaded(controllerOne), + initialCameraPosition: CameraPosition( + target: center, + zoom: 11.0, + ), + gestureRecognizers: + >[ + Factory( + () => EagerGestureRecognizer(), + ), + ].toSet(), + ), + ), + ), + ], + ), + ), + ), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 30.0), + child: Column( + children: [ + const Text('This map doesn\'t consume the vertical drags.'), + const Padding( + padding: EdgeInsets.only(bottom: 12.0), + child: + Text('It still gets other gestures (e.g scale or tap).'), + ), + Center( + child: SizedBox( + width: 300.0, + height: 300.0, + child: NBMap( + onMapCreated: onMapCreatedTwo, + onStyleLoadedCallback: () => onStyleLoaded(controllerTwo), + initialCameraPosition: CameraPosition( + target: center, + zoom: 11.0, + ), + gestureRecognizers: + >[ + Factory( + () => ScaleGestureRecognizer(), + ), + ].toSet(), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + void onMapCreatedOne(NextbillionMapController controller) { + this.controllerOne = controller; + } + + void onMapCreatedTwo(NextbillionMapController controller) { + this.controllerTwo = controller; + } + + void onStyleLoaded(NextbillionMapController controller) { + controller.addSymbol(SymbolOptions( + geometry: LatLng( + center.latitude, + center.longitude, + ), + iconImage: "airport-15")); + controller.addLine( + LineOptions( + geometry: [ + LatLng(-33.86711, 151.1947171), + LatLng(-33.86711, 151.1947171), + LatLng(-32.86711, 151.1947171), + LatLng(-33.86711, 152.1947171), + ], + lineColor: "#ff0000", + lineWidth: 7.0, + lineOpacity: 0.5, + ), + ); + } +} diff --git a/example/lib/sources.dart b/example/lib/sources.dart new file mode 100644 index 0000000..096b25c --- /dev/null +++ b/example/lib/sources.dart @@ -0,0 +1,361 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class StyleInfo { + final String name; + final String baseStyle; + final Future Function(NextbillionMapController) addDetails; + final CameraPosition position; + + const StyleInfo( + {required this.name, + required this.baseStyle, + required this.addDetails, + required this.position}); +} + +class Sources extends ExamplePage { + Sources() : super(const Icon(Icons.map), 'Various Sources'); + + @override + Widget build(BuildContext context) { + return const FullMap(); + } +} + +class FullMap extends StatefulWidget { + const FullMap(); + + @override + State createState() => FullMapState(); +} + +class FullMapState extends State { + NextbillionMapController? controller; + final watercolorRasterId = "watercolorRaster"; + int selectedStyleId = 0; + + _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + } + + static Future addRaster(NextbillionMapController controller) async { + await controller.addSource( + "watercolor", + RasterSourceProperties( + tiles: [ + 'https://stamen-tiles.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg' + ], + tileSize: 256, + attribution: + 'Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under CC BY SA'), + ); + await controller.addLayer( + "watercolor", "watercolor", RasterLayerProperties()); + } + + static Future addGeojsonCluster( + NextbillionMapController controller) async { + await controller.addSource( + "earthquakes", + GeojsonSourceProperties( + data: '', + cluster: true, + clusterMaxZoom: 14, // Max zoom to cluster points on + clusterRadius: + 50 // Radius of each cluster when clustering points (defaults to 50) + )); + await controller.addLayer( + "earthquakes", + "earthquakes-circles", + CircleLayerProperties(circleColor: [ + Expressions.step, + [Expressions.get, 'point_count'], + '#51bbd6', + 100, + '#f1f075', + 750, + '#f28cb1' + ], circleRadius: [ + Expressions.step, + [Expressions.get, 'point_count'], + 20, + 100, + 30, + 750, + 40 + ])); + await controller.addLayer( + "earthquakes", + "earthquakes-count", + SymbolLayerProperties( + textField: [Expressions.get, 'point_count_abbreviated'], + textFont: ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + textSize: 12, + )); + } + + static Future addGeojsonHeatmap( + NextbillionMapController controller) async { + await controller.addSource( + "earthquakes-heatmap-source", + GeojsonSourceProperties( + data: '', + )); + await controller.addLayer( + "earthquakes-heatmap-source", + "earthquakes-heatmap-layer", + HeatmapLayerProperties( + heatmapColor: [ + Expressions.interpolate, + ["linear"], + ["heatmap-density"], + 0, + "rgba(33.0, 102.0, 172.0, 0.0)", + 0.2, + "rgb(103.0, 169.0, 207.0)", + 0.4, + "rgb(209.0, 229.0, 240.0)", + 0.6, + "rgb(253.0, 219.0, 240.0)", + 0.8, + "rgb(239.0, 138.0, 98.0)", + 1, + "rgb(178.0, 24.0, 43.0)", + ], + heatmapWeight: [ + Expressions.interpolate, + ["linear"], + [Expressions.get, "mag"], + 0, + 0, + 6, + 1, + ], + heatmapIntensity: [ + Expressions.interpolate, + ["linear"], + [Expressions.zoom], + 0, + 1, + 9, + 3, + ], + heatmapRadius: [ + Expressions.interpolate, + ["linear"], + [Expressions.zoom], + 0, + 2, + 9, + 20, + ], + heatmapOpacity: [ + Expressions.interpolate, + ["linear"], + [Expressions.zoom], + 7, + 1, + 9, + 0.5 + ], + )); + } + + static Future addIndoorBuilding( + NextbillionMapController controller) async { + final jsonStr = + await rootBundle.loadString("assets/fill-extrusion/indoor_3d_map.json"); + await controller.addGeoJsonSource( + "indoor-building-source", jsonDecode(jsonStr)); + await controller.addFillExtrusionLayer( + "indoor-building-source", + "indoor-building-layer", + FillExtrusionLayerProperties( + fillExtrusionOpacity: 0.5, + fillExtrusionHeight: [Expressions.get, "height"], + fillExtrusionBase: [Expressions.get, "base_height"], + fillExtrusionColor: [Expressions.get, "color"], + )); + } + + static Future addVector(NextbillionMapController controller) async { + await controller.addSource( + "terrain", + VectorSourceProperties( + url: "", + )); + + await controller.addLayer( + "terrain", + "contour", + LineLayerProperties( + lineColor: "#ff69b4", + lineWidth: 1, + lineCap: "round", + lineJoin: "round", + ), + sourceLayer: "contour"); + } + + static Future addImage(NextbillionMapController controller) async { + await controller.addSource( + "radar", + ImageSourceProperties(url: "", coordinates: [ + [-80.425, 46.437], + [-71.516, 46.437], + [-71.516, 37.936], + [-80.425, 37.936] + ])); + + await controller.addRasterLayer( + "radar", + "radar", + RasterLayerProperties(rasterFadeDuration: 0), + ); + } + + static Future addVideo(NextbillionMapController controller) async { + await controller.addSource( + "video", + VideoSourceProperties(urls: [ + '', + '' + ], coordinates: [ + [-122.51596391201019, 37.56238816766053], + [-122.51467645168304, 37.56410183312965], + [-122.51309394836426, 37.563391708549425], + [-122.51423120498657, 37.56161849366671] + ])); + + await controller.addRasterLayer( + "video", + "video", + RasterLayerProperties(), + ); + } + + static Future addDem(NextbillionMapController controller) async { + await controller.addSource("dem", RasterDemSourceProperties(url: "")); + + await controller.addLayer( + "dem", + "hillshade", + HillshadeLayerProperties( + hillshadeExaggeration: 1, + hillshadeShadowColor: Colors.blue.toHexStringRGB()), + ); + } + + static const _stylesAndLoaders = [ + StyleInfo( + name: "Vector", + baseStyle: NbMapStyles.LIGHT, + addDetails: addVector, + position: CameraPosition(target: LatLng(33.3832, -118.4333), zoom: 12), + ), + StyleInfo( + name: "Dem", + baseStyle: NbMapStyles.EMPTY, + addDetails: addDem, + position: CameraPosition(target: LatLng(33.5, -118.1), zoom: 8), + ), + StyleInfo( + name: "Geojson cluster", + baseStyle: NbMapStyles.LIGHT, + addDetails: addGeojsonCluster, + position: CameraPosition(target: LatLng(33.5, -118.1), zoom: 5), + ), + StyleInfo( + name: "Geojson heatmap", + baseStyle: NbMapStyles.DARK, + addDetails: addGeojsonHeatmap, + position: CameraPosition(target: LatLng(33.5, -118.1), zoom: 5), + ), + StyleInfo( + name: "Indoor Building", + baseStyle: NbMapStyles.LIGHT, + addDetails: addIndoorBuilding, + position: CameraPosition( + target: LatLng(41.86625, -87.61694), zoom: 16, tilt: 20, bearing: 40), + ), + StyleInfo( + name: "Raster", + baseStyle: NbMapStyles.EMPTY, + addDetails: addRaster, + position: CameraPosition(target: LatLng(40, -100), zoom: 3), + ), + StyleInfo( + name: "Image", + baseStyle: NbMapStyles.DARK, + addDetails: addImage, + position: CameraPosition(target: LatLng(43, -75), zoom: 6), + ), + //video only supported on web + if (kIsWeb) + StyleInfo( + name: "Video", + baseStyle: NbMapStyles.SATELLITE, + addDetails: addVideo, + position: CameraPosition( + target: LatLng(37.562984, -122.514426), zoom: 17, bearing: -96), + ), + ]; + + _onStyleLoadedCallback() async { + final styleInfo = _stylesAndLoaders[selectedStyleId]; + styleInfo.addDetails(controller!); + controller! + .animateCamera(CameraUpdate.newCameraPosition(styleInfo.position)); + } + + @override + Widget build(BuildContext context) { + final styleInfo = _stylesAndLoaders[selectedStyleId]; + final nextName = + _stylesAndLoaders[(selectedStyleId + 1) % _stylesAndLoaders.length] + .name; + return new Scaffold( + floatingActionButton: Padding( + padding: const EdgeInsets.all(32.0), + child: FloatingActionButton.extended( + icon: Icon(Icons.swap_horiz), + label: SizedBox( + width: 120, child: Center(child: Text("To $nextName"))), + onPressed: () => setState( + () => selectedStyleId = + (selectedStyleId + 1) % _stylesAndLoaders.length, + ), + ), + ), + body: Stack( + children: [ + NBMap( + styleString: styleInfo.baseStyle, + onMapCreated: _onMapCreated, + initialCameraPosition: styleInfo.position, + onStyleLoadedCallback: _onStyleLoadedCallback, + ), + Container( + padding: EdgeInsets.all(8), + alignment: Alignment.topCenter, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "Current source ${styleInfo.name}", + textScaler: TextScaler.linear(1.4), + ), + ), + ), + ), + ], + )); + } +} diff --git a/example/lib/take_snapshot.dart b/example/lib/take_snapshot.dart new file mode 100644 index 0000000..4647495 --- /dev/null +++ b/example/lib/take_snapshot.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class TakeSnapPage extends ExamplePage { + TakeSnapPage() : super(const Icon(Icons.camera_alt), 'Take snapshot'); + + @override + Widget build(BuildContext context) { + return const TakeSnapshot(); + } +} + +class TakeSnapshot extends StatefulWidget { + const TakeSnapshot(); + + @override + State createState() => FullMapState(); +} + +class FullMapState extends State { + FullMapState(); + + NextbillionMapController? mapController; + final mapKey = GlobalKey(); + String? snapshotResult; + + void _onMapCreated(NextbillionMapController controller) { + mapController = controller; + } + + void _onTakeSnapshot([bool writeToDisk = true]) async { + final renderBox = mapKey.currentContext?.findRenderObject() as RenderBox; + + final snapshotOptions = SnapshotOptions( + width: renderBox.size.width, + height: renderBox.size.height, + writeToDisk: writeToDisk, + withLogo: false, + ); + final result = await mapController?.takeSnapshot(snapshotOptions); + debugPrint("result: $result"); + _setResult(result); + } + + void _onTakeSnapshotWithBounds() async { + final renderBox = mapKey.currentContext?.findRenderObject() as RenderBox; + final bounds = await mapController?.getVisibleRegion(); + + final snapshotOptions = SnapshotOptions( + width: renderBox.size.width, + height: renderBox.size.height, + writeToDisk: true, + withLogo: false, + bounds: bounds, + ); + final uri = await mapController?.takeSnapshot(snapshotOptions); + + _setResult(uri); + } + + void _onTakeSnapshotWithCameraPosition() async { + final renderBox = mapKey.currentContext?.findRenderObject() as RenderBox; + + final snapshotOptions = SnapshotOptions( + width: renderBox.size.width, + height: renderBox.size.height, + writeToDisk: true, + withLogo: false, + centerCoordinate: LatLng(40.79796, -74.126410), + zoomLevel: 12, + pitch: 30, + heading: 20, + ); + final uri = await mapController?.takeSnapshot(snapshotOptions); + _setResult(uri); + } + + void _setResult(String? result) { + if (result != null) { + setState(() { + snapshotResult = result.replaceAll("file:", ""); + }); + } + } + + Uint8List convertBase64Image(String base64String) { + return Base64Decoder().convert(base64String.split(',').last); + } + + @override + Widget build(BuildContext context) { + final height = MediaQuery.of(context).size.height; + return Column( + children: [ + Expanded( + child: NBMap( + key: mapKey, + onMapCreated: _onMapCreated, + initialCameraPosition: + const CameraPosition(target: LatLng(0.0, 0.0)), + myLocationEnabled: true, + styleString: NbMapStyles.SATELLITE, + ), + ), + const SizedBox( + height: 5, + ), + Container( + height: height * 0.4, + child: Column( + children: [ + Wrap( + spacing: 10, + alignment: WrapAlignment.center, + children: [ + ElevatedButton( + onPressed: _onTakeSnapshot, + child: Text("Take Snap"), + ), + ElevatedButton( + onPressed: _onTakeSnapshotWithBounds, + child: Text("With Bounds"), + ), + ElevatedButton( + onPressed: _onTakeSnapshotWithCameraPosition, + child: Text("With Camera Position"), + ), + ElevatedButton( + onPressed: () => _onTakeSnapshot(false), + child: Text("With Base64"), + ), + ], + ), + const SizedBox( + height: 10, + ), + if (snapshotResult != null) + Container( + decoration: BoxDecoration(border: Border.all()), + child: snapshotResult!.contains("base64") + ? Image.memory( + convertBase64Image(snapshotResult!), + gaplessPlayback: true, + height: height * 0.20, + ) + : Image.file( + File(snapshotResult!), + height: height * 0.20, + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/example/lib/track_current_location.dart b/example/lib/track_current_location.dart new file mode 100644 index 0000000..b74207c --- /dev/null +++ b/example/lib/track_current_location.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'page.dart'; + +class TrackCurrentLocationPage extends ExamplePage { + TrackCurrentLocationPage() + : super(const Icon(Icons.place), 'TrackCurrentLocation'); + + @override + Widget build(BuildContext context) { + return TrackCurrentLocation(); + } +} + +class TrackCurrentLocation extends StatefulWidget { + @override + TrackCurrentLocationState createState() => TrackCurrentLocationState(); +} + +class TrackCurrentLocationState extends State { + NextbillionMapController? controller; + + String locationTrackImage = "assets/symbols/location_on.png"; + + void _onMapCreated(NextbillionMapController controller) { + this.controller = controller; + } + + _onStyleLoadedCallback() async { + controller?.updateMyLocationTrackingMode(MyLocationTrackingMode.Tracking); + } + + _onUserLocationUpdate(UserLocation location) { + print('${location.position.longitude}, ${location.position.latitude}'); + } + + _onCameraTrackingChanged() { + setState(() { + locationTrackImage = 'assets/symbols/location_off.png'; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + NBMap( + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoadedCallback, + initialCameraPosition: const CameraPosition( + target: LatLng(0, 0), + zoom: 14.0, + ), + trackCameraPosition: true, + myLocationEnabled: true, + myLocationTrackingMode: MyLocationTrackingMode.Tracking, + onUserLocationUpdated: _onUserLocationUpdate, + onCameraTrackingDismissed: _onCameraTrackingChanged, + ), + Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 10.0, bottom: 100), + child: GestureDetector( + child: Image( + image: AssetImage(locationTrackImage), + width: 28, + height: 28, + ), + onTap: () { + controller?.updateMyLocationTrackingMode( + MyLocationTrackingMode.Tracking); + setState(() { + locationTrackImage = 'assets/symbols/location_on.png'; + }); + }), + ), + ], + ) + ], + ), + ); + } + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } +} diff --git a/example/macos/.gitignore b/example/macos/.gitignore new file mode 100644 index 0000000..d2fd377 --- /dev/null +++ b/example/macos/.gitignore @@ -0,0 +1,6 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/xcuserdata/ diff --git a/example/macos/Podfile b/example/macos/Podfile new file mode 100644 index 0000000..d60ec71 --- /dev/null +++ b/example/macos/Podfile @@ -0,0 +1,82 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + pods_ary = [] + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) { |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + pods_ary.push({:name => podname, :path => podpath}); + else + puts "Invalid plugin specification: #{line}" + end + } + return pods_ary +end + +def pubspec_supports_macos(file) + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return false; + end + File.foreach(file_abs_path) { |line| + return true if line =~ /^\s*macos:/ + } + return false +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + ephemeral_dir = File.join('Flutter', 'ephemeral') + symlink_dir = File.join(ephemeral_dir, '.symlinks') + symlink_plugins_dir = File.join(symlink_dir, 'plugins') + system("rm -rf #{symlink_dir}") + system("mkdir -p #{symlink_plugins_dir}") + + # Flutter Pods + generated_xcconfig = parse_KV_file(File.join(ephemeral_dir, 'Flutter-Generated.xcconfig')) + if generated_xcconfig.empty? + puts "Flutter-Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." + end + generated_xcconfig.map { |p| + if p[:name] == 'FLUTTER_FRAMEWORK_DIR' + symlink = File.join(symlink_dir, 'flutter') + File.symlink(File.dirname(p[:path]), symlink) + pod 'FlutterMacOS', :path => File.join(symlink, File.basename(p[:path])) + end + } + + # Plugin Pods + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.map { |p| + symlink = File.join(symlink_plugins_dir, p[:name]) + File.symlink(p[:path], symlink) + if pubspec_supports_macos(File.join(symlink, 'pubspec.yaml')) + pod p[:name], :path => File.join(symlink, 'macos') + end + } +end + +# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. +install! 'cocoapods', :disable_input_output_paths => true diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..331479d --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,596 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; }; + 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D73912F022F37F9E000D13A0 /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; }; + D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */, + 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */, + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FlutterMacOS.framework; path = Flutter/ephemeral/FlutterMacOS.framework; sourceTree = SOURCE_ROOT; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + D73912EF22F37F9E000D13A0 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/ephemeral/App.framework; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D73912F022F37F9E000D13A0 /* App.framework in Frameworks */, + 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + D73912EF22F37F9E000D13A0 /* App.framework */, + 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0930; + ORGANIZATIONNAME = "The Flutter Authors"; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh\ntouch Flutter/ephemeral/tripwire\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..764c74b --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..df12c33 --- /dev/null +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..8f3dd47 --- /dev/null +++ b/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + return true + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..3c4935a Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..ed4cc16 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..483be61 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bcbf36d Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..9c0a652 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..e71a726 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..8a31fe2 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/example/macos/Runner/Base.lproj/MainMenu.xib b/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..537341a --- /dev/null +++ b/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..c1599b4 --- /dev/null +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,8 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example diff --git a/example/macos/Runner/Configs/Debug.xcconfig b/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Release.xcconfig b/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Warnings.xcconfig b/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..decbd0e --- /dev/null +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = frame + contentViewController = flutterViewController + setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..3e813f3 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,79 @@ +name: nb_maps_flutter_example +description: Demonstrates how to use the nb_maps_flutter plugin. +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=2.12.0 <3.0.0' + +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.0 + path_provider: ^2.0.0 + http: ^0.13.0 + collection: ^1.0.0 + device_info_plus: ^10.1.2 + nb_maps_flutter: + path: ../ + permission_handler: ^11.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.io/assets-and-images/#resolution-aware. + + assets: + - assets/fill/cat_silhouette_pattern.png + - assets/fill-extrusion/indoor_3d_map.json + - assets/symbols/custom-icon.png + - assets/symbols/location_off.png + - assets/symbols/location_on.png + - assets/symbols/2.0x/custom-icon.png + - assets/symbols/3.0x/custom-icon.png + - assets/style.json + + # For details regarding adding assets from package dependencies, see + # https://flutter.io/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.io/custom-fonts/#from-packages diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..c638001 --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "minimal-ui", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7723c0b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,35 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Classes/Constants.swift b/ios/Classes/Constants.swift new file mode 100644 index 0000000..9fcd5ce --- /dev/null +++ b/ios/Classes/Constants.swift @@ -0,0 +1,50 @@ +import Nbmap + +/* + * The mapping is based on the values defined here: + */ + +class Constants { + static let symbolIconAnchorMapping = [ + "center": NGLIconAnchor.center, + "left": NGLIconAnchor.left, + "right": NGLIconAnchor.right, + "top": NGLIconAnchor.top, + "bottom": NGLIconAnchor.bottom, + "top-left": NGLIconAnchor.topLeft, + "top-right": NGLIconAnchor.topRight, + "bottom-left": NGLIconAnchor.bottomLeft, + "bottom-right": NGLIconAnchor.bottomRight, + ] + + static let symbolTextJustificationMapping = [ + "auto": NGLTextJustification.auto, + "center": NGLTextJustification.center, + "left": NGLTextJustification.left, + "right": NGLTextJustification.right, + ] + + static let symbolTextAnchorMapping = [ + "center": NGLTextAnchor.center, + "left": NGLTextAnchor.left, + "right": NGLTextAnchor.right, + "top": NGLTextAnchor.top, + "bottom": NGLTextAnchor.bottom, + "top-left": NGLTextAnchor.topLeft, + "top-right": NGLTextAnchor.topRight, + "bottom-left": NGLTextAnchor.bottomLeft, + "bottom-right": NGLTextAnchor.bottomRight, + ] + + static let symbolTextTransformationMapping = [ + "none": NGLTextTransform.none, + "lowercase": NGLTextTransform.lowercase, + "uppercase": NGLTextTransform.uppercase, + ] + + static let lineJoinMapping = [ + "bevel": NGLLineJoin.bevel, + "miter": NGLLineJoin.miter, + "round": NGLLineJoin.round, + ] +} diff --git a/ios/Classes/Convert.swift b/ios/Classes/Convert.swift new file mode 100644 index 0000000..bd71a03 --- /dev/null +++ b/ios/Classes/Convert.swift @@ -0,0 +1,199 @@ +import Nbmap + +class Convert { + class func interpretNextbillionMapOptions(options: Any?, delegate: NextbillionMapOptionsSink) { + guard let options = options as? [String: Any] else { return } + if let cameraTargetBounds = options["cameraTargetBounds"] as? [[[Double]]] { + delegate + .setCameraTargetBounds(bounds: NGLCoordinateBounds.fromArray(cameraTargetBounds[0])) + } + if let compassEnabled = options["compassEnabled"] as? Bool { + delegate.setCompassEnabled(compassEnabled: compassEnabled) + } + if let minMaxZoomPreference = options["minMaxZoomPreference"] as? [Double] { + delegate.setMinMaxZoomPreference( + min: minMaxZoomPreference[0], + max: minMaxZoomPreference[1] + ) + } + if let styleString = options["styleString"] as? String { + delegate.setStyleString(styleString: styleString) + } + if let rotateGesturesEnabled = options["rotateGesturesEnabled"] as? Bool { + delegate.setRotateGesturesEnabled(rotateGesturesEnabled: rotateGesturesEnabled) + } + if let scrollGesturesEnabled = options["scrollGesturesEnabled"] as? Bool { + delegate.setScrollGesturesEnabled(scrollGesturesEnabled: scrollGesturesEnabled) + } + if let tiltGesturesEnabled = options["tiltGesturesEnabled"] as? Bool { + delegate.setTiltGesturesEnabled(tiltGesturesEnabled: tiltGesturesEnabled) + } + if let trackCameraPosition = options["trackCameraPosition"] as? Bool { + delegate.setTrackCameraPosition(trackCameraPosition: trackCameraPosition) + } + if let zoomGesturesEnabled = options["zoomGesturesEnabled"] as? Bool { + delegate.setZoomGesturesEnabled(zoomGesturesEnabled: zoomGesturesEnabled) + } + if let myLocationEnabled = options["myLocationEnabled"] as? Bool { + delegate.setMyLocationEnabled(myLocationEnabled: myLocationEnabled) + } + if let myLocationTrackingMode = options["myLocationTrackingMode"] as? UInt, + let trackingMode = NGLUserTrackingMode(rawValue: myLocationTrackingMode) + { + delegate.setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + if let myLocationRenderMode = options["myLocationRenderMode"] as? Int, + let renderMode = MyLocationRenderMode(rawValue: myLocationRenderMode) + { + delegate.setMyLocationRenderMode(myLocationRenderMode: renderMode) + } + if let logoViewMargins = options["logoViewMargins"] as? [Double] { + delegate.setLogoViewMargins(x: logoViewMargins[0], y: logoViewMargins[1]) + } + if let compassViewPosition = options["compassViewPosition"] as? UInt, + let position = NGLOrnamentPosition(rawValue: compassViewPosition) + { + delegate.setCompassViewPosition(position: position) + } + if let compassViewMargins = options["compassViewMargins"] as? [Double] { + delegate.setCompassViewMargins(x: compassViewMargins[0], y: compassViewMargins[1]) + } + if let attributionButtonMargins = options["attributionButtonMargins"] as? [Double] { + delegate.setAttributionButtonMargins( + x: attributionButtonMargins[0], + y: attributionButtonMargins[1] + ) + } + if let attributionButtonPosition = options["attributionButtonPosition"] as? UInt, + let position = NGLOrnamentPosition(rawValue: attributionButtonPosition) + { + delegate.setAttributionButtonPosition(position: position) + } + } + + class func parseCameraUpdate(cameraUpdate: [Any], mapView: NGLMapView) -> NGLMapCamera? { + guard let type = cameraUpdate[0] as? String else { return nil } + switch type { + case "newCameraPosition": + guard let cameraPosition = cameraUpdate[1] as? [String: Any] else { return nil } + return NGLMapCamera.fromDict(cameraPosition, mapView: mapView) + case "newLatLng": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + return camera + case "newLatLngBounds": + guard let bounds = cameraUpdate[1] as? [[Double]] else { return nil } + guard let paddingLeft = cameraUpdate[2] as? CGFloat else { return nil } + guard let paddingTop = cameraUpdate[3] as? CGFloat else { return nil } + guard let paddingRight = cameraUpdate[4] as? CGFloat else { return nil } + guard let paddingBottom = cameraUpdate[5] as? CGFloat else { return nil } + return mapView.cameraThatFitsCoordinateBounds( + NGLCoordinateBounds.fromArray(bounds), + edgePadding: UIEdgeInsets( + top: paddingTop, + left: paddingLeft, + bottom: paddingBottom, + right: paddingRight + ) + ) + case "newLatLngZoom": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + guard let zoom = cameraUpdate[2] as? Double else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + let altitude = getAltitude(zoom: zoom, mapView: mapView) + return NGLMapCamera( + lookingAtCenter: camera.centerCoordinate, + altitude: altitude, + pitch: camera.pitch, + heading: camera.heading + ) + case "scrollBy": + guard let x = cameraUpdate[1] as? CGFloat else { return nil } + guard let y = cameraUpdate[2] as? CGFloat else { return nil } + let camera = mapView.camera + let mapPoint = mapView.convert(camera.centerCoordinate, toPointTo: mapView) + let movedPoint = CGPoint(x: mapPoint.x + x, y: mapPoint.y + y) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + case "zoomBy": + guard let zoomBy = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + zoomBy, mapView: mapView) + camera.altitude = altitude + if cameraUpdate.count == 2 { + return camera + } else { + guard let point = cameraUpdate[2] as? [CGFloat], + point.count == 2 else { return nil } + let movedPoint = CGPoint(x: point[0], y: point[1]) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + } + case "zoomIn": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomOut": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom - 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomTo": + guard let zoom = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let altitude = getAltitude(zoom: zoom, mapView: mapView) + camera.altitude = altitude + return camera + case "bearingTo": + guard let bearing = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + camera.heading = bearing + return camera + case "tiltTo": + guard let tilt = cameraUpdate[1] as? CGFloat else { return nil } + let camera = mapView.camera + camera.pitch = tilt + return camera + default: + print("\(type) not implemented!") + } + return nil + } + + class func getZoom(mapView: NGLMapView) -> Double { + return NGLZoomLevelForAltitude( + mapView.camera.altitude, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getAltitude(zoom: Double, mapView: NGLMapView) -> Double { + return NGLAltitudeForZoomLevel( + zoom, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getCoordinates(options: Any?) -> [CLLocationCoordinate2D] { + var coordinates: [CLLocationCoordinate2D] = [] + + if let options = options as? [String: Any], + let geometry = options["geometry"] as? [[Double]], geometry.count > 0 + { + for coordinate in geometry { + coordinates.append(CLLocationCoordinate2DMake(coordinate[0], coordinate[1])) + } + } + return coordinates + } +} diff --git a/ios/Classes/Enums.swift b/ios/Classes/Enums.swift new file mode 100644 index 0000000..6d621d3 --- /dev/null +++ b/ios/Classes/Enums.swift @@ -0,0 +1,3 @@ +enum MyLocationRenderMode: Int { + case Normal, Compass, Gps +} diff --git a/ios/Classes/Extensions.swift b/ios/Classes/Extensions.swift new file mode 100644 index 0000000..da96561 --- /dev/null +++ b/ios/Classes/Extensions.swift @@ -0,0 +1,148 @@ +import Nbmap + +extension NGLMapCamera { + func toDict(mapView: NGLMapView) -> [String: Any] { + let zoom = NGLZoomLevelForAltitude( + altitude, + pitch, + centerCoordinate.latitude, + mapView.frame.size + ) + return ["bearing": heading, + "target": centerCoordinate.toArray(), + "tilt": pitch, + "zoom": zoom] + } + + static func fromDict(_ dict: [String: Any], mapView: NGLMapView) -> NGLMapCamera? { + guard let target = dict["target"] as? [Double], + let zoom = dict["zoom"] as? Double, + let tilt = dict["tilt"] as? CGFloat, + let bearing = dict["bearing"] as? Double else { return nil } + let location = CLLocationCoordinate2D.fromArray(target) + let altitude = NGLAltitudeForZoomLevel(zoom, tilt, location.latitude, mapView.frame.size) + return NGLMapCamera( + lookingAtCenter: location, + altitude: altitude, + pitch: tilt, + heading: bearing + ) + } +} + +extension CLLocation { + func toDict() -> [String: Any]? { + return ["position": coordinate.toArray(), + "altitude": altitude, + "bearing": course, + "speed": speed, + "horizontalAccuracy": horizontalAccuracy, + "verticalAccuracy": verticalAccuracy, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLHeading { + func toDict() -> [String: Any]? { + return ["magneticHeading": magneticHeading, + "trueHeading": trueHeading, + "headingAccuracy": headingAccuracy, + "x": x, + "y": y, + "z": z, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLLocationCoordinate2D { + func toArray() -> [Double] { + return [latitude, longitude] + } + + static func fromArray(_ array: [Double]) -> CLLocationCoordinate2D { + return CLLocationCoordinate2D(latitude: array[0], longitude: array[1]) + } +} + +extension NGLCoordinateBounds { + func toArray() -> [[Double]] { + return [sw.toArray(), ne.toArray()] + } + + static func fromArray(_ array: [[Double]]) -> NGLCoordinateBounds { + let southwest = CLLocationCoordinate2D.fromArray(array[0]) + let northeast = CLLocationCoordinate2D.fromArray(array[1]) + return NGLCoordinateBounds(sw: southwest, ne: northeast) + } +} + +extension UIImage { + static func loadFromFile(imagePath: String, imageName: String) -> UIImage? { + // Add the trailing slash in path if missing. + let path = imagePath.hasSuffix("/") ? imagePath : "\(imagePath)/" + // Build scale dependant image path. + var scale = UIScreen.main.scale + var absolutePath = "\(path)\(scale)x/\(imageName)" + // Check if the image exists, if not try a an unscaled path. + if Bundle.main.path(forResource: absolutePath, ofType: nil) == nil { + absolutePath = "\(path)\(imageName)" + } else { + // found asset with higher resolution - increase scale even further to compensate + scale *= scale + } + // Load image if it exists. + if let path = Bundle.main.path(forResource: absolutePath, ofType: nil) { + let imageUrl = URL(fileURLWithPath: path) + if let imageData: Data = try? Data(contentsOf: imageUrl), + let image = UIImage(data: imageData, scale: scale) + { + return image + } + } + return nil + } +} + +public extension UIColor { + convenience init?(hexString: String) { + let r, g, b, a: CGFloat + + if hexString.hasPrefix("#") { + let start = hexString.index(hexString.startIndex, offsetBy: 1) + let hexColor = hexString[start...] + + let scanner = Scanner(string: String(hexColor)) + var hexNumber: UInt64 = 0 + + if hexColor.count == 6 { + if scanner.scanHexInt64(&hexNumber) { + r = CGFloat((hexNumber & 0xFF0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x00FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000FF) / 255 + a = 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } else if hexColor.count == 8 { + if scanner.scanHexInt64(&hexNumber) { + a = CGFloat((hexNumber & 0xFF00_0000) >> 24) / 255 + r = CGFloat((hexNumber & 0x00FF_0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x0000_FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000_00FF) / 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } + } + + return nil + } +} + +extension Array { + var tail: Array { + return Array(dropFirst()) + } +} diff --git a/ios/Classes/LayerPropertyConverter.swift b/ios/Classes/LayerPropertyConverter.swift new file mode 100644 index 0000000..cf286a3 --- /dev/null +++ b/ios/Classes/LayerPropertyConverter.swift @@ -0,0 +1,413 @@ + + +import Nbmap + +class LayerPropertyConverter { + class func addSymbolProperties(symbolLayer: NGLSymbolStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "icon-opacity": + symbolLayer.iconOpacity = expression + case "icon-color": + symbolLayer.iconColor = expression + case "icon-halo-color": + symbolLayer.iconHaloColor = expression + case "icon-halo-width": + symbolLayer.iconHaloWidth = expression + case "icon-halo-blur": + symbolLayer.iconHaloBlur = expression + case "icon-translate": + symbolLayer.iconTranslation = expression + case "icon-translate-anchor": + symbolLayer.iconTranslationAnchor = expression + case "text-opacity": + symbolLayer.textOpacity = expression + case "text-color": + symbolLayer.textColor = expression + case "text-halo-color": + symbolLayer.textHaloColor = expression + case "text-halo-width": + symbolLayer.textHaloWidth = expression + case "text-halo-blur": + symbolLayer.textHaloBlur = expression + case "text-translate": + symbolLayer.textTranslation = expression + case "text-translate-anchor": + symbolLayer.textTranslationAnchor = expression + case "symbol-placement": + symbolLayer.symbolPlacement = expression + case "symbol-spacing": + symbolLayer.symbolSpacing = expression + case "symbol-avoid-edges": + symbolLayer.symbolAvoidsEdges = expression + case "symbol-sort-key": + symbolLayer.symbolSortKey = expression + case "symbol-z-order": + symbolLayer.symbolZOrder = expression + case "icon-allow-overlap": + symbolLayer.iconAllowsOverlap = expression + case "icon-ignore-placement": + symbolLayer.iconIgnoresPlacement = expression + case "icon-optional": + symbolLayer.iconOptional = expression + case "icon-rotation-alignment": + symbolLayer.iconRotationAlignment = expression + case "icon-size": + symbolLayer.iconScale = expression + case "icon-text-fit": + symbolLayer.iconTextFit = expression + case "icon-text-fit-padding": + symbolLayer.iconTextFitPadding = expression + case "icon-image": + symbolLayer.iconImageName = expression + case "icon-rotate": + symbolLayer.iconRotation = expression + case "icon-padding": + symbolLayer.iconPadding = expression + case "icon-keep-upright": + symbolLayer.keepsIconUpright = expression + case "icon-offset": + symbolLayer.iconOffset = expression + case "icon-anchor": + symbolLayer.iconAnchor = expression + case "icon-pitch-alignment": + symbolLayer.iconPitchAlignment = expression + case "text-pitch-alignment": + symbolLayer.textPitchAlignment = expression + case "text-rotation-alignment": + symbolLayer.textRotationAlignment = expression + case "text-field": + symbolLayer.text = expression + case "text-font": + symbolLayer.textFontNames = expression + case "text-size": + symbolLayer.textFontSize = expression + case "text-max-width": + symbolLayer.maximumTextWidth = expression + case "text-line-height": + symbolLayer.textLineHeight = expression + case "text-letter-spacing": + symbolLayer.textLetterSpacing = expression + case "text-justify": + symbolLayer.textJustification = expression + case "text-radial-offset": + symbolLayer.textRadialOffset = expression + case "text-variable-anchor": + symbolLayer.textVariableAnchor = expression + case "text-anchor": + symbolLayer.textAnchor = expression + case "text-max-angle": + symbolLayer.maximumTextAngle = expression + case "text-writing-mode": + symbolLayer.textWritingModes = expression + case "text-rotate": + symbolLayer.textRotation = expression + case "text-padding": + symbolLayer.textPadding = expression + case "text-keep-upright": + symbolLayer.keepsTextUpright = expression + case "text-transform": + symbolLayer.textTransform = expression + case "text-offset": + symbolLayer.textOffset = expression + case "text-allow-overlap": + symbolLayer.textAllowsOverlap = expression + case "text-ignore-placement": + symbolLayer.textIgnoresPlacement = expression + case "text-optional": + symbolLayer.textOptional = expression + case "visibility": + symbolLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addCircleProperties(circleLayer: NGLCircleStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "circle-radius": + circleLayer.circleRadius = expression + case "circle-color": + circleLayer.circleColor = expression + case "circle-blur": + circleLayer.circleBlur = expression + case "circle-opacity": + circleLayer.circleOpacity = expression + case "circle-translate": + circleLayer.circleTranslation = expression + case "circle-translate-anchor": + circleLayer.circleTranslationAnchor = expression + case "circle-pitch-scale": + circleLayer.circleScaleAlignment = expression + case "circle-pitch-alignment": + circleLayer.circlePitchAlignment = expression + case "circle-stroke-width": + circleLayer.circleStrokeWidth = expression + case "circle-stroke-color": + circleLayer.circleStrokeColor = expression + case "circle-stroke-opacity": + circleLayer.circleStrokeOpacity = expression + case "circle-sort-key": + circleLayer.circleSortKey = expression + case "visibility": + circleLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addLineProperties(lineLayer: NGLLineStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "line-opacity": + lineLayer.lineOpacity = expression + case "line-color": + lineLayer.lineColor = expression + case "line-translate": + lineLayer.lineTranslation = expression + case "line-translate-anchor": + lineLayer.lineTranslationAnchor = expression + case "line-width": + lineLayer.lineWidth = expression + case "line-gap-width": + lineLayer.lineGapWidth = expression + case "line-offset": + lineLayer.lineOffset = expression + case "line-blur": + lineLayer.lineBlur = expression + case "line-dasharray": + lineLayer.lineDashPattern = expression + case "line-pattern": + lineLayer.linePattern = expression + case "line-gradient": + lineLayer.lineGradient = expression + case "line-cap": + lineLayer.lineCap = expression + case "line-join": + lineLayer.lineJoin = expression + case "line-miter-limit": + lineLayer.lineMiterLimit = expression + case "line-round-limit": + lineLayer.lineRoundLimit = expression + case "line-sort-key": + lineLayer.lineSortKey = expression + case "visibility": + lineLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addFillProperties(fillLayer: NGLFillStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "fill-antialias": + fillLayer.fillAntialiased = expression + case "fill-opacity": + fillLayer.fillOpacity = expression + case "fill-color": + fillLayer.fillColor = expression + case "fill-outline-color": + fillLayer.fillOutlineColor = expression + case "fill-translate": + fillLayer.fillTranslation = expression + case "fill-translate-anchor": + fillLayer.fillTranslationAnchor = expression + case "fill-pattern": + fillLayer.fillPattern = expression + case "fill-sort-key": + fillLayer.fillSortKey = expression + case "visibility": + fillLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addFillExtrusionProperties( + fillExtrusionLayer: NGLFillExtrusionStyleLayer, + properties: [String: String] + ) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "fill-extrusion-opacity": + fillExtrusionLayer.fillExtrusionOpacity = expression + case "fill-extrusion-color": + fillExtrusionLayer.fillExtrusionColor = expression + case "fill-extrusion-translate": + fillExtrusionLayer.fillExtrusionTranslation = expression + case "fill-extrusion-translate-anchor": + fillExtrusionLayer.fillExtrusionTranslationAnchor = expression + case "fill-extrusion-pattern": + fillExtrusionLayer.fillExtrusionPattern = expression + case "fill-extrusion-height": + fillExtrusionLayer.fillExtrusionHeight = expression + case "fill-extrusion-base": + fillExtrusionLayer.fillExtrusionBase = expression + case "fill-extrusion-vertical-gradient": + fillExtrusionLayer.fillExtrusionHasVerticalGradient = expression + case "visibility": + fillExtrusionLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addRasterProperties(rasterLayer: NGLRasterStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "raster-opacity": + rasterLayer.rasterOpacity = expression + case "raster-hue-rotate": + rasterLayer.rasterHueRotation = expression + case "raster-brightness-min": + rasterLayer.minimumRasterBrightness = expression + case "raster-brightness-max": + rasterLayer.maximumRasterBrightness = expression + case "raster-saturation": + rasterLayer.rasterSaturation = expression + case "raster-contrast": + rasterLayer.rasterContrast = expression + case "raster-resampling": + rasterLayer.rasterResamplingMode = expression + case "raster-fade-duration": + rasterLayer.rasterFadeDuration = expression + case "visibility": + rasterLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addHillshadeProperties( + hillshadeLayer: NGLHillshadeStyleLayer, + properties: [String: String] + ) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "hillshade-illumination-direction": + hillshadeLayer.hillshadeIlluminationDirection = expression + case "hillshade-illumination-anchor": + hillshadeLayer.hillshadeIlluminationAnchor = expression + case "hillshade-exaggeration": + hillshadeLayer.hillshadeExaggeration = expression + case "hillshade-shadow-color": + hillshadeLayer.hillshadeShadowColor = expression + case "hillshade-highlight-color": + hillshadeLayer.hillshadeHighlightColor = expression + case "hillshade-accent-color": + hillshadeLayer.hillshadeAccentColor = expression + case "visibility": + hillshadeLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + class func addHeatmapProperties( + heatmapLayer: NGLHeatmapStyleLayer, + properties: [String: String] + ) { + for (propertyName, propertyValue) in properties { + let expression = interpretExpression( + propertyName: propertyName, + expression: propertyValue + ) + switch propertyName { + case "heatmap-radius": + heatmapLayer.heatmapRadius = expression + case "heatmap-weight": + heatmapLayer.heatmapWeight = expression + case "heatmap-intensity": + heatmapLayer.heatmapIntensity = expression + case "heatmap-color": + heatmapLayer.heatmapColor = expression + case "heatmap-opacity": + heatmapLayer.heatmapOpacity = expression + case "visibility": + heatmapLayer.isVisible = propertyValue == "visible" + + default: + break + } + } + } + + private class func interpretExpression(propertyName: String, + expression: String) -> NSExpression? + { + let isColor = propertyName.contains("color") + + do { + let json = try JSONSerialization.jsonObject( + with: expression.data(using: .utf8)!, + options: .fragmentsAllowed + ) + if isColor { + if let color = json as? String { + return NSExpression(forConstantValue: UIColor(hexString: color)) + } + } + + if let offset = json as? [Any] { + if offset.count == 2, offset.first is String, offset.first as? String == "literal" { + if let vector = offset.last as? [Any] { + if vector.count == 2 { + if let x = vector.first as? Double, let y = vector.last as? Double { + return NSExpression( + forConstantValue: NSValue(cgVector: CGVector(dx: x, + dy: y)) + ) + } + } + } + } + } + return NSExpression(nglJSONObject: json) + } catch {} + return nil + } +} diff --git a/ios/Classes/MethodCallError.swift b/ios/Classes/MethodCallError.swift new file mode 100644 index 0000000..8806bec --- /dev/null +++ b/ios/Classes/MethodCallError.swift @@ -0,0 +1,41 @@ +import Flutter + +enum MethodCallError: Error { + case invalidLayerType(details: String) + case invalidExpression + + var code: String { + switch self { + case .invalidLayerType: + return "invalidLayerType" + case .invalidExpression: + return "invalidExpression" + } + } + + var message: String { + switch self { + case .invalidLayerType: + return "Invalid layer type" + case .invalidExpression: + return "Invalid expression" + } + } + + var details: String { + switch self { + case let .invalidLayerType(details): + return details + case .invalidExpression: + return "Could not parse expression." + } + } + + var flutterError: FlutterError { + return FlutterError( + code: code, + message: message, + details: details + ) + } +} diff --git a/ios/Classes/NbMapFactory.swift b/ios/Classes/NbMapFactory.swift new file mode 100644 index 0000000..3c11f0c --- /dev/null +++ b/ios/Classes/NbMapFactory.swift @@ -0,0 +1,25 @@ +import Flutter + +class NbMapFactory: NSObject, FlutterPlatformViewFactory { + var registrar: FlutterPluginRegistrar + + init(withRegistrar registrar: FlutterPluginRegistrar) { + self.registrar = registrar + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, + arguments args: Any?) -> FlutterPlatformView + { + return NextbillionMapController( + withFrame: frame, + viewIdentifier: viewId, + arguments: args, + registrar: registrar + ) + } +} diff --git a/ios/Classes/NextbillionMapController.swift b/ios/Classes/NextbillionMapController.swift new file mode 100644 index 0000000..6ea1ebb --- /dev/null +++ b/ios/Classes/NextbillionMapController.swift @@ -0,0 +1,1801 @@ +import Flutter +import Nbmap +import UIKit + +class NextbillionMapController: NSObject, FlutterPlatformView, NGLMapViewDelegate, NextbillionMapOptionsSink, + UIGestureRecognizerDelegate +{ + private var registrar: FlutterPluginRegistrar + private var channel: FlutterMethodChannel? + + private var mapView: NGLMapView + private var isMapReady = false + private var dragEnabled = true + private var isFirstStyleLoad = true + private var onStyleLoadedCalled = false + private var mapReadyResult: FlutterResult? + private var previousDragCoordinate: CLLocationCoordinate2D? + private var originDragCoordinate: CLLocationCoordinate2D? + private var dragFeature: NGLFeature? + + private var initialTilt: CGFloat? + private var cameraTargetBounds: NGLCoordinateBounds? + private var trackCameraPosition = false + private var myLocationEnabled = false + private var scrollingEnabled = true + + private var interactiveFeatureLayerIds = Set() + private var addedShapesByLayer = [String: NGLShape]() + + func view() -> UIView { + return mapView + } + + init( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + registrar: FlutterPluginRegistrar + ) { + mapView = NGLMapView(frame: frame) + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.registrar = registrar + + super.init() + + channel = FlutterMethodChannel( + name: "plugins.flutter.io/nbmaps_maps_\(viewId)", + binaryMessenger: registrar.messenger() + ) + channel! + .setMethodCallHandler { [weak self] in self?.onMethodCall(methodCall: $0, result: $1) } + + mapView.delegate = self + + let singleTap = UITapGestureRecognizer( + target: self, + action: #selector(handleMapTap(sender:)) + ) + for recognizer in mapView.gestureRecognizers! where recognizer is UITapGestureRecognizer { + singleTap.require(toFail: recognizer) + } + mapView.addGestureRecognizer(singleTap) + + let longPress = UILongPressGestureRecognizer( + target: self, + action: #selector(handleMapLongPress(sender:)) + ) + for recognizer in mapView.gestureRecognizers! + where recognizer is UILongPressGestureRecognizer + { + longPress.require(toFail: recognizer) + } + mapView.addGestureRecognizer(longPress) + + if let args = args as? [String: Any] { + Convert.interpretNextbillionMapOptions(options: args["options"], delegate: self) + if let initialCameraPosition = args["initialCameraPosition"] as? [String: Any], + let camera = NGLMapCamera.fromDict(initialCameraPosition, mapView: mapView), + let zoom = initialCameraPosition["zoom"] as? Double + { + mapView.setCenter( + camera.centerCoordinate, + zoomLevel: zoom, + direction: camera.heading, + animated: false + ) + initialTilt = camera.pitch + } + if let onAttributionClickOverride = args["onAttributionClickOverride"] as? Bool { + if onAttributionClickOverride { + setupAttribution(mapView) + } + } + + if let enabled = args["dragEnabled"] as? Bool { + dragEnabled = enabled + } + } + if dragEnabled { + let pan = UIPanGestureRecognizer( + target: self, + action: #selector(handleMapPan(sender:)) + ) + pan.delegate = self + mapView.addGestureRecognizer(pan) + } + } + +// func removeAllForController(controller: NGLAnnotationController, ids: [String]) { +// let idSet = Set(ids) +// let annotations = controller.styleAnnotations() +// controller.removeStyleAnnotations(annotations.filter { idSet.contains($0.identifier) }) +// } + + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer + ) -> Bool { + return true + } + + func onMethodCall(methodCall: FlutterMethodCall, result: @escaping FlutterResult) { + switch methodCall.method { + case "map#waitForMap": + if isMapReady { + result(nil) + // only call map#onStyleLoaded here if isMapReady has happend and isFirstStyleLoad is true + if isFirstStyleLoad { + isFirstStyleLoad = false + + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } else { + mapReadyResult = result + } + case "map#update": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + Convert.interpretNextbillionMapOptions(options: arguments["options"], delegate: self) + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#invalidateAmbientCache": + NGLOfflineStorage.shared.invalidateAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#updateMyLocationTrackingMode": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let myLocationTrackingMode = arguments["mode"] as? UInt, + let trackingMode = NGLUserTrackingMode(rawValue: myLocationTrackingMode) + { + setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + result(nil) + case "map#matchMapLanguageWithDeviceDefault": + if let style = mapView.style { + style.localizeLabels(into: nil) + } + result(nil) + case "map#updateContentInsets": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + + if let bounds = arguments["bounds"] as? [String: Any], + let top = bounds["top"] as? CGFloat, + let left = bounds["left"] as? CGFloat, + let bottom = bounds["bottom"] as? CGFloat, + let right = bounds["right"] as? CGFloat, + let animated = arguments["animated"] as? Bool + { + mapView.setContentInset( + UIEdgeInsets(top: top, left: left, bottom: bottom, right: right), + animated: animated + ) { + result(nil) + } + } else { + result(nil) + } + case "locationComponent#getLastLocation": + var reply = [String: NSObject]() + if let loc = mapView.userLocation?.location?.coordinate { + reply["latitude"] = loc.latitude as NSObject + reply["longitude"] = loc.longitude as NSObject + result(reply) + } else { + result(nil) + } + case "map#setMapLanguage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let localIdentifier = arguments["language"] as? String, let style = mapView.style { + let locale = Locale(identifier: localIdentifier) + style.localizeLabels(into: locale) + } + result(nil) + case "map#queryRenderedFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var styleLayerIdentifiers: Set? + if let layerIds = arguments["layerIds"] as? [String] { + styleLayerIdentifiers = Set(layerIds) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(nglJSONObject: filter) + } + var reply = [String: NSObject]() + var features: [NGLFeature] = [] + if let x = arguments["x"] as? Double, let y = arguments["y"] as? Double { + features = mapView.visibleFeatures( + at: CGPoint(x: x, y: y), + styleLayerIdentifiers: styleLayerIdentifiers, + predicate: filterExpression + ) + } + if let top = arguments["top"] as? Double, + let bottom = arguments["bottom"] as? Double, + let left = arguments["left"] as? Double, + let right = arguments["right"] as? Double + { + features = mapView.visibleFeatures( + in: CGRect(x: left, y: top, width: right, height: bottom), + styleLayerIdentifiers: styleLayerIdentifiers, + predicate: filterExpression + ) + } + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .ascii) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + case "map#setTelemetryEnabled": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + let telemetryEnabled = arguments["enabled"] as? Bool + UserDefaults.standard.set(telemetryEnabled, forKey: "NGLNbMapsMetricsEnabled") + result(nil) + case "map#getTelemetryEnabled": + let telemetryEnabled = UserDefaults.standard.bool(forKey: "NGLNbMapsMetricsEnabled") + result(telemetryEnabled) + case "map#getVisibleRegion": + var reply = [String: NSObject]() + let visibleRegion = mapView.visibleCoordinateBounds + reply["sw"] = [visibleRegion.sw.latitude, visibleRegion.sw.longitude] as NSObject + reply["ne"] = [visibleRegion.ne.latitude, visibleRegion.ne.longitude] as NSObject + result(reply) + case "map#toScreenLocation": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let latitude = arguments["latitude"] as? Double else { return } + guard let longitude = arguments["longitude"] as? Double else { return } + let latlng = CLLocationCoordinate2DMake(latitude, longitude) + let returnVal = mapView.convert(latlng, toPointTo: mapView) + var reply = [String: NSObject]() + reply["x"] = returnVal.x as NSObject + reply["y"] = returnVal.y as NSObject + result(reply) + case "map#toScreenLocationBatch": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let data = arguments["coordinates"] as? FlutterStandardTypedData else { return } + let latLngs = data.data.withUnsafeBytes { + Array( + UnsafeBufferPointer( + start: $0.baseAddress!.assumingMemoryBound(to: Double.self), + count: Int(data.elementCount) + ) + ) + } + var reply: [Double] = Array(repeating: 0.0, count: latLngs.count) + for i in stride(from: 0, to: latLngs.count, by: 2) { + let coordinate = CLLocationCoordinate2DMake(latLngs[i], latLngs[i + 1]) + let returnVal = mapView.convert(coordinate, toPointTo: mapView) + reply[i] = Double(returnVal.x) + reply[i + 1] = Double(returnVal.y) + } + result(FlutterStandardTypedData( + float64: Data(bytes: &reply, count: reply.count * 8) + )) + case "map#getMetersPerPixelAtLatitude": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var reply = [String: NSObject]() + guard let latitude = arguments["latitude"] as? Double else { return } + let returnVal = mapView.metersPerPoint(atLatitude: latitude) + reply["metersperpixel"] = returnVal as NSObject + result(reply) + case "map#toLatLng": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let x = arguments["x"] as? Double else { return } + guard let y = arguments["y"] as? Double else { return } + let screenPoint = CGPoint(x: x, y: y) + let coordinates: CLLocationCoordinate2D = mapView.convert( + screenPoint, + toCoordinateFrom: mapView + ) + var reply = [String: NSObject]() + reply["latitude"] = coordinates.latitude as NSObject + reply["longitude"] = coordinates.longitude as NSObject + result(reply) + case "camera#move": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + if let camera = Convert + .parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) + { + mapView.setCamera(camera, animated: false) + } + result(nil) + case "camera#animate": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + if let camera = Convert + .parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) + { + if let duration = arguments["duration"] as? TimeInterval { + mapView.setCamera(camera, withDuration: TimeInterval(duration / 1000), + animationTimingFunction: CAMediaTimingFunction(name: CAMediaTimingFunctionName + .easeInEaseOut)) + result(nil) + } else { + mapView.setCamera(camera, animated: true) + } + } + result(nil) + + case "symbolLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + removeLayer(layerId: layerId) + let addResult = addSymbolLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "lineLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + removeLayer(layerId: layerId) + let addResult = addLineLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "fillLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + removeLayer(layerId: layerId) + let addResult = addFillLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "fillExtrusionLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + removeLayer(layerId: layerId) + let addResult = addFillExtrusionLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "circleLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + removeLayer(layerId: layerId) + let addResult = addCircleLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "hillshadeLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + removeLayer(layerId: layerId) + addHillshadeLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + result(nil) + + case "heatmapLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + removeLayer(layerId: layerId) + addHeatmapLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + result(nil) + + case "rasterLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + addRasterLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + result(nil) + + case "style#setStyleString": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let styleString = arguments["styleString"] as? String else { return } + setStyleString(styleString: styleString) + result(nil) + + case "style#addImage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let name = arguments["name"] as? String else { return } + // guard let length = arguments["length"] as? NSNumber else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let sdf = arguments["sdf"] as? Bool else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data, scale: UIScreen.main.scale) else { return } + if sdf { + mapView.style?.setImage(image.withRenderingMode(.alwaysTemplate), forName: name) + } else { + mapView.style?.setImage(image, forName: name) + } + result(nil) + + case "style#addImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + + guard let coordinates = arguments["coordinates"] as? [[Double]] else { return } + let quad = NGLCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][0], + longitude: coordinates[0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][0], + longitude: coordinates[3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][0], + longitude: coordinates[2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][0], + longitude: coordinates[1][1] + ) + ) + + // Check for duplicateSource error + if mapView.style?.source(withIdentifier: imageSourceId) != nil { + result(FlutterError( + code: "duplicateSource", + message: "Source with imageSourceId \(imageSourceId) already exists", + details: "Can't add duplicate source with imageSourceId: \(imageSourceId)" + )) + return + } + + let source = NGLImageSource( + identifier: imageSourceId, + coordinateQuad: quad, + image: image + ) + mapView.style?.addSource(source) + + result(nil) + case "style#updateImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let imageSource = mapView.style? + .source(withIdentifier: imageSourceId) as? NGLImageSource else { return } + let bytes = arguments["bytes"] as? FlutterStandardTypedData + if bytes != nil { + guard let data = bytes!.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + imageSource.image = image + } + let coordinates = arguments["coordinates"] as? [[Double]] + if coordinates != nil { + let quad = NGLCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates![0][0], + longitude: coordinates![0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates![3][0], + longitude: coordinates![3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates![2][0], + longitude: coordinates![2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates![1][0], + longitude: coordinates![1][1] + ) + ) + imageSource.coordinates = quad + } + result(nil) + + case "style#removeSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let source = mapView.style?.source(withIdentifier: sourceId) else { + result(nil) + return + } + mapView.style?.removeSource(source) + result(nil) + case "style#addLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + + let layer = NGLRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + + mapView.style?.addLayer(layer) + result(nil) + case "style#addLayerBelow": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let belowLayerId = arguments["belowLayerId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + // Check for noSuchLayer error + guard let belowLayer = mapView.style?.layer(withIdentifier: belowLayerId) else { + result(FlutterError( + code: "noSuchLayer", + message: "No layer found with layerId \(belowLayerId)", + details: "Can't insert layer below layer with id \(belowLayerId), as no such layer exists." + )) + return + } + + let layer = NGLRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + + mapView.style?.insertLayer(layer, below: belowLayer) + result(nil) + + case "style#removeLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + removeLayer(layerId: layerId) + result(nil) + + case "style#setFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let filter = arguments["filter"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(nil) + return + } + switch setFilter(layer, filter) { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#setVisibility": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let isVisible = arguments["isVisible"] as? Bool else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(nil) + return + } + layer.isVisible = isVisible + result(nil) + + case "source#addGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + addSourceGeojson(sourceId: sourceId, geojson: geojson) + result(nil) + + case "style#addSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: Any] else { return } + addSource(sourceId: sourceId, properties: properties) + result(nil) + case "style#findBelowLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let belowAt = arguments["belowAt"] as? [String] else { return } + guard let styleLayers = mapView.style?.layers else { return } + for (index, layer) in styleLayers.enumerated().reversed() { + if !(layer is NGLSymbolStyleLayer) && + !(layer is NGLFillExtrusionStyleLayer) && + (belowAt.filter{ layer.identifier.contains($0) }.isEmpty) { + if index == styleLayers.count - 1 { + result(styleLayers[index].identifier) + } else { + result(styleLayers[index+1].identifier) + } + break + } + } + result(nil) + case "source#setGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + setSource(sourceId: sourceId, geojson: geojson) + result(nil) + + case "source#setFeature": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojsonFeature"] as? String else { return } + setFeature(sourceId: sourceId, geojsonFeature: geojson) + result(nil) + case "snapshot#takeSnapshot": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + let camera = NGLMapCamera() + + guard let pitch = arguments["pitch"] as? NSNumber else { + result(FlutterError(code: "invalidArgument", message: "pitch is not a number", + details: nil)) + return + } + camera.pitch = pitch.doubleValue + + guard let heading = arguments["heading"] as? NSNumber else { + result(FlutterError(code: "invalidArgument", message: "heading is not a number", + details: nil)) + return + } + camera.heading = heading.doubleValue + + camera.centerCoordinate = mapView.centerCoordinate + if arguments["centerCoordinate"] != nil { + guard let centerCoordinate = arguments["centerCoordinate"] as? [NSNumber] else { + result(FlutterError( + code: "invalidArgument", + message: "centerCoordinate is not a number list", + details: nil + )) + return + } + camera.centerCoordinate = CLLocationCoordinate2D( + latitude: centerCoordinate[0].doubleValue, + longitude: centerCoordinate[1].doubleValue + ) + } + + guard let width = arguments["width"] as? NSNumber else { + result(FlutterError(code: "invalidArgument", message: "width is not a number", + details: nil)) + return + } + guard let height = arguments["height"] as? NSNumber else { + result(FlutterError(code: "invalidArgument", message: "height is not a number", + details: nil)) + return + } + + let size = CGSize(width: width.doubleValue, height: height.doubleValue) + + var styleURL: URL = mapView.styleURL + if arguments["styleUri"] != nil { + guard let styleUri = arguments["styleUri"] as? String else { + result( + FlutterError(code: "invalidArgument", message: "styleUri is not a string", + details: nil) + ) + return + } + styleURL = URL(string: styleUri)! + } + + let snapshotOptions: NGLMapSnapshotOptions = .init( + styleURL: styleURL, + camera: camera, + size: size + ) + + snapshotOptions.zoomLevel = mapView.zoomLevel + if arguments["zoomLevel"] != nil { + guard let zoomLevel = arguments["zoomLevel"] as? NSNumber else { + result(FlutterError(code: "invalidArgument", + message: "zoomLevel is not a number", details: nil)) + return + } + snapshotOptions.zoomLevel = zoomLevel.doubleValue + } + + if arguments["bounds"] != nil { + guard let bounds = arguments["bounds"] as? [[NSNumber]] else { + result(FlutterError(code: "invalidArgument", + message: "bounds is not a number list", details: nil)) + return + } + let sw = bounds[0] + let ne = bounds[1] + snapshotOptions.coordinateBounds = NGLCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: sw[0].doubleValue, + longitude: sw[1].doubleValue), + ne: CLLocationCoordinate2D( + latitude: ne[0].doubleValue, + longitude: ne[1].doubleValue + ) + ) + } + + let snapshotter: NGLMapSnapshotter? = NGLMapSnapshotter(options: snapshotOptions) + + snapshotter?.start { snapshot, error in + if error != nil { + result(FlutterError( + code: "canCreateSnapshot", + message: error?.localizedDescription, + details: error.debugDescription + )) + } else if let image = snapshot?.image { + guard let writeToDisk = arguments["writeToDisk"] as? NSNumber else { + result(FlutterError(code: "invalidArgument", + message: "writeToDisk is not a boolean", details: nil)) + return + } + + let value = writeToDisk.boolValue ? RNMBImageUtils + .createTempFile(image) : RNMBImageUtils.createBase64(image) + result(value.absoluteString) + } + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func removeLayer(layerId: String) { + if let layer = mapView.style?.layer(withIdentifier: layerId) { + mapView.style?.removeLayer(layer) + interactiveFeatureLayerIds.remove(layerId) + } + } + + private func loadIconImage(name: String) -> UIImage? { + // Build up the full path of the asset. + // First find the last '/' ans split the image name in the asset directory and the image file name. + if let range = name.range(of: "/", options: [.backwards]) { + let directory = String(name[.. NGLMapCamera? { + return trackCameraPosition ? mapView.camera : nil + } + + /* + * Scan layers from top to bottom and return the first matching feature + */ + private func firstFeatureOnLayers(at: CGPoint) -> NGLFeature? { + guard let style = mapView.style else { return nil } + + // get layers in order (interactiveFeatureLayerIds is unordered) + let clickableLayers = style.layers.filter { layer in + interactiveFeatureLayerIds.contains(layer.identifier) + } + + for layer in clickableLayers.reversed() { + let features = mapView.visibleFeatures( + at: at, + styleLayerIdentifiers: [layer.identifier] + ) + if let feature = features.first { + return feature + } + } + return nil + } + + /* + * UITapGestureRecognizer + * On tap invoke the map#onMapClick callback. + */ + @IBAction func handleMapTap(sender: UITapGestureRecognizer) { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + if let feature = firstFeatureOnLayers(at: point), let id = feature.identifier { + channel?.invokeMethod("feature#onTap", arguments: [ + "id": id, + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } else { + channel?.invokeMethod("map#onMapClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + fileprivate func invokeFeatureDrag( + _ point: CGPoint, + _ coordinate: CLLocationCoordinate2D, + _ eventType: String + ) { + if let feature = dragFeature, + let id = feature.identifier, + let previous = previousDragCoordinate, + let origin = originDragCoordinate + { + channel?.invokeMethod("feature#onDrag", arguments: [ + "id": id, + "x": point.x, + "y": point.y, + "originLng": origin.longitude, + "originLat": origin.latitude, + "currentLng": coordinate.longitude, + "currentLat": coordinate.latitude, + "eventType": eventType, + "deltaLng": coordinate.longitude - previous.longitude, + "deltaLat": coordinate.latitude - previous.latitude, + ]) + } + } + + @IBAction func handleMapPan(sender: UIPanGestureRecognizer) { + let began = sender.state == UIGestureRecognizer.State.began + let end = sender.state == UIGestureRecognizer.State.ended + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + if dragFeature == nil, began, sender.numberOfTouches == 1, + let feature = firstFeatureOnLayers(at: point), + let draggable = feature.attribute(forKey: "draggable") as? Bool, + draggable + { + sender.state = UIGestureRecognizer.State.began + dragFeature = feature + originDragCoordinate = coordinate + previousDragCoordinate = coordinate + mapView.allowsScrolling = false + let eventType = "start" + invokeFeatureDrag(point, coordinate, eventType) + for gestureRecognizer in mapView.gestureRecognizers! { + if let _ = gestureRecognizer as? UIPanGestureRecognizer { + gestureRecognizer.addTarget(self, action: #selector(handleMapPan)) + break + } + } + } + if end, dragFeature != nil { + mapView.allowsScrolling = true + let eventType = "end" + invokeFeatureDrag(point, coordinate, eventType) + dragFeature = nil + originDragCoordinate = nil + previousDragCoordinate = nil + } + + if !began, !end, dragFeature != nil { + let eventType = "drag" + invokeFeatureDrag(point, coordinate, eventType) + previousDragCoordinate = coordinate + } + } + + /* + * UILongPressGestureRecognizer + * After a long press invoke the map#onMapLongClick callback. + */ + @IBAction func handleMapLongPress(sender: UILongPressGestureRecognizer) { + // Fire when the long press starts + if sender.state == .began { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + channel?.invokeMethod("map#onMapLongClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + /* + * Override the attribution button's click target to handle the event locally. + * Called if the application supplies an onAttributionClick handler. + */ + func setupAttribution(_ mapView: NGLMapView) { + mapView.attributionButton.removeTarget( + mapView, + action: #selector(mapView.showAttribution), + for: .touchUpInside + ) + mapView.attributionButton.addTarget( + self, + action: #selector(showAttribution), + for: UIControl.Event.touchUpInside + ) + } + + /* + * Custom click handler for the attribution button. This callback is bound when + * the application specifies an onAttributionClick handler. + */ + @objc func showAttribution() { + channel?.invokeMethod("map#onAttributionClick", arguments: []) + } + + /* + * NGLMapViewDelegate + */ + func mapView(_ mapView: NGLMapView, didFinishLoading _: NGLStyle) { +// isMapReady = true +// updateMyLocationEnabled() +// +// if let initialTilt = initialTilt { +// let camera = mapView.camera +// camera.pitch = initialTilt +// mapView.setCamera(camera, animated: false) +// } +// +// addedShapesByLayer.removeAll() +// interactiveFeatureLayerIds.removeAll() +// +// mapReadyResult?(nil) +// +// // On first launch we only call map#onStyleLoaded if map#waitForMap has already been called +// if !isFirstStyleLoad || mapReadyResult != nil { +// isFirstStyleLoad = false +// +// if let channel = channel { +// channel.invokeMethod("map#onStyleLoaded", arguments: nil) +// } +// } + } + + func mapViewDidFinishLoadingMap(_ mapView: NGLMapView) { + isMapReady = true + updateMyLocationEnabled() + + if let initialTilt = initialTilt { + let camera = mapView.camera + camera.pitch = initialTilt + mapView.setCamera(camera, animated: false) + } + + addedShapesByLayer.removeAll() + interactiveFeatureLayerIds.removeAll() + + mapReadyResult?(nil) + + // On first launch we only call map#onStyleLoaded if map#waitForMap has already been called + if !isFirstStyleLoad || mapReadyResult != nil { + isFirstStyleLoad = false + + if let channel = channel { + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } + + // handle missing images + func mapView(_: NGLMapView, didFailToLoadImage name: String) -> UIImage? { + return loadIconImage(name: name) + } + + func mapView(_ mapView: NGLMapView, shouldChangeFrom _: NGLMapCamera, + to newCamera: NGLMapCamera) -> Bool + { + guard let bbox = cameraTargetBounds else { return true } + + // Get the current camera to restore it after. + let currentCamera = mapView.camera + + // From the new camera obtain the center to test if it’s inside the boundaries. + let newCameraCenter = newCamera.centerCoordinate + + // Set the map’s visible bounds to newCamera. + mapView.camera = newCamera + let newVisibleCoordinates = mapView.visibleCoordinateBounds + + // Revert the camera. + mapView.camera = currentCamera + + // Test if the newCameraCenter and newVisibleCoordinates are inside bbox. + let inside = NGLCoordinateInCoordinateBounds(newCameraCenter, bbox) + let intersects = NGLCoordinateInCoordinateBounds(newVisibleCoordinates.ne, bbox) && + NGLCoordinateInCoordinateBounds(newVisibleCoordinates.sw, bbox) + + return inside && intersects + } + + func mapView(_: NGLMapView, didUpdate userLocation: NGLUserLocation?) { + if let channel = channel, let userLocation = userLocation, + let location = userLocation.location + { + channel.invokeMethod("map#onUserLocationUpdated", arguments: [ + "userLocation": location.toDict(), + "heading": userLocation.heading?.toDict(), + ]) + } + } + + func mapView(_: NGLMapView, didChange mode: NGLUserTrackingMode, animated _: Bool) { + if let channel = channel { + channel.invokeMethod("map#onCameraTrackingChanged", arguments: ["mode": mode.rawValue]) + if mode == .none { + channel.invokeMethod("map#onCameraTrackingDismissed", arguments: []) + } + } + } + + func addSymbolLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLSymbolStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addSymbolProperties( + symbolLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + } + } + return .success(()) + } + + func addLineLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLLineStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addLineProperties(lineLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + } + } + return .success(()) + } + + func addFillLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLFillStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillProperties(fillLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + } + } + return .success(()) + } + + func addFillExtrusionLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLFillExtrusionStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillExtrusionProperties( + fillExtrusionLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + } + } + return .success(()) + } + + func addCircleLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLCircleStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addCircleProperties( + circleLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + } + } + return .success(()) + } + + func setFilter(_ layer: NGLStyleLayer, _ filter: String) -> Result { + do { + let filter = try JSONSerialization.jsonObject( + with: filter.data(using: .utf8)!, + options: .fragmentsAllowed + ) + if filter is NSNull { + return .success(()) + } + let predicate = NSPredicate(nglJSONObject: filter) + if let layer = layer as? NGLVectorStyleLayer { + layer.predicate = predicate + } else { + return .failure(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + )) + } + return .success(()) + } catch { + return .failure(MethodCallError.invalidExpression) + } + } + + func addHillshadeLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLHillshadeStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHillshadeProperties( + hillshadeLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + } + } + } + + func addHeatmapLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLHeatmapStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHeatmapProperties( + heatmapLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + } + } + } + + func addRasterLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) { + if let style = mapView.style { + if let source = style.source(withIdentifier: sourceId) { + let layer = NGLRasterStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addRasterProperties( + rasterLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + } + } + } + + func addSource(sourceId: String, properties: [String: Any]) { + if let style = mapView.style, let type = properties["type"] as? String { + var source: NGLSource? + + switch type { + case "vector": + source = SourcePropertyConverter.buildVectorTileSource( + identifier: sourceId, + properties: properties + ) + case "raster": + source = SourcePropertyConverter.buildRasterTileSource( + identifier: sourceId, + properties: properties + ) + case "raster-dem": + source = SourcePropertyConverter.buildRasterDemSource( + identifier: sourceId, + properties: properties + ) + case "image": + source = SourcePropertyConverter.buildImageSource( + identifier: sourceId, + properties: properties + ) + case "geojson": + source = SourcePropertyConverter.buildShapeSource( + identifier: sourceId, + properties: properties + ) + default: + // unsupported source type + source = nil + } + if let source = source { + style.addSource(source) + } + } + } + + func mapViewDidBecomeIdle(_: NGLMapView) { + if let channel = channel { + channel.invokeMethod("map#onIdle", arguments: []) + } + } + + func mapView(_: NGLMapView, regionWillChangeAnimated _: Bool) { + if let channel = channel { + channel.invokeMethod("camera#onMoveStarted", arguments: []) + } + } + + func mapViewRegionIsChanging(_ mapView: NGLMapView) { + if !trackCameraPosition { return } + if let channel = channel { + channel.invokeMethod("camera#onMove", arguments: [ + "position": getCamera()?.toDict(mapView: mapView), + ]) + } + } + + func mapView(_ mapView: NGLMapView, regionDidChangeAnimated _: Bool) { + let arguments = trackCameraPosition ? [ + "position": getCamera()?.toDict(mapView: mapView) + ] : [:] + if let channel = channel { + channel.invokeMethod("camera#onIdle", arguments: arguments) + } + } + + func addSourceGeojson(sourceId: String, geojson: String) { + do { + let parsed = try NGLShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + let source = NGLShapeSource(identifier: sourceId, shape: parsed, options: [:]) + addedShapesByLayer[sourceId] = parsed + mapView.style?.addSource(source) + print(source) + } catch {} + } + + func setSource(sourceId: String, geojson: String) { + do { + let parsed = try NGLShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + if let source = mapView.style?.source(withIdentifier: sourceId) as? NGLShapeSource { + addedShapesByLayer[sourceId] = parsed + source.shape = parsed + } + } catch {} + } + + func setFeature(sourceId: String, geojsonFeature: String) { + do { + let newShape = try NGLShape( + data: geojsonFeature.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + if let source = mapView.style?.source(withIdentifier: sourceId) as? NGLShapeSource, + let shape = addedShapesByLayer[sourceId] as? NGLShapeCollectionFeature, + let feature = newShape as? NGLShape & NGLFeature + { + if let index = shape.shapes + .firstIndex(where: { + if let id = $0.identifier as? String, + let featureId = feature.identifier as? String + { return id == featureId } + + if let id = $0.identifier as? NSNumber, + let featureId = feature.identifier as? NSNumber + { return id == featureId } + return false + }) + { + var shapes = shape.shapes + shapes[index] = feature + + source.shape = NGLShapeCollectionFeature(shapes: shapes) + } + + addedShapesByLayer[sourceId] = source.shape + } + + } catch {} + } + + /* + * NextbillionMapOptionsSink + */ + func setCameraTargetBounds(bounds: NGLCoordinateBounds?) { + cameraTargetBounds = bounds + } + + func setCompassEnabled(compassEnabled: Bool) { + mapView.compassView.isHidden = compassEnabled + mapView.compassView.isHidden = !compassEnabled + } + + func setMinMaxZoomPreference(min: Double, max: Double) { + mapView.minimumZoomLevel = min + mapView.maximumZoomLevel = max + } + + func setStyleString(styleString: String) { + // Check if json, url, absolute path or asset path: + if styleString.isEmpty { + NSLog("setStyleString - string empty") + } else if styleString.hasPrefix("{") || styleString.hasPrefix("[") { + // Currently the iOS NbMaps SDK does not have a builder for json. + NSLog("setStyleString - JSON style currently not supported") + } else if styleString.hasPrefix("/") { + // Absolute path + mapView.styleURL = URL(fileURLWithPath: styleString, isDirectory: false) + } else if + !styleString.hasPrefix("http://"), + !styleString.hasPrefix("https://"), + !styleString.hasPrefix("nbmaps://") + { + // We are assuming that the style will be loaded from an asset here. + let assetPath = registrar.lookupKey(forAsset: styleString) + mapView.styleURL = URL(string: assetPath, relativeTo: Bundle.main.resourceURL) + + } else { + mapView.styleURL = URL(string: styleString) + } + } + + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) { + mapView.allowsRotating = rotateGesturesEnabled + } + + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) { + mapView.allowsScrolling = scrollGesturesEnabled + scrollingEnabled = scrollGesturesEnabled + } + + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) { + mapView.allowsTilting = tiltGesturesEnabled + } + + func setTrackCameraPosition(trackCameraPosition: Bool) { + self.trackCameraPosition = trackCameraPosition + } + + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) { + mapView.allowsZooming = zoomGesturesEnabled + } + + func setMyLocationEnabled(myLocationEnabled: Bool) { + if self.myLocationEnabled == myLocationEnabled { + return + } + self.myLocationEnabled = myLocationEnabled + updateMyLocationEnabled() + } + + func setMyLocationTrackingMode(myLocationTrackingMode: NGLUserTrackingMode) { + mapView.userTrackingMode = myLocationTrackingMode + } + + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) { + switch myLocationRenderMode { + case .Normal: + mapView.showsUserHeadingIndicator = false + case .Compass: + mapView.showsUserHeadingIndicator = true + case .Gps: + NSLog("RenderMode.GPS currently not supported") + } + } + + func setLogoViewMargins(x: Double, y: Double) { + mapView.logoViewMargins = CGPoint(x: x, y: y) + } + + func setCompassViewPosition(position: NGLOrnamentPosition) { + mapView.compassViewPosition = position + } + + func setCompassViewMargins(x: Double, y: Double) { + mapView.compassViewMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonMargins(x: Double, y: Double) { + mapView.attributionButtonMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonPosition(position: NGLOrnamentPosition) { + mapView.attributionButtonPosition = position + } +} diff --git a/ios/Classes/NextbillionMapOptionsSink.swift b/ios/Classes/NextbillionMapOptionsSink.swift new file mode 100644 index 0000000..45895c2 --- /dev/null +++ b/ios/Classes/NextbillionMapOptionsSink.swift @@ -0,0 +1,22 @@ + +import Nbmap + +protocol NextbillionMapOptionsSink { + func setCameraTargetBounds(bounds: NGLCoordinateBounds?) + func setCompassEnabled(compassEnabled: Bool) + func setStyleString(styleString: String) + func setMinMaxZoomPreference(min: Double, max: Double) + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) + func setTrackCameraPosition(trackCameraPosition: Bool) + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) + func setMyLocationEnabled(myLocationEnabled: Bool) + func setMyLocationTrackingMode(myLocationTrackingMode: NGLUserTrackingMode) + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) + func setLogoViewMargins(x: Double, y: Double) + func setCompassViewPosition(position: NGLOrnamentPosition) + func setCompassViewMargins(x: Double, y: Double) + func setAttributionButtonMargins(x: Double, y: Double) + func setAttributionButtonPosition(position: NGLOrnamentPosition) +} diff --git a/ios/Classes/OfflineChannelHandler.swift b/ios/Classes/OfflineChannelHandler.swift new file mode 100644 index 0000000..b5f4746 --- /dev/null +++ b/ios/Classes/OfflineChannelHandler.swift @@ -0,0 +1,69 @@ +// +// OfflineChannelHandler.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation + +class OfflineChannelHandler: NSObject, FlutterStreamHandler { + private var sink: FlutterEventSink? + + init(messenger: FlutterBinaryMessenger, channelName: String) { + super.init() + let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger) + eventChannel.setStreamHandler(self) + } + + // MARK: FlutterStreamHandler protocol compliance + + func onListen(withArguments _: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments _: Any?) -> FlutterError? { + sink = nil + return nil + } + + // MARK: Util methods + + func onError(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink?(FlutterError(code: errorCode, message: errorMessage, details: errorDetails)) + } + + func onSuccess() { + let body = ["status": "success"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onStart() { + let body = ["status": "start"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onProgress(progress: Double) { + let body: [String: Any] = ["status": "progress", "progress": progress] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } +} diff --git a/ios/Classes/OfflineManagerUtils.swift b/ios/Classes/OfflineManagerUtils.swift new file mode 100644 index 0000000..326d60f --- /dev/null +++ b/ios/Classes/OfflineManagerUtils.swift @@ -0,0 +1,101 @@ +// +// OfflineManagerUtils.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Flutter +import Foundation +import Nbmap + +class OfflineManagerUtils { + static var activeDownloaders: [Int: OfflinePackDownloader] = [:] + + static func downloadRegion( + definition: OfflineRegionDefinition, + metadata: [String: Any], + result: @escaping FlutterResult, + registrar _: FlutterPluginRegistrar, + channelHandler: OfflineChannelHandler + ) { + // Prepare downloader + let downloader = OfflinePackDownloader( + result: result, + channelHandler: channelHandler, + regionDefintion: definition, + metadata: metadata + ) + + // Download region + let id = downloader.download() + // retain downloader by its generated id + activeDownloaders[id] = downloader + } + + static func regionsList(result: @escaping FlutterResult) { + let offlineStorage = NGLOfflineStorage.shared + guard let packs = offlineStorage.packs else { + result("[]") + return + } + let regionsArgs = packs.compactMap { pack in + OfflineRegion.fromOfflinePack(pack)?.toDictionary() + } + guard let regionsArgsJsonData = try? JSONSerialization.data(withJSONObject: regionsArgs), + let regionsArgsJsonString = String(data: regionsArgsJsonData, encoding: .utf8) + else { + result(FlutterError(code: "RegionListError", message: nil, details: nil)) + return + } + result(regionsArgsJsonString) + } + + static func setOfflineTileCountLimit(result: @escaping FlutterResult, maximumCount: UInt64) { + let offlineStorage = NGLOfflineStorage.shared + offlineStorage.setMaximumAllowedNbmapTiles(maximumCount) + result(nil) + } + + static func deleteRegion(result: @escaping FlutterResult, id: Int) { + let offlineStorage = NGLOfflineStorage.shared + guard let pacs = offlineStorage.packs else { return } + let packToRemove = pacs.first(where: { pack -> Bool in + let contextJsonObject = try? JSONSerialization.jsonObject(with: pack.context) + let contextJsonDict = contextJsonObject as? [String: Any] + if let regionId = contextJsonDict?["id"] as? Int { + return regionId == id + } else { + return false + } + }) + if let packToRemoveUnwrapped = packToRemove { + // deletion is only safe if the download is suspended + packToRemoveUnwrapped.suspend() + OfflineManagerUtils.releaseDownloader(id: id) + + offlineStorage.removePack(packToRemoveUnwrapped) { error in + if let error = error { + result(FlutterError( + code: "DeleteRegionError", + message: error.localizedDescription, + details: nil + )) + } else { + result(nil) + } + } + } else { + result(FlutterError( + code: "DeleteRegionError", + message: "There is no region with given id to delete", + details: nil + )) + } + } + + /// Removes downloader from cache so it's memory can be deallocated + static func releaseDownloader(id: Int) { + activeDownloaders.removeValue(forKey: id) + } +} diff --git a/ios/Classes/OfflinePackDownloadManager.swift b/ios/Classes/OfflinePackDownloadManager.swift new file mode 100644 index 0000000..27cc955 --- /dev/null +++ b/ios/Classes/OfflinePackDownloadManager.swift @@ -0,0 +1,223 @@ +// +// OfflinePackDownloadManager.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation +import Nbmap + +class OfflinePackDownloader { + // MARK: Properties + + private let result: FlutterResult + private let channelHandler: OfflineChannelHandler + private let regionDefinition: OfflineRegionDefinition + private let metadata: [String: Any] + + /// Currently managed pack + private var pack: NGLOfflinePack? + + /// This variable is set to true when this downloader has finished downloading and called the result method. It is used to prevent + /// the result method being called multiple times + private var isCompleted = false + + // MARK: Initializers + + init( + result: @escaping FlutterResult, + channelHandler: OfflineChannelHandler, + regionDefintion: OfflineRegionDefinition, + metadata: [String: Any] + ) { + self.result = result + self.channelHandler = channelHandler + regionDefinition = regionDefintion + self.metadata = metadata + + setupNotifications() + } + + deinit { + print("Removing offline pack notification observers") + NotificationCenter.default.removeObserver(self) + } + + // MARK: Public methods + + func download() -> Int { + let storage = NGLOfflineStorage.shared + // While the Android SDK generates a region ID in createOfflineRegion, the iOS + // SDK does not have this feature. Therefore, we generate a region ID here. + let id = UUID().hashValue + let regionData = OfflineRegion(id: id, metadata: metadata, definition: regionDefinition) + let tilePyramidRegion = regionDefinition.toNGLTilePyramidOfflineRegion() + storage + .addPack(for: tilePyramidRegion, + withContext: regionData.prepareContext()) { [weak self] pack, error in + if let pack = pack { + self?.onPackCreated(pack: pack) + } else { + self?.onPackCreationError(error: error) + } + } + return id + } + + // MARK: Pack management + + private func onPackCreated(pack: NGLOfflinePack) { + if let region = OfflineRegion.fromOfflinePack(pack), + let regionData = try? JSONSerialization.data(withJSONObject: region.toDictionary()) + { + // Start downloading + self.pack = pack + pack.resume() + // Provide region with generated + result(String(data: regionData, encoding: .utf8)) + channelHandler.onStart() + } else { + onPackCreationError(error: OfflinePackError.InvalidPackData) + } + } + + private func onPackCreationError(error: Error?) { + // Reset downloading state + channelHandler.onError( + errorCode: "nbmapsInvalidRegionDefinition", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "nbmapsInvalidRegionDefinition", + message: error?.localizedDescription, + details: nil + )) + } + + // MARK: Progress obseration + + @objc private func onPackDownloadProgress(notification: NSNotification) { + // Verify if correct pack is checked + guard let pack = notification.object as? NGLOfflinePack, + verifyPack(pack: pack) else { return } + // Calculate progress of downloading + let packProgress = pack.progress + let downloadProgress = calculateDownloadingProgress( + requiredResourceCount: packProgress.countOfResourcesExpected, + completedResourceCount: packProgress.countOfResourcesCompleted + ) + // Check if downloading is complete + if pack.state == .complete { + print("Region downloaded successfully") + // set download state to inactive + // This can be called multiple times but result can only be called once. We use this + // check to ensure that + guard !isCompleted else { return } + isCompleted = true + channelHandler.onSuccess() + result(nil) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.releaseDownloader(id: region.id) + } + } else { + print("Region download progress \(downloadProgress)") + channelHandler.onProgress(progress: downloadProgress) + } + } + + @objc private func onPackDownloadError(notification: NSNotification) { + guard let pack = notification.object as? NGLOfflinePack, + verifyPack(pack: pack) else { return } + let error = notification.userInfo?[NGLOfflinePackUserInfoKey.error] as? NSError + print("Pack download error: \(String(describing: error?.localizedDescription))") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "Downloading error", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "Downloading error", + message: error?.localizedDescription, + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + @objc private func onMaximumAllowedNbMapsTiles(notification: NSNotification) { + guard let pack = notification.object as? NGLOfflinePack, + verifyPack(pack: pack) else { return } + let maximumCount = (notification.userInfo?[NGLOfflinePackUserInfoKey.maximumCount] + as AnyObject).uint64Value ?? 0 + print("NbMaps tile count limit exceeded: \(maximumCount)") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "nbmapsTileCountLimitExceeded", + errorMessage: "NbMaps tile count limit exceeded: \(maximumCount)", + errorDetails: nil + ) + result(FlutterError( + code: "nbmapsTileCountLimitExceeded", + message: "NbMaps tile count limit exceeded: \(maximumCount)", + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + // MARK: Util methods + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadProgress(notification:)), + name: NSNotification.Name.NGLOfflinePackProgressChanged, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadError(notification:)), + name: NSNotification.Name.NGLOfflinePackError, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onMaximumAllowedNbMapsTiles(notification:)), + name: NSNotification.Name.NGLOfflinePackMaximumNbmapTilesReached, + object: nil + ) + } + + /// Since NotificationCenter will send notifications about all packs downloads we need to make sure we only handle packs + /// managed by this downloader. So this method checks if the pack we got from a notification is the same as the pack being + /// managed by this downloader and if it is it returns true. Otherwise it returns false + private func verifyPack(pack: NGLOfflinePack) -> Bool { + guard let currentlyManagedPack = self.pack else { + // No pack is being managed yet + return false + } + // We can tell whether 2 packs are the same by comparing metadata we assigned earlier + return pack.state != .invalid && pack.context == currentlyManagedPack.context + } + + private func calculateDownloadingProgress( + requiredResourceCount: UInt64, + completedResourceCount: UInt64 + ) -> Double { + return requiredResourceCount > 0 + ? 100.0 * Double(completedResourceCount) / Double(requiredResourceCount) + : 0.0 + } +} + +enum OfflinePackError: Error { + case InvalidPackData +} diff --git a/ios/Classes/OfflineRegion.swift b/ios/Classes/OfflineRegion.swift new file mode 100644 index 0000000..c28ef77 --- /dev/null +++ b/ios/Classes/OfflineRegion.swift @@ -0,0 +1,57 @@ +// +// OfflineRegionData.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Foundation +import Nbmap + +class OfflineRegion { + let id: Int + let metadata: [String: Any] + let definition: OfflineRegionDefinition + + enum CodingKeys: CodingKey { + case id, metadata, definition + } + + init(id: Int, metadata: [String: Any], definition: OfflineRegionDefinition) { + self.id = id + self.metadata = metadata + self.definition = definition + } + + func prepareContext() -> Data { + let context = ["metadata": metadata, "id": id] as [String: Any] + let jsonData = try? JSONSerialization.data(withJSONObject: context, options: []) + return jsonData ?? Data() + } + + func toDictionary() -> [String: Any] { + return [ + "id": id, + "metadata": metadata, + "definition": definition.toDictionary(), + ] + } + + static func fromOfflinePack(_ pack: NGLOfflinePack) -> OfflineRegion? { + guard let region = pack.region as? NGLTilePyramidOfflineRegion, + let dataObject = try? JSONSerialization.jsonObject(with: pack.context, options: []), + let dict = dataObject as? [String: Any], + let id = dict["id"] as? Int, + let metadata = dict["metadata"] as? [String: Any] else { return nil } + return OfflineRegion( + id: id, + metadata: metadata, + definition: OfflineRegionDefinition( + bounds: [region.bounds.sw, region.bounds.ne].map { [$0.latitude, $0.longitude] }, + mapStyleUrl: region.styleURL, + minZoom: region.minimumZoomLevel, + maxZoom: region.maximumZoomLevel + ) + ) + } +} diff --git a/ios/Classes/OfflineRegionDefinition.swift b/ios/Classes/OfflineRegionDefinition.swift new file mode 100644 index 0000000..de2b2ad --- /dev/null +++ b/ios/Classes/OfflineRegionDefinition.swift @@ -0,0 +1,56 @@ +import Foundation +import Nbmap + +class OfflineRegionDefinition { + let bounds: [[Double]] + let mapStyleUrl: URL + let minZoom: Double + let maxZoom: Double + + init(bounds: [[Double]], mapStyleUrl: URL, minZoom: Double, maxZoom: Double) { + self.bounds = bounds + self.mapStyleUrl = mapStyleUrl + self.minZoom = minZoom + self.maxZoom = maxZoom + } + + func getBounds() -> NGLCoordinateBounds { + return NGLCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: bounds[0][0], longitude: bounds[0][1]), + ne: CLLocationCoordinate2D(latitude: bounds[1][0], longitude: bounds[1][1]) + ) + } + + static func fromDictionary(_ jsonDict: [String: Any]) -> OfflineRegionDefinition? { + guard let bounds = jsonDict["bounds"] as? [[Double]], + let mapStyleUrlString = jsonDict["mapStyleUrl"] as? String, + let mapStyleUrl = URL(string: mapStyleUrlString), + let minZoom = jsonDict["minZoom"] as? Double, + let maxZoom = jsonDict["maxZoom"] as? Double + else { return nil } + return OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: mapStyleUrl, + minZoom: minZoom, + maxZoom: maxZoom + ) + } + + func toDictionary() -> [String: Any] { + return [ + "bounds": bounds, + "mapStyleUrl": mapStyleUrl.absoluteString, + "minZoom": minZoom, + "maxZoom": maxZoom, + ] + } + + func toNGLTilePyramidOfflineRegion() -> NGLTilePyramidOfflineRegion { + return NGLTilePyramidOfflineRegion( + styleURL: mapStyleUrl, + bounds: getBounds(), + fromZoomLevel: minZoom, + toZoomLevel: maxZoom + ) + } +} diff --git a/ios/Classes/RNMBImageUtils.swift b/ios/Classes/RNMBImageUtils.swift new file mode 100644 index 0000000..59ff12c --- /dev/null +++ b/ios/Classes/RNMBImageUtils.swift @@ -0,0 +1,26 @@ +// +// RNMBImageUtils.swift +// nb_maps_flutter +// +// Created by mac on 30/05/2022. +// + +enum RNMBImageUtils { + static func createTempFile(_ image: UIImage) -> URL { + let fileID = UUID().uuidString + let pathComponent = "Documents/rctngl-snapshot-\(fileID).jpeg" + + let filePath = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(pathComponent) + + let data = image.jpegData(compressionQuality: 1.0) + try! data?.write(to: filePath, options: [.atomic]) + return filePath + } + + static func createBase64(_ image: UIImage) -> URL { + let data = image.jpegData(compressionQuality: 1.0) + let b64string: String = data!.base64EncodedString(options: [.endLineWithCarriageReturn]) + let result = "data:image/jpeg;base64,\(b64string)" + return URL(string: result)! + } +} diff --git a/ios/Classes/SourcePropertyConverter.swift b/ios/Classes/SourcePropertyConverter.swift new file mode 100644 index 0000000..982eb13 --- /dev/null +++ b/ios/Classes/SourcePropertyConverter.swift @@ -0,0 +1,177 @@ +import Foundation +import Nbmap + +class SourcePropertyConverter { + class func interpretTileOptions(properties: [String: Any]) -> [NGLTileSourceOption: Any] { + var options = [NGLTileSourceOption: Any]() + + if let bounds = properties["bounds"] as? [Double] { + options[.coordinateBounds] = + NSValue(nglCoordinateBounds: boundsFromArray(coordinates: bounds)) + } + if let minzoom = properties["minzoom"] as? Double { + options[.minimumZoomLevel] = minzoom + } + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + if let tileSize = properties["tileSize"] as? Double { + options[.tileSize] = Int(tileSize) + } + if let scheme = properties["scheme"] as? String { + let system: NGLTileCoordinateSystem = (scheme == "tms" ? .TMS : .XYZ) + options[.tileCoordinateSystem] = system.rawValue + } + return options + // TODO: attribution not implemneted for IOS + } + + class func buildRasterTileSource(identifier: String, + properties: [String: Any]) -> NGLRasterTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return NGLRasterTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + let options = interpretTileOptions(properties: properties) + return NGLRasterTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: options + ) + } + return nil + } + + class func buildVectorTileSource(identifier: String, + properties: [String: Any]) -> NGLVectorTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return NGLVectorTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return NGLVectorTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func buildRasterDemSource(identifier: String, + properties: [String: Any]) -> NGLRasterDEMSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return NGLRasterDEMSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return NGLRasterDEMSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func interpretShapeOptions(properties: [String: Any]) -> [NGLShapeSourceOption: Any] { + var options = [NGLShapeSourceOption: Any]() + + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + + if let buffer = properties["buffer"] as? Double { + options[.buffer] = buffer + } + if let tolerance = properties["tolerance"] as? Double { + options[.simplificationTolerance] = tolerance + } + + if let cluster = properties["cluster"] as? Bool { + options[.clustered] = cluster + } + if let clusterRadius = properties["clusterRadius"] as? Double { + options[.clusterRadius] = clusterRadius + } + if let clusterMaxZoom = properties["clusterMaxZoom"] as? Double { + options[.maximumZoomLevelForClustering] = clusterMaxZoom + } + + // TODO: clusterProperties not implemneted for IOS + + if let lineMetrics = properties["lineMetrics"] as? Bool { + options[.lineDistanceMetrics] = lineMetrics + } + return options + } + + class func buildShapeSource(identifier: String, properties: [String: Any]) -> NGLShapeSource? { + let options = interpretShapeOptions(properties: properties) + if let data = properties["data"] as? String, let url = URL(string: data) { + return NGLShapeSource(identifier: identifier, url: url, options: options) + } + if let data = properties["data"] { + do { + let geoJsonData = try JSONSerialization.data(withJSONObject: data) + let shape = try NGLShape(data: geoJsonData, encoding: String.Encoding.utf8.rawValue) + return NGLShapeSource(identifier: identifier, shape: shape, options: options) + } catch {} + } + return nil + } + + class func buildImageSource(identifier: String, properties: [String: Any]) -> NGLImageSource? { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl), + let coordinates = properties["coordinates"] as? [[Double]] + { + return NGLImageSource( + identifier: identifier, + coordinateQuad: quadFromArray(coordinates: coordinates), + url: url + ) + } + return nil + } + + class func addShapeProperties(properties: [String: Any], source: NGLShapeSource) { + do { + if let data = properties["data"] as? String { + let parsed = try NGLShape( + data: data.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + source.shape = parsed + } + } catch {} + } + + class func quadFromArray(coordinates: [[Double]]) -> NGLCoordinateQuad { + return NGLCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][1], + longitude: coordinates[0][0] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][1], + longitude: coordinates[3][0] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][1], + longitude: coordinates[2][0] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][1], + longitude: coordinates[1][0] + ) + ) + } + + class func boundsFromArray(coordinates: [Double]) -> NGLCoordinateBounds { + return NGLCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: coordinates[1], longitude: coordinates[0]), + ne: CLLocationCoordinate2D(latitude: coordinates[3], longitude: coordinates[2]) + ) + } +} diff --git a/ios/Classes/SwiftNbMapsFlutterPlugin.swift b/ios/Classes/SwiftNbMapsFlutterPlugin.swift new file mode 100644 index 0000000..9a75186 --- /dev/null +++ b/ios/Classes/SwiftNbMapsFlutterPlugin.swift @@ -0,0 +1,215 @@ +import Flutter +import Foundation +import Nbmap +import UIKit + +public class SwiftNbMapsFlutterPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = NbMapFactory(withRegistrar: registrar) + registrar.register(instance, withId: "plugins.flutter.io/nb_maps_flutter") + + let channel = FlutterMethodChannel( + name: "plugins.flutter.io/nb_maps_flutter", + binaryMessenger: registrar.messenger() + ) + + let nextBillionChannel = FlutterMethodChannel( + name: "plugins.flutter.io/nextbillion_init", + binaryMessenger: registrar.messenger() + ) + + nextBillionChannel.setMethodCallHandler{ call, result in + switch call.method { + case "nextbillion/init_nextbillion": + if let args = call.arguments as? [String: Any] { + if let token = args["accessKey"] as? String? { + NGLAccountManager.accessToken = token + } + + let libraryBundle = Bundle(for: SwiftNbMapsFlutterPlugin.self) + + let version = libraryBundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") ?? "Unknown" + let buildNumber = libraryBundle.object(forInfoDictionaryKey: "CFBundleVersion") ?? "Unknown" + + let crossPlatformInfo: String = "Flutter-\(version)-\(buildNumber)" + NGLAccountManager.crossPlatformInfo = crossPlatformInfo + } + result(nil) + case "nextbillion/get_access_key": + if let token = NGLAccountManager.accessToken { + result(token) + } + case "nextbillion/set_access_key": + if let args = call.arguments as? [String: Any] { + if let token = args["accessKey"] as? String? { + NGLAccountManager.accessToken = token + } + } + result(nil) + case "nextbillion/get_base_uri": + result(NGLAccountManager.apiBaseURL.absoluteString) + case "nextbillion/set_base_uri": + if let args = call.arguments as? [String: Any] { + if let baseUri = args["baseUri"] as? String? { + NGLAccountManager.setAPIBaseURL(URL(string: baseUri!)!) + } + } + result(nil) + case "nextbillion/set_key_header_name": + if let args = call.arguments as? [String: Any] { + if let apiKeyHeaderName = args["apiKeyHeaderName"] as? String? { + NGLAccountManager.apiKeyHeaderName = apiKeyHeaderName + } + } + result(nil) + case "nextbillion/get_key_header_name": + result(NGLAccountManager.apiKeyHeaderName) + + case "nextbillion/get_nb_id": + result(NGLAccountManager.nbId) + case "nextbillion/get_user_id": + result(NGLAccountManager.userId) + case "nextbillion/set_user_id": + if let args = call.arguments as? [String: Any] { + if let userId = args["userId"] as? String? { + NGLAccountManager.userId = userId + } + } + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + + } + + channel.setMethodCallHandler { methodCall, result in + switch methodCall.method { + case "setHttpHeaders": + guard let arguments = methodCall.arguments as? [String: Any], + let headers = arguments["headers"] as? [String: String] + else { + result(FlutterError( + code: "setHttpHeadersError", + message: "could not decode arguments", + details: nil + )) + result(nil) + return + } + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers // your headers here + NGLNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + result(nil) + case "installOfflineMapTiles": + guard let arguments = methodCall.arguments as? [String: String] else { return } + let tilesdb = arguments["tilesdb"] + installOfflineMapTiles(registrar: registrar, tilesdb: tilesdb!) + result(nil) + case "downloadOfflineRegion": + // Get download region arguments from caller + guard let args = methodCall.arguments as? [String: Any], + let definitionDictionary = args["definition"] as? [String: Any], + let metadata = args["metadata"] as? [String: Any], + let defintion = OfflineRegionDefinition.fromDictionary(definitionDictionary), + let channelName = args["channelName"] as? String + else { + print( + "downloadOfflineRegion unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + // Prepare channel + let channelHandler = OfflineChannelHandler( + messenger: registrar.messenger(), + channelName: channelName + ) + OfflineManagerUtils.downloadRegion( + definition: defintion, + metadata: metadata, + result: result, + registrar: registrar, + channelHandler: channelHandler + ) + case "setOfflineTileCountLimit": + guard let arguments = methodCall.arguments as? [String: Any], + let limit = arguments["limit"] as? UInt64 + else { + result(FlutterError( + code: "SetOfflineTileCountLimitError", + message: "could not decode arguments", + details: nil + )) + return + } + OfflineManagerUtils.setOfflineTileCountLimit(result: result, maximumCount: limit) + case "getListOfRegions": + // Note: this does not download anything from internet, it only fetches data drom database + OfflineManagerUtils.regionsList(result: result) + case "deleteOfflineRegion": + guard let args = methodCall.arguments as? [String: Any], + let id = args["id"] as? Int + else { + result(nil) + return + } + OfflineManagerUtils.deleteRegion(result: result, id: id) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func getTilesUrl() -> URL { + guard var cachesUrl = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first, + let bundleId = Bundle.main + .object(forInfoDictionaryKey: kCFBundleIdentifierKey as String) as? String + else { + fatalError("Could not get map tiles directory") + } + cachesUrl.appendPathComponent(bundleId) + cachesUrl.appendPathComponent(".nbmaps") + cachesUrl.appendPathComponent("cache.db") + return cachesUrl + } + + private static func installOfflineMapTiles(registrar: FlutterPluginRegistrar, tilesdb: String) { + var tilesUrl = getTilesUrl() + let bundlePath = getTilesDbPath(registrar: registrar, tilesdb: tilesdb) + NSLog( + "Cached tiles not found, copying from bundle... \(String(describing: bundlePath)) ==> \(tilesUrl)" + ) + do { + let parentDir = tilesUrl.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parentDir, + withIntermediateDirectories: true, + attributes: nil + ) + if FileManager.default.fileExists(atPath: tilesUrl.path) { + try FileManager.default.removeItem(atPath: tilesUrl.path) + } + try FileManager.default.copyItem(atPath: bundlePath!, toPath: tilesUrl.path) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try tilesUrl.setResourceValues(resourceValues) + } catch { + NSLog("Error copying bundled tiles: \(error)") + } + } + + private static func getTilesDbPath(registrar: FlutterPluginRegistrar, + tilesdb: String) -> String? + { + if tilesdb.starts(with: "/") { + return tilesdb + } else { + let key = registrar.lookupKey(forAsset: tilesdb) + return Bundle.main.path(forResource: key, ofType: nil) + } + } +} diff --git a/ios/nb_maps_flutter.podspec b/ios/nb_maps_flutter.podspec new file mode 100644 index 0000000..e88a65b --- /dev/null +++ b/ios/nb_maps_flutter.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'nb_maps_flutter' + s.version = '1.2.0' + s.summary = 'A new Flutter plugin.' + s.description = <<-DESC +A new Flutter plugin. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.swift_version = '5.0' + s.dependency 'NextBillionMap', '= 1.1.5' + s.ios.deployment_target = '9.0' +end + diff --git a/lib/nb_maps_flutter.dart b/lib/nb_maps_flutter.dart new file mode 100644 index 0000000..4860ce3 --- /dev/null +++ b/lib/nb_maps_flutter.dart @@ -0,0 +1,38 @@ +library nb_maps_flutter; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/rendering.dart'; + +part 'src/controller.dart'; +part 'src/nb_map.dart'; +part 'src/global.dart'; +part 'src/offline_region.dart'; +part 'src/download_region_status.dart'; +part 'src/layer_expressions.dart'; +part 'src/layer_properties.dart'; +part 'src/color_tools.dart'; +part 'src/annotation_manager.dart'; +part 'src/util.dart'; + +part 'src/platform_interface/annotation.dart'; +part 'src/platform_interface/callbacks.dart'; +part 'src/platform_interface/camera.dart'; +part 'src/platform_interface/circle.dart'; +part 'src/platform_interface/line.dart'; +part 'src/platform_interface/location.dart'; +part 'src/platform_interface/method_channel_nbmaps.dart'; +part 'src/platform_interface/symbol.dart'; +part 'src/platform_interface/fill.dart'; +part 'src/platform_interface/ui.dart'; +part 'src/platform_interface/snapshot.dart'; +part 'src/platform_interface/nbmaps_platform_interface.dart'; +part 'src/platform_interface/source_properties.dart'; +part 'src/platform_interface/nextbillion.dart'; diff --git a/lib/src/annotation_manager.dart b/lib/src/annotation_manager.dart new file mode 100644 index 0000000..c81a1a4 --- /dev/null +++ b/lib/src/annotation_manager.dart @@ -0,0 +1,350 @@ +part of nb_maps_flutter; + +abstract class AnnotationManager { + final NextbillionMapController controller; + final _idToAnnotation = {}; + final _idToLayerIndex = {}; + + /// Called if a annotation is tapped + final void Function(T)? onTap; + + /// base id of the manager. User [layerdIds] to get the actual ids. + String get id => "${managerType}-$randomPostFix"; + + final String managerType; + + final String randomPostFix; + + List get layerIds => + [for (int i = 0; i < allLayerProperties.length; i++) _makeLayerId(i)]; + + /// If disabled the manager offers no interaction for the created symbols + final bool enableInteraction; + + /// implemented to define the layer properties + List get allLayerProperties; + + /// used to spedicy the layer and annotation will life on + /// This can be replaced by layer filters a soon as they are implemented + final int Function(T)? selectLayer; + + /// get the an annotation by its id + T? byId(String id) => _idToAnnotation[id]; + + Set get annotations => _idToAnnotation.values.toSet(); + + AnnotationManager( + this.controller, { + required this.managerType, + this.onTap, + this.selectLayer, + required this.enableInteraction, + }) : randomPostFix = "nbmap-annotation" { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + controller.addGeoJsonSource(layerId, buildFeatureCollection([]), + promoteId: "id"); + controller.addLayer(layerId, layerId, allLayerProperties[i]); + } + + if (onTap != null) { + controller.onFeatureTapped.add(_onFeatureTapped); + } + controller.onFeatureDrag.add(_onDrag); + } + + /// This function can be used to rebuild all layers after their properties + /// changed + Future _rebuildLayers() async { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + await controller.addLayer(layerId, layerId, allLayerProperties[i]); + } + } + + _onFeatureTapped(dynamic id, Point point, LatLng coordinates) { + final annotation = _idToAnnotation[id]; + if (annotation != null) { + onTap!(annotation); + } + } + + String _makeLayerId(int layerIndex) => "${id}-$layerIndex"; + + Future _setAll() async { + if (selectLayer != null) { + final featureBuckets = [for (final _ in allLayerProperties) []]; + + for (final annotation in _idToAnnotation.values) { + final layerIndex = selectLayer!(annotation); + _idToLayerIndex[annotation.id] = layerIndex; + featureBuckets[layerIndex].add(annotation); + } + + for (var i = 0; i < featureBuckets.length; i++) { + await controller.setGeoJsonSource( + _makeLayerId(i), + buildFeatureCollection( + [for (final l in featureBuckets[i]) l.toGeoJson()])); + } + } else { + await controller.setGeoJsonSource( + _makeLayerId(0), + buildFeatureCollection( + [for (final l in _idToAnnotation.values) l.toGeoJson()])); + } + } + + /// Adds a multiple annotations to the map. This much faster than calling add + /// multiple times + Future addAll(Iterable annotations) async { + for (var a in annotations) { + _idToAnnotation[a.id] = a; + } + await _setAll(); + } + + /// add a single annotation to the map + Future add(T annotation) async { + _idToAnnotation[annotation.id] = annotation; + await _setAll(); + } + + /// Removes multiple annotations from the map + Future removeAll(Iterable annotations) async { + for (var a in annotations) { + _idToAnnotation.remove(a.id); + } + await _setAll(); + } + + /// Remove a single annotation form the map + Future remove(T annotation) async { + _idToAnnotation.remove(annotation.id); + await _setAll(); + } + + /// Removes all annotations from the map + Future clear() async { + _idToAnnotation.clear(); + + await _setAll(); + } + + /// Fully dipose of all the the resouces managed by the annotation manager. + /// The manager cannot be used after this has been called + Future dispose() async { + _idToAnnotation.clear(); + await _setAll(); + for (var i = 0; i < allLayerProperties.length; i++) { + await controller.removeLayer(_makeLayerId(i)); + await controller.removeSource(_makeLayerId(i)); + } + } + + _onDrag(dynamic id, + {required Point point, + required LatLng origin, + required LatLng current, + required LatLng delta, + required DragEventType eventType}) { + final annotation = byId(id); + if (annotation != null) { + annotation.translate(delta); + set(annotation); + } + } + + /// Set an existing anntotation to the map. Use this to do a fast update for a + /// single annotation + Future set(T anntotation) async { + assert(_idToAnnotation.containsKey(anntotation.id), + "you can only set existing annotations"); + _idToAnnotation[anntotation.id] = anntotation; + final oldLayerIndex = _idToLayerIndex[anntotation.id]; + final layerIndex = selectLayer != null ? selectLayer!(anntotation) : 0; + if (oldLayerIndex != layerIndex) { + // if the annotation has to be moved to another layer/source we have to + // set all + await _setAll(); + } else { + await controller.setGeoJsonFeature( + _makeLayerId(layerIndex), anntotation.toGeoJson()); + } + } +} + +class LineManager extends AnnotationManager { + LineManager(NextbillionMapController controller, + {void Function(Line)? onTap, bool enableInteraction = true}) + : super( + controller, + managerType: "line", + onTap: onTap, + enableInteraction: enableInteraction, + selectLayer: (Line line) => line.options.linePattern == null ? 0 : 1, + ); + + static const _baseProperties = LineLayerProperties( + lineJoin: [Expressions.get, 'lineJoin'], + lineOpacity: [Expressions.get, 'lineOpacity'], + lineColor: [Expressions.get, 'lineColor'], + lineWidth: [Expressions.get, 'lineWidth'], + lineGapWidth: [Expressions.get, 'lineGapWidth'], + lineOffset: [Expressions.get, 'lineOffset'], + lineBlur: [Expressions.get, 'lineBlur'], + ); + @override + List get allLayerProperties => [ + _baseProperties, + _baseProperties.copyWith( + LineLayerProperties(linePattern: [Expressions.get, 'linePattern'])), + ]; +} + +class FillManager extends AnnotationManager { + FillManager( + NextbillionMapController controller, { + void Function(Fill)? onTap, + bool enableInteraction = true, + }) : super( + controller, + managerType: "fill", + onTap: onTap, + enableInteraction: enableInteraction, + selectLayer: (Fill fill) => fill.options.fillPattern == null ? 0 : 1, + ); + @override + List get allLayerProperties => const [ + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + ), + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + fillPattern: [Expressions.get, 'fillPattern'], + ) + ]; +} + +class CircleManager extends AnnotationManager { + CircleManager( + NextbillionMapController controller, { + void Function(Circle)? onTap, + bool enableInteraction = true, + }) : super( + controller, + managerType: "circle", + enableInteraction: enableInteraction, + onTap: onTap, + ); + @override + List get allLayerProperties => const [ + CircleLayerProperties( + circleRadius: [Expressions.get, 'circleRadius'], + circleColor: [Expressions.get, 'circleColor'], + circleBlur: [Expressions.get, 'circleBlur'], + circleOpacity: [Expressions.get, 'circleOpacity'], + circleStrokeWidth: [Expressions.get, 'circleStrokeWidth'], + circleStrokeColor: [Expressions.get, 'circleStrokeColor'], + circleStrokeOpacity: [Expressions.get, 'circleStrokeOpacity'], + ) + ]; +} + +class SymbolManager extends AnnotationManager { + SymbolManager( + NextbillionMapController controller, { + void Function(Symbol)? onTap, + bool iconAllowOverlap = false, + bool textAllowOverlap = false, + bool iconIgnorePlacement = false, + bool textIgnorePlacement = false, + bool enableInteraction = true, + }) : _iconAllowOverlap = iconAllowOverlap, + _textAllowOverlap = textAllowOverlap, + _iconIgnorePlacement = iconIgnorePlacement, + _textIgnorePlacement = textIgnorePlacement, + super( + controller, + managerType: "symbol", + enableInteraction: enableInteraction, + onTap: onTap, + ); + + bool _iconAllowOverlap; + bool _textAllowOverlap; + bool _iconIgnorePlacement; + bool _textIgnorePlacement; + + Future setIconAllowOverlap(bool value) async { + _iconAllowOverlap = value; + await _rebuildLayers(); + } + + Future setTextAllowOverlap(bool value) async { + _textAllowOverlap = value; + await _rebuildLayers(); + } + + Future setIconIgnorePlacement(bool value) async { + _iconIgnorePlacement = value; + await _rebuildLayers(); + } + + Future setTextIgnorePlacement(bool value) async { + _textIgnorePlacement = value; + await _rebuildLayers(); + } + + @override + List get allLayerProperties => [ + SymbolLayerProperties( + iconSize: [Expressions.get, 'iconSize'], + iconImage: [Expressions.get, 'iconImage'], + iconRotate: [Expressions.get, 'iconRotate'], + iconOffset: [Expressions.get, 'iconOffset'], + iconAnchor: [Expressions.get, 'iconAnchor'], + iconOpacity: [Expressions.get, 'iconOpacity'], + iconColor: [Expressions.get, 'iconColor'], + iconHaloColor: [Expressions.get, 'iconHaloColor'], + iconHaloWidth: [Expressions.get, 'iconHaloWidth'], + iconHaloBlur: [Expressions.get, 'iconHaloBlur'], + // note that web does not support setting this in a fully data driven + // way this is a upstream issue + textFont: kIsWeb + ? null + : [ + Expressions.caseExpression, + [Expressions.has, 'fontNames'], + [Expressions.get, 'fontNames'], + [ + Expressions.literal, + ["Open Sans Regular", "Arial Unicode MS Regular"] + ], + ], + textField: [Expressions.get, 'textField'], + textSize: [Expressions.get, 'textSize'], + textMaxWidth: [Expressions.get, 'textMaxWidth'], + textLetterSpacing: [Expressions.get, 'textLetterSpacing'], + textJustify: [Expressions.get, 'textJustify'], + textAnchor: [Expressions.get, 'textAnchor'], + textRotate: [Expressions.get, 'textRotate'], + textTransform: [Expressions.get, 'textTransform'], + textOffset: [Expressions.get, 'textOffset'], + textOpacity: [Expressions.get, 'textOpacity'], + textColor: [Expressions.get, 'textColor'], + textHaloColor: [Expressions.get, 'textHaloColor'], + textHaloWidth: [Expressions.get, 'textHaloWidth'], + textHaloBlur: [Expressions.get, 'textHaloBlur'], + symbolSortKey: [Expressions.get, 'zIndex'], + iconAllowOverlap: _iconAllowOverlap, + iconIgnorePlacement: _iconIgnorePlacement, + textAllowOverlap: _textAllowOverlap, + textIgnorePlacement: _textIgnorePlacement, + ) + ]; +} diff --git a/lib/src/color_tools.dart b/lib/src/color_tools.dart new file mode 100644 index 0000000..48ac43e --- /dev/null +++ b/lib/src/color_tools.dart @@ -0,0 +1,10 @@ +part of nb_maps_flutter; + +extension NbMapColorConversion on Color { + String toHexStringRGB() { + final r = red.toRadixString(16).padLeft(2, '0'); + final g = green.toRadixString(16).padLeft(2, '0'); + final b = blue.toRadixString(16).padLeft(2, '0'); + return '#$r$g$b'; + } +} diff --git a/lib/src/controller.dart b/lib/src/controller.dart new file mode 100644 index 0000000..71e96e3 --- /dev/null +++ b/lib/src/controller.dart @@ -0,0 +1,1644 @@ +part of nb_maps_flutter; + +typedef void OnMapClickCallback(Point point, LatLng coordinates); + +typedef void OnFeatureInteractionCallback( + dynamic id, Point point, LatLng coordinates); + +typedef void OnFeatureDragnCallback(dynamic id, + {required Point point, + required LatLng origin, + required LatLng current, + required LatLng delta, + required DragEventType eventType}); + +typedef void OnMapLongClickCallback(Point point, LatLng coordinates); + +typedef void OnAttributionClickCallback(); + +typedef void OnStyleLoadedCallback(); + +typedef void OnUserLocationUpdated(UserLocation location); + +typedef void OnCameraTrackingDismissedCallback(); +typedef void OnCameraTrackingChangedCallback(MyLocationTrackingMode mode); + +typedef void OnCameraIdleCallback(); + +typedef void OnMapIdleCallback(); + +/// Controller for a single NBMap instance running on the host platform. +/// +/// Change listeners are notified upon changes to any of +/// +/// * the [options] property +/// * the collection of [Symbol]s added to this map +/// * the collection of [Line]s added to this map +/// * the [isCameraMoving] property +/// * the [cameraPosition] property +/// +/// Listeners are notified after changes have been applied on the platform side. +/// +/// Symbol tap events can be received by adding callbacks to [onSymbolTapped]. +/// Line tap events can be received by adding callbacks to [onLineTapped]. +/// Circle tap events can be received by adding callbacks to [onCircleTapped]. +class NextbillionMapController extends ChangeNotifier { + NextbillionMapController({ + required NbMapsGlPlatform nbMapsGlPlatform, + required CameraPosition initialCameraPosition, + required Iterable annotationOrder, + required Iterable annotationConsumeTapEvents, + this.onStyleLoadedCallback, + this.onMapClick, + this.onMapLongClick, + this.onAttributionClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onMapIdle, + this.onUserLocationUpdated, + this.onCameraIdle, + }) : _nbMapsGlPlatform = nbMapsGlPlatform { + _cameraPosition = initialCameraPosition; + + _nbMapsGlPlatform.onFeatureTappedPlatform.add((payload) { + for (final fun + in List.from(onFeatureTapped)) { + fun(payload["id"], payload["point"], payload["latLng"]); + } + }); + + _nbMapsGlPlatform.onFeatureDraggedPlatform.add((payload) { + for (final fun in List.from(onFeatureDrag)) { + final DragEventType enmDragEventType = DragEventType.values + .firstWhere((element) => element.index == payload["eventType"]); + fun(payload["id"], + point: payload["point"], + origin: payload["origin"], + current: payload["current"], + delta: payload["delta"], + eventType: enmDragEventType); + } + }); + + _nbMapsGlPlatform.onCameraMoveStartedPlatform.add((_) { + _isCameraMoving = true; + notifyListeners(); + }); + + _nbMapsGlPlatform.onCameraMovePlatform.add((cameraPosition) { + _cameraPosition = cameraPosition; + notifyListeners(); + }); + + _nbMapsGlPlatform.onCameraIdlePlatform.add((cameraPosition) { + _isCameraMoving = false; + if (cameraPosition != null) { + _cameraPosition = cameraPosition; + } + if (onCameraIdle != null) { + onCameraIdle!(); + } + notifyListeners(); + }); + + _nbMapsGlPlatform.onMapStyleLoadedPlatform.add((_) { + final interactionEnabled = annotationConsumeTapEvents.toSet(); + for (var type in annotationOrder.toSet()) { + final enableInteraction = interactionEnabled.contains(type); + switch (type) { + case AnnotationType.fill: + fillManager = FillManager(this, + onTap: onFillTapped, enableInteraction: enableInteraction); + break; + case AnnotationType.line: + lineManager = LineManager(this, + onTap: onLineTapped, enableInteraction: enableInteraction); + break; + case AnnotationType.circle: + circleManager = CircleManager(this, + onTap: onCircleTapped, enableInteraction: enableInteraction); + break; + case AnnotationType.symbol: + symbolManager = SymbolManager(this, + onTap: onSymbolTapped, enableInteraction: enableInteraction); + break; + default: + } + } + if (onStyleLoadedCallback != null) { + onStyleLoadedCallback!(); + } + }); + + _nbMapsGlPlatform.onMapClickPlatform.add((dict) { + if (onMapClick != null) { + onMapClick!(dict['point'], dict['latLng']); + } + }); + + _nbMapsGlPlatform.onMapLongClickPlatform.add((dict) { + if (onMapLongClick != null) { + onMapLongClick!(dict['point'], dict['latLng']); + } + }); + + _nbMapsGlPlatform.onAttributionClickPlatform.add((_) { + if (onAttributionClick != null) { + onAttributionClick!(); + } + }); + + _nbMapsGlPlatform.onCameraTrackingChangedPlatform.add((mode) { + if (onCameraTrackingChanged != null) { + onCameraTrackingChanged!(mode); + } + }); + + _nbMapsGlPlatform.onCameraTrackingDismissedPlatform.add((_) { + if (onCameraTrackingDismissed != null) { + onCameraTrackingDismissed!(); + } + }); + + _nbMapsGlPlatform.onMapIdlePlatform.add((_) { + if (onMapIdle != null) { + onMapIdle!(); + } + }); + _nbMapsGlPlatform.onUserLocationUpdatedPlatform.add((location) { + onUserLocationUpdated?.call(location); + }); + } + + bool _disposed = false; + + bool get disposed => _disposed; + + FillManager? fillManager; + LineManager? lineManager; + CircleManager? circleManager; + SymbolManager? symbolManager; + + final OnStyleLoadedCallback? onStyleLoadedCallback; + final OnMapClickCallback? onMapClick; + final OnMapLongClickCallback? onMapLongClick; + + final OnUserLocationUpdated? onUserLocationUpdated; + final OnAttributionClickCallback? onAttributionClick; + + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + final OnCameraIdleCallback? onCameraIdle; + + final OnMapIdleCallback? onMapIdle; + + /// Callbacks to receive tap events for symbols placed on this map. + final ArgumentCallbacks onSymbolTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for symbols placed on this map. + final ArgumentCallbacks onCircleTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for fills placed on this map. + final ArgumentCallbacks onFillTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for features (geojson layer) placed on this map. + final onFeatureTapped = []; + + final onFeatureDrag = []; + + /// Callbacks to receive tap events for info windows on symbols + @Deprecated("InfoWindow tapped is no longer supported") + final ArgumentCallbacks onInfoWindowTapped = + ArgumentCallbacks(); + + /// The current set of symbols on this map. + /// + /// The returned set will be a detached snapshot of the symbols collection. + Set get symbols => symbolManager!.annotations; + + /// Callbacks to receive tap events for lines placed on this map. + final ArgumentCallbacks onLineTapped = ArgumentCallbacks(); + + /// The current set of lines on this map. + /// + /// The returned set will be a detached snapshot of the lines collection. + Set get lines => lineManager!.annotations; + + /// The current set of circles on this map. + /// + /// The returned set will be a detached snapshot of the circles collection. + Set get circles => circleManager!.annotations; + + /// The current set of fills on this map. + /// + /// The returned set will be a detached snapshot of the fills collection. + Set get fills => fillManager!.annotations; + + /// True if the map camera is currently moving. + bool get isCameraMoving => _isCameraMoving; + bool _isCameraMoving = false; + + /// Returns the most recent camera position reported by the platform side. + /// Will be null, if [NBMap.trackCameraPosition] is false. + CameraPosition? get cameraPosition => _cameraPosition; + CameraPosition? _cameraPosition; + + final NbMapsGlPlatform _nbMapsGlPlatform; //ignore: unused_field + + /// Updates configuration options of the map user interface. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future _updateMapOptions(Map optionsUpdate) async { + if (_disposed) { + return; + } + _cameraPosition = await _nbMapsGlPlatform.updateMapOptions(optionsUpdate); + notifyListeners(); + } + + /// Triggers a resize event for the map on web (ignored on Android or iOS). + /// + /// Checks first if a resize is required or if it looks like it is already correctly resized. + /// If it looks good, the resize call will be skipped. + /// + /// To force resize map (without any checks) have a look at forceResizeWebMap() + void resizeWebMap() { + if (_disposed) { + return; + } + _nbMapsGlPlatform.resizeWebMap(); + } + + /// Triggers a hard map resize event on web and does not check if it is required or not. + void forceResizeWebMap() { + if (_disposed) { + return; + } + _nbMapsGlPlatform.forceResizeWebMap(); + } + + /// Starts an animated change of the map camera position. + /// + /// [duration] is the amount of time, that the transition animation should take. + /// + /// The returned [Future] completes after the change has been started on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future animateCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + if (_disposed) { + return false; + } + return await _nbMapsGlPlatform.animateCamera(cameraUpdate, + duration: duration); + } + + /// Instantaneously re-position the camera. + /// Note: moveCamera() quickly moves the camera, which can be visually jarring for a user. Strongly consider using the animateCamera() methods instead because it's less abrupt. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future moveCamera(CameraUpdate cameraUpdate) async { + if (_disposed) { + return false; + } + return await _nbMapsGlPlatform.moveCamera(cameraUpdate); + } + + /// Adds a new geojson source + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + Future addGeoJsonSource(String sourceId, Map geojson, + {String? promoteId}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addGeoJsonSource(sourceId, geojson, + promoteId: promoteId); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonSource( + String sourceId, Map geojson) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.setGeoJsonSource(sourceId, geojson); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonFeature( + String sourceId, Map geojsonFeature) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.setFeatureForGeoJsonSource( + sourceId, geojsonFeature); + } + + /// Add a symbol layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + Future addSymbolLayer( + String sourceId, String layerId, SymbolLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addSymbolLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a line layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + Future addLineLayer( + String sourceId, String layerId, LineLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addLineLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a fill layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + Future addFillLayer( + String sourceId, String layerId, FillLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addFillLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a fill extrusion layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + Future addFillExtrusionLayer( + String sourceId, String layerId, FillExtrusionLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addFillExtrusionLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a circle layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + Future addCircleLayer( + String sourceId, String layerId, CircleLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addCircleLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a raster layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addRasterLayer( + String sourceId, String layerId, RasterLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addRasterLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a hillshade layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHillshadeLayer( + String sourceId, String layerId, HillshadeLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addHillshadeLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a heatmap layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHeatmapLayer( + String sourceId, String layerId, HeatmapLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + if (_disposed) { + return; + } + await _nbMapsGlPlatform.addHeatmapLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Updates user location tracking mode. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateMyLocationTrackingMode( + MyLocationTrackingMode myLocationTrackingMode) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform + .updateMyLocationTrackingMode(myLocationTrackingMode); + } + + /// Updates the language of the map labels to match the device's language. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future matchMapLanguageWithDeviceDefault() async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.matchMapLanguageWithDeviceDefault(); + } + + /// Updates the distance from the edges of the map view’s frame to the edges + /// of the map view’s logical viewport, optionally animating the change. + /// + /// When the value of this property is equal to `EdgeInsets.zero`, viewport + /// properties such as centerCoordinate assume a viewport that matches the map + /// view’s frame. Otherwise, those properties are inset, excluding part of the + /// frame from the viewport. For instance, if the only the top edge is inset, + /// the map center is effectively shifted downward. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateContentInsets(EdgeInsets insets, + [bool animated = false]) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.updateContentInsets(insets, animated); + } + + /// Updates the language of the map labels to match the specified language. + /// Attention: This may only be called after onStyleLoaded() has been invoked. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMapLanguage(String language) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.setMapLanguage(language); + } + + /// Enables or disables the collection of anonymized telemetry data. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setTelemetryEnabled(bool enabled) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.setTelemetryEnabled(enabled); + } + + /// Retrieves whether collection of anonymized telemetry data is enabled. + /// + /// The returned [Future] completes after the query has been made on the + /// platform side. + Future getTelemetryEnabled() async { + if (_disposed) { + return false; + } + return await _nbMapsGlPlatform.getTelemetryEnabled(); + } + + /// Adds a symbol to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified. + Future addSymbol(SymbolOptions options, [Map? data]) async { + if (_disposed) { + return null; + } + final effectiveOptions = SymbolOptions.defaultOptions.copyWith(options); + final symbol = Symbol(getRandomString(), effectiveOptions, data); + await symbolManager!.add(symbol); + if (_disposed) { + return null; + } + notifyListeners(); + return symbol; + } + + /// Adds multiple symbols to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified. + Future?> addSymbols(List options, + [List? data]) async { + if (_disposed) { + return null; + } + final symbols = [ + for (var i = 0; i < options.length; i++) + Symbol(getRandomString(), + SymbolOptions.defaultOptions.copyWith(options[i]), data?[i]) + ]; + await symbolManager!.addAll(symbols); + if (_disposed) { + return null; + } + notifyListeners(); + return symbols; + } + + /// Updates the specified [symbol] with the given [changes]. The symbol must + /// be a current member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateSymbol(Symbol symbol, SymbolOptions changes) async { + if (_disposed) { + return; + } + await symbolManager! + .set(symbol..options = symbol.options.copyWith(changes)); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Retrieves the current position of the symbol. + /// This may be different from the value of `symbol.options.geometry` if the symbol is draggable. + /// In that case this method provides the symbol's actual position, and `symbol.options.geometry` the last programmatically set position. + Future getSymbolLatLng(Symbol symbol) async { + if (_disposed) { + return null; + } + return symbol.options.geometry!; + } + + /// Removes the specified [symbol] from the map. The symbol must be a current + /// member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbol(Symbol symbol) async { + if (_disposed) { + return; + } + await symbolManager!.remove(symbol); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes the specified [symbols] from the map. The symbols must be current + /// members of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbols(Iterable symbols) async { + if (_disposed) { + return; + } + await symbolManager!.removeAll(symbols); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes all [symbols] from the map. + /// + /// Change listeners are notified once all symbols have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearSymbols() async { + if (_disposed) { + return; + } + symbolManager!.clear(); + notifyListeners(); + } + + /// Adds a line to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the line has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified. + Future addLine(LineOptions options, [Map? data]) async { + if (_disposed) { + return null; + } + final effectiveOptions = LineOptions.defaultOptions.copyWith(options); + final line = Line(getRandomString(), effectiveOptions, data); + await lineManager!.add(line); + if (_disposed) { + return null; + } + notifyListeners(); + return line; + } + + /// Adds multiple lines to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the lines have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified. + Future?> addLines(List options, + [List? data]) async { + if (_disposed) { + return null; + } + final lines = [ + for (var i = 0; i < options.length; i++) + Line(getRandomString(), LineOptions.defaultOptions.copyWith(options[i]), + data?[i]) + ]; + await lineManager!.addAll(lines); + if (_disposed) { + return null; + } + notifyListeners(); + return lines; + } + + /// Updates the specified [line] with the given [changes]. The line must + /// be a current member of the [lines] set.‚ + /// + /// Change listeners are notified once the line has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateLine(Line line, LineOptions changes) async { + if (_disposed) { + return; + } + line.options = line.options.copyWith(changes); + await lineManager!.set(line); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Retrieves the current position of the line. + /// This may be different from the value of `line.options.geometry` if the line is draggable. + /// In that case this method provides the line's actual position, and `line.options.geometry` the last programmatically set position. + Future?> getLineLatLngs(Line line) async { + if (_disposed) { + return null; + } + return line.options.geometry!; + } + + /// Removes the specified [line] from the map. The line must be a current + /// member of the [lines] set. + /// + /// Change listeners are notified once the line has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLine(Line line) async { + if (_disposed) { + return; + } + await lineManager!.remove(line); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes the specified [lines] from the map. The lines must be current + /// members of the [lines] set. + /// + /// Change listeners are notified once the lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLines(Iterable lines) async { + if (_disposed) { + return; + } + await lineManager!.removeAll(lines); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes all [lines] from the map. + /// + /// Change listeners are notified once all lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearLines() async { + if (_disposed) { + return; + } + await lineManager!.clear(); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Adds a circle to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the circle has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified. + Future addCircle(CircleOptions options, [Map? data]) async { + if (_disposed) { + return null; + } + final CircleOptions effectiveOptions = + CircleOptions.defaultOptions.copyWith(options); + final circle = Circle(getRandomString(), effectiveOptions, data); + await circleManager!.add(circle); + if (_disposed) { + return null; + } + notifyListeners(); + return circle; + } + + /// Adds multiple circles to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the circles have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified. + Future?> addCircles(List options, + [List? data]) async { + if (_disposed) { + return null; + } + final cricles = [ + for (var i = 0; i < options.length; i++) + Circle(getRandomString(), + CircleOptions.defaultOptions.copyWith(options[i]), data?[i]) + ]; + await circleManager!.addAll(cricles); + + notifyListeners(); + if (_disposed) { + return null; + } + return cricles; + } + + /// Updates the specified [circle] with the given [changes]. The circle must + /// be a current member of the [circles] set. + /// + /// Change listeners are notified once the circle has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateCircle(Circle circle, CircleOptions changes) async { + if (_disposed) { + return; + } + circle.options = circle.options.copyWith(changes); + await circleManager!.set(circle); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Retrieves the current position of the circle. + /// This may be different from the value of `circle.options.geometry` if the circle is draggable. + /// In that case this method provides the circle's actual position, and `circle.options.geometry` the last programmatically set position. + Future getCircleLatLng(Circle circle) async { + if (_disposed) { + return null; + } + return circle.options.geometry!; + } + + /// Removes the specified [circle] from the map. The circle must be a current + /// member of the [circles] set. + /// + /// Change listeners are notified once the circle has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircle(Circle circle) async { + if (_disposed) { + return; + } + await circleManager!.remove(circle); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes the specified [circles] from the map. The circles must be current + /// members of the [circles] set. + /// + /// Change listeners are notified once the circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircles(Iterable circles) async { + if (_disposed) { + return; + } + await circleManager!.removeAll(circles); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes all [circles] from the map. + /// + /// Change listeners are notified once all circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearCircles() async { + if (_disposed) { + return; + } + await circleManager!.clear(); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Adds a fill to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the fill has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fill once listeners have + /// been notified. + Future addFill(FillOptions options, [Map? data]) async { + if (_disposed) { + return null; + } + final FillOptions effectiveOptions = + FillOptions.defaultOptions.copyWith(options); + final fill = Fill(getRandomString(), effectiveOptions, data); + await fillManager!.add(fill); + if (_disposed) { + return null; + } + notifyListeners(); + return fill; + } + + /// Adds multiple fills to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the fills has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fills once listeners have + /// been notified. + Future> addFills(List options, + [List? data]) async { + if (_disposed) { + return []; + } + final fills = [ + for (var i = 0; i < options.length; i++) + Fill(getRandomString(), FillOptions.defaultOptions.copyWith(options[i]), + data?[i]) + ]; + await fillManager!.addAll(fills); + + notifyListeners(); + return fills; + } + + /// Updates the specified [fill] with the given [changes]. The fill must + /// be a current member of the [fills] set. + /// + /// Change listeners are notified once the fill has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateFill(Fill fill, FillOptions changes) async { + if (_disposed) { + return; + } + fill.options = fill.options.copyWith(changes); + await fillManager!.set(fill); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes all [fill] from the map. + /// + /// Change listeners are notified once all fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearFills() async { + if (_disposed) { + return; + } + await fillManager!.clear(); + if (_disposed) { + return; + } + notifyListeners(); + } + + /// Removes the specified [fill] from the map. The fill must be a current + /// member of the [fills] set. + /// + /// Change listeners are notified once the fill has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFill(Fill fill) async { + if (_disposed) { + return; + } + await fillManager!.remove(fill); + notifyListeners(); + } + + /// Removes the specified [fills] from the map. The fills must be current + /// members of the [fills] set. + /// + /// Change listeners are notified once the fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFills(Iterable fills) async { + if (_disposed) { + return; + } + await fillManager!.removeAll(fills); + notifyListeners(); + } + + /// Query rendered features at a point in screen cooridnates + Future queryRenderedFeatures( + Point point, List layerIds, List? filter) async { + if (_disposed) { + return []; + } + return await _nbMapsGlPlatform.queryRenderedFeatures( + point, layerIds, filter); + } + + /// Query rendered features in a Rect in screen coordinates + Future queryRenderedFeaturesInRect( + Rect rect, List layerIds, String? filter) async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.queryRenderedFeaturesInRect( + rect, layerIds, filter); + } + + Future invalidateAmbientCache() async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.invalidateAmbientCache(); + } + + /// Get last my location + /// + /// Return last latlng, nullable + Future requestMyLocationLatLng() async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.requestMyLocationLatLng(); + } + + /// This method returns the boundaries of the region currently displayed in the map. + Future getVisibleRegion() async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.getVisibleRegion(); + } + + /// Update map style for MapView + Future setStyleString(String styleString) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.setStyleString(styleString); + } + + /// Adds an image to the style currently displayed in the map, so that it can later be referred to by the provided name. + /// + /// This allows you to add an image to the currently displayed style once, and from there on refer to it e.g. in the [Symbol.iconImage] anytime you add a [Symbol] later on. + /// Set [sdf] to true if the image you add is an SDF image. + /// Returns after the image has successfully been added to the style. + /// Note: This can only be called after OnStyleLoadedCallback has been invoked and any added images will have to be re-added if a new style is loaded. + /// + /// Example: Adding an asset image and using it in a new symbol: + /// ```dart + /// Future addImageFromAsset() async{ + /// final ByteData bytes = await rootBundle.load("assets/someAssetImage.jpg"); + /// final Uint8List list = bytes.buffer.asUint8List(); + /// await controller.addImage("assetImage", list); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "assetImage", + /// ), + /// ); + /// } + /// ``` + /// + /// Example: Adding a network image (with the http package) and using it in a new symbol: + /// ```dart + /// Future addImageFromUrl() async{ + /// var response = await get("https://example.com/image.png"); + /// await controller.addImage("testImage", response.bodyBytes); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "testImage", + /// ), + /// ); + /// } + /// ``` + Future addImage(String name, Uint8List bytes, + [bool sdf = false]) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.addImage(name, bytes, sdf); + } + + Future setSymbolIconAllowOverlap(bool enable) async { + if (_disposed) { + return; + } + await symbolManager?.setIconAllowOverlap(enable); + } + + Future setSymbolIconIgnorePlacement(bool enable) async { + if (_disposed) { + return; + } + await symbolManager?.setIconIgnorePlacement(enable); + } + + Future setSymbolTextAllowOverlap(bool enable) async { + if (_disposed) { + return; + } + await symbolManager?.setTextAllowOverlap(enable); + } + + Future setSymbolTextIgnorePlacement(bool enable) async { + if (_disposed) { + return; + } + await symbolManager?.setTextIgnorePlacement(enable); + } + + /// Adds an image source to the style currently displayed in the map, so that it can later be referred to by the provided id. + Future addImageSource( + String imageSourceId, Uint8List bytes, LatLngQuad coordinates) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.addImageSource( + imageSourceId, bytes, coordinates); + } + + /// Update an image source to the style currently displayed in the map, so that it can later be referred to by the provided id. + Future updateImageSource( + String imageSourceId, Uint8List? bytes, LatLngQuad? coordinates) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.updateImageSource( + imageSourceId, bytes, coordinates); + } + + /// Removes previously added image source by id + @Deprecated("This method was renamed to removeSource") + Future removeImageSource(String imageSourceId) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.removeSource(imageSourceId); + } + + /// Removes previously added source by id + Future removeSource(String sourceId) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.removeSource(sourceId); + } + + /// Adds a NbMaps image layer to the map's style at render time. + Future addImageLayer(String layerId, String imageSourceId, + {double? minzoom, double? maxzoom}) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.addLayer( + layerId, imageSourceId, minzoom, maxzoom); + } + + /// Adds a NbMaps image layer below the layer provided with belowLayerId to the map's style at render time. + Future addImageLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Adds a NbMaps image layer below the layer provided with belowLayerId to the map's style at render time. Only works for image sources! + @Deprecated("This method was renamed to addImageLayerBelow for clarity.") + Future addLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Removes a nbmaps style layer + Future removeLayer(String layerId) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.removeLayer(layerId); + } + + Future setFilter(String layerId, dynamic filter) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.setFilter(layerId, filter); + } + + /// Sets the visibility by specifying [isVisible] of the layer with + /// the specified id [layerId]. + /// Returns silently if [layerId] does not exist. + Future setVisibility(String layerId, bool isVisible) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.setVisibility(layerId, isVisible); + } + + /// Returns the point on the screen that corresponds to a geographical coordinate ([latLng]). The screen location is in screen pixels (not display pixels) relative to the top left of the map (not of the whole screen) + /// + /// Note: The resulting x and y coordinates are rounded to [int] on web, on other platforms they may differ very slightly (in the range of about 10^-10) from the actual nearest screen coordinate. + /// You therefore might want to round them appropriately, depending on your use case. + /// + /// Returns null if [latLng] is not currently visible on the map. + Future toScreenLocation(LatLng latLng) async { + if (_disposed) { + return Point(0, 0); + } + return await _nbMapsGlPlatform.toScreenLocation(latLng); + } + + Future?> toScreenLocationBatch(Iterable latLngs) async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.toScreenLocationBatch(latLngs); + } + + /// Returns the geographic location (as [LatLng]) that corresponds to a point on the screen. The screen location is specified in screen pixels (not display pixels) relative to the top left of the map (not the top left of the whole screen). + Future toLatLng(Point screenLocation) async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.toLatLng(screenLocation); + } + + /// Returns the distance spanned by one pixel at the specified [latitude] and current zoom level. + /// The distance between pixels decreases as the latitude approaches the poles. This relationship parallels the relationship between longitudinal coordinates at different latitudes. + Future getMetersPerPixelAtLatitude(double latitude) async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.getMetersPerPixelAtLatitude(latitude); + } + + /// Add a new source to the map + Future addSource(String sourceid, SourceProperties properties) async { + if (_disposed) { + return; + } + return await _nbMapsGlPlatform.addSource(sourceid, properties); + } + + /// Add a layer to the map with the given properties + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. If the layer already exists, the layer is updated. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events this has no effect for [RasterLayerProperties] and + /// [HillshadeLayerProperties]. + /// [sourceLayer] is used to selected a specific source layer from Vector + /// source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// [filter] is not supported by RasterLayer and HillshadeLayer. + /// + Future addLayer( + String sourceId, String layerId, LayerProperties properties, + {String? belowLayerId, + bool enableInteraction = true, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter}) async { + if (_disposed) { + return; + } + if (properties is FillLayerProperties) { + addFillLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is FillExtrusionLayerProperties) { + addFillExtrusionLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is LineLayerProperties) { + addLineLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is SymbolLayerProperties) { + addSymbolLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is CircleLayerProperties) { + addCircleLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is RasterLayerProperties) { + if (filter != null) { + throw UnimplementedError("RasterLayer does not support filter"); + } + addRasterLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HillshadeLayerProperties) { + if (filter != null) { + throw UnimplementedError("HillShadeLayer does not support filter"); + } + addHillshadeLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HeatmapLayerProperties) { + addHeatmapLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else { + throw UnimplementedError("Unknown layer type $properties"); + } + } + + /// Generates static raster images of the map. Each snapshot image depicts a portion of a map defined by an [SnapshotOptions] object you provide + /// Android/iOS: Return snapshot uri in app specific cache storage or base64 string + /// Web: Return base64 string with current camera posision of [NBMap] + /// + /// Default will return snapshot uri in Android and iOS + /// If you want base64 value, you must set writeToDisk option to False + Future takeSnapshot(SnapshotOptions snapshotOptions) async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.takeSnapshot(snapshotOptions); + } + + Future findBelowLayerId(List belowAt) async { + if (_disposed) { + return null; + } + return await _nbMapsGlPlatform.findBelowLayerId(belowAt); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + _nbMapsGlPlatform.dispose(); + } +} diff --git a/lib/src/download_region_status.dart b/lib/src/download_region_status.dart new file mode 100644 index 0000000..d1b647d --- /dev/null +++ b/lib/src/download_region_status.dart @@ -0,0 +1,25 @@ +part of nb_maps_flutter; + +abstract class DownloadRegionStatus {} + +class Success extends DownloadRegionStatus {} + +class InProgress extends DownloadRegionStatus { + final double progress; + + InProgress(this.progress); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.InProgress', progress = $progress"; +} + +class Error extends DownloadRegionStatus { + final PlatformException cause; + + Error(this.cause); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.Error', cause = ${cause.toString()}"; +} diff --git a/lib/src/global.dart b/lib/src/global.dart new file mode 100644 index 0000000..e77b67e --- /dev/null +++ b/lib/src/global.dart @@ -0,0 +1,175 @@ +part of nb_maps_flutter; + +typedef EventChannelCreator = EventChannel Function(String name); + +MethodChannel globalChannel = + MethodChannel('plugins.flutter.io/nb_maps_flutter'); + +@visibleForTesting +void setTestingGlobalChannel(MethodChannel channel) { + globalChannel = channel; +} + +/// Copy tiles db file passed in to the tiles cache directory (sideloaded) to +/// make tiles available offline. +Future installOfflineMapTiles(String tilesDb) async { + await globalChannel.invokeMethod( + 'installOfflineMapTiles', + { + 'tilesdb': tilesDb, + }, + ); +} + +enum DragEventType { start, drag, end } + +Future setOffline( + bool offline, { + String? accessToken, +}) => + globalChannel.invokeMethod( + 'setOffline', + { + 'offline': offline, + 'accessToken': accessToken, + }, + ); + +Future setHttpHeaders(Map headers) { + return globalChannel.invokeMethod( + 'setHttpHeaders', + { + 'headers': headers, + }, + ); +} + +Future> mergeOfflineRegions( + String path, { + String? accessToken, +}) async { + String regionsJson = await globalChannel.invokeMethod( + 'mergeOfflineRegions', + { + 'path': path, + 'accessToken': accessToken, + }, + ); + Iterable regions = json.decode(regionsJson); + return regions.map((region) => OfflineRegion.fromMap(region)).toList(); +} + +Future> getListOfRegions({String? accessToken}) async { + String regionsJson = await globalChannel.invokeMethod( + 'getListOfRegions', + { + 'accessToken': accessToken, + }, + ); + Iterable regions = json.decode(regionsJson); + return regions.map((region) => OfflineRegion.fromMap(region)).toList(); +} + +Future updateOfflineRegionMetadata( + int id, + Map metadata, { + String? accessToken, +}) async { + final regionJson = await globalChannel.invokeMethod( + 'updateOfflineRegionMetadata', + { + 'id': id, + 'accessToken': accessToken, + 'metadata': metadata, + }, + ); + + return OfflineRegion.fromMap(json.decode(regionJson)); +} + +Future setOfflineTileCountLimit(int limit, {String? accessToken}) => + globalChannel.invokeMethod( + 'setOfflineTileCountLimit', + { + 'limit': limit, + 'accessToken': accessToken, + }, + ); + +Future deleteOfflineRegion(int id, {String? accessToken}) => + globalChannel.invokeMethod( + 'deleteOfflineRegion', + { + 'id': id, + 'accessToken': accessToken, + }, + ); + +Future downloadOfflineRegion( + OfflineRegionDefinition definition, { + Map metadata = const {}, + String? accessToken, + Function(DownloadRegionStatus event)? onEvent, + EventChannelCreator? eventChannelCreator, +}) async { + String channelName = + 'downloadOfflineRegion_${DateTime.now().microsecondsSinceEpoch}'; + + final result = await globalChannel + .invokeMethod('downloadOfflineRegion', { + 'accessToken': accessToken, + 'channelName': channelName, + 'definition': definition.toMap(), + 'metadata': metadata, + }); + + if (onEvent != null) { + EventChannel eventChannel = + eventChannelCreator?.call(channelName) ?? EventChannel('channelName'); + + eventChannel.receiveBroadcastStream().handleError((error) { + if (error is PlatformException) { + onEvent(Error(error)); + return Error(error); + } + var unknownError = Error( + PlatformException( + code: 'UnknowException', + message: + 'This error is unhandled by plugin. Please contact us if needed.', + details: error, + ), + ); + onEvent(unknownError); + return unknownError; + }).listen((data) { + final Map jsonData = json.decode(data); + DownloadRegionStatus? status; + switch (jsonData['status']) { + case 'start': + status = InProgress(0.0); + break; + case 'progress': + final dynamic value = jsonData['progress']; + double progress = 0.0; + + if (value is int) { + progress = value.toDouble(); + } + + if (value is double) { + progress = value; + } + + status = InProgress(progress); + break; + case 'success': + status = Success(); + break; + } + onEvent(status ?? (throw 'Invalid event status ${jsonData['status']}')); + }); + } + + return OfflineRegion.fromMap(json.decode(result)); +} diff --git a/lib/src/layer_expressions.dart b/lib/src/layer_expressions.dart new file mode 100644 index 0000000..2b97b90 --- /dev/null +++ b/lib/src/layer_expressions.dart @@ -0,0 +1,653 @@ +part of nb_maps_flutter; + +class Expressions { + /// Binds expressions to named variables, which can then be referenced in + /// the result expression using ["var", "variable_name"]. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const let = "let"; + + /// References variable bound using "let". + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const varExpression = "var"; + + /// Provides a literal array or object value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const literal = "literal"; + + /// Asserts that the input is an array (optionally with a specific item + /// type and length). If, when the input expression is evaluated, it is + /// not of the asserted type, then this assertion will cause the whole + /// expression to be aborted. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const array = "array"; + + /// Retrieves an item from an array. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const at = "at"; + + /// Determines whether an item exists in an array or a substring exists in + /// a string. + /// + /// Sdk Support: + /// basic functionality with js + static const inExpression = "in"; + + /// Selects the first output whose corresponding test condition evaluates + /// to true, or the fallback value otherwise. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const caseExpression = "case"; + + /// Selects the output whose label value matches the input value, or the + /// fallback value if no match is found. The input can be any expression + /// (e.g. `["get", "building_type"]`). Each label must be either: + /// * a single literal value; or + /// * an array of literal values, whose values must be all strings or all + /// numbers (e.g. `[100, 101]` or `["c", "b"]`). The input matches if any + /// of the values in the array matches, similar to the `"in"` + /// operator.Each label must be unique. If the input type does not match + /// the type of the labels, the result will be the fallback value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const match = "match"; + + /// Evaluates each expression in turn until the first non-null value is + /// obtained, and returns that value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const coalesce = "coalesce"; + + /// Produces discrete, stepped results by evaluating a piecewise-constant + /// function defined by pairs of input and output values ("stops"). The + /// `input` may be any numeric expression (e.g., `["get", "population"]`). + /// Stop inputs must be numeric literals in strictly ascending order. + /// Returns the output value of the stop just less than the input, or the + /// first output if the input is less than the first stop. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const step = "step"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). The `input` may be any numeric + /// expression (e.g., `["get", "population"]`). Stop inputs must be + /// numeric literals in strictly ascending order. The output type must be + /// `number`, `array`, or `color`.Interpolation types:- + /// `["linear"]`: interpolates linearly between the pair of stops just + /// less than and just greater than the input.- `["exponential", base]`: + /// interpolates exponentially between the stops just less than and just + /// greater than the input. `base` controls the rate at which the output + /// increases: higher values make the output increase more towards the + /// high end of the range. With values close to 1 the output increases + /// linearly.- `["cubic-bezier", x1, y1, x2, y2]`: interpolates using the + /// cubic bezier curve defined by the given control points. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const interpolate = "interpolate"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// Hue-Chroma-Luminance color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateHcl = "interpolate-hcl"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// CIELAB color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateLab = "interpolate-lab"; + + /// Returns mathematical constant ln(2). + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln2 = "ln2"; + + /// Returns the mathematical constant pi. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const pi = "pi"; + + /// Returns the mathematical constant e. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const e = "e"; + + /// Returns a string describing the type of the given value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const typeof = "typeof"; + + /// Asserts that the input value is a string. If multiple values are + /// provided, each one is evaluated in order until a string is obtained. + /// If none of the inputs are strings, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const string = "string"; + + /// Asserts that the input value is a number. If multiple values are + /// provided, each one is evaluated in order until a number is obtained. + /// If none of the inputs are numbers, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const number = "number"; + + /// Asserts that the input value is a boolean. If multiple values are + /// provided, each one is evaluated in order until a boolean is obtained. + /// If none of the inputs are booleans, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const boolean = "boolean"; + + /// Asserts that the input value is an object. If multiple values are + /// provided, each one is evaluated in order until an object is obtained. + /// If none of the inputs are objects, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const object = "object"; + + /// Returns a `collator` for use in locale-dependent comparison + /// operations. The `case-sensitive` and `diacritic-sensitive` options + /// default to `false`. The `locale` argument specifies the IETF language + /// tag of the locale to use. If none is provided, the default locale is + /// used. If the requested locale is not available, the `collator` will + /// use a system-defined fallback locale. Use `resolved-locale` to test + /// the results of locale fallback behavior. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const collator = "collator"; + + /// Returns `formatted` text containing annotations for use in + /// mixed-format `text-field` entries. For a `text-field` entries of a + /// string type, following option object's properties are supported: If + /// set, the `text-font` value overrides the font specified by the root + /// layout properties. If set, the `font-scale` value specifies a scaling + /// factor relative to the `text-size` specified in the root layout + /// properties. If set, the `text-color` value overrides the color + /// specified by the root paint properties for this layer. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const format = "format"; + + /// Returns an `image` type for use in `icon-image`, `*-pattern` entries + /// and as a section in the `format` expression. If set, the `image` + /// argument will check that the requested image exists in the style and + /// will return either the resolved image name or `null`, depending on + /// whether or not the image is currently in the style. This validation + /// process is synchronous and requires the image to have been added to + /// the style before requesting it in the `image` argument. + /// + /// Sdk Support: + /// basic functionality with js, android, ios + static const image = "image"; + + /// Converts the input number into a string representation using the + /// providing formatting rules. If set, the `locale` argument specifies + /// the locale to use, as a BCP 47 language tag. If set, the `currency` + /// argument specifies an ISO 4217 code to use for currency-style + /// formatting. If set, the `min-fraction-digits` and + /// `max-fraction-digits` arguments specify the minimum and maximum number + /// of fractional digits to include. + /// + /// Sdk Support: + /// basic functionality with js + static const numberFormat = "number-format"; + + /// Converts the input value to a string. If the input is `null`, the + /// result is `""`. If the input is a boolean, the result is `"true"` or + /// `"false"`. If the input is a number, it is converted to a string as + /// specified by the ["NumberToString" + /// algorithm](https://tc39.github.io/ecma262/#sec-tostring-applied-to-the-number-type) + /// of the ECMAScript Language Specification. If the input is a color, it + /// is converted to a string of the form `"rgba(r,g,b,a)"`, where `r`, + /// `g`, and `b` are numerals ranging from 0 to 255, and `a` ranges from 0 + /// to 1. Otherwise, the input is converted to a string in the format + /// specified by the + /// [`JSON.stringify`](https://tc39.github.io/ecma262/#sec-json.stringify) + /// function of the ECMAScript Language Specification. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toStringExpression = "to-string"; + + /// Converts the input value to a number, if possible. If the input is + /// `null` or `false`, the result is 0. If the input is `true`, the result + /// is 1. If the input is a string, it is converted to a number as + /// specified by the ["ToNumber Applied to the String Type" + /// algorithm](https://tc39.github.io/ecma262/#sec-tonumber-applied-to-the-string-type) + /// of the ECMAScript Language Specification. If multiple values are + /// provided, each one is evaluated in order until the first successful + /// conversion is obtained. If none of the inputs can be converted, the + /// expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toNumber = "to-number"; + + /// Converts the input value to a boolean. The result is `false` when then + /// input is an empty string, 0, `false`, `null`, or `NaN`; otherwise it + /// is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toBoolean = "to-boolean"; + + /// Returns a four-element array containing the input color's red, green, + /// blue, and alpha components, in that order. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toRgba = "to-rgba"; + + /// Converts the input value to a color. If multiple values are provided, + /// each one is evaluated in order until the first successful conversion + /// is obtained. If none of the inputs can be converted, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toColor = "to-color"; + + /// Creates a color value from red, green, and blue components, which must + /// range between 0 and 255, and an alpha component of 1. If any component + /// is out of range, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgb = "rgb"; + + /// Creates a color value from red, green, blue components, which must + /// range between 0 and 255, and an alpha component which must range + /// between 0 and 1. If any component is out of range, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgba = "rgba"; + + /// Retrieves a property value from the current feature's properties, or + /// from another object if a second argument is provided. Returns null if + /// the requested property is missing. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const get = "get"; + + /// Tests for the presence of an property value in the current feature's + /// properties, or from another object if a second argument is provided. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const has = "has"; + + /// Gets the length of an array or string. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const length = "length"; + + /// Gets the feature properties object. Note that in some cases, it may + /// be more efficient to use ["get", "property_name"] directly. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const properties = "properties"; + + /// Retrieves a property value from the current feature's state. Returns + /// null if the requested property is not present on the feature's state. + /// A feature's state is not part of the GeoJSON or vector tile data, and + /// must be set programmatically on each feature. Features are identified + /// by their `id` attribute, which must be an integer or a string that can + /// be cast to an integer. Note that ["feature-state"] can only be used + /// with paint properties that support data-driven styling. + /// + /// Sdk Support: + /// basic functionality with js + static const featureState = "feature-state"; + + /// Gets the feature's geometry type: Point, MultiPoint, LineString, + /// MultiLineString, Polygon, MultiPolygon. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const geometryType = "geometry-type"; + + /// Gets the feature's id, if it has one. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const id = "id"; + + /// Gets the current zoom level. Note that in style layout and paint + /// properties, ["zoom"] may only appear as the input to a top-level + /// "step" or "interpolate" expression. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const zoom = "zoom"; + + /// Gets the kernel density estimation of a pixel in a heatmap layer, + /// which is a relative measure of how many data points are crowded around + /// a particular pixel. Can only be used in the `heatmap-color` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const heatmapDensity = "heatmap-density"; + + /// Gets the progress along a gradient line. Can only be used in the + /// `line-gradient` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const lineProgress = "line-progress"; + + /// Gets the value of a cluster property accumulated so far. Can only be + /// used in the `clusterProperties` option of a clustered GeoJSON source. + /// + /// Sdk Support: + /// basic functionality with js + static const accumulated = "accumulated"; + + /// Returns the sum of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const plus = "+"; + + /// Returns the product of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const multiply = "*"; + + /// For two inputs, returns the result of subtracting the second input + /// from the first. For a single input, returns the result of subtracting + /// it from 0. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const minus = "-"; + + /// Returns the result of floating point division of the first input by + /// the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const divide = "/"; + + /// Returns the remainder after integer division of the first input by the + /// second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const precent = "%"; + + /// Returns the result of raising the first input to the power specified + /// by the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const xor = "^"; + + /// Returns the square root of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sqrt = "sqrt"; + + /// Returns the base-ten logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log10 = "log10"; + + /// Returns the natural logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln = "ln"; + + /// Returns the base-two logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log2 = "log2"; + + /// Returns the sine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sin = "sin"; + + /// Returns the cosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const cos = "cos"; + + /// Returns the tangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const tan = "tan"; + + /// Returns the arcsine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const asin = "asin"; + + /// Returns the arccosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const acos = "acos"; + + /// Returns the arctangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const atan = "atan"; + + /// Returns the minimum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const min = "min"; + + /// Returns the maximum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const max = "max"; + + /// Rounds the input to the nearest integer. Halfway values are rounded + /// away from zero. For example, `["round", -1.5]` evaluates to -2. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const round = "round"; + + /// Returns the absolute value of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const abs = "abs"; + + /// Returns the smallest integer that is greater than or equal to the + /// input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ceil = "ceil"; + + /// Returns the largest integer that is less than or equal to the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const floor = "floor"; + + /// Returns `true` if the input values are equal, `false` otherwise. The + /// comparison is strictly typed: values of different runtime types are + /// always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const equal = "=="; + + /// Returns `true` if the input values are not equal, `false` otherwise. + /// The comparison is strictly typed: values of different runtime types + /// are always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const notEqual = "!="; + + /// Returns `true` if the first input is strictly greater than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const larger = ">"; + + /// Returns `true` if the first input is strictly less than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smaller = "<"; + + /// Returns `true` if the first input is greater than or equal to the + /// second, `false` otherwise. The arguments are required to be either + /// both strings or both numbers; if during evaluation they are not, + /// expression evaluation produces an error. Cases where this constraint + /// is known not to hold at parse time are considered in valid and will + /// produce a parse error. Accepts an optional `collator` argument to + /// control locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const largerOrEqual = ">="; + + /// Returns `true` if the first input is less than or equal to the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smallerOrEqual = "<="; + + /// Returns `true` if all the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `false`, the result is `false` + /// and no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const all = "all"; + + /// Returns `true` if any of the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `true`, the result is `true` and + /// no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const any = "any"; + + /// Logical negation. Returns `true` if the input is `false`, and `false` + /// if the input is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const not = "!"; + + /// Returns `true` if the input string is expected to render legibly. + /// Returns `false` if the input string contains sections that cannot be + /// rendered without potential loss of meaning (e.g. Indic scripts that + /// require complex text shaping, or right-to-left scripts if the the + /// + /// Sdk Support: + /// basic functionality with js, android + static const isSupportedScript = "is-supported-script"; + + /// Returns the input string converted to uppercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const upcase = "upcase"; + + /// Returns the input string converted to lowercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const downcase = "downcase"; + + /// Returns a `string` consisting of the concatenation of the inputs. Each + /// input is converted to a string as if by `to-string`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const concat = "concat"; + + /// Returns the IETF language tag of the locale being used by the provided + /// `collator`. This can be used to determine the default system locale, + /// or to determine if a requested locale was successfully loaded. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const resolvedLocale = "resolved-locale"; +} diff --git a/lib/src/layer_properties.dart b/lib/src/layer_properties.dart new file mode 100644 index 0000000..7c8cf36 --- /dev/null +++ b/lib/src/layer_properties.dart @@ -0,0 +1,2377 @@ +part of nb_maps_flutter; + +abstract class LayerProperties { + Map toJson(); +} + +class SymbolLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the icon will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOpacity; + + /// The color of the icon. This can only be used with sdf icons. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconColor; + + /// The color of the icon's halo. Icon halos can only be used with SDF + /// icons. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloColor; + + /// Distance of halo to the icon outline. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloWidth; + + /// Fade out the halo towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloBlur; + + /// Distance that the icon's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslate; + + /// Controls the frame of reference for `icon-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Icons are translated relative to the map. + /// "viewport" + /// Icons are translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslateAnchor; + + /// The opacity at which the text will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOpacity; + + /// The color with which the text will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textColor; + + /// The color of the text's halo, which helps it stand out from + /// backgrounds. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloColor; + + /// Distance of halo to the font outline. Max text halo width is 1/4 of + /// the font-size. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloWidth; + + /// The halo's fadeout distance towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloBlur; + + /// Distance that the text's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslate; + + /// Controls the frame of reference for `text-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The text is translated relative to the map. + /// "viewport" + /// The text is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslateAnchor; + + // Layout Properties + /// Label placement relative to its geometry. + /// + /// Type: enum + /// default: point + /// Options: + /// "point" + /// The label is placed at the point where the geometry is located. + /// "line" + /// The label is placed along the line of the geometry. Can only be + /// used on `LineString` and `Polygon` geometries. + /// "line-center" + /// The label is placed at the center of the line of the geometry. + /// Can only be used on `LineString` and `Polygon` geometries. Note + /// that a single feature in a vector tile may contain multiple line + /// geometries. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolPlacement; + + /// Distance between two symbol anchors. + /// + /// Type: number + /// default: 250 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolSpacing; + + /// If true, the symbols will not cross tile edges to avoid mutual + /// collisions. Recommended in layers that don't have enough padding in + /// the vector tile to prevent collisions, or if it is a point symbol + /// layer placed after a line symbol layer. When using a client that + /// supports global collision detection, like NbMaps GL JS version 0.42.0 + /// or greater, enabling this property is not needed to prevent clipped + /// labels at tile boundaries. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolAvoidEdges; + + /// Sorts features in ascending order based on this value. Features with + /// lower sort keys are drawn and placed first. When `icon-allow-overlap` + /// or `text-allow-overlap` is `false`, features with a lower sort key + /// will have priority during placement. When `icon-allow-overlap` or + /// `text-allow-overlap` is set to `true`, features with a higher sort key + /// will overlap over features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic symbolSortKey; + + /// Controls the order in which overlapping symbols in the same layer are + /// rendered + /// + /// Type: enum + /// default: auto + /// Options: + /// "auto" + /// If `symbol-sort-key` is set, sort based on that. Otherwise sort + /// symbols by their y-position relative to the viewport. + /// "viewport-y" + /// Symbols will be sorted by their y-position relative to the + /// viewport. + /// "source" + /// Symbols will be rendered in the same order as the source data + /// with no sorting applied. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolZOrder; + + /// If true, the icon will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// icon. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconIgnorePlacement; + + /// If true, text will display without their corresponding icons when the + /// icon collides with other symbols and the text does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconOptional; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of icons. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns icons + /// east-west. When `symbol-placement` is set to `line` or + /// `line-center`, aligns icon x-axes with the line. + /// "viewport" + /// Produces icons whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconRotationAlignment; + + /// Scales the original size of the icon by the provided factor. The new + /// pixel size of the image will be the original pixel size multiplied by + /// `icon-size`. 1 is the original size; 3 triples the size of the image. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconSize; + + /// Scales the icon to fit around the associated text. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The icon is displayed at its intrinsic aspect ratio. + /// "width" + /// The icon is scaled in the x-dimension to fit the width of the + /// text. + /// "height" + /// The icon is scaled in the y-dimension to fit the height of the + /// text. + /// "both" + /// The icon is scaled in both x- and y-dimensions. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFit; + + /// Size of the additional area added to dimensions determined by + /// `icon-text-fit`, in clockwise order: top, right, bottom, left. + /// + /// Type: array + /// default: [0, 0, 0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFitPadding; + + /// Name of image in sprite to use for drawing an image background. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconImage; + + /// Rotates the icon clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconRotate; + + /// Size of the additional area around the icon bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPadding; + + /// If true, the icon may be flipped to prevent it from being rendered + /// upside-down. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconKeepUpright; + + /// Offset distance of icon from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. Each + /// component is multiplied by the value of `icon-size` to obtain the + /// final offset in pixels. When combined with `icon-rotate` the offset + /// will be as if the rotated direction was up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOffset; + + /// Part of the icon placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the icon is placed closest to the anchor. + /// "left" + /// The left side of the icon is placed closest to the anchor. + /// "right" + /// The right side of the icon is placed closest to the anchor. + /// "top" + /// The top of the icon is placed closest to the anchor. + /// "bottom" + /// The bottom of the icon is placed closest to the anchor. + /// "top-left" + /// The top left corner of the icon is placed closest to the anchor. + /// "top-right" + /// The top right corner of the icon is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the icon is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the icon is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconAnchor; + + /// Orientation of icon when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The icon is aligned to the plane of the map. + /// "viewport" + /// The icon is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `icon-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPitchAlignment; + + /// Orientation of text when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The text is aligned to the plane of the map. + /// "viewport" + /// The text is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `text-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPitchAlignment; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of the individual glyphs forming the text. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns text east-west. + /// When `symbol-placement` is set to `line` or `line-center`, aligns + /// text x-axes with the line. + /// "viewport" + /// Produces glyphs whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textRotationAlignment; + + /// Value to use for a text label. If a plain `string` is provided, it + /// will be treated as a `formatted` with default/inherited formatting + /// options. + /// + /// Type: formatted + /// default: + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textField; + + /// Font stack to use for displaying text. + /// + /// Type: array + /// default: [Open Sans Regular, Arial Unicode MS Regular] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textFont; + + /// Font size. + /// + /// Type: number + /// default: 16 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textSize; + + /// The maximum line width for text wrapping. + /// + /// Type: number + /// default: 10 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textMaxWidth; + + /// Text leading value for multi-line text. + /// + /// Type: number + /// default: 1.2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textLineHeight; + + /// Text tracking amount. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textLetterSpacing; + + /// Text justification options. + /// + /// Type: enum + /// default: center + /// Options: + /// "auto" + /// The text is aligned towards the anchor position. + /// "left" + /// The text is aligned to the left. + /// "center" + /// The text is centered. + /// "right" + /// The text is aligned to the right. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textJustify; + + /// Radial offset of text, in the direction of the symbol's anchor. Useful + /// in combination with `text-variable-anchor`, which defaults to using + /// the two-dimensional `text-offset` if present. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRadialOffset; + + /// To increase the chance of placing high-priority labels on the map, you + /// can provide an array of `text-anchor` locations: the renderer will + /// attempt to place the label at each location, in order, before moving + /// onto the next label. Use `text-justify: auto` to choose justification + /// based on anchor position. To apply an offset, use the + /// `text-radial-offset` or the two-dimensional `text-offset`. + /// + /// Type: array + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textVariableAnchor; + + /// Part of the text placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textAnchor; + + /// Maximum angle change between adjacent characters. + /// + /// Type: number + /// default: 45 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textMaxAngle; + + /// The property allows control over a symbol's orientation. Note that the + /// property values act as a hint, so that a symbol whose language doesn’t + /// support the provided orientation will be laid out in its natural + /// orientation. Example: English point symbol will be rendered + /// horizontally even if array value contains single 'vertical' enum + /// value. The order of elements in an array define priority order for the + /// placement of an orientation variant. + /// + /// Type: array + /// Options: + /// "horizontal" + /// If a text's language supports horizontal writing mode, symbols + /// with point placement would be laid out horizontally. + /// "vertical" + /// If a text's language supports vertical writing mode, symbols with + /// point placement would be laid out vertically. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textWritingMode; + + /// Rotates the text clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRotate; + + /// Size of the additional area around the text bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPadding; + + /// If true, the text may be flipped vertically to prevent it from being + /// rendered upside-down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textKeepUpright; + + /// Specifies how to capitalize text, similar to the CSS `text-transform` + /// property. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The text is not altered. + /// "uppercase" + /// Forces all letters to be displayed in uppercase. + /// "lowercase" + /// Forces all letters to be displayed in lowercase. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textTransform; + + /// Offset distance of text from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. If used + /// with text-variable-anchor, input values will be taken as absolute + /// values. Offsets along the x- and y-axis will be applied automatically + /// based on the anchor position. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOffset; + + /// If true, the text will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// text. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textIgnorePlacement; + + /// If true, icons will display without their corresponding text when the + /// text collides with other symbols and the icon does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textOptional; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const SymbolLayerProperties({ + this.iconOpacity, + this.iconColor, + this.iconHaloColor, + this.iconHaloWidth, + this.iconHaloBlur, + this.iconTranslate, + this.iconTranslateAnchor, + this.textOpacity, + this.textColor, + this.textHaloColor, + this.textHaloWidth, + this.textHaloBlur, + this.textTranslate, + this.textTranslateAnchor, + this.symbolPlacement, + this.symbolSpacing, + this.symbolAvoidEdges, + this.symbolSortKey, + this.symbolZOrder, + this.iconAllowOverlap, + this.iconIgnorePlacement, + this.iconOptional, + this.iconRotationAlignment, + this.iconSize, + this.iconTextFit, + this.iconTextFitPadding, + this.iconImage, + this.iconRotate, + this.iconPadding, + this.iconKeepUpright, + this.iconOffset, + this.iconAnchor, + this.iconPitchAlignment, + this.textPitchAlignment, + this.textRotationAlignment, + this.textField, + this.textFont, + this.textSize, + this.textMaxWidth, + this.textLineHeight, + this.textLetterSpacing, + this.textJustify, + this.textRadialOffset, + this.textVariableAnchor, + this.textAnchor, + this.textMaxAngle, + this.textWritingMode, + this.textRotate, + this.textPadding, + this.textKeepUpright, + this.textTransform, + this.textOffset, + this.textAllowOverlap, + this.textIgnorePlacement, + this.textOptional, + this.visibility, + }); + + SymbolLayerProperties copyWith(SymbolLayerProperties changes) { + return SymbolLayerProperties( + iconOpacity: changes.iconOpacity ?? iconOpacity, + iconColor: changes.iconColor ?? iconColor, + iconHaloColor: changes.iconHaloColor ?? iconHaloColor, + iconHaloWidth: changes.iconHaloWidth ?? iconHaloWidth, + iconHaloBlur: changes.iconHaloBlur ?? iconHaloBlur, + iconTranslate: changes.iconTranslate ?? iconTranslate, + iconTranslateAnchor: changes.iconTranslateAnchor ?? iconTranslateAnchor, + textOpacity: changes.textOpacity ?? textOpacity, + textColor: changes.textColor ?? textColor, + textHaloColor: changes.textHaloColor ?? textHaloColor, + textHaloWidth: changes.textHaloWidth ?? textHaloWidth, + textHaloBlur: changes.textHaloBlur ?? textHaloBlur, + textTranslate: changes.textTranslate ?? textTranslate, + textTranslateAnchor: changes.textTranslateAnchor ?? textTranslateAnchor, + symbolPlacement: changes.symbolPlacement ?? symbolPlacement, + symbolSpacing: changes.symbolSpacing ?? symbolSpacing, + symbolAvoidEdges: changes.symbolAvoidEdges ?? symbolAvoidEdges, + symbolSortKey: changes.symbolSortKey ?? symbolSortKey, + symbolZOrder: changes.symbolZOrder ?? symbolZOrder, + iconAllowOverlap: changes.iconAllowOverlap ?? iconAllowOverlap, + iconIgnorePlacement: changes.iconIgnorePlacement ?? iconIgnorePlacement, + iconOptional: changes.iconOptional ?? iconOptional, + iconRotationAlignment: + changes.iconRotationAlignment ?? iconRotationAlignment, + iconSize: changes.iconSize ?? iconSize, + iconTextFit: changes.iconTextFit ?? iconTextFit, + iconTextFitPadding: changes.iconTextFitPadding ?? iconTextFitPadding, + iconImage: changes.iconImage ?? iconImage, + iconRotate: changes.iconRotate ?? iconRotate, + iconPadding: changes.iconPadding ?? iconPadding, + iconKeepUpright: changes.iconKeepUpright ?? iconKeepUpright, + iconOffset: changes.iconOffset ?? iconOffset, + iconAnchor: changes.iconAnchor ?? iconAnchor, + iconPitchAlignment: changes.iconPitchAlignment ?? iconPitchAlignment, + textPitchAlignment: changes.textPitchAlignment ?? textPitchAlignment, + textRotationAlignment: + changes.textRotationAlignment ?? textRotationAlignment, + textField: changes.textField ?? textField, + textFont: changes.textFont ?? textFont, + textSize: changes.textSize ?? textSize, + textMaxWidth: changes.textMaxWidth ?? textMaxWidth, + textLineHeight: changes.textLineHeight ?? textLineHeight, + textLetterSpacing: changes.textLetterSpacing ?? textLetterSpacing, + textJustify: changes.textJustify ?? textJustify, + textRadialOffset: changes.textRadialOffset ?? textRadialOffset, + textVariableAnchor: changes.textVariableAnchor ?? textVariableAnchor, + textAnchor: changes.textAnchor ?? textAnchor, + textMaxAngle: changes.textMaxAngle ?? textMaxAngle, + textWritingMode: changes.textWritingMode ?? textWritingMode, + textRotate: changes.textRotate ?? textRotate, + textPadding: changes.textPadding ?? textPadding, + textKeepUpright: changes.textKeepUpright ?? textKeepUpright, + textTransform: changes.textTransform ?? textTransform, + textOffset: changes.textOffset ?? textOffset, + textAllowOverlap: changes.textAllowOverlap ?? textAllowOverlap, + textIgnorePlacement: changes.textIgnorePlacement ?? textIgnorePlacement, + textOptional: changes.textOptional ?? textOptional, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('icon-opacity', iconOpacity); + addIfPresent('icon-color', iconColor); + addIfPresent('icon-halo-color', iconHaloColor); + addIfPresent('icon-halo-width', iconHaloWidth); + addIfPresent('icon-halo-blur', iconHaloBlur); + addIfPresent('icon-translate', iconTranslate); + addIfPresent('icon-translate-anchor', iconTranslateAnchor); + addIfPresent('text-opacity', textOpacity); + addIfPresent('text-color', textColor); + addIfPresent('text-halo-color', textHaloColor); + addIfPresent('text-halo-width', textHaloWidth); + addIfPresent('text-halo-blur', textHaloBlur); + addIfPresent('text-translate', textTranslate); + addIfPresent('text-translate-anchor', textTranslateAnchor); + addIfPresent('symbol-placement', symbolPlacement); + addIfPresent('symbol-spacing', symbolSpacing); + addIfPresent('symbol-avoid-edges', symbolAvoidEdges); + addIfPresent('symbol-sort-key', symbolSortKey); + addIfPresent('symbol-z-order', symbolZOrder); + addIfPresent('icon-allow-overlap', iconAllowOverlap); + addIfPresent('icon-ignore-placement', iconIgnorePlacement); + addIfPresent('icon-optional', iconOptional); + addIfPresent('icon-rotation-alignment', iconRotationAlignment); + addIfPresent('icon-size', iconSize); + addIfPresent('icon-text-fit', iconTextFit); + addIfPresent('icon-text-fit-padding', iconTextFitPadding); + addIfPresent('icon-image', iconImage); + addIfPresent('icon-rotate', iconRotate); + addIfPresent('icon-padding', iconPadding); + addIfPresent('icon-keep-upright', iconKeepUpright); + addIfPresent('icon-offset', iconOffset); + addIfPresent('icon-anchor', iconAnchor); + addIfPresent('icon-pitch-alignment', iconPitchAlignment); + addIfPresent('text-pitch-alignment', textPitchAlignment); + addIfPresent('text-rotation-alignment', textRotationAlignment); + addIfPresent('text-field', textField); + addIfPresent('text-font', textFont); + addIfPresent('text-size', textSize); + addIfPresent('text-max-width', textMaxWidth); + addIfPresent('text-line-height', textLineHeight); + addIfPresent('text-letter-spacing', textLetterSpacing); + addIfPresent('text-justify', textJustify); + addIfPresent('text-radial-offset', textRadialOffset); + addIfPresent('text-variable-anchor', textVariableAnchor); + addIfPresent('text-anchor', textAnchor); + addIfPresent('text-max-angle', textMaxAngle); + addIfPresent('text-writing-mode', textWritingMode); + addIfPresent('text-rotate', textRotate); + addIfPresent('text-padding', textPadding); + addIfPresent('text-keep-upright', textKeepUpright); + addIfPresent('text-transform', textTransform); + addIfPresent('text-offset', textOffset); + addIfPresent('text-allow-overlap', textAllowOverlap); + addIfPresent('text-ignore-placement', textIgnorePlacement); + addIfPresent('text-optional', textOptional); + addIfPresent('visibility', visibility); + return json; + } + + factory SymbolLayerProperties.fromJson(Map json) { + return SymbolLayerProperties( + iconOpacity: json['icon-opacity'], + iconColor: json['icon-color'], + iconHaloColor: json['icon-halo-color'], + iconHaloWidth: json['icon-halo-width'], + iconHaloBlur: json['icon-halo-blur'], + iconTranslate: json['icon-translate'], + iconTranslateAnchor: json['icon-translate-anchor'], + textOpacity: json['text-opacity'], + textColor: json['text-color'], + textHaloColor: json['text-halo-color'], + textHaloWidth: json['text-halo-width'], + textHaloBlur: json['text-halo-blur'], + textTranslate: json['text-translate'], + textTranslateAnchor: json['text-translate-anchor'], + symbolPlacement: json['symbol-placement'], + symbolSpacing: json['symbol-spacing'], + symbolAvoidEdges: json['symbol-avoid-edges'], + symbolSortKey: json['symbol-sort-key'], + symbolZOrder: json['symbol-z-order'], + iconAllowOverlap: json['icon-allow-overlap'], + iconIgnorePlacement: json['icon-ignore-placement'], + iconOptional: json['icon-optional'], + iconRotationAlignment: json['icon-rotation-alignment'], + iconSize: json['icon-size'], + iconTextFit: json['icon-text-fit'], + iconTextFitPadding: json['icon-text-fit-padding'], + iconImage: json['icon-image'], + iconRotate: json['icon-rotate'], + iconPadding: json['icon-padding'], + iconKeepUpright: json['icon-keep-upright'], + iconOffset: json['icon-offset'], + iconAnchor: json['icon-anchor'], + iconPitchAlignment: json['icon-pitch-alignment'], + textPitchAlignment: json['text-pitch-alignment'], + textRotationAlignment: json['text-rotation-alignment'], + textField: json['text-field'], + textFont: json['text-font'], + textSize: json['text-size'], + textMaxWidth: json['text-max-width'], + textLineHeight: json['text-line-height'], + textLetterSpacing: json['text-letter-spacing'], + textJustify: json['text-justify'], + textRadialOffset: json['text-radial-offset'], + textVariableAnchor: json['text-variable-anchor'], + textAnchor: json['text-anchor'], + textMaxAngle: json['text-max-angle'], + textWritingMode: json['text-writing-mode'], + textRotate: json['text-rotate'], + textPadding: json['text-padding'], + textKeepUpright: json['text-keep-upright'], + textTransform: json['text-transform'], + textOffset: json['text-offset'], + textAllowOverlap: json['text-allow-overlap'], + textIgnorePlacement: json['text-ignore-placement'], + textOptional: json['text-optional'], + visibility: json['visibility'], + ); + } +} + +class CircleLayerProperties implements LayerProperties { + // Paint Properties + /// Circle radius. + /// + /// Type: number + /// default: 5 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleRadius; + + /// The fill color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleColor; + + /// Amount to blur the circle. 1 blurs the circle such that only the + /// centerpoint is full opacity. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleBlur; + + /// The opacity at which the circle will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleOpacity; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslate; + + /// Controls the frame of reference for `circle-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The circle is translated relative to the map. + /// "viewport" + /// The circle is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslateAnchor; + + /// Controls the scaling behavior of the circle when the map is pitched. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Circles are scaled according to their apparent distance to the + /// camera. + /// "viewport" + /// Circles are not scaled. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchScale; + + /// Orientation of circle when map is pitched. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The circle is aligned to the plane of the map. + /// "viewport" + /// The circle is aligned to the plane of the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchAlignment; + + /// The width of the circle's stroke. Strokes are placed outside of the + /// `circle-radius`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeWidth; + + /// The stroke color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeColor; + + /// The opacity of the circle's stroke. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeOpacity; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic circleSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const CircleLayerProperties({ + this.circleRadius, + this.circleColor, + this.circleBlur, + this.circleOpacity, + this.circleTranslate, + this.circleTranslateAnchor, + this.circlePitchScale, + this.circlePitchAlignment, + this.circleStrokeWidth, + this.circleStrokeColor, + this.circleStrokeOpacity, + this.circleSortKey, + this.visibility, + }); + + CircleLayerProperties copyWith(CircleLayerProperties changes) { + return CircleLayerProperties( + circleRadius: changes.circleRadius ?? circleRadius, + circleColor: changes.circleColor ?? circleColor, + circleBlur: changes.circleBlur ?? circleBlur, + circleOpacity: changes.circleOpacity ?? circleOpacity, + circleTranslate: changes.circleTranslate ?? circleTranslate, + circleTranslateAnchor: + changes.circleTranslateAnchor ?? circleTranslateAnchor, + circlePitchScale: changes.circlePitchScale ?? circlePitchScale, + circlePitchAlignment: + changes.circlePitchAlignment ?? circlePitchAlignment, + circleStrokeWidth: changes.circleStrokeWidth ?? circleStrokeWidth, + circleStrokeColor: changes.circleStrokeColor ?? circleStrokeColor, + circleStrokeOpacity: changes.circleStrokeOpacity ?? circleStrokeOpacity, + circleSortKey: changes.circleSortKey ?? circleSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('circle-radius', circleRadius); + addIfPresent('circle-color', circleColor); + addIfPresent('circle-blur', circleBlur); + addIfPresent('circle-opacity', circleOpacity); + addIfPresent('circle-translate', circleTranslate); + addIfPresent('circle-translate-anchor', circleTranslateAnchor); + addIfPresent('circle-pitch-scale', circlePitchScale); + addIfPresent('circle-pitch-alignment', circlePitchAlignment); + addIfPresent('circle-stroke-width', circleStrokeWidth); + addIfPresent('circle-stroke-color', circleStrokeColor); + addIfPresent('circle-stroke-opacity', circleStrokeOpacity); + addIfPresent('circle-sort-key', circleSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory CircleLayerProperties.fromJson(Map json) { + return CircleLayerProperties( + circleRadius: json['circle-radius'], + circleColor: json['circle-color'], + circleBlur: json['circle-blur'], + circleOpacity: json['circle-opacity'], + circleTranslate: json['circle-translate'], + circleTranslateAnchor: json['circle-translate-anchor'], + circlePitchScale: json['circle-pitch-scale'], + circlePitchAlignment: json['circle-pitch-alignment'], + circleStrokeWidth: json['circle-stroke-width'], + circleStrokeColor: json['circle-stroke-color'], + circleStrokeOpacity: json['circle-stroke-opacity'], + circleSortKey: json['circle-sort-key'], + visibility: json['visibility'], + ); + } +} + +class LineLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the line will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOpacity; + + /// The color with which the line will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslate; + + /// Controls the frame of reference for `line-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The line is translated relative to the map. + /// "viewport" + /// The line is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslateAnchor; + + /// Stroke thickness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineWidth; + + /// Draws a line casing outside of a line's actual path. Value indicates + /// the width of the inner gap. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineGapWidth; + + /// The line's offset. For linear features, a positive value offsets the + /// line to the right, relative to the direction of the line, and a + /// negative value to the left. For polygon features, a positive value + /// results in an inset, and a negative value results in an outset. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOffset; + + /// Blur applied to the line, in pixels. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineBlur; + + /// Specifies the lengths of the alternating dashes and gaps that form the + /// dash pattern. The lengths are later scaled by the line width. To + /// convert a dash length to pixels, multiply the length by the current + /// line width. Note that GeoJSON sources with `lineMetrics: true` + /// specified won't render dashed lines to the expected scale. Also note + /// that zoom-dependent expressions will be evaluated only at integer zoom + /// levels. + /// + /// Type: array + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineDasharray; + + /// Name of image in sprite to use for drawing image lines. For seamless + /// patterns, image width must be a factor of two (2, 4, 8, ..., 512). + /// Note that zoom-dependent expressions will be evaluated only at integer + /// zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic linePattern; + + /// Defines a gradient with which to color a line feature. Can only be + /// used with GeoJSON sources that specify `"lineMetrics": true`. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineGradient; + + // Layout Properties + /// The display of line endings. + /// + /// Type: enum + /// default: butt + /// Options: + /// "butt" + /// A cap with a squared-off end which is drawn to the exact endpoint + /// of the line. + /// "round" + /// A cap with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "square" + /// A cap with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineCap; + + /// The display of lines when joining. + /// + /// Type: enum + /// default: miter + /// Options: + /// "bevel" + /// A join with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// "round" + /// A join with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "miter" + /// A join with a sharp, angled corner which is drawn with the outer + /// sides beyond the endpoint of the path until they meet. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineJoin; + + /// Used to automatically convert miter joins to bevel joins for sharp + /// angles. + /// + /// Type: number + /// default: 2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineMiterLimit; + + /// Used to automatically convert round joins to miter joins for shallow + /// angles. + /// + /// Type: number + /// default: 1.05 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineRoundLimit; + + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic lineSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const LineLayerProperties({ + this.lineOpacity, + this.lineColor, + this.lineTranslate, + this.lineTranslateAnchor, + this.lineWidth, + this.lineGapWidth, + this.lineOffset, + this.lineBlur, + this.lineDasharray, + this.linePattern, + this.lineGradient, + this.lineCap, + this.lineJoin, + this.lineMiterLimit, + this.lineRoundLimit, + this.lineSortKey, + this.visibility, + }); + + LineLayerProperties copyWith(LineLayerProperties changes) { + return LineLayerProperties( + lineOpacity: changes.lineOpacity ?? lineOpacity, + lineColor: changes.lineColor ?? lineColor, + lineTranslate: changes.lineTranslate ?? lineTranslate, + lineTranslateAnchor: changes.lineTranslateAnchor ?? lineTranslateAnchor, + lineWidth: changes.lineWidth ?? lineWidth, + lineGapWidth: changes.lineGapWidth ?? lineGapWidth, + lineOffset: changes.lineOffset ?? lineOffset, + lineBlur: changes.lineBlur ?? lineBlur, + lineDasharray: changes.lineDasharray ?? lineDasharray, + linePattern: changes.linePattern ?? linePattern, + lineGradient: changes.lineGradient ?? lineGradient, + lineCap: changes.lineCap ?? lineCap, + lineJoin: changes.lineJoin ?? lineJoin, + lineMiterLimit: changes.lineMiterLimit ?? lineMiterLimit, + lineRoundLimit: changes.lineRoundLimit ?? lineRoundLimit, + lineSortKey: changes.lineSortKey ?? lineSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('line-opacity', lineOpacity); + addIfPresent('line-color', lineColor); + addIfPresent('line-translate', lineTranslate); + addIfPresent('line-translate-anchor', lineTranslateAnchor); + addIfPresent('line-width', lineWidth); + addIfPresent('line-gap-width', lineGapWidth); + addIfPresent('line-offset', lineOffset); + addIfPresent('line-blur', lineBlur); + addIfPresent('line-dasharray', lineDasharray); + addIfPresent('line-pattern', linePattern); + addIfPresent('line-gradient', lineGradient); + addIfPresent('line-cap', lineCap); + addIfPresent('line-join', lineJoin); + addIfPresent('line-miter-limit', lineMiterLimit); + addIfPresent('line-round-limit', lineRoundLimit); + addIfPresent('line-sort-key', lineSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory LineLayerProperties.fromJson(Map json) { + return LineLayerProperties( + lineOpacity: json['line-opacity'], + lineColor: json['line-color'], + lineTranslate: json['line-translate'], + lineTranslateAnchor: json['line-translate-anchor'], + lineWidth: json['line-width'], + lineGapWidth: json['line-gap-width'], + lineOffset: json['line-offset'], + lineBlur: json['line-blur'], + lineDasharray: json['line-dasharray'], + linePattern: json['line-pattern'], + lineGradient: json['line-gradient'], + lineCap: json['line-cap'], + lineJoin: json['line-join'], + lineMiterLimit: json['line-miter-limit'], + lineRoundLimit: json['line-round-limit'], + lineSortKey: json['line-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillLayerProperties implements LayerProperties { + // Paint Properties + /// Whether or not the fill should be antialiased. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillAntialias; + + /// The opacity of the entire fill layer. In contrast to the `fill-color`, + /// this value will also affect the 1px stroke around the fill, if the + /// stroke is used. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOpacity; + + /// The color of the filled part of this layer. This color can be + /// specified as `rgba` with an alpha component and the color's opacity + /// will not affect the opacity of the 1px stroke, if it is used. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillColor; + + /// The outline color of the fill. Matches the value of `fill-color` if + /// unspecified. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOutlineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslate; + + /// Controls the frame of reference for `fill-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill is translated relative to the map. + /// "viewport" + /// The fill is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslateAnchor; + + /// Name of image in sprite to use for drawing image fills. For seamless + /// patterns, image width and height must be a factor of two (2, 4, 8, + /// ..., 512). Note that zoom-dependent expressions will be evaluated only + /// at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillPattern; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic fillSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillLayerProperties({ + this.fillAntialias, + this.fillOpacity, + this.fillColor, + this.fillOutlineColor, + this.fillTranslate, + this.fillTranslateAnchor, + this.fillPattern, + this.fillSortKey, + this.visibility, + }); + + FillLayerProperties copyWith(FillLayerProperties changes) { + return FillLayerProperties( + fillAntialias: changes.fillAntialias ?? fillAntialias, + fillOpacity: changes.fillOpacity ?? fillOpacity, + fillColor: changes.fillColor ?? fillColor, + fillOutlineColor: changes.fillOutlineColor ?? fillOutlineColor, + fillTranslate: changes.fillTranslate ?? fillTranslate, + fillTranslateAnchor: changes.fillTranslateAnchor ?? fillTranslateAnchor, + fillPattern: changes.fillPattern ?? fillPattern, + fillSortKey: changes.fillSortKey ?? fillSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('fill-antialias', fillAntialias); + addIfPresent('fill-opacity', fillOpacity); + addIfPresent('fill-color', fillColor); + addIfPresent('fill-outline-color', fillOutlineColor); + addIfPresent('fill-translate', fillTranslate); + addIfPresent('fill-translate-anchor', fillTranslateAnchor); + addIfPresent('fill-pattern', fillPattern); + addIfPresent('fill-sort-key', fillSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory FillLayerProperties.fromJson(Map json) { + return FillLayerProperties( + fillAntialias: json['fill-antialias'], + fillOpacity: json['fill-opacity'], + fillColor: json['fill-color'], + fillOutlineColor: json['fill-outline-color'], + fillTranslate: json['fill-translate'], + fillTranslateAnchor: json['fill-translate-anchor'], + fillPattern: json['fill-pattern'], + fillSortKey: json['fill-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillExtrusionLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity of the entire fill extrusion layer. This is rendered on a + /// per-layer, not per-feature, basis, and data-driven styling is not + /// available. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionOpacity; + + /// The base color of the extruded fill. The extrusion's surfaces will be + /// shaded differently based on this color in combination with the root + /// `light` settings. If this color is specified as `rgba` with an alpha + /// component, the alpha component will be ignored; use + /// `fill-extrusion-opacity` to set layer opacity. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up (on the flat plane), respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslate; + + /// Controls the frame of reference for `fill-extrusion-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill extrusion is translated relative to the map. + /// "viewport" + /// The fill extrusion is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslateAnchor; + + /// Name of image in sprite to use for drawing images on extruded fills. + /// For seamless patterns, image width and height must be a factor of two + /// (2, 4, 8, ..., 512). Note that zoom-dependent expressions will be + /// evaluated only at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillExtrusionPattern; + + /// The height with which to extrude this layer. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionHeight; + + /// The height with which to extrude the base of this layer. Must be less + /// than or equal to `fill-extrusion-height`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionBase; + + /// Whether to apply a vertical gradient to the sides of a fill-extrusion + /// layer. If true, sides will be shaded slightly darker farther down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, ios, macos + final dynamic fillExtrusionVerticalGradient; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillExtrusionLayerProperties({ + this.fillExtrusionOpacity, + this.fillExtrusionColor, + this.fillExtrusionTranslate, + this.fillExtrusionTranslateAnchor, + this.fillExtrusionPattern, + this.fillExtrusionHeight, + this.fillExtrusionBase, + this.fillExtrusionVerticalGradient, + this.visibility, + }); + + FillExtrusionLayerProperties copyWith(FillExtrusionLayerProperties changes) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: + changes.fillExtrusionOpacity ?? fillExtrusionOpacity, + fillExtrusionColor: changes.fillExtrusionColor ?? fillExtrusionColor, + fillExtrusionTranslate: + changes.fillExtrusionTranslate ?? fillExtrusionTranslate, + fillExtrusionTranslateAnchor: + changes.fillExtrusionTranslateAnchor ?? fillExtrusionTranslateAnchor, + fillExtrusionPattern: + changes.fillExtrusionPattern ?? fillExtrusionPattern, + fillExtrusionHeight: changes.fillExtrusionHeight ?? fillExtrusionHeight, + fillExtrusionBase: changes.fillExtrusionBase ?? fillExtrusionBase, + fillExtrusionVerticalGradient: changes.fillExtrusionVerticalGradient ?? + fillExtrusionVerticalGradient, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('fill-extrusion-opacity', fillExtrusionOpacity); + addIfPresent('fill-extrusion-color', fillExtrusionColor); + addIfPresent('fill-extrusion-translate', fillExtrusionTranslate); + addIfPresent( + 'fill-extrusion-translate-anchor', fillExtrusionTranslateAnchor); + addIfPresent('fill-extrusion-pattern', fillExtrusionPattern); + addIfPresent('fill-extrusion-height', fillExtrusionHeight); + addIfPresent('fill-extrusion-base', fillExtrusionBase); + addIfPresent( + 'fill-extrusion-vertical-gradient', fillExtrusionVerticalGradient); + addIfPresent('visibility', visibility); + return json; + } + + factory FillExtrusionLayerProperties.fromJson(Map json) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: json['fill-extrusion-opacity'], + fillExtrusionColor: json['fill-extrusion-color'], + fillExtrusionTranslate: json['fill-extrusion-translate'], + fillExtrusionTranslateAnchor: json['fill-extrusion-translate-anchor'], + fillExtrusionPattern: json['fill-extrusion-pattern'], + fillExtrusionHeight: json['fill-extrusion-height'], + fillExtrusionBase: json['fill-extrusion-base'], + fillExtrusionVerticalGradient: json['fill-extrusion-vertical-gradient'], + visibility: json['visibility'], + ); + } +} + +class RasterLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the image will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterOpacity; + + /// Rotates hues around the color wheel. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterHueRotate; + + /// Increase or reduce the brightness of the image. The value is the + /// minimum brightness. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMin; + + /// Increase or reduce the brightness of the image. The value is the + /// maximum brightness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMax; + + /// Increase or reduce the saturation of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterSaturation; + + /// Increase or reduce the contrast of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterContrast; + + /// The resampling/interpolation method to use for overscaling, also known + /// as texture magnification filter + /// + /// Type: enum + /// default: linear + /// Options: + /// "linear" + /// (Bi)linear filtering interpolates pixel values using the weighted + /// average of the four closest original source pixels creating a + /// smooth but blurry look when overscaled + /// "nearest" + /// Nearest neighbor filtering interpolates pixel values using the + /// nearest original source pixel creating a sharp but pixelated look + /// when overscaled + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterResampling; + + /// Fade duration when a new tile is added. + /// + /// Type: number + /// default: 300 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterFadeDuration; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const RasterLayerProperties({ + this.rasterOpacity, + this.rasterHueRotate, + this.rasterBrightnessMin, + this.rasterBrightnessMax, + this.rasterSaturation, + this.rasterContrast, + this.rasterResampling, + this.rasterFadeDuration, + this.visibility, + }); + + RasterLayerProperties copyWith(RasterLayerProperties changes) { + return RasterLayerProperties( + rasterOpacity: changes.rasterOpacity ?? rasterOpacity, + rasterHueRotate: changes.rasterHueRotate ?? rasterHueRotate, + rasterBrightnessMin: changes.rasterBrightnessMin ?? rasterBrightnessMin, + rasterBrightnessMax: changes.rasterBrightnessMax ?? rasterBrightnessMax, + rasterSaturation: changes.rasterSaturation ?? rasterSaturation, + rasterContrast: changes.rasterContrast ?? rasterContrast, + rasterResampling: changes.rasterResampling ?? rasterResampling, + rasterFadeDuration: changes.rasterFadeDuration ?? rasterFadeDuration, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('raster-opacity', rasterOpacity); + addIfPresent('raster-hue-rotate', rasterHueRotate); + addIfPresent('raster-brightness-min', rasterBrightnessMin); + addIfPresent('raster-brightness-max', rasterBrightnessMax); + addIfPresent('raster-saturation', rasterSaturation); + addIfPresent('raster-contrast', rasterContrast); + addIfPresent('raster-resampling', rasterResampling); + addIfPresent('raster-fade-duration', rasterFadeDuration); + addIfPresent('visibility', visibility); + return json; + } + + factory RasterLayerProperties.fromJson(Map json) { + return RasterLayerProperties( + rasterOpacity: json['raster-opacity'], + rasterHueRotate: json['raster-hue-rotate'], + rasterBrightnessMin: json['raster-brightness-min'], + rasterBrightnessMax: json['raster-brightness-max'], + rasterSaturation: json['raster-saturation'], + rasterContrast: json['raster-contrast'], + rasterResampling: json['raster-resampling'], + rasterFadeDuration: json['raster-fade-duration'], + visibility: json['visibility'], + ); + } +} + +class HillshadeLayerProperties implements LayerProperties { + // Paint Properties + /// The direction of the light source used to generate the hillshading + /// with 0 as the top of the viewport if `hillshade-illumination-anchor` + /// is set to `viewport` and due north if `hillshade-illumination-anchor` + /// is set to `map`. + /// + /// Type: number + /// default: 335 + /// minimum: 0 + /// maximum: 359 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationDirection; + + /// Direction of light source when map is rotated. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The hillshade illumination is relative to the north direction. + /// "viewport" + /// The hillshade illumination is relative to the top of the + /// viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationAnchor; + + /// Intensity of the hillshade + /// + /// Type: number + /// default: 0.5 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeExaggeration; + + /// The shading color of areas that face away from the light source. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeShadowColor; + + /// The shading color of areas that faces towards the light source. + /// + /// Type: color + /// default: #FFFFFF + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeHighlightColor; + + /// The shading color used to accentuate rugged terrain like sharp cliffs + /// and gorges. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeAccentColor; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HillshadeLayerProperties({ + this.hillshadeIlluminationDirection, + this.hillshadeIlluminationAnchor, + this.hillshadeExaggeration, + this.hillshadeShadowColor, + this.hillshadeHighlightColor, + this.hillshadeAccentColor, + this.visibility, + }); + + HillshadeLayerProperties copyWith(HillshadeLayerProperties changes) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: changes.hillshadeIlluminationDirection ?? + hillshadeIlluminationDirection, + hillshadeIlluminationAnchor: + changes.hillshadeIlluminationAnchor ?? hillshadeIlluminationAnchor, + hillshadeExaggeration: + changes.hillshadeExaggeration ?? hillshadeExaggeration, + hillshadeShadowColor: + changes.hillshadeShadowColor ?? hillshadeShadowColor, + hillshadeHighlightColor: + changes.hillshadeHighlightColor ?? hillshadeHighlightColor, + hillshadeAccentColor: + changes.hillshadeAccentColor ?? hillshadeAccentColor, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent( + 'hillshade-illumination-direction', hillshadeIlluminationDirection); + addIfPresent('hillshade-illumination-anchor', hillshadeIlluminationAnchor); + addIfPresent('hillshade-exaggeration', hillshadeExaggeration); + addIfPresent('hillshade-shadow-color', hillshadeShadowColor); + addIfPresent('hillshade-highlight-color', hillshadeHighlightColor); + addIfPresent('hillshade-accent-color', hillshadeAccentColor); + addIfPresent('visibility', visibility); + return json; + } + + factory HillshadeLayerProperties.fromJson(Map json) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: json['hillshade-illumination-direction'], + hillshadeIlluminationAnchor: json['hillshade-illumination-anchor'], + hillshadeExaggeration: json['hillshade-exaggeration'], + hillshadeShadowColor: json['hillshade-shadow-color'], + hillshadeHighlightColor: json['hillshade-highlight-color'], + hillshadeAccentColor: json['hillshade-accent-color'], + visibility: json['visibility'], + ); + } +} + +class HeatmapLayerProperties implements LayerProperties { + // Paint Properties + /// Radius of influence of one heatmap point in pixels. Increasing the + /// value makes the heatmap smoother, but less detailed. + /// + /// Type: number + /// default: 30 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapRadius; + + /// A measure of how much an individual point contributes to the heatmap. + /// A value of 10 would be equivalent to having 10 points of weight 1 in + /// the same spot. Especially useful when combined with clustering. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapWeight; + + /// Similar to `heatmap-weight` but controls the intensity of the heatmap + /// globally. Primarily used for adjusting the heatmap based on zoom + /// level. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapIntensity; + + /// Defines the color of each pixel based on its density value in a + /// heatmap. Should be an expression that uses `["heatmap-density"]` as + /// input. + /// + /// Type: color + /// default: [interpolate, [linear], [heatmap-density], 0, rgba(0, 0, 255, 0), 0.1, royalblue, 0.3, cyan, 0.5, lime, 0.7, yellow, 1, red] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapColor; + + /// The global opacity at which the heatmap layer will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapOpacity; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HeatmapLayerProperties({ + this.heatmapRadius, + this.heatmapWeight, + this.heatmapIntensity, + this.heatmapColor, + this.heatmapOpacity, + this.visibility, + }); + + HeatmapLayerProperties copyWith(HeatmapLayerProperties changes) { + return HeatmapLayerProperties( + heatmapRadius: changes.heatmapRadius ?? heatmapRadius, + heatmapWeight: changes.heatmapWeight ?? heatmapWeight, + heatmapIntensity: changes.heatmapIntensity ?? heatmapIntensity, + heatmapColor: changes.heatmapColor ?? heatmapColor, + heatmapOpacity: changes.heatmapOpacity ?? heatmapOpacity, + visibility: changes.visibility ?? visibility, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('heatmap-radius', heatmapRadius); + addIfPresent('heatmap-weight', heatmapWeight); + addIfPresent('heatmap-intensity', heatmapIntensity); + addIfPresent('heatmap-color', heatmapColor); + addIfPresent('heatmap-opacity', heatmapOpacity); + addIfPresent('visibility', visibility); + return json; + } + + factory HeatmapLayerProperties.fromJson(Map json) { + return HeatmapLayerProperties( + heatmapRadius: json['heatmap-radius'], + heatmapWeight: json['heatmap-weight'], + heatmapIntensity: json['heatmap-intensity'], + heatmapColor: json['heatmap-color'], + heatmapOpacity: json['heatmap-opacity'], + visibility: json['visibility'], + ); + } +} diff --git a/lib/src/nb_map.dart b/lib/src/nb_map.dart new file mode 100644 index 0000000..5b4df02 --- /dev/null +++ b/lib/src/nb_map.dart @@ -0,0 +1,473 @@ +part of nb_maps_flutter; + +enum AnnotationType { fill, line, circle, symbol } + +typedef void MapCreatedCallback(NextbillionMapController controller); + +class NBMap extends StatefulWidget { + const NBMap({ + Key? key, + required this.initialCameraPosition, + this.onMapCreated, + this.onStyleLoadedCallback, + this.gestureRecognizers, + this.compassEnabled = true, + this.cameraTargetBounds = CameraTargetBounds.unbounded, + this.styleString, + this.minMaxZoomPreference = MinMaxZoomPreference.unbounded, + this.rotateGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.zoomGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.doubleClickZoomEnabled, + this.dragEnabled = true, + this.trackCameraPosition = false, + this.myLocationEnabled = false, + this.myLocationTrackingMode = MyLocationTrackingMode.None, + this.myLocationRenderMode = MyLocationRenderMode.COMPASS, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition, + this.attributionButtonMargins, + this.onMapClick, + this.onUserLocationUpdated, + this.onMapLongClick, + this.onAttributionClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onCameraIdle, + this.onMapIdle, + this.annotationOrder = const [ + AnnotationType.line, + AnnotationType.symbol, + AnnotationType.circle, + AnnotationType.fill, + ], + this.annotationConsumeTapEvents = const [ + AnnotationType.symbol, + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + ], + this.useDelayedDisposal, + this.useHybridCompositionOverride, + }) : assert(annotationOrder.length <= 4), + assert(annotationConsumeTapEvents.length > 0), + super(key: key); + + /// Defines the layer order of annotations displayed on map + /// + /// Any annotation type can only be contained once, so 0 to 4 types + /// + /// Note that setting this to be empty gives a big performance boost for + /// android. However if you do so annotations will not work. + final List annotationOrder; + + /// Defines the layer order of click annotations + /// + /// (must contain at least 1 annotation type, 4 items max) + final List annotationConsumeTapEvents; + + /// Please note: you should only add annotations (e.g. symbols or circles) after `onStyleLoadedCallback` has been called. + final MapCreatedCallback? onMapCreated; + + /// Called when the map style has been successfully loaded and the annotation managers have been enabled. + /// Please note: you should only add annotations (e.g. symbols or circles) after this callback has been called. + final OnStyleLoadedCallback? onStyleLoadedCallback; + + /// The initial position of the map's camera. + final CameraPosition initialCameraPosition; + + /// True if the map should show a compass when rotated. + final bool compassEnabled; + + /// True if drag functionality should be enabled. + /// + /// Disable to avoid performance issues that from the drag event listeners. + /// Biggest impact in android + final bool dragEnabled; + + /// Geographical bounding box for the camera target. + final CameraTargetBounds cameraTargetBounds; + + /// Style URL or Style JSON + /// Can be a NbMapStyle constant, any NbMaps Style URL, + final String? styleString; + + /// Preferred bounds for the camera zoom level. + /// + /// Actual bounds depend on map data and device. + final MinMaxZoomPreference minMaxZoomPreference; + + /// True if the map view should respond to rotate gestures. + final bool rotateGesturesEnabled; + + /// True if the map view should respond to scroll gestures. + final bool scrollGesturesEnabled; + + /// True if the map view should respond to zoom gestures. + final bool zoomGesturesEnabled; + + /// True if the map view should respond to tilt gestures. + final bool tiltGesturesEnabled; + + /// Set to true to forcefully disable/enable if map should respond to double + /// click to zoom. + /// + /// This takes presedence over zoomGesturesEnabled. Only supported for web. + final bool? doubleClickZoomEnabled; + + /// True if you want to be notified of map camera movements by the NextBillionMapController. Default is false. + /// + /// If this is set to true and the user pans/zooms/rotates the map, NextBillionMapController (which is a ChangeNotifier) + /// will notify it's listeners and you can then get the new NextBillionMapController.cameraPosition. + final bool trackCameraPosition; + + /// True if a "My Location" layer should be shown on the map. + /// + /// This layer includes a location indicator at the current device location, + /// as well as a My Location button. + /// * The indicator is a small blue dot if the device is stationary, or a + /// chevron if the device is moving. + /// * The My Location button animates to focus on the user's current location + /// if the user's location is currently known. + /// + /// Enabling this feature requires adding location permissions to both native + /// platforms of your app. + /// * On Android add either + /// `` + /// or `` + /// to your `AndroidManifest.xml` file. `ACCESS_COARSE_LOCATION` returns a + /// location with an accuracy approximately equivalent to a city block, while + /// `ACCESS_FINE_LOCATION` returns as precise a location as possible, although + /// it consumes more battery power. You will also need to request these + /// permissions during run-time. If they are not granted, the My Location + /// feature will fail silently. + /// * On iOS add a `NSLocationWhenInUseUsageDescription` key to your + /// `Info.plist` file. This will automatically prompt the user for permissions + /// when the map tries to turn on the My Location layer. + final bool myLocationEnabled; + + /// The mode used to let the map's camera follow the device's physical location. + /// `myLocationEnabled` needs to be true for values other than `MyLocationTrackingMode.None` to work. + final MyLocationTrackingMode myLocationTrackingMode; + + /// The mode to render the user location symbol + final MyLocationRenderMode myLocationRenderMode; + + /// Set the layout margins for the Nbmaps Logo + final Point? logoViewMargins; + + /// Set the position for the Nbmaps Compass + final CompassViewPosition? compassViewPosition; + + /// Set the layout margins for the Nbmaps Compass + final Point? compassViewMargins; + + /// Set the position for the Nbmaps Attribution Button + final AttributionButtonPosition? attributionButtonPosition; + + /// Set the layout margins for the Nbmaps Attribution Buttons. If you set this + /// value, you may also want to set [attributionButtonPosition] to harmonize + /// the layout between iOS and Android, since the underlying frameworks have + /// different defaults. + final Point? attributionButtonMargins; + + /// Which gestures should be consumed by the map. + /// + /// It is possible for other gesture recognizers to be competing with the map on pointer + /// events, e.g if the map is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The map will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the map will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + final OnMapClickCallback? onMapClick; + final OnMapClickCallback? onMapLongClick; + + final OnAttributionClickCallback? onAttributionClick; + + /// While the `myLocationEnabled` property is set to `true`, this method is + /// called whenever a new location update is received by the map view. + final OnUserLocationUpdated? onUserLocationUpdated; + + /// Called when the map's camera no longer follows the physical device location, e.g. because the user moved the map + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + + /// Called when the location tracking mode changes + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + // Called when camera movement has ended. + final OnCameraIdleCallback? onCameraIdle; + + /// Called when map view is entering an idle state, and no more drawing will + /// be necessary until new data is loaded or there is some interaction with + /// the map. + /// * No camera transitions are in progress + /// * All currently requested tiles have loaded + /// * All fade/transition animations have completed + final OnMapIdleCallback? onMapIdle; + + // This flag has no effect anymore and will be removed in the next major release. + @deprecated + final bool? useDelayedDisposal; + + /// Override hybrid mode per map instance + final bool? useHybridCompositionOverride; + + /// (better for Android 9 and below but may result in errors on Android 12) + /// or leave it `true` (default) to use Hybrid composition (Slower on Android 9 and below). + static bool get useHybridComposition => + MethodChannelNbMapsGl.useHybridComposition; + static set useHybridComposition(bool useHybridComposition) => + MethodChannelNbMapsGl.useHybridComposition = useHybridComposition; + + @override + State createState() => _NBMapState(); +} + +class _NBMapState extends State { + final Completer _controller = + Completer(); + + late NextBillionMapOptions _nextbillionMapOptions; + final NbMapsGlPlatform _nbmapsGlPlatform = NbMapsGlPlatform.createInstance(); + + @override + Widget build(BuildContext context) { + assert( + widget.annotationOrder.toSet().length == widget.annotationOrder.length, + "annotationOrder must not have duplicate types"); + final Map creationParams = { + 'initialCameraPosition': widget.initialCameraPosition.toMap(), + 'options': NextBillionMapOptions.fromWidget(widget).toMap(), + 'onAttributionClickOverride': widget.onAttributionClick != null, + 'dragEnabled': widget.dragEnabled, + 'useHybridCompositionOverride': widget.useHybridCompositionOverride, + }; + return _nbmapsGlPlatform.buildView( + creationParams, onPlatformViewCreated, widget.gestureRecognizers); + } + + @override + void initState() { + super.initState(); + _nextbillionMapOptions = NextBillionMapOptions.fromWidget(widget); + } + + @override + void dispose() async { + super.dispose(); + if (_controller.isCompleted) { + final controller = await _controller.future; + controller.dispose(); + } + } + + @override + void didUpdateWidget(NBMap oldWidget) { + super.didUpdateWidget(oldWidget); + final NextBillionMapOptions newOptions = + NextBillionMapOptions.fromWidget(widget); + final Map updates = + _nextbillionMapOptions.updatesMap(newOptions); + _updateOptions(updates); + _nextbillionMapOptions = newOptions; + } + + void _updateOptions(Map updates) async { + if (updates.isEmpty) { + return; + } + final NextbillionMapController controller = await _controller.future; + controller._updateMapOptions(updates); + } + + Future onPlatformViewCreated(int id) async { + final NextbillionMapController controller = NextbillionMapController( + nbMapsGlPlatform: _nbmapsGlPlatform, + initialCameraPosition: widget.initialCameraPosition, + onStyleLoadedCallback: () { + _controller.future.then((_) { + if (widget.onStyleLoadedCallback != null) { + widget.onStyleLoadedCallback!(); + } + }); + }, + onMapClick: widget.onMapClick, + onUserLocationUpdated: widget.onUserLocationUpdated, + onMapLongClick: widget.onMapLongClick, + onAttributionClick: widget.onAttributionClick, + onCameraTrackingDismissed: widget.onCameraTrackingDismissed, + onCameraTrackingChanged: widget.onCameraTrackingChanged, + onCameraIdle: widget.onCameraIdle, + onMapIdle: widget.onMapIdle, + annotationOrder: widget.annotationOrder, + annotationConsumeTapEvents: widget.annotationConsumeTapEvents, + ); + await _nbmapsGlPlatform.initPlatform(id); + _controller.complete(controller); + if (widget.onMapCreated != null) { + widget.onMapCreated!(controller); + } + } +} + +/// Configuration options for the NbMaps user interface. +/// +/// When used to modify the configuration, setting a value to null will indicate that the configuration option should not be changed. +class NextBillionMapOptions { + NextBillionMapOptions({ + this.compassEnabled, + this.cameraTargetBounds, + this.styleString, + this.minMaxZoomPreference, + required this.rotateGesturesEnabled, + required this.scrollGesturesEnabled, + required this.tiltGesturesEnabled, + required this.zoomGesturesEnabled, + required this.doubleClickZoomEnabled, + this.trackCameraPosition, + this.myLocationEnabled, + this.myLocationTrackingMode, + this.myLocationRenderMode, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition, + this.attributionButtonMargins, + }); + + static NextBillionMapOptions fromWidget(NBMap map) { + return NextBillionMapOptions( + compassEnabled: map.compassEnabled, + cameraTargetBounds: map.cameraTargetBounds, + styleString: map.styleString, + minMaxZoomPreference: map.minMaxZoomPreference, + rotateGesturesEnabled: map.rotateGesturesEnabled, + scrollGesturesEnabled: map.scrollGesturesEnabled, + tiltGesturesEnabled: map.tiltGesturesEnabled, + trackCameraPosition: map.trackCameraPosition, + zoomGesturesEnabled: map.zoomGesturesEnabled, + doubleClickZoomEnabled: + map.doubleClickZoomEnabled ?? map.zoomGesturesEnabled, + myLocationEnabled: map.myLocationEnabled, + myLocationTrackingMode: map.myLocationTrackingMode, + myLocationRenderMode: map.myLocationRenderMode, + logoViewMargins: map.logoViewMargins, + compassViewPosition: map.compassViewPosition, + compassViewMargins: map.compassViewMargins, + attributionButtonPosition: map.attributionButtonPosition, + attributionButtonMargins: map.attributionButtonMargins, + ); + } + + final bool? compassEnabled; + + final CameraTargetBounds? cameraTargetBounds; + + final String? styleString; + + final MinMaxZoomPreference? minMaxZoomPreference; + + final bool rotateGesturesEnabled; + + final bool scrollGesturesEnabled; + + final bool tiltGesturesEnabled; + + final bool zoomGesturesEnabled; + + final bool doubleClickZoomEnabled; + + final bool? trackCameraPosition; + + final bool? myLocationEnabled; + + final MyLocationTrackingMode? myLocationTrackingMode; + + final MyLocationRenderMode? myLocationRenderMode; + + final Point? logoViewMargins; + + final CompassViewPosition? compassViewPosition; + + final Point? compassViewMargins; + + final AttributionButtonPosition? attributionButtonPosition; + + final Point? attributionButtonMargins; + + final _gestureGroup = { + 'rotateGesturesEnabled', + 'scrollGesturesEnabled', + 'tiltGesturesEnabled', + 'zoomGesturesEnabled', + 'doubleClickZoomEnabled' + }; + + Map toMap() { + final Map optionsMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + optionsMap[fieldName] = value; + } + } + + List? pointToArray(Point? fieldName) { + if (fieldName != null) { + return [fieldName.x, fieldName.y]; + } + + return null; + } + + addIfNonNull('compassEnabled', compassEnabled); + addIfNonNull('cameraTargetBounds', cameraTargetBounds?.toJson()); + addIfNonNull('styleString', styleString); + addIfNonNull('minMaxZoomPreference', minMaxZoomPreference?.toJson()); + + addIfNonNull('rotateGesturesEnabled', rotateGesturesEnabled); + addIfNonNull('scrollGesturesEnabled', scrollGesturesEnabled); + addIfNonNull('tiltGesturesEnabled', tiltGesturesEnabled); + addIfNonNull('zoomGesturesEnabled', zoomGesturesEnabled); + addIfNonNull('doubleClickZoomEnabled', doubleClickZoomEnabled); + + addIfNonNull('trackCameraPosition', trackCameraPosition); + addIfNonNull('myLocationEnabled', myLocationEnabled); + addIfNonNull('myLocationTrackingMode', myLocationTrackingMode?.index); + addIfNonNull('myLocationRenderMode', myLocationRenderMode?.index); + addIfNonNull('logoViewMargins', pointToArray(logoViewMargins)); + addIfNonNull('compassViewPosition', compassViewPosition?.index); + addIfNonNull('compassViewMargins', pointToArray(compassViewMargins)); + addIfNonNull('attributionButtonPosition', attributionButtonPosition?.index); + addIfNonNull( + 'attributionButtonMargins', pointToArray(attributionButtonMargins)); + return optionsMap; + } + + Map updatesMap(NextBillionMapOptions newOptions) { + final Map prevOptionsMap = toMap(); + final newOptionsMap = newOptions.toMap(); + + // if any gesture is updated also all other gestures have to the saved to + // the update + + final gesturesRequireUpdate = + _gestureGroup.any((key) => newOptionsMap[key] != prevOptionsMap[key]); + + return newOptionsMap + ..removeWhere((String key, dynamic value) { + if (_gestureGroup.contains(key)) return !gesturesRequireUpdate; + final oldValue = prevOptionsMap[key]; + if (oldValue is List && value is List) { + return listEquals(oldValue, value); + } + return oldValue == value; + }); + } +} diff --git a/lib/src/offline_region.dart b/lib/src/offline_region.dart new file mode 100644 index 0000000..698bd75 --- /dev/null +++ b/lib/src/offline_region.dart @@ -0,0 +1,76 @@ +part of nb_maps_flutter; + +/// Description of region to be downloaded. Identifier will be generated when +/// the download is initiated. +class OfflineRegionDefinition { + const OfflineRegionDefinition({ + required this.bounds, + required this.mapStyleUrl, + required this.minZoom, + required this.maxZoom, + this.includeIdeographs = false, + }); + + final LatLngBounds bounds; + final String mapStyleUrl; + final double minZoom; + final double maxZoom; + final bool includeIdeographs; + + @override + String toString() => + "$runtimeType, bounds = $bounds, mapStyleUrl = $mapStyleUrl, minZoom = $minZoom, maxZoom = $maxZoom"; + + Map toMap() { + final Map data = Map(); + data['bounds'] = bounds.toList(); + data['mapStyleUrl'] = mapStyleUrl; + data['minZoom'] = minZoom; + data['maxZoom'] = maxZoom; + data['includeIdeographs'] = includeIdeographs; + return data; + } + + factory OfflineRegionDefinition.fromMap(Map map) { + return OfflineRegionDefinition( + bounds: _latLngBoundsFromList(map['bounds']), + mapStyleUrl: map['mapStyleUrl'], + // small integers may deserialize to Int + minZoom: map['minZoom'].toDouble(), + maxZoom: map['maxZoom'].toDouble(), + includeIdeographs: map['includeIdeographs'] ?? false, + ); + } + + static LatLngBounds _latLngBoundsFromList(List json) { + return LatLngBounds( + southwest: LatLng(json[0][0], json[0][1]), + northeast: LatLng(json[1][0], json[1][1]), + ); + } +} + +/// Description of a downloaded region including its identifier. +class OfflineRegion { + const OfflineRegion({ + required this.id, + required this.definition, + required this.metadata, + }); + + final int id; + final OfflineRegionDefinition definition; + final Map metadata; + + factory OfflineRegion.fromMap(Map json) { + return OfflineRegion( + id: json['id'], + definition: OfflineRegionDefinition.fromMap(json['definition']), + metadata: json['metadata'], + ); + } + + @override + String toString() => + "$runtimeType, id = $id, definition = $definition, metadata = $metadata"; +} diff --git a/lib/src/platform_interface/annotation.dart b/lib/src/platform_interface/annotation.dart new file mode 100644 index 0000000..5e4c0ea --- /dev/null +++ b/lib/src/platform_interface/annotation.dart @@ -0,0 +1,8 @@ +part of nb_maps_flutter; + +abstract class Annotation { + String get id; + Map toGeoJson(); + + void translate(LatLng delta); +} diff --git a/lib/src/platform_interface/callbacks.dart b/lib/src/platform_interface/callbacks.dart new file mode 100644 index 0000000..3856e39 --- /dev/null +++ b/lib/src/platform_interface/callbacks.dart @@ -0,0 +1,57 @@ +part of nb_maps_flutter; + +/// Callback function taking a single argument. +typedef void ArgumentCallback(T argument); + +/// Mutable collection of [ArgumentCallback] instances, itself an [ArgumentCallback]. +/// +/// Additions and removals happening during a single [call] invocation do not +/// change who gets a callback until the next such invocation. +/// +/// Optimized for the singleton case. +class ArgumentCallbacks { + final List> _callbacks = >[]; + + /// Callback method. Invokes the corresponding method on each callback + /// in this collection. + /// + /// The list of callbacks being invoked is computed at the start of the + /// method and is unaffected by any changes subsequently made to this + /// collection. + void call(T argument) { + final int length = _callbacks.length; + if (length == 1) { + _callbacks[0].call(argument); + } else if (0 < length) { + for (ArgumentCallback callback + in List>.from(_callbacks)) { + callback(argument); + } + } + } + + /// Adds a callback to this collection. + void add(ArgumentCallback callback) { + _callbacks.add(callback); + } + + /// Removes a callback from this collection. + /// + /// Does nothing, if the callback was not present. + void remove(ArgumentCallback callback) { + _callbacks.remove(callback); + } + + /// Removes all callbacks + void clear() { + _callbacks.clear(); + } + + /// Whether this collection is empty. + bool get isEmpty => _callbacks.isEmpty; + + /// Whether this collection is non-empty. + bool get isNotEmpty => _callbacks.isNotEmpty; + + int get length => _callbacks.length; +} diff --git a/lib/src/platform_interface/camera.dart b/lib/src/platform_interface/camera.dart new file mode 100644 index 0000000..6a05382 --- /dev/null +++ b/lib/src/platform_interface/camera.dart @@ -0,0 +1,191 @@ +part of nb_maps_flutter; + +/// The position of the map "camera", the view point from which the world is +/// shown in the map view. Aggregates the camera's [target] geographical +/// location, its [zoom] level, [tilt] angle, and [bearing]. +class CameraPosition { + const CameraPosition({ + this.bearing = 0.0, + required this.target, + this.tilt = 0.0, + this.zoom = 0.0, + }); + + /// The camera's bearing in degrees, measured clockwise from north. + /// + /// A bearing of 0.0, the default, means the camera points north. + /// A bearing of 90.0 means the camera points east. + final double bearing; + + /// The geographical location that the camera is pointing at. + final LatLng target; + + /// The angle, in degrees, of the camera angle from the nadir. + /// + /// A tilt of 0.0, the default and minimum supported value, means the camera + /// is directly facing the Earth. + /// + /// The maximum tilt value depends on the current zoom level. Values beyond + /// the supported range are allowed, but on applying them to a map they will + /// be silently clamped to the supported range. + final double tilt; + + /// The zoom level of the camera. + /// + /// A zoom of 0.0, the default, means the screen width of the world is 256. + /// Adding 1.0 to the zoom level doubles the screen width of the map. So at + /// zoom level 3.0, the screen width of the world is 2³x256=2048. + /// + /// Larger zoom levels thus means the camera is placed closer to the surface + /// of the Earth, revealing more detail in a narrower geographical region. + /// + /// The supported zoom level range depends on the map data and device. Values + /// beyond the supported range are allowed, but on applying them to a map they + /// will be silently clamped to the supported range. + final double zoom; + + dynamic toMap() => { + 'bearing': bearing, + 'target': target.toJson(), + 'tilt': tilt, + 'zoom': zoom, + }; + + @visibleForTesting + static CameraPosition? fromMap(dynamic json) { + if (json == null) { + return null; + } + return CameraPosition( + bearing: json['bearing'], + target: LatLng._fromJson(json['target']), + tilt: json['tilt'], + zoom: json['zoom'], + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (runtimeType != other.runtimeType) return false; + + return other is CameraPosition && + bearing == other.bearing && + target == other.target && + tilt == other.tilt && + zoom == other.zoom; + } + + @override + int get hashCode => Object.hash(bearing, target, tilt, zoom); + + @override + String toString() => + 'CameraPosition(bearing: $bearing, target: $target, tilt: $tilt, zoom: $zoom)'; +} + +/// Defines a camera move, supporting absolute moves as well as moves relative +/// the current position. +class CameraUpdate { + CameraUpdate._(this._json); + + /// Returns a camera update that moves the camera to the specified position. + static CameraUpdate newCameraPosition(CameraPosition cameraPosition) { + return CameraUpdate._( + ['newCameraPosition', cameraPosition.toMap()], + ); + } + + /// Returns a camera update that moves the camera target to the specified + /// geographical location. + static CameraUpdate newLatLng(LatLng latLng) { + return CameraUpdate._(['newLatLng', latLng.toJson()]); + } + + /// Returns a camera update that transforms the camera so that the specified + /// geographical bounding box is centered in the map view at the greatest + /// possible zoom level. A non-zero [left], [top], [right] and [bottom] padding + /// insets the bounding box from the map view's edges. + /// The camera's new tilt and bearing will both be 0.0. + static CameraUpdate newLatLngBounds(LatLngBounds bounds, + {double left = 0, double top = 0, double right = 0, double bottom = 0}) { + return CameraUpdate._([ + 'newLatLngBounds', + bounds.toList(), + left, + top, + right, + bottom, + ]); + } + + /// Returns a camera update that moves the camera target to the specified + /// geographical location and zoom level. + static CameraUpdate newLatLngZoom(LatLng latLng, double zoom) { + return CameraUpdate._( + ['newLatLngZoom', latLng.toJson(), zoom], + ); + } + + /// Returns a camera update that moves the camera target the specified screen + /// distance. + /// + /// For a camera with bearing 0.0 (pointing north), scrolling by 50,75 moves + /// the camera's target to a geographical location that is 50 to the east and + /// 75 to the south of the current location, measured in screen coordinates. + static CameraUpdate scrollBy(double dx, double dy) { + return CameraUpdate._( + ['scrollBy', dx, dy], + ); + } + + /// Returns a camera update that modifies the camera zoom level by the + /// specified amount. The optional [focus] is a screen point whose underlying + /// geographical location should be invariant, if possible, by the movement. + static CameraUpdate zoomBy(double amount, [Offset? focus]) { + if (focus == null) { + return CameraUpdate._(['zoomBy', amount]); + } else { + return CameraUpdate._([ + 'zoomBy', + amount, + [focus.dx, focus.dy], + ]); + } + } + + /// Returns a camera update that zooms the camera in, bringing the camera + /// closer to the surface of the Earth. + /// + /// Equivalent to the result of calling `zoomBy(1.0)`. + static CameraUpdate zoomIn() { + return CameraUpdate._(['zoomIn']); + } + + /// Returns a camera update that zooms the camera out, bringing the camera + /// further away from the surface of the Earth. + /// + /// Equivalent to the result of calling `zoomBy(-1.0)`. + static CameraUpdate zoomOut() { + return CameraUpdate._(['zoomOut']); + } + + /// Returns a camera update that sets the camera zoom level. + static CameraUpdate zoomTo(double zoom) { + return CameraUpdate._(['zoomTo', zoom]); + } + + /// Returns a camera update that sets the camera bearing. + static CameraUpdate bearingTo(double bearing) { + return CameraUpdate._(['bearingTo', bearing]); + } + + /// Returns a camera update that sets the camera bearing. + static CameraUpdate tiltTo(double tilt) { + return CameraUpdate._(['tiltTo', tilt]); + } + + final dynamic _json; + + dynamic toJson() => _json; +} diff --git a/lib/src/platform_interface/circle.dart b/lib/src/platform_interface/circle.dart new file mode 100644 index 0000000..65b9ac2 --- /dev/null +++ b/lib/src/platform_interface/circle.dart @@ -0,0 +1,118 @@ +part of nb_maps_flutter; + +class Circle implements Annotation { + Circle(this._id, this.options, [this._data]); + + /// A unique identifier for this circle. + /// + /// The identifier is an arbitrary unique string. + final String _id; + String get id => _id; + + final Map? _data; + Map? get data => _data; + + /// The circle configuration options most recently applied programmatically + /// via the map controller. + /// + /// The returned value does not reflect any changes made to the circle through + /// touch events. Add listeners to the owning map controller to track those. + CircleOptions options; + + @override + Map toGeoJson() { + final geojson = options.toGeoJson(); + geojson["id"] = id; + geojson["properties"]["id"] = id; + + return geojson; + } + + @override + void translate(LatLng delta) { + options = options + .copyWith(CircleOptions(geometry: this.options.geometry! + delta)); + } +} + +/// Configuration options for [Circle] instances. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class CircleOptions { + /// Creates a set of circle configuration options. + /// + /// By default, every non-specified field is null, meaning no desire to change + /// circle defaults or current configuration. + const CircleOptions({ + this.circleRadius, + this.circleColor, + this.circleBlur, + this.circleOpacity, + this.circleStrokeWidth, + this.circleStrokeColor, + this.circleStrokeOpacity, + this.geometry, + this.draggable, + }); + + final double? circleRadius; + final String? circleColor; + final double? circleBlur; + final double? circleOpacity; + final double? circleStrokeWidth; + final String? circleStrokeColor; + final double? circleStrokeOpacity; + final LatLng? geometry; + final bool? draggable; + + static const CircleOptions defaultOptions = CircleOptions(); + + CircleOptions copyWith(CircleOptions changes) { + return CircleOptions( + circleRadius: changes.circleRadius ?? circleRadius, + circleColor: changes.circleColor ?? circleColor, + circleBlur: changes.circleBlur ?? circleBlur, + circleOpacity: changes.circleOpacity ?? circleOpacity, + circleStrokeWidth: changes.circleStrokeWidth ?? circleStrokeWidth, + circleStrokeColor: changes.circleStrokeColor ?? circleStrokeColor, + circleStrokeOpacity: changes.circleStrokeOpacity ?? circleStrokeOpacity, + geometry: changes.geometry ?? geometry, + draggable: changes.draggable ?? draggable, + ); + } + + dynamic toJson([bool addGeometry = true]) { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('circleRadius', circleRadius); + addIfPresent('circleColor', circleColor); + addIfPresent('circleBlur', circleBlur); + addIfPresent('circleOpacity', circleOpacity); + addIfPresent('circleStrokeWidth', circleStrokeWidth); + addIfPresent('circleStrokeColor', circleStrokeColor); + addIfPresent('circleStrokeOpacity', circleStrokeOpacity); + if (addGeometry) { + addIfPresent('geometry', geometry?.toJson()); + } + addIfPresent('draggable', draggable); + return json; + } + + Map toGeoJson() { + return { + "type": "Feature", + "properties": toJson(false), + "geometry": { + "type": "Point", + "coordinates": geometry!.toGeoJsonCoordinates() + } + }; + } +} diff --git a/lib/src/platform_interface/fill.dart b/lib/src/platform_interface/fill.dart new file mode 100644 index 0000000..ec03866 --- /dev/null +++ b/lib/src/platform_interface/fill.dart @@ -0,0 +1,128 @@ +part of nb_maps_flutter; + +FillOptions translateFillOptions(FillOptions options, LatLng delta) { + if (options.geometry != null) { + List> newGeometry = []; + for (var ring in options.geometry!) { + List newRing = []; + for (var coords in ring) { + newRing.add(coords + delta); + } + newGeometry.add(newRing); + } + return options.copyWith(FillOptions(geometry: newGeometry)); + } + return options; +} + +class Fill implements Annotation { + Fill(this._id, this.options, [this._data]); + + /// A unique identifier for this fill. + /// + /// The identifier is an arbitrary unique string. + final String _id; + String get id => _id; + + final Map? _data; + Map? get data => _data; + + /// The fill configuration options most recently applied programmatically + /// via the map controller. + /// + /// The returned value does not reflect any changes made to the fill through + /// touch events. Add listeners to the owning map controller to track those. + FillOptions options; + + @override + Map toGeoJson() { + final geojson = options.toGeoJson(); + geojson["id"] = id; + geojson["properties"]["id"] = id; + + return geojson; + } + + @override + void translate(LatLng delta) { + options = translateFillOptions(options, delta); + } +} + +/// Configuration options for [Fill] instances. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class FillOptions { + /// Creates a set of fill configuration options. + /// + /// By default, every non-specified field is null, meaning no desire to change + /// fill defaults or current configuration. + const FillOptions( + {this.fillOpacity, + this.fillColor, + this.fillOutlineColor, + this.fillPattern, + this.geometry, + this.draggable}); + + final double? fillOpacity; + final String? fillColor; + final String? fillOutlineColor; + final String? fillPattern; + final List>? geometry; + final bool? draggable; + + static const FillOptions defaultOptions = FillOptions(); + + FillOptions copyWith(FillOptions changes) { + return FillOptions( + fillOpacity: changes.fillOpacity ?? fillOpacity, + fillColor: changes.fillColor ?? fillColor, + fillOutlineColor: changes.fillOutlineColor ?? fillOutlineColor, + fillPattern: changes.fillPattern ?? fillPattern, + geometry: changes.geometry ?? geometry, + draggable: changes.draggable ?? draggable, + ); + } + + dynamic toJson([bool addGeometry = true]) { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('fillOpacity', fillOpacity); + addIfPresent('fillColor', fillColor); + addIfPresent('fillOutlineColor', fillOutlineColor); + addIfPresent('fillPattern', fillPattern); + if (addGeometry) { + addIfPresent( + 'geometry', + geometry + ?.map((List latLngList) => + latLngList.map((LatLng latLng) => latLng.toJson()).toList()) + .toList()); + } + addIfPresent('draggable', draggable); + return json; + } + + Map toGeoJson() { + return { + "type": "Feature", + "properties": toJson(false), + "geometry": { + "type": "Polygon", + "coordinates": geometry! + .map((List latLngList) => latLngList + .map((LatLng latLng) => latLng.toGeoJsonCoordinates()) + .toList()) + .toList() + } + }; + } +} diff --git a/lib/src/platform_interface/line.dart b/lib/src/platform_interface/line.dart new file mode 100644 index 0000000..5b1d56a --- /dev/null +++ b/lib/src/platform_interface/line.dart @@ -0,0 +1,124 @@ +part of nb_maps_flutter; + +class Line implements Annotation { + Line(this._id, this.options, [this._data]); + + /// A unique identifier for this line. + /// + /// The identifier is an arbitrary unique string. + final String _id; + + String get id => _id; + + final Map? _data; + + Map? get data => _data; + + /// The line configuration options most recently applied programmatically + /// via the map controller. + /// + /// The returned value does not reflect any changes made to the line through + /// touch events. Add listeners to the owning map controller to track those. + LineOptions options; + + Map toGeoJson() { + final geojson = options.toGeoJson(); + geojson["id"] = id; + geojson["properties"]["id"] = id; + + return geojson; + } + + @override + void translate(LatLng delta) { + options = options.copyWith(LineOptions( + geometry: this.options.geometry?.map((e) => e + delta).toList())); + } +} + +/// Configuration options for [Line] instances. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class LineOptions { + /// Creates a set of line configuration options. + /// + /// By default, every non-specified field is null, meaning no desire to change + /// line defaults or current configuration. + const LineOptions({ + this.lineJoin, + this.lineOpacity, + this.lineColor, + this.lineWidth, + this.lineGapWidth, + this.lineOffset, + this.lineBlur, + this.linePattern, + this.geometry, + this.draggable, + }); + + final String? lineJoin; + final double? lineOpacity; + final String? lineColor; + final double? lineWidth; + final double? lineGapWidth; + final double? lineOffset; + final double? lineBlur; + final String? linePattern; + final List? geometry; + final bool? draggable; + + static const LineOptions defaultOptions = LineOptions(); + + LineOptions copyWith(LineOptions changes) { + return LineOptions( + lineJoin: changes.lineJoin ?? lineJoin, + lineOpacity: changes.lineOpacity ?? lineOpacity, + lineColor: changes.lineColor ?? lineColor, + lineWidth: changes.lineWidth ?? lineWidth, + lineGapWidth: changes.lineGapWidth ?? lineGapWidth, + lineOffset: changes.lineOffset ?? lineOffset, + lineBlur: changes.lineBlur ?? lineBlur, + linePattern: changes.linePattern ?? linePattern, + geometry: changes.geometry ?? geometry, + draggable: changes.draggable ?? draggable, + ); + } + + dynamic toJson([bool addGeometry = true]) { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('lineJoin', lineJoin); + addIfPresent('lineOpacity', lineOpacity); + addIfPresent('lineColor', lineColor); + addIfPresent('lineWidth', lineWidth); + addIfPresent('lineGapWidth', lineGapWidth); + addIfPresent('lineOffset', lineOffset); + addIfPresent('lineBlur', lineBlur); + addIfPresent('linePattern', linePattern); + if (addGeometry) { + addIfPresent('geometry', + geometry?.map((LatLng latLng) => latLng.toJson()).toList()); + } + addIfPresent('draggable', draggable); + return json; + } + + Map toGeoJson() { + return { + "type": "Feature", + "properties": toJson(false), + "geometry": { + "type": "LineString", + "coordinates": geometry!.map((c) => c.toGeoJsonCoordinates()).toList() + } + }; + } +} diff --git a/lib/src/platform_interface/location.dart b/lib/src/platform_interface/location.dart new file mode 100644 index 0000000..2a86c78 --- /dev/null +++ b/lib/src/platform_interface/location.dart @@ -0,0 +1,280 @@ +part of nb_maps_flutter; + +/// A pair of latitude and longitude coordinates, stored as degrees. +class LatLng { + /// Creates a geographical location specified in degrees [latitude] and + /// [longitude]. + /// + /// The latitude is clamped to the inclusive interval from -90.0 to +90.0. + /// + /// The longitude is normalized to the half-open interval from -180.0 + /// (inclusive) to +180.0 (exclusive) + const LatLng(double latitude, double longitude) + : latitude = + (latitude < -90.0 ? -90.0 : (90.0 < latitude ? 90.0 : latitude)), + longitude = (longitude + 180.0) % 360.0 - 180.0; + + /// The latitude in degrees between -90.0 and 90.0, both inclusive. + final double latitude; + + /// The longitude in degrees between -180.0 (inclusive) and 180.0 (exclusive). + final double longitude; + + LatLng operator +(LatLng o) { + return LatLng(latitude + o.latitude, longitude + o.longitude); + } + + LatLng operator -(LatLng o) { + return LatLng(latitude - o.latitude, longitude - o.longitude); + } + + dynamic toJson() { + return [latitude, longitude]; + } + + dynamic toGeoJsonCoordinates() { + return [longitude, latitude]; + } + + static LatLng _fromJson(List json) { + return LatLng(json[0], json[1]); + } + + @override + String toString() => '$runtimeType($latitude, $longitude)'; + + @override + bool operator ==(Object o) { + return o is LatLng && o.latitude == latitude && o.longitude == longitude; + } + + @override + int get hashCode => Object.hash(latitude, longitude); +} + +/// A latitude/longitude aligned rectangle. +/// +/// The rectangle conceptually includes all points (lat, lng) where +/// * lat ∈ [`southwest.latitude`, `northeast.latitude`] +/// * lng ∈ [`southwest.longitude`, `northeast.longitude`], +/// if `southwest.longitude` ≤ `northeast.longitude`, +/// * lng ∈ [-180, `northeast.longitude`] ∪ [`southwest.longitude`, 180[, +/// if `northeast.longitude` < `southwest.longitude` +class LatLngBounds { + /// Creates geographical bounding box with the specified corners. + /// + /// The latitude of the southwest corner cannot be larger than the + /// latitude of the northeast corner. + LatLngBounds({required this.southwest, required this.northeast}) + : assert(southwest.latitude <= northeast.latitude); + + /// The southwest corner of the rectangle. + LatLng southwest; + + /// The northeast corner of the rectangle. + LatLng northeast; + + factory LatLngBounds.fromMultiLatLng(List multiLatLng) { + double? minLat; + double? maxLat; + double? minLon; + double? maxLon; + + for (LatLng item in multiLatLng) { + double latitude = item.latitude; + double longitude = item.longitude; + + if (minLat == null) { + minLat = latitude; + } else if (minLat > latitude) { + minLat = latitude; + } + + if (maxLat == null) { + maxLat = latitude; + } else if (maxLat < latitude) { + maxLat = latitude; + } + + if (minLon == null) { + minLon = longitude; + } else if (minLon > longitude) { + minLon = longitude; + } + + if (maxLon == null) { + maxLon = longitude; + } else if (maxLon < longitude) { + maxLon = longitude; + } + } + + return LatLngBounds( + southwest: LatLng(minLat!, minLon!), + northeast: LatLng(maxLat!, maxLon!)); + } + + dynamic toList() { + return [southwest.toJson(), northeast.toJson()]; + } + + @visibleForTesting + static LatLngBounds? fromList(dynamic json) { + if (json == null) { + return null; + } + return LatLngBounds( + southwest: LatLng._fromJson(json[0]), + northeast: LatLng._fromJson(json[1]), + ); + } + + @override + String toString() { + return '$runtimeType($southwest, $northeast)'; + } + + @override + bool operator ==(Object o) { + return o is LatLngBounds && + o.southwest == southwest && + o.northeast == northeast; + } + + @override + int get hashCode => Object.hash(southwest, northeast); +} + +/// A geographical area representing a non-aligned quadrilateral +/// This class does not wrap values to the world bounds +class LatLngQuad { + const LatLngQuad({ + required this.topLeft, + required this.topRight, + required this.bottomRight, + required this.bottomLeft, + }); + + final LatLng topLeft; + + final LatLng topRight; + + final LatLng bottomRight; + + final LatLng bottomLeft; + + dynamic toList() { + return [ + topLeft.toJson(), + topRight.toJson(), + bottomRight.toJson(), + bottomLeft.toJson() + ]; + } + + @visibleForTesting + static LatLngQuad? fromList(dynamic json) { + if (json == null) { + return null; + } + return LatLngQuad( + topLeft: LatLng._fromJson(json[0]), + topRight: LatLng._fromJson(json[1]), + bottomRight: LatLng._fromJson(json[2]), + bottomLeft: LatLng._fromJson(json[3]), + ); + } + + @override + String toString() { + return '$runtimeType($topLeft, $topRight, $bottomRight, $bottomLeft)'; + } + + @override + bool operator ==(Object o) { + return o is LatLngQuad && + o.topLeft == topLeft && + o.topRight == topRight && + o.bottomRight == bottomRight && + o.bottomLeft == bottomLeft; + } + + @override + int get hashCode => Object.hash(topLeft, topRight, bottomRight, bottomLeft); +} + +/// User's observed location +class UserLocation { + /// User's position in latitude and longitude + final LatLng position; + + /// User's altitude in meters + final double? altitude; + + /// Direction user is traveling, measured in degrees + final double? bearing; + + /// User's speed in meters per second + final double? speed; + + /// The radius of uncertainty for the location, measured in meters + final double? horizontalAccuracy; + + /// Accuracy of the altitude measurement, in meters + final double? verticalAccuracy; + + /// Time the user's location was observed + final DateTime timestamp; + + /// The heading of the user location, null if not available. + final UserHeading? heading; + + const UserLocation( + {required this.position, + required this.altitude, + required this.bearing, + required this.speed, + required this.horizontalAccuracy, + required this.verticalAccuracy, + required this.timestamp, + required this.heading}); +} + +/// Type represents a geomagnetic value, measured in microteslas, relative to a +/// device axis in three dimensional space. +class UserHeading { + /// Represents the direction in degrees, where 0 degrees is magnetic North. + /// The direction is referenced from the top of the device regardless of + /// device orientation as well as the orientation of the user interface. + final double? magneticHeading; + + /// Represents the direction in degrees, where 0 degrees is true North. The + /// direction is referenced from the top of the device regardless of device + /// orientation as well as the orientation of the user interface + final double? trueHeading; + + /// Represents the maximum deviation of where the magnetic heading may differ + /// from the actual geomagnetic heading in degrees. A negative value indicates + /// an invalid heading. + final double? headingAccuracy; + + /// Returns a raw value for the geomagnetism measured in the x-axis. + final double? x; + + /// Returns a raw value for the geomagnetism measured in the y-axis. + final double? y; + + /// Returns a raw value for the geomagnetism measured in the z-axis. + final double? z; + + /// Returns a timestamp for when the magnetic heading was determined. + final DateTime timestamp; + const UserHeading( + {required this.magneticHeading, + required this.trueHeading, + required this.headingAccuracy, + required this.x, + required this.y, + required this.z, + required this.timestamp}); +} diff --git a/lib/src/platform_interface/method_channel_nbmaps.dart b/lib/src/platform_interface/method_channel_nbmaps.dart new file mode 100644 index 0000000..f73c619 --- /dev/null +++ b/lib/src/platform_interface/method_channel_nbmaps.dart @@ -0,0 +1,797 @@ +part of nb_maps_flutter; + +class MethodChannelNbMapsGl extends NbMapsGlPlatform { + late MethodChannel _channel; + static bool useHybridComposition = false; + + @visibleForTesting + Future handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'infoWindow#onTap': + final String? symbolId = call.arguments['symbol']; + if (symbolId != null) { + onInfoWindowTappedPlatform(symbolId); + } + break; + + case 'feature#onTap': + final id = call.arguments['id']; + final double x = call.arguments['x']; + final double y = call.arguments['y']; + final double lng = call.arguments['lng']; + final double lat = call.arguments['lat']; + onFeatureTappedPlatform({ + 'id': id, + 'point': Point(x, y), + 'latLng': LatLng(lat, lng) + }); + break; + case 'feature#onDrag': + final id = call.arguments['id']; + final double x = call.arguments['x']; + final double y = call.arguments['y']; + final double originLat = call.arguments['originLat']; + final double originLng = call.arguments['originLng']; + + final double currentLat = call.arguments['currentLat']; + final double currentLng = call.arguments['currentLng']; + + final double deltaLat = call.arguments['deltaLat']; + final double deltaLng = call.arguments['deltaLng']; + final String eventType = call.arguments['eventType']; + + onFeatureDraggedPlatform({ + 'id': id, + 'point': Point(x, y), + 'origin': LatLng(originLat, originLng), + 'current': LatLng(currentLat, currentLng), + 'delta': LatLng(deltaLat, deltaLng), + 'eventType': eventType, + }); + break; + + case 'camera#onMoveStarted': + onCameraMoveStartedPlatform(null); + break; + case 'camera#onMove': + final cameraPosition = + CameraPosition.fromMap(call.arguments['position'])!; + onCameraMovePlatform(cameraPosition); + break; + case 'camera#onIdle': + final cameraPosition = + CameraPosition.fromMap(call.arguments['position']); + onCameraIdlePlatform(cameraPosition); + break; + case 'map#onStyleLoaded': + onMapStyleLoadedPlatform(null); + break; + case 'map#onMapClick': + final double x = call.arguments['x']; + final double y = call.arguments['y']; + final double lng = call.arguments['lng']; + final double lat = call.arguments['lat']; + onMapClickPlatform( + {'point': Point(x, y), 'latLng': LatLng(lat, lng)}); + break; + case 'map#onMapLongClick': + final double x = call.arguments['x']; + final double y = call.arguments['y']; + final double lng = call.arguments['lng']; + final double lat = call.arguments['lat']; + onMapLongClickPlatform( + {'point': Point(x, y), 'latLng': LatLng(lat, lng)}); + + break; + case 'map#onAttributionClick': + onAttributionClickPlatform(null); + break; + case 'map#onCameraTrackingChanged': + final int mode = call.arguments['mode']; + onCameraTrackingChangedPlatform(MyLocationTrackingMode.values[mode]); + break; + case 'map#onCameraTrackingDismissed': + onCameraTrackingDismissedPlatform(null); + break; + case 'map#onIdle': + onMapIdlePlatform(null); + break; + case 'map#onUserLocationUpdated': + final dynamic userLocation = call.arguments['userLocation']; + final dynamic heading = call.arguments['heading']; + onUserLocationUpdatedPlatform(UserLocation( + position: LatLng( + userLocation['position'][0], + userLocation['position'][1], + ), + altitude: userLocation['altitude'], + bearing: userLocation['bearing'], + speed: userLocation['speed'], + horizontalAccuracy: userLocation['horizontalAccuracy'], + verticalAccuracy: userLocation['verticalAccuracy'], + heading: heading == null + ? null + : UserHeading( + magneticHeading: heading['magneticHeading'], + trueHeading: heading['trueHeading'], + headingAccuracy: heading['headingAccuracy'], + x: heading['x'], + y: heading['y'], + z: heading['x'], + timestamp: DateTime.fromMillisecondsSinceEpoch( + heading['timestamp']), + ), + timestamp: DateTime.fromMillisecondsSinceEpoch( + userLocation['timestamp']))); + break; + default: + throw MissingPluginException(); + } + } + + @override + Future initPlatform(int id) async { + _channel = MethodChannel('plugins.flutter.io/nbmaps_maps_$id'); + _channel.setMethodCallHandler(handleMethodCall); + await _channel.invokeMethod('map#waitForMap'); + } + + @visibleForTesting + void setTestingMethodChanenl(MethodChannel channel) { + _channel = channel; + _channel.setMethodCallHandler(handleMethodCall); + } + + @override + Widget buildView( + Map creationParams, + OnPlatformViewCreatedCallback onPlatformViewCreated, + Set>? gestureRecognizers) { + if (defaultTargetPlatform == TargetPlatform.android) { + final useHybridCompositionParam = + (creationParams['useHybridCompositionOverride'] ?? + useHybridComposition) as bool; + if (useHybridCompositionParam) { + return PlatformViewLink( + viewType: 'plugins.flutter.io/nb_maps_flutter', + surfaceFactory: ( + BuildContext context, + PlatformViewController controller, + ) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: gestureRecognizers ?? + const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (PlatformViewCreationParams params) { + late AndroidViewController controller; + controller = PlatformViewsService.initAndroidView( + id: params.id, + viewType: 'plugins.flutter.io/nb_maps_flutter', + layoutDirection: TextDirection.ltr, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ); + controller.addOnPlatformViewCreatedListener( + params.onPlatformViewCreated, + ); + controller.addOnPlatformViewCreatedListener( + onPlatformViewCreated, + ); + + controller.create(); + return controller; + }, + ); + } else { + return AndroidView( + viewType: 'plugins.flutter.io/nb_maps_flutter', + onPlatformViewCreated: onPlatformViewCreated, + gestureRecognizers: gestureRecognizers, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + ); + } + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + return UiKitView( + viewType: 'plugins.flutter.io/nb_maps_flutter', + onPlatformViewCreated: onPlatformViewCreated, + gestureRecognizers: gestureRecognizers, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + ); + } + return Text( + '$defaultTargetPlatform is not yet supported by the maps plugin'); + } + + @override + Future updateMapOptions( + Map optionsUpdate) async { + final dynamic json = await _channel.invokeMethod( + 'map#update', + { + 'options': optionsUpdate, + }, + ); + return CameraPosition.fromMap(json); + } + + @override + Future animateCamera(cameraUpdate, {Duration? duration}) async { + return await _channel.invokeMethod('camera#animate', { + 'cameraUpdate': cameraUpdate.toJson(), + 'duration': duration?.inMilliseconds, + }); + } + + @override + Future moveCamera(CameraUpdate cameraUpdate) async { + return await _channel.invokeMethod('camera#move', { + 'cameraUpdate': cameraUpdate.toJson(), + }); + } + + @override + Future updateMyLocationTrackingMode( + MyLocationTrackingMode myLocationTrackingMode) async { + await _channel + .invokeMethod('map#updateMyLocationTrackingMode', { + 'mode': myLocationTrackingMode.index, + }); + } + + @override + Future matchMapLanguageWithDeviceDefault() async { + await _channel.invokeMethod('map#matchMapLanguageWithDeviceDefault'); + } + + @override + Future updateContentInsets(EdgeInsets insets, bool animated) async { + await _channel.invokeMethod('map#updateContentInsets', { + 'bounds': { + 'top': insets.top, + 'left': insets.left, + 'bottom': insets.bottom, + 'right': insets.right, + }, + 'animated': animated, + }); + } + + @override + Future setMapLanguage(String language) async { + await _channel.invokeMethod('map#setMapLanguage', { + 'language': language, + }); + } + + @override + Future setTelemetryEnabled(bool enabled) async { + await _channel.invokeMethod('map#setTelemetryEnabled', { + 'enabled': enabled, + }); + } + + @override + Future getTelemetryEnabled() async { + return await _channel.invokeMethod('map#getTelemetryEnabled'); + } + + @override + Future queryRenderedFeatures( + Point point, List layerIds, List? filter) async { + try { + final Map reply = await _channel.invokeMethod( + 'map#queryRenderedFeatures', + { + 'x': point.x, + 'y': point.y, + 'layerIds': layerIds, + 'filter': filter, + }, + ); + return reply['features'].map((feature) => jsonDecode(feature)).toList(); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future queryRenderedFeaturesInRect( + Rect rect, List layerIds, String? filter) async { + try { + final Map reply = await _channel.invokeMethod( + 'map#queryRenderedFeatures', + { + 'left': rect.left, + 'top': rect.top, + 'right': rect.right, + 'bottom': rect.bottom, + 'layerIds': layerIds, + 'filter': filter, + }, + ); + return reply['features'].map((feature) => jsonDecode(feature)).toList(); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future invalidateAmbientCache() async { + try { + await _channel.invokeMethod('map#invalidateAmbientCache'); + return null; + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future requestMyLocationLatLng() async { + try { + final Map reply = await _channel.invokeMethod( + 'locationComponent#getLastLocation', null); + double latitude = 0.0, longitude = 0.0; + if (reply.containsKey('latitude') && reply['latitude'] != null) { + latitude = double.parse(reply['latitude'].toString()); + } + if (reply.containsKey('longitude') && reply['longitude'] != null) { + longitude = double.parse(reply['longitude'].toString()); + } + return LatLng(latitude, longitude); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future getVisibleRegion() async { + try { + final Map reply = + await _channel.invokeMethod('map#getVisibleRegion', null); + final southwest = reply['sw'] as List; + final northeast = reply['ne'] as List; + return LatLngBounds( + southwest: LatLng(southwest[0], southwest[1]), + northeast: LatLng(northeast[0], northeast[1]), + ); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future addImage(String name, Uint8List bytes, + [bool sdf = false]) async { + try { + return await _channel.invokeMethod('style#addImage', { + 'name': name, + 'bytes': bytes, + 'length': bytes.length, + 'sdf': sdf + }); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future addImageSource( + String imageSourceId, Uint8List bytes, LatLngQuad coordinates) async { + try { + return await _channel + .invokeMethod('style#addImageSource', { + 'imageSourceId': imageSourceId, + 'bytes': bytes, + 'length': bytes.length, + 'coordinates': coordinates.toList() + }); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future updateImageSource( + String imageSourceId, Uint8List? bytes, LatLngQuad? coordinates) async { + try { + return await _channel + .invokeMethod('style#updateImageSource', { + 'imageSourceId': imageSourceId, + 'bytes': bytes, + 'length': bytes?.length, + 'coordinates': coordinates?.toList() + }); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future toScreenLocation(LatLng latLng) async { + try { + var screenPosMap = + await _channel.invokeMethod('map#toScreenLocation', { + 'latitude': latLng.latitude, + 'longitude': latLng.longitude, + }); + return Point(screenPosMap['x'], screenPosMap['y']); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future> toScreenLocationBatch(Iterable latLngs) async { + try { + var coordinates = Float64List.fromList(latLngs + .map((e) => [e.latitude, e.longitude]) + .expand((e) => e) + .toList()); + Float64List result = await _channel.invokeMethod( + 'map#toScreenLocationBatch', {"coordinates": coordinates}); + + var points = []; + for (int i = 0; i < result.length; i += 2) { + points.add(Point(result[i], result[i + 1])); + } + + return points; + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future removeSource(String sourceId) async { + try { + return await _channel.invokeMethod( + 'style#removeSource', + {'sourceId': sourceId}, + ); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future addLayer(String imageLayerId, String imageSourceId, + double? minzoom, double? maxzoom) async { + try { + return await _channel.invokeMethod('style#addLayer', { + 'imageLayerId': imageLayerId, + 'imageSourceId': imageSourceId, + 'minzoom': minzoom, + 'maxzoom': maxzoom + }); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future addLayerBelow(String imageLayerId, String imageSourceId, + String belowLayerId, double? minzoom, double? maxzoom) async { + try { + return await _channel + .invokeMethod('style#addLayerBelow', { + 'imageLayerId': imageLayerId, + 'imageSourceId': imageSourceId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom + }); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future removeLayer(String layerId) async { + try { + return await _channel.invokeMethod( + 'style#removeLayer', {'layerId': layerId}); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future setFilter(String layerId, dynamic filter) async { + try { + return await _channel.invokeMethod('style#setFilter', + {'layerId': layerId, 'filter': jsonEncode(filter)}); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future setVisibility(String layerId, bool isVisible) async { + try { + return await _channel.invokeMethod('style#setVisibility', + {'layerId': layerId, 'isVisible': isVisible}); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future toLatLng(Point screenLocation) async { + try { + var latLngMap = + await _channel.invokeMethod('map#toLatLng', { + 'x': screenLocation.x, + 'y': screenLocation.y, + }); + return LatLng(latLngMap['latitude'], latLngMap['longitude']); + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future getMetersPerPixelAtLatitude(double latitude) async { + try { + var latLngMap = await _channel + .invokeMethod('map#getMetersPerPixelAtLatitude', { + 'latitude': latitude, + }); + return latLngMap['metersperpixel']; + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future addGeoJsonSource(String sourceId, Map geojson, + {String? promoteId}) async { + await _channel.invokeMethod('source#addGeoJson', { + 'sourceId': sourceId, + 'geojson': jsonEncode(geojson), + }); + } + + @override + Future setGeoJsonSource( + String sourceId, Map geojson) async { + await _channel.invokeMethod('source#setGeoJson', { + 'sourceId': sourceId, + 'geojson': jsonEncode(geojson), + }); + } + + @override + Future addSymbolLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}) async { + await _channel.invokeMethod('symbolLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + @override + Future addLineLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}) async { + await _channel.invokeMethod('lineLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + @override + Future addCircleLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}) async { + await _channel.invokeMethod('circleLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + @override + Future addFillLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}) async { + await _channel.invokeMethod('fillLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + @override + Future addFillExtrusionLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}) async { + await _channel.invokeMethod('fillExtrusionLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + @override + void dispose() { + super.dispose(); + _channel.setMethodCallHandler(null); + } + + @override + Future addSource(String sourceId, SourceProperties properties) async { + await _channel.invokeMethod('style#addSource', { + 'sourceId': sourceId, + 'properties': properties.toJson(), + }); + } + + //sourceLayer is unused + @override + Future addRasterLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _channel.invokeMethod('rasterLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + //sourceLayer is unused + @override + Future addHillshadeLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _channel.invokeMethod('hillshadeLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + //sourceLayer is unused + @override + Future addHeatmapLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _channel.invokeMethod('heatmapLayer#add', { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }); + } + + Future setFeatureForGeoJsonSource( + String sourceId, Map geojsonFeature) async { + await _channel.invokeMethod('source#setFeature', { + 'sourceId': sourceId, + 'geojsonFeature': jsonEncode(geojsonFeature) + }); + } + + @override + void forceResizeWebMap() {} + + @override + void resizeWebMap() {} + + @override + Future takeSnapshot(SnapshotOptions snapshotOptions) async { + try { + debugPrint("${snapshotOptions.toJson()}"); + var uri = await _channel.invokeMethod( + 'snapshot#takeSnapshot', snapshotOptions.toJson()); + return uri; + } on PlatformException catch (e) { + return new Future.error(e); + } + } + + @override + Future findBelowLayerId(List belowAt) async { + return await _channel + .invokeMethod('style#findBelowLayer', {"belowAt": belowAt}); + } + + @override + Future setStyleString(String styleString) async { + try { + var uri = await _channel + .invokeMethod('style#setStyleString', {"styleString": styleString}); + return uri; + } on PlatformException catch (e) { + return new Future.error(e); + } + } +} diff --git a/lib/src/platform_interface/nbmaps_platform_interface.dart b/lib/src/platform_interface/nbmaps_platform_interface.dart new file mode 100644 index 0000000..b32001f --- /dev/null +++ b/lib/src/platform_interface/nbmaps_platform_interface.dart @@ -0,0 +1,210 @@ +// ignore_for_file: unnecessary_getters_setters + +part of nb_maps_flutter; + +typedef OnPlatformViewCreatedCallback = void Function(int); + +abstract class NbMapsGlPlatform { + /// The default instance of [NbMapsGlPlatform] to use. + /// + /// Defaults to [MethodChannelNbMapsGl]. + /// + /// Platform-specific plugins should set this with their own platform-specific + /// class that extends [NbMapsGlPlatform] when they register themselves. + static NbMapsGlPlatform Function() createInstance = + () => MethodChannelNbMapsGl(); + + final onInfoWindowTappedPlatform = ArgumentCallbacks(); + + final onFeatureTappedPlatform = ArgumentCallbacks>(); + + final onFeatureDraggedPlatform = ArgumentCallbacks>(); + + final onCameraMoveStartedPlatform = ArgumentCallbacks(); + + final onCameraMovePlatform = ArgumentCallbacks(); + + final onCameraIdlePlatform = ArgumentCallbacks(); + + final onMapStyleLoadedPlatform = ArgumentCallbacks(); + + final onMapClickPlatform = ArgumentCallbacks>(); + + final onMapLongClickPlatform = ArgumentCallbacks>(); + + final ArgumentCallbacks onAttributionClickPlatform = + ArgumentCallbacks(); + + final ArgumentCallbacks + onCameraTrackingChangedPlatform = + ArgumentCallbacks(); + + final onCameraTrackingDismissedPlatform = ArgumentCallbacks(); + + final onMapIdlePlatform = ArgumentCallbacks(); + + final onUserLocationUpdatedPlatform = ArgumentCallbacks(); + + Future initPlatform(int id); + Widget buildView( + Map creationParams, + OnPlatformViewCreatedCallback onPlatformViewCreated, + Set>? gestureRecognizers); + Future updateMapOptions(Map optionsUpdate); + Future animateCamera(CameraUpdate cameraUpdate, {Duration? duration}); + Future moveCamera(CameraUpdate cameraUpdate); + Future updateMyLocationTrackingMode( + MyLocationTrackingMode myLocationTrackingMode); + + Future matchMapLanguageWithDeviceDefault(); + + void resizeWebMap(); + void forceResizeWebMap(); + + Future updateContentInsets(EdgeInsets insets, bool animated); + Future setMapLanguage(String language); + Future setTelemetryEnabled(bool enabled); + + Future getTelemetryEnabled(); + Future queryRenderedFeatures( + Point point, List layerIds, List? filter); + + Future queryRenderedFeaturesInRect( + Rect rect, List layerIds, String? filter); + Future invalidateAmbientCache(); + Future requestMyLocationLatLng(); + + Future getVisibleRegion(); + + Future setStyleString(String styleString); + + Future addImage(String name, Uint8List bytes, [bool sdf = false]); + + Future addImageSource( + String imageSourceId, Uint8List bytes, LatLngQuad coordinates); + + Future updateImageSource( + String imageSourceId, Uint8List? bytes, LatLngQuad? coordinates); + + Future addLayer(String imageLayerId, String imageSourceId, + double? minzoom, double? maxzoom); + + Future addLayerBelow(String imageLayerId, String imageSourceId, + String belowLayerId, double? minzoom, double? maxzoom); + + Future removeLayer(String imageLayerId); + + Future setFilter(String layerId, dynamic filter); + + Future setVisibility(String layerId, bool isVisible); + + Future toScreenLocation(LatLng latLng); + + Future> toScreenLocationBatch(Iterable latLngs); + + Future toLatLng(Point screenLocation); + + Future getMetersPerPixelAtLatitude(double latitude); + + Future addGeoJsonSource(String sourceId, Map geojson, + {String? promoteId}); + + Future setGeoJsonSource(String sourceId, Map geojson); + + Future setFeatureForGeoJsonSource( + String sourceId, Map geojsonFeature); + + Future removeSource(String sourceId); + + Future addSymbolLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}); + + Future addLineLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}); + + Future addCircleLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}); + + Future addFillLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}); + + Future addFillExtrusionLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + required bool enableInteraction}); + + Future addRasterLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}); + + Future addHillshadeLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}); + + Future addHeatmapLayer( + String sourceId, String layerId, Map properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}); + + Future addSource(String sourceId, SourceProperties properties); + + Future takeSnapshot(SnapshotOptions snapshotOptions); + + Future findBelowLayerId(List belowAt); + + @mustCallSuper + void dispose() { + // clear all callbacks to avoid cyclic refs + onInfoWindowTappedPlatform.clear(); + onFeatureTappedPlatform.clear(); + onFeatureDraggedPlatform.clear(); + onCameraMoveStartedPlatform.clear(); + onCameraMovePlatform.clear(); + onCameraIdlePlatform.clear(); + onMapStyleLoadedPlatform.clear(); + + onMapClickPlatform.clear(); + onMapLongClickPlatform.clear(); + onAttributionClickPlatform.clear(); + onCameraTrackingChangedPlatform.clear(); + onCameraTrackingDismissedPlatform.clear(); + onMapIdlePlatform.clear(); + onUserLocationUpdatedPlatform.clear(); + } +} diff --git a/lib/src/platform_interface/nextbillion.dart b/lib/src/platform_interface/nextbillion.dart new file mode 100644 index 0000000..fca5330 --- /dev/null +++ b/lib/src/platform_interface/nextbillion.dart @@ -0,0 +1,64 @@ +part of nb_maps_flutter; + +class NextBillion { + static MethodChannel _nextBillionChannel = + MethodChannel("plugins.flutter.io/nextbillion_init"); + + get channel => _nextBillionChannel; + + @visibleForTesting + static setMockMethodChannel(MethodChannel channel) { + _nextBillionChannel = channel; + } + + static Future initNextBillion(String accessKey) async { + Map config = {"accessKey": accessKey}; + return await _nextBillionChannel.invokeMethod( + "nextbillion/init_nextbillion", config); + } + + static Future getAccessKey() async { + return await _nextBillionChannel.invokeMethod("nextbillion/get_access_key"); + } + + static Future setAccessKey(String accessKey) async { + Map config = {"accessKey": accessKey}; + return await _nextBillionChannel.invokeMethod( + "nextbillion/set_access_key", config); + } + + static Future getBaseUri() async { + return await _nextBillionChannel.invokeMethod("nextbillion/get_base_uri"); + } + + static Future setBaseUri(String baseUri) async { + Map config = {"baseUri": baseUri}; + return await _nextBillionChannel.invokeMethod( + "nextbillion/set_base_uri", config); + } + + static Future setApiKeyHeaderName(String apiKeyHeaderName) async { + Map config = {"apiKeyHeaderName": apiKeyHeaderName}; + return await _nextBillionChannel.invokeMethod( + "nextbillion/set_key_header_name", config); + } + + static Future getApiKeyHeaderName() async { + return await _nextBillionChannel + .invokeMethod("nextbillion/get_key_header_name"); + } + + static Future getNbId() async { + return await _nextBillionChannel.invokeMethod("nextbillion/get_nb_id"); + } + + static Future setUserId(String id) async { + Map config = {"userId": id}; + return await _nextBillionChannel.invokeMethod( + "nextbillion/set_user_id", config); + } + + static Future getUserId() async { + return await _nextBillionChannel.invokeMethod("nextbillion/get_user_id"); + } +} diff --git a/lib/src/platform_interface/snapshot.dart b/lib/src/platform_interface/snapshot.dart new file mode 100644 index 0000000..1215b6c --- /dev/null +++ b/lib/src/platform_interface/snapshot.dart @@ -0,0 +1,176 @@ +part of nb_maps_flutter; + +abstract class PlatformWrapper { + bool get isAndroid; +} + +class PlatformWrapperImpl extends PlatformWrapper { + static final PlatformWrapperImpl _instance = PlatformWrapperImpl._(); + + factory PlatformWrapperImpl() { + return _instance; + } + + PlatformWrapperImpl._(); + + @override + bool get isAndroid => Platform.isAndroid; +} + +/// Set of options for taking map snapshot +class SnapshotOptions { + /// Dimensions of the snapshot + /// The width of the image + final double width; + + /// Dimensions of the snapshot + /// The height of the image + final double height; + + /// If you want to take snapshot with camera position option + /// + /// Current center coordinate of camera position + final LatLng? centerCoordinate; + + /// The coordinate rectangle that encompasses the bounds to capture. This is applied after the camera position + final LatLngBounds? bounds; + + /// If you want to take snapshot with camera position option + /// + /// Zoom level of camera position + final double? zoomLevel; + + /// If you want to take snapshot with camera position option + /// + /// Pitch toward the horizon measured in degrees, with 0 degrees resulting in a two-dimensional map + final double? pitch; + + /// If you want to take snapshot with camera position option + /// + /// Heading measured in degrees clockwise from true north + final double? heading; + + /// URL of the map style to snapshot. The URL may be a full HTTP or HTTPS URL, a NBMap style URL + final String? styleUri; + + /// StyleJson of the map style to snapshot + final String? styleJson; + + /// Android Only: The flag indicating to show the Nbmap logo + final bool withLogo; + + /// True: Save snapshot in cache and return path + /// False: Return base64 value + final bool writeToDisk; + + final PlatformWrapper _platformWrapper; + + ///The [width] and [height] arguments must not be null + SnapshotOptions( + {required this.width, + required this.height, + this.centerCoordinate, + this.bounds, + this.zoomLevel, + double? pitch, + double? heading, + this.styleUri, + this.styleJson, + bool? withLogo, + bool? writeToDisk, + PlatformWrapper? platformWrapper}) + : this.withLogo = withLogo ?? false, + this.writeToDisk = writeToDisk ?? true, + this.pitch = pitch ?? 0, + this.heading = heading ?? 0, + this._platformWrapper = platformWrapper ?? PlatformWrapperImpl(); + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('width', Platform.isAndroid ? width.toInt() : width); + addIfPresent('height', Platform.isAndroid ? height.toInt() : height); + + if (bounds != null) { + if (_platformWrapper.isAndroid) { + final featureCollection = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + bounds!.northeast.longitude, + bounds!.northeast.latitude + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + bounds!.southwest.longitude, + bounds!.southwest.latitude + ] + } + } + ] + }; + addIfPresent("bounds", featureCollection.toString()); + } else { + final list = [ + [ + bounds!.southwest.latitude, + bounds!.southwest.longitude, + ], + [ + bounds!.northeast.latitude, + bounds!.northeast.longitude, + ] + ]; + addIfPresent("bounds", list); + } + } + if (centerCoordinate != null && zoomLevel != null) { + if (_platformWrapper.isAndroid) { + final feature = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + centerCoordinate!.longitude, + centerCoordinate!.latitude + ] + } + }; + addIfPresent('centerCoordinate', feature.toString()); + } else { + final list = [ + centerCoordinate!.latitude, + centerCoordinate!.longitude, + ]; + addIfPresent('centerCoordinate', list); + } + + addIfPresent('zoomLevel', zoomLevel); + } + addIfPresent('pitch', pitch); + addIfPresent('heading', heading); + addIfPresent('styleUri', styleUri); + addIfPresent('styleJson', styleJson); + addIfPresent('withLogo', withLogo); + addIfPresent('writeToDisk', writeToDisk); + return json; + } +} diff --git a/lib/src/platform_interface/source_properties.dart b/lib/src/platform_interface/source_properties.dart new file mode 100644 index 0000000..a1cf435 --- /dev/null +++ b/lib/src/platform_interface/source_properties.dart @@ -0,0 +1,669 @@ +part of nb_maps_flutter; + +abstract class SourceProperties { + Map toJson(); +} + +class VectorSourceProperties implements SourceProperties { + /// A URL to a TileJSON resource. Supported protocols are `http:`, + /// + /// Type: string + final String? url; + + /// An array of one or more tile source URLs, as in the TileJSON spec. + /// + /// Type: array + final List? tiles; + + /// An array containing the longitude and latitude of the southwest and + /// northeast corners of the source's bounding box in the following order: + /// `[sw.lng, sw.lat, ne.lng, ne.lat]`. When this property is included in + /// a source, no tiles outside of the given bounds are requested by NBMap + /// GL. + /// + /// Type: array + /// default: [-180, -85.051129, 180, 85.051129] + final List? bounds; + + /// Influences the y direction of the tile coordinates. The + /// global-mercator (aka Spherical Mercator) profile is assumed. + /// + /// Type: enum + /// default: xyz + /// Options: + /// "xyz" + /// Slippy map tilenames scheme. + /// "tms" + /// OSGeo spec scheme. + final String? scheme; + + /// Minimum zoom level for which tiles are available, as in the TileJSON + /// spec. + /// + /// Type: number + /// default: 0 + final double? minzoom; + + /// Maximum zoom level for which tiles are available, as in the TileJSON + /// spec. Data from tiles at the maxzoom are used when displaying the map + /// at higher zoom levels. + /// + /// Type: number + /// default: 22 + final double? maxzoom; + + /// Contains an attribution to be displayed when the map is shown to a + /// user. + /// + /// Type: string + final String? attribution; + + /// A property to use as a feature id (for feature state). Either a + /// property name, or an object of the form `{: + /// }`. If specified as a string for a vector tile source, + /// the same property is used across all its source layers. + /// + /// Type: promoteId + final String? promoteId; + + const VectorSourceProperties({ + this.url, + this.tiles, + this.bounds = const [-180, -85.051129, 180, 85.051129], + this.scheme = "xyz", + this.minzoom = 0, + this.maxzoom = 22, + this.attribution, + this.promoteId, + }); + + VectorSourceProperties copyWith( + {String? url, + List? tiles, + List? bounds, + String? scheme, + double? minzoom, + double? maxzoom, + String? attribution, + String? promoteId}) { + return VectorSourceProperties( + url: url ?? this.url, + tiles: tiles ?? this.tiles, + bounds: bounds ?? this.bounds, + scheme: scheme ?? this.scheme, + minzoom: minzoom ?? this.minzoom, + maxzoom: maxzoom ?? this.maxzoom, + attribution: attribution ?? this.attribution, + promoteId: promoteId ?? this.promoteId, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + json["type"] = "vector"; + addIfPresent('url', url); + addIfPresent('tiles', tiles); + addIfPresent('bounds', bounds); + addIfPresent('scheme', scheme); + addIfPresent('minzoom', minzoom); + addIfPresent('maxzoom', maxzoom); + addIfPresent('attribution', attribution); + addIfPresent('promoteId', promoteId); + return json; + } + + factory VectorSourceProperties.fromJson(Map json) { + return VectorSourceProperties( + url: json['url'], + tiles: json['tiles'], + bounds: json['bounds'], + scheme: json['scheme'], + minzoom: json['minzoom'], + maxzoom: json['maxzoom'], + attribution: json['attribution'], + promoteId: json['promoteId'], + ); + } +} + +class RasterSourceProperties implements SourceProperties { + /// A URL to a TileJSON resource. Supported protocols are `http:`, + /// + /// Type: string + final String? url; + + /// An array of one or more tile source URLs, as in the TileJSON spec. + /// + /// Type: array + final List? tiles; + + /// An array containing the longitude and latitude of the southwest and + /// northeast corners of the source's bounding box in the following order: + /// `[sw.lng, sw.lat, ne.lng, ne.lat]`. When this property is included in + /// a source, no tiles outside of the given bounds are requested by NBMap + /// GL. + /// + /// Type: array + /// default: [-180, -85.051129, 180, 85.051129] + final List? bounds; + + /// Minimum zoom level for which tiles are available, as in the TileJSON + /// spec. + /// + /// Type: number + /// default: 0 + final double? minzoom; + + /// Maximum zoom level for which tiles are available, as in the TileJSON + /// spec. Data from tiles at the maxzoom are used when displaying the map + /// at higher zoom levels. + /// + /// Type: number + /// default: 22 + final double? maxzoom; + + /// The minimum visual size to display tiles for this layer. Only + /// configurable for raster layers. + /// + /// Type: number + /// default: 512 + final double? tileSize; + + /// Influences the y direction of the tile coordinates. The + /// global-mercator (aka Spherical Mercator) profile is assumed. + /// + /// Type: enum + /// default: xyz + /// Options: + /// "xyz" + /// Slippy map tilenames scheme. + /// "tms" + /// OSGeo spec scheme. + final String? scheme; + + /// Contains an attribution to be displayed when the map is shown to a + /// user. + /// + /// Type: string + final String? attribution; + + const RasterSourceProperties({ + this.url, + this.tiles, + this.bounds = const [-180, -85.051129, 180, 85.051129], + this.minzoom = 0, + this.maxzoom = 22, + this.tileSize = 512, + this.scheme = "xyz", + this.attribution, + }); + + RasterSourceProperties copyWith({ + String? url, + List? tiles, + List? bounds, + double? minzoom, + double? maxzoom, + double? tileSize, + String? scheme, + String? attribution, + }) { + return RasterSourceProperties( + url: url ?? this.url, + tiles: tiles ?? this.tiles, + bounds: bounds ?? this.bounds, + minzoom: minzoom ?? this.minzoom, + maxzoom: maxzoom ?? this.maxzoom, + tileSize: tileSize ?? this.tileSize, + scheme: scheme ?? this.scheme, + attribution: attribution ?? this.attribution, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + json["type"] = "raster"; + addIfPresent('url', url); + addIfPresent('tiles', tiles); + addIfPresent('bounds', bounds); + addIfPresent('minzoom', minzoom); + addIfPresent('maxzoom', maxzoom); + addIfPresent('tileSize', tileSize); + addIfPresent('scheme', scheme); + addIfPresent('attribution', attribution); + return json; + } + + factory RasterSourceProperties.fromJson(Map json) { + return RasterSourceProperties( + url: json['url'], + tiles: json['tiles'], + bounds: json['bounds'], + minzoom: json['minzoom'], + maxzoom: json['maxzoom'], + tileSize: json['tileSize'], + scheme: json['scheme'], + attribution: json['attribution'], + ); + } +} + +class RasterDemSourceProperties implements SourceProperties { + /// A URL to a TileJSON resource. Supported protocols are `http:`, + /// + /// Type: string + final String? url; + + /// An array of one or more tile source URLs, as in the TileJSON spec. + /// + /// Type: array + final List? tiles; + + /// An array containing the longitude and latitude of the southwest and + /// northeast corners of the source's bounding box in the following order: + /// `[sw.lng, sw.lat, ne.lng, ne.lat]`. When this property is included in + /// a source, no tiles outside of the given bounds are requested by NBMap + /// GL. + /// + /// Type: array + /// default: [-180, -85.051129, 180, 85.051129] + final List? bounds; + + /// Minimum zoom level for which tiles are available, as in the TileJSON + /// spec. + /// + /// Type: number + /// default: 0 + final double? minzoom; + + /// Maximum zoom level for which tiles are available, as in the TileJSON + /// spec. Data from tiles at the maxzoom are used when displaying the map + /// at higher zoom levels. + /// + /// Type: number + /// default: 22 + final double? maxzoom; + + /// The minimum visual size to display tiles for this layer. Only + /// configurable for raster layers. + /// + /// Type: number + /// default: 512 + final double? tileSize; + + /// Contains an attribution to be displayed when the map is shown to a + /// user. + /// + /// Type: string + final String? attribution; + + /// Options: + /// "terrarium" + /// Terrarium format PNG tiles. See + /// https://aws.amazon.com/es/public-datasets/terrain/ for more info. + + final String? encoding; + + const RasterDemSourceProperties({ + this.url, + this.tiles, + this.bounds = const [-180, -85.051129, 180, 85.051129], + this.minzoom = 0, + this.maxzoom = 22, + this.tileSize = 512, + this.attribution, + this.encoding = "nbmap", + }); + + RasterDemSourceProperties copyWith({ + String? url, + List? tiles, + List? bounds, + double? minzoom, + double? maxzoom, + double? tileSize, + String? attribution, + String? encoding, + }) { + return RasterDemSourceProperties( + url: url ?? this.url, + tiles: tiles ?? this.tiles, + bounds: bounds ?? this.bounds, + minzoom: minzoom ?? this.minzoom, + maxzoom: maxzoom ?? this.maxzoom, + tileSize: tileSize ?? this.tileSize, + attribution: attribution ?? this.attribution, + encoding: encoding ?? this.encoding, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + json["type"] = "raster-dem"; + addIfPresent('url', url); + addIfPresent('tiles', tiles); + addIfPresent('bounds', bounds); + addIfPresent('minzoom', minzoom); + addIfPresent('maxzoom', maxzoom); + addIfPresent('tileSize', tileSize); + addIfPresent('attribution', attribution); + addIfPresent('encoding', encoding); + return json; + } + + factory RasterDemSourceProperties.fromJson(Map json) { + return RasterDemSourceProperties( + url: json['url'], + tiles: json['tiles'], + bounds: json['bounds'], + minzoom: json['minzoom'], + maxzoom: json['maxzoom'], + tileSize: json['tileSize'], + attribution: json['attribution'], + encoding: json['encoding'], + ); + } +} + +class GeojsonSourceProperties implements SourceProperties { + /// A URL to a GeoJSON file, or inline GeoJSON. + /// + /// Type: * + final Object? data; + + /// Maximum zoom level at which to create vector tiles (higher means + /// greater detail at high zoom levels). + /// + /// Type: number + /// default: 18 + final double? maxzoom; + + /// Contains an attribution to be displayed when the map is shown to a + /// user. + /// + /// Type: string + final String? attribution; + + /// Size of the tile buffer on each side. A value of 0 produces no buffer. + /// A value of 512 produces a buffer as wide as the tile itself. Larger + /// values produce fewer rendering artifacts near tile edges and slower + /// performance. + /// + /// Type: number + /// default: 128 + /// minimum: 0 + /// maximum: 512 + final double? buffer; + + /// Douglas-Peucker simplification tolerance (higher means simpler + /// geometries and faster performance). + /// + /// Type: number + /// default: 0.375 + final double? tolerance; + + /// If the data is a collection of point features, setting this to true + /// clusters the points by radius into groups. Cluster groups become new + /// `Point` features in the source with additional properties: + /// * `cluster` Is `true` if the point is a cluster + /// * `cluster_id` A unqiue id for the cluster to be used in conjunction + /// with the [cluster inspection + /// * `point_count` Number of original points grouped into this cluster + /// * `point_count_abbreviated` An abbreviated point count + /// + /// Type: boolean + /// default: false + final bool? cluster; + + /// Radius of each cluster if clustering is enabled. A value of 512 + /// indicates a radius equal to the width of a tile. + /// + /// Type: number + /// default: 50 + /// minimum: 0 + final double? clusterRadius; + + /// Max zoom on which to cluster points if clustering is enabled. Defaults + /// to one zoom less than maxzoom (so that last zoom features are not + /// clustered). + /// + /// Type: number + final double? clusterMaxZoom; + + /// An object defining custom properties on the generated clusters if + /// clustering is enabled, aggregating values from clustered points. Has + /// the form `{"property_name": [operator, map_expression]}`. `operator` + /// is any expression function that accepts at least 2 operands (e.g. + /// `"+"` or `"max"`) — it accumulates the property value from + /// clusters/points the cluster contains; `map_expression` produces the + /// value of a single point.Example: `{"sum": ["+", ["get", + /// "scalerank"]]}`.For more advanced use cases, in place of `operator`, + /// you can use a custom reduce expression that references a special + /// `["accumulated"]` value, e.g.:`{"sum": [["+", ["accumulated"], + /// ["get", "sum"]], ["get", "scalerank"]]}` + /// + /// Type: * + final Object? clusterProperties; + + /// Whether to calculate line distance metrics. This is required for line + /// layers that specify `line-gradient` values. + /// + /// Type: boolean + /// default: false + final bool? lineMetrics; + + /// Whether to generate ids for the geojson features. When enabled, the + /// `feature.id` property will be auto assigned based on its index in the + /// `features` array, over-writing any previous values. + /// + /// Type: boolean + /// default: false + final bool? generateId; + + /// A property to use as a feature id (for feature state). Either a + /// property name, or an object of the form `{: + /// }`. + /// + /// Type: promoteId + final String? promoteId; + + const GeojsonSourceProperties({ + this.data, + this.maxzoom = 18, + this.attribution, + this.buffer = 128, + this.tolerance = 0.375, + this.cluster = false, + this.clusterRadius = 50, + this.clusterMaxZoom, + this.clusterProperties, + this.lineMetrics = false, + this.generateId = false, + this.promoteId, + }); + + GeojsonSourceProperties copyWith( + {Object? data, + double? maxzoom, + String? attribution, + double? buffer, + double? tolerance, + bool? cluster, + double? clusterRadius, + double? clusterMaxZoom, + Object? clusterProperties, + bool? lineMetrics, + bool? generateId, + String? promoteId}) { + return GeojsonSourceProperties( + data: data ?? this.data, + maxzoom: maxzoom ?? this.maxzoom, + attribution: attribution ?? this.attribution, + buffer: buffer ?? this.buffer, + tolerance: tolerance ?? this.tolerance, + cluster: cluster ?? this.cluster, + clusterRadius: clusterRadius ?? this.clusterRadius, + clusterMaxZoom: clusterMaxZoom ?? this.clusterMaxZoom, + clusterProperties: clusterProperties ?? this.clusterProperties, + lineMetrics: lineMetrics ?? this.lineMetrics, + generateId: generateId ?? this.generateId, + promoteId: promoteId ?? this.promoteId, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + json["type"] = "geojson"; + addIfPresent('data', data); + addIfPresent('maxzoom', maxzoom); + addIfPresent('attribution', attribution); + addIfPresent('buffer', buffer); + addIfPresent('tolerance', tolerance); + addIfPresent('cluster', cluster); + addIfPresent('clusterRadius', clusterRadius); + addIfPresent('clusterMaxZoom', clusterMaxZoom); + addIfPresent('clusterProperties', clusterProperties); + addIfPresent('lineMetrics', lineMetrics); + addIfPresent('generateId', generateId); + addIfPresent('promoteId', promoteId); + return json; + } + + factory GeojsonSourceProperties.fromJson(Map json) { + return GeojsonSourceProperties( + data: json['data'], + maxzoom: json['maxzoom'], + attribution: json['attribution'], + buffer: json['buffer'], + tolerance: json['tolerance'], + cluster: json['cluster'], + clusterRadius: json['clusterRadius'], + clusterMaxZoom: json['clusterMaxZoom'], + clusterProperties: json['clusterProperties'], + lineMetrics: json['lineMetrics'], + generateId: json['generateId'], + promoteId: json['promoteId'], + ); + } +} + +class VideoSourceProperties implements SourceProperties { + /// URLs to video content in order of preferred format. + /// + /// Type: array + final List? urls; + + /// Corners of video specified in longitude, latitude pairs. + /// + /// Type: array + final List? coordinates; + + const VideoSourceProperties({ + this.urls, + this.coordinates, + }); + + VideoSourceProperties copyWith( + {List? urls, List? coordinates}) { + return VideoSourceProperties( + urls: urls ?? this.urls, + coordinates: coordinates ?? this.coordinates, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + json["type"] = "video"; + addIfPresent('urls', urls); + addIfPresent('coordinates', coordinates); + return json; + } + + factory VideoSourceProperties.fromJson(Map json) { + return VideoSourceProperties( + urls: json['urls'], + coordinates: json['coordinates'], + ); + } +} + +class ImageSourceProperties implements SourceProperties { + /// URL that points to an image. + /// + /// Type: string + final String? url; + + /// Corners of image specified in longitude, latitude pairs. + /// + /// Type: array + final List? coordinates; + + const ImageSourceProperties({ + this.url, + this.coordinates, + }); + + ImageSourceProperties copyWith({String? url, List? coordinates}) { + return ImageSourceProperties( + url: url ?? this.url, + coordinates: coordinates ?? this.coordinates, + ); + } + + Map toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + json["type"] = "image"; + addIfPresent('url', url); + addIfPresent('coordinates', coordinates); + return json; + } + + factory ImageSourceProperties.fromJson(Map json) { + return ImageSourceProperties( + url: json['url'], + coordinates: json['coordinates'], + ); + } +} diff --git a/lib/src/platform_interface/symbol.dart b/lib/src/platform_interface/symbol.dart new file mode 100644 index 0000000..f073ab7 --- /dev/null +++ b/lib/src/platform_interface/symbol.dart @@ -0,0 +1,204 @@ +part of nb_maps_flutter; + +class Symbol implements Annotation { + Symbol(this._id, this.options, [this._data]); + + /// A unique identifier for this symbol. + /// + /// The identifier is an arbitrary unique string. + final String _id; + + String get id => _id; + + final Map? _data; + Map? get data => _data; + + /// The symbol configuration options most recently applied programmatically + /// via the map controller. + /// + /// The returned value does not reflect any changes made to the symbol through + /// touch events. Add listeners to the owning map controller to track those. + SymbolOptions options; + + @override + Map toGeoJson() { + final geojson = options.toGeoJson(); + geojson["id"] = id; + geojson["properties"]["id"] = id; + + return geojson; + } + + @override + void translate(LatLng delta) { + options = options + .copyWith(SymbolOptions(geometry: this.options.geometry! + delta)); + } +} + +dynamic _offsetToJson(Offset? offset) { + if (offset == null) { + return null; + } + return [offset.dx, offset.dy]; +} + +/// Configuration options for [Symbol] instances. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class SymbolOptions { + /// Creates a set of symbol configuration options. + /// + /// By default, every non-specified field is null, meaning no desire to change + /// symbol defaults or current configuration. + const SymbolOptions({ + this.iconSize, + this.iconImage, + this.iconRotate, + this.iconOffset, + this.iconAnchor, + this.fontNames, + this.textField, + this.textSize, + this.textMaxWidth, + this.textLetterSpacing, + this.textJustify, + this.textAnchor, + this.textRotate, + this.textTransform, + this.textOffset, + this.iconOpacity, + this.iconColor, + this.iconHaloColor, + this.iconHaloWidth, + this.iconHaloBlur, + this.textOpacity, + this.textColor, + this.textHaloColor, + this.textHaloWidth, + this.textHaloBlur, + this.geometry, + this.zIndex, + this.draggable, + }); + + final double? iconSize; + final String? iconImage; + final double? iconRotate; + final Offset? iconOffset; + final String? iconAnchor; + + /// Not supported on web + final List? fontNames; + final String? textField; + final double? textSize; + final double? textMaxWidth; + final double? textLetterSpacing; + final String? textJustify; + final String? textAnchor; + final double? textRotate; + final String? textTransform; + final Offset? textOffset; + final double? iconOpacity; + final String? iconColor; + final String? iconHaloColor; + final double? iconHaloWidth; + final double? iconHaloBlur; + final double? textOpacity; + final String? textColor; + final String? textHaloColor; + final double? textHaloWidth; + final double? textHaloBlur; + final LatLng? geometry; + final int? zIndex; + final bool? draggable; + + static const SymbolOptions defaultOptions = SymbolOptions(); + + SymbolOptions copyWith(SymbolOptions changes) { + return SymbolOptions( + iconSize: changes.iconSize ?? iconSize, + iconImage: changes.iconImage ?? iconImage, + iconRotate: changes.iconRotate ?? iconRotate, + iconOffset: changes.iconOffset ?? iconOffset, + iconAnchor: changes.iconAnchor ?? iconAnchor, + fontNames: changes.fontNames ?? fontNames, + textField: changes.textField ?? textField, + textSize: changes.textSize ?? textSize, + textMaxWidth: changes.textMaxWidth ?? textMaxWidth, + textLetterSpacing: changes.textLetterSpacing ?? textLetterSpacing, + textJustify: changes.textJustify ?? textJustify, + textAnchor: changes.textAnchor ?? textAnchor, + textRotate: changes.textRotate ?? textRotate, + textTransform: changes.textTransform ?? textTransform, + textOffset: changes.textOffset ?? textOffset, + iconOpacity: changes.iconOpacity ?? iconOpacity, + iconColor: changes.iconColor ?? iconColor, + iconHaloColor: changes.iconHaloColor ?? iconHaloColor, + iconHaloWidth: changes.iconHaloWidth ?? iconHaloWidth, + iconHaloBlur: changes.iconHaloBlur ?? iconHaloBlur, + textOpacity: changes.textOpacity ?? textOpacity, + textColor: changes.textColor ?? textColor, + textHaloColor: changes.textHaloColor ?? textHaloColor, + textHaloWidth: changes.textHaloWidth ?? textHaloWidth, + textHaloBlur: changes.textHaloBlur ?? textHaloBlur, + geometry: changes.geometry ?? geometry, + zIndex: changes.zIndex ?? zIndex, + draggable: changes.draggable ?? draggable, + ); + } + + dynamic toJson([bool addGeometry = true]) { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('iconSize', iconSize); + addIfPresent('iconImage', iconImage); + addIfPresent('iconRotate', iconRotate); + addIfPresent('iconOffset', _offsetToJson(iconOffset)); + addIfPresent('iconAnchor', iconAnchor); + addIfPresent('fontNames', fontNames); + addIfPresent('textField', textField); + addIfPresent('textSize', textSize); + addIfPresent('textMaxWidth', textMaxWidth); + addIfPresent('textLetterSpacing', textLetterSpacing); + addIfPresent('textJustify', textJustify); + addIfPresent('textAnchor', textAnchor); + addIfPresent('textRotate', textRotate); + addIfPresent('textTransform', textTransform); + addIfPresent('textOffset', _offsetToJson(textOffset)); + addIfPresent('iconOpacity', iconOpacity); + addIfPresent('iconColor', iconColor); + addIfPresent('iconHaloColor', iconHaloColor); + addIfPresent('iconHaloWidth', iconHaloWidth); + addIfPresent('iconHaloBlur', iconHaloBlur); + addIfPresent('textOpacity', textOpacity); + addIfPresent('textColor', textColor); + addIfPresent('textHaloColor', textHaloColor); + addIfPresent('textHaloWidth', textHaloWidth); + addIfPresent('textHaloBlur', textHaloBlur); + if (addGeometry) { + addIfPresent('geometry', geometry?.toJson()); + } + addIfPresent('zIndex', zIndex); + addIfPresent('draggable', draggable); + return json; + } + + Map toGeoJson() { + return { + "type": "Feature", + "properties": toJson(false), + "geometry": { + "type": "Point", + "coordinates": geometry!.toGeoJsonCoordinates() + } + }; + } +} diff --git a/lib/src/platform_interface/ui.dart b/lib/src/platform_interface/ui.dart new file mode 100644 index 0000000..914c75f --- /dev/null +++ b/lib/src/platform_interface/ui.dart @@ -0,0 +1,156 @@ +part of nb_maps_flutter; + +class NbMapStyles { + static const String NBMAP_STREETS = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light"; + + /// Outdoors: A general-purpose style tailored to outdoor activities. Using this constant means + /// your map style will always use the latest version and may change as we improve the style. + static const String OUTDOORS = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light"; + + /// Light: Subtle light backdrop for data visualizations. Using this constant means your map + /// style will always use the latest version and may change as we improve the style. + static const String LIGHT = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light"; + + /// Empty: Basic empty style + static const String EMPTY = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light"; + + /// Dark: Subtle dark backdrop for data visualizations. Using this constant means your map style + /// will always use the latest version and may change as we improve the style. + static const String DARK = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-dark"; + + /// Satellite: A beautiful global satellite and aerial imagery layer. Using this constant means + /// your map style will always use the latest version and may change as we improve the style. + static const String SATELLITE = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-satellite"; + + /// Satellite Streets: Global satellite and aerial imagery with unobtrusive labels. Using this + /// constant means your map style will always use the latest version and may change as we + /// improve the style. + static const String SATELLITE_STREETS = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-satellite"; + + /// Traffic Day: Color-coded roads based on live traffic congestion data. Traffic data is currently + /// available in + /// countries. Using this constant means your map style will always use the latest version and + /// may change as we improve the style. + static const String TRAFFIC_DAY = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light&traffic_incidents=2/incidents_light&traffic_flow=2/flow_relative-light"; + + /// Traffic Night: Color-coded roads based on live traffic congestion data, designed to maximize + /// legibility in low-light situations. Traffic data is currently available in + /// countries. Using this constant means your map style will always use the latest version and + /// may change as we improve the style. + static const String TRAFFIC_NIGHT = + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-dark&traffic_incidents=2/incidents_dark&traffic_flow=2/flow_relative-dark"; +} + +/// The camera mode, which determines how the map camera will track the rendered location. +enum MyLocationTrackingMode { + None, + Tracking, + TrackingCompass, + TrackingGPS, +} + +/// Render mode +enum MyLocationRenderMode { + NORMAL, + COMPASS, + GPS, +} + +/// Compass View Position +enum CompassViewPosition { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +/// Attribution Button Position +enum AttributionButtonPosition { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +/// Bounds for the map camera target. +// Used with [NbMapOptions] to wrap a [LatLngBounds] value. This allows +// distinguishing between specifying an unbounded target (null `LatLngBounds`) +// from not specifying anything (null `CameraTargetBounds`). +class CameraTargetBounds { + /// Creates a camera target bounds with the specified bounding box, or null + /// to indicate that the camera target is not bounded. + const CameraTargetBounds(this.bounds); + + /// The geographical bounding box for the map camera target. + /// + /// A null value means the camera target is unbounded. + final LatLngBounds? bounds; + + /// Unbounded camera target. + static const CameraTargetBounds unbounded = CameraTargetBounds(null); + + dynamic toJson() => [bounds?.toList()]; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (runtimeType != other.runtimeType) return false; + + return other is CameraTargetBounds && bounds == other.bounds; + } + + @override + int get hashCode => bounds.hashCode; + + @override + String toString() { + return 'CameraTargetBounds(bounds: $bounds)'; + } +} + +/// Preferred bounds for map camera zoom level. +// Used with [NbMapOptions] to wrap min and max zoom. This allows +// distinguishing between specifying unbounded zooming (null `minZoom` and +// `maxZoom`) from not specifying anything (null `MinMaxZoomPreference`). +class MinMaxZoomPreference { + const MinMaxZoomPreference(this.minZoom, this.maxZoom) + : assert(minZoom == null || maxZoom == null || minZoom <= maxZoom); + + /// The preferred minimum zoom level or null, if unbounded from below. + final double? minZoom; + + /// The preferred maximum zoom level or null, if unbounded from above. + final double? maxZoom; + + /// Unbounded zooming. + static const MinMaxZoomPreference unbounded = + MinMaxZoomPreference(null, null); + + dynamic toJson() => [minZoom, maxZoom]; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (runtimeType != other.runtimeType) return false; + + return other is MinMaxZoomPreference && + minZoom == other.minZoom && + maxZoom == other.maxZoom; + } + + @override + int get hashCode => Object.hash(minZoom, maxZoom); + + @override + String toString() { + return 'MinMaxZoomPreference(minZoom: $minZoom, maxZoom: $maxZoom)'; + } +} diff --git a/lib/src/util.dart b/lib/src/util.dart new file mode 100644 index 0000000..9f6a578 --- /dev/null +++ b/lib/src/util.dart @@ -0,0 +1,14 @@ +part of nb_maps_flutter; + +Map buildFeatureCollection( + List> features) { + return {"type": "FeatureCollection", "features": features}; +} + +final _random = Random(); +String getRandomString([int length = 10]) { + const charSet = + 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + return String.fromCharCodes(Iterable.generate( + length, (_) => charSet.codeUnitAt(_random.nextInt(charSet.length)))); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1754c50 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,30 @@ +name: nb_maps_flutter +description: A Flutter plugin for integrating Nextbillion's Maps SDK to your Flutter Project. +version: 1.2.0 +homepage: https://github.com/nextbillion-ai/nb-maps-flutter + +dependencies: + flutter: + sdk: flutter + collection: ^1.15.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + test: ^1.24.9 + mockito: ^5.0.17 + build_runner: ^2.1.5 + +flutter: + plugin: + platforms: + android: + package: ai.nextbillion.maps_flutter + pluginClass: NbMapsPlugin + ios: + pluginClass: SwiftNbMapsFlutterPlugin + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: ">=3.3.0" diff --git a/test/src/annotation_manager_test.dart b/test/src/annotation_manager_test.dart new file mode 100644 index 0000000..38b1d26 --- /dev/null +++ b/test/src/annotation_manager_test.dart @@ -0,0 +1,406 @@ +import 'package:flutter/services.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'global_test.mocks.dart'; + +void main() { + late MethodChannel mockMethodChannel; + late MethodChannelNbMapsGl nbMapsGlPlatform; + late NextbillionMapController controller; + late LineManager lineManager; + late FillManager fillManager; + late CircleManager circleManager; + late SymbolManager symbolManager; + + setUp(() { + final initialCameraPosition = CameraPosition(target: LatLng(0.0, 0.0)); + final annotationOrder = []; + final annotationConsumeTapEvents = []; + + mockMethodChannel = MockMethodChannel(); + //source#setFeature + when(mockMethodChannel.invokeMethod('source#setFeature', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('source#setFeatureState', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('source#setGeoJson', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('source#addGeoJson', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('lineLayer#add', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('symbolLayer#add', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('circleLayer#add', any)) + .thenAnswer((_) async => null); + + when(mockMethodChannel.invokeMethod('fillLayer#add', any)) + .thenAnswer((_) async => null); + + nbMapsGlPlatform = MethodChannelNbMapsGl(); + nbMapsGlPlatform.setTestingMethodChanenl(mockMethodChannel); + controller = NextbillionMapController( + nbMapsGlPlatform: nbMapsGlPlatform, + initialCameraPosition: initialCameraPosition, + annotationOrder: annotationOrder, + annotationConsumeTapEvents: annotationConsumeTapEvents); + + lineManager = LineManager(controller); + fillManager = FillManager(controller); + circleManager = CircleManager(controller); + symbolManager = SymbolManager(controller); + }); + + group('LineManager', () { + test('Add a line annotation', () async { + // Arrange + final lineOptions = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FF0000', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + final line = Line('line1', lineOptions); + + // Act + await lineManager.add(line); + + // Assert + expect(lineManager.annotations.contains(line), isTrue); + }); + + test('Remove a line annotation', () async { + // Arrange + final lineOptions = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FF0000', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + final line = Line('line1', lineOptions); + + await lineManager.add(line); + + // Act + await lineManager.remove(line); + + // Assert + expect(lineManager.annotations.contains(line), isFalse); + }); + + test('Set line annotation', () async { + // Arrange + final lineOptions = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FF0000', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + + final line = Line('line1', lineOptions); + await lineManager.add(line); + + // Act + final changes = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FFFFFF', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + line.options = line.options.copyWith(changes); + await lineManager.set(line); + + // Assert + expect(lineManager.annotations.first.options.lineColor, '#FFFFFF'); + }); + }); + + group('FillManager', () { + test('Add a fill annotation', () async { + // Arrange + FillOptions fillOptions = FillOptions( + fillColor: '#FF0000', + fillOpacity: 0.5, + fillOutlineColor: '#0000FF', + fillPattern: 'solid', + geometry: [ + [LatLng(37.7749, -122.4194), LatLng(37.8095, -122.3927)] + ], + draggable: true, + ); + Fill fill = Fill('fill1', fillOptions); + + // Act + await fillManager.add(fill); + + // Assert + expect(fillManager.annotations.contains(fill), isTrue); + }); + + test('Remove a fill annotation', () async { + // Arrange + FillOptions fillOptions = FillOptions( + fillColor: '#FF0000', + fillOpacity: 0.5, + fillOutlineColor: '#0000FF', + fillPattern: 'solid', + geometry: [ + [LatLng(37.7749, -122.4194), LatLng(37.8095, -122.3927)] + ], + draggable: true, + ); + + Fill fill = Fill('fill1', fillOptions); + + await fillManager.add(fill); + + // Act + await fillManager.remove(fill); + + // Assert + expect(fillManager.annotations.contains(fill), isFalse); + }); + + test('Set fill annotation', () async { + // Arrange + FillOptions fillOptions = FillOptions( + fillColor: '#FF0000', + fillOpacity: 0.5, + fillOutlineColor: '#0000FF', + fillPattern: 'solid', + geometry: [ + [LatLng(37.7749, -122.4194), LatLng(37.8095, -122.3927)] + ], + draggable: true, + ); + Fill fill = Fill('fill1', fillOptions); + fillManager.add(fill); + + // Act + FillOptions changes = FillOptions( + fillColor: '#FFFFFF', + fillOpacity: 0.5, + fillOutlineColor: '#FFFFFF', + fillPattern: 'solid', + geometry: [ + [LatLng(37.7749, -122.4194), LatLng(37.8095, -122.3927)] + ], + draggable: true, + ); + fill.options = fill.options.copyWith(changes); + await fillManager.set(fill); + + // Assert + expect(fillManager.annotations.first.options.fillColor, '#FFFFFF'); + }); + }); + + group('CircleManager', () { + test('Add a circle annotation', () async { + // Arrange + CircleOptions circleOptions = CircleOptions( + circleColor: '#FF0000', + circleRadius: 10.0, + circleStrokeColor: '#0000FF', + circleStrokeWidth: 2.0, + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + Circle circle = Circle('circle1', circleOptions); + + // Act + await circleManager.add(circle); + + // Assert + expect(circleManager.annotations.contains(circle), isTrue); + }); + + test('Remove a circle annotation', () async { + // Arrange + CircleOptions circleOptions = CircleOptions( + circleColor: '#FF0000', + circleRadius: 10.0, + circleStrokeColor: '#0000FF', + circleStrokeWidth: 2.0, + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + Circle circle = Circle('circle1', circleOptions); + await circleManager.add(circle); + + // Act + await circleManager.remove(circle); + + // Assert + expect(circleManager.annotations.contains(circle), isFalse); + }); + + test('Set circle annotation', () async { + // Arrange + CircleOptions circleOptions = CircleOptions( + circleColor: '#FF0000', + circleRadius: 10.0, + circleStrokeColor: '#0000FF', + circleStrokeWidth: 2.0, + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + Circle circle = Circle('circle1', circleOptions); + + await circleManager.add(circle); + + // Act + CircleOptions changes = CircleOptions( + circleColor: '#0000FF', + circleRadius: 10.0, + circleStrokeColor: '#0000FF', + circleStrokeWidth: 2.0, + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + circle.options = circle.options.copyWith(changes); + await circleManager.set(circle); + + // Assert + expect(circleManager.annotations.first.options.circleColor, '#0000FF'); + }); + }); + + group('SymbolManager', () { + test('Add a symbol annotation', () async { + // Arrange + SymbolOptions symbolOptions = SymbolOptions( + iconSize: 10, + iconImage: 'iconImage', + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + + Symbol symbol = Symbol('symbol1', symbolOptions); + + // Act + await symbolManager.add(symbol); + + // Assert + expect(symbolManager.annotations.contains(symbol), isTrue); + }); + + test('Remove a symbol annotation', () async { + // Arrange + SymbolOptions symbolOptions = SymbolOptions( + iconSize: 10, + iconImage: 'iconImage', + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + + Symbol symbol = Symbol('symbol1', symbolOptions); + await symbolManager.add(symbol); + + // Act + await symbolManager.remove(symbol); + + // Assert + expect(symbolManager.annotations.contains(symbol), isFalse); + }); + + test('Set symbol annotation', () async { + // Arrange + SymbolOptions symbolOptions = SymbolOptions( + iconSize: 10, + iconImage: 'iconImage', + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + + Symbol symbol = Symbol('symbol1', symbolOptions); + await symbolManager.add(symbol); + + // Act + SymbolOptions changes = SymbolOptions( + iconSize: 20, + iconImage: 'iconImage', + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + symbol.options = symbol.options.copyWith(changes); + await symbolManager.set(symbol); + + // Assert + expect(symbolManager.annotations.first.options.iconSize, 20); + }); + + test('test setIconAllowOverlap', () async { + // Arrange + SymbolOptions symbolOptions = SymbolOptions( + iconSize: 10, + iconImage: 'iconImage', + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + + Symbol symbol = Symbol('symbol1', symbolOptions); + await symbolManager.clear(); + await symbolManager.add(symbol); + + // Act + await symbolManager.setIconAllowOverlap(true); + await symbolManager.setTextAllowOverlap(true); + await symbolManager.setIconIgnorePlacement(true); + await symbolManager.setTextIgnorePlacement(true); + + // Assert + SymbolLayerProperties symbolLayerProperties = + symbolManager.allLayerProperties.first as SymbolLayerProperties; + + expect(symbolLayerProperties.iconAllowOverlap, true); + expect(symbolLayerProperties.textAllowOverlap, true); + expect(symbolLayerProperties.iconIgnorePlacement, true); + expect(symbolLayerProperties.textIgnorePlacement, true); + }); + }); +} diff --git a/test/src/color_tools_test.dart b/test/src/color_tools_test.dart new file mode 100644 index 0000000..09bdc0c --- /dev/null +++ b/test/src/color_tools_test.dart @@ -0,0 +1,41 @@ +import 'dart:ui'; + +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('NbMapColorConversion', () { + test('toHexStringRGB should convert color to hex string correctly', () { + // Arrange + final color = Color.fromARGB(255, 255, 0, 0); + + // Act + final result = color.toHexStringRGB(); + + // Assert + expect(result, equals('#ff0000')); + }); + + test('toHexStringRGB should pad single-digit values with zeros', () { + // Arrange + final color = Color.fromARGB(255, 1, 2, 3); + + // Act + final result = color.toHexStringRGB(); + + // Assert + expect(result, equals('#010203')); + }); + + test('toHexStringRGB should handle transparent color', () { + // Arrange + final color = Color.fromARGB(0, 255, 0, 0); + + // Act + final result = color.toHexStringRGB(); + + // Assert + expect(result, equals('#ff0000')); + }); + }); +} \ No newline at end of file diff --git a/test/src/download_region_status_test.dart b/test/src/download_region_status_test.dart new file mode 100644 index 0000000..3124aab --- /dev/null +++ b/test/src/download_region_status_test.dart @@ -0,0 +1,75 @@ +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:flutter/services.dart'; +import 'package:test/test.dart'; + +void main() { + group('DownloadRegionStatus', () { + test('Success should be an instance of DownloadRegionStatus', () { + // Arrange + DownloadRegionStatus status = Success(); + + // Assert + expect(status, isA()); + }); + + test('InProgress should be an instance of DownloadRegionStatus', () { + // Arrange + double progress = 0.5; + DownloadRegionStatus status = InProgress(progress); + + // Assert + expect(status, isA()); + }); + + test('InProgress should have correct progress value', () { + // Arrange + double progress = 0.5; + InProgress status = InProgress(progress); + + // Assert + expect(status.progress, equals(progress)); + }); + + test('InProgress should have correct toString representation', () { + // Arrange + double progress = 0.5; + DownloadRegionStatus status = InProgress(progress); + + // Assert + expect( + status.toString(), + "Instance of 'DownloadRegionStatus.InProgress', progress = $progress", + ); + }); + + test('Error should be an instance of DownloadRegionStatus', () { + // Arrange + PlatformException cause = PlatformException(code: 'error_code', message: 'error_message'); + DownloadRegionStatus status = Error(cause); + + // Assert + expect(status, isA()); + }); + + test('Error should have correct cause value', () { + // Arrange + PlatformException cause = PlatformException(code: 'error_code', message: 'error_message'); + Error status = Error(cause); + + // Assert + expect(status.cause, equals(cause)); + }); + + test('Error should have correct toString representation', () { + // Arrange + PlatformException cause = PlatformException(code: 'error_code', message: 'error_message'); + DownloadRegionStatus status = Error(cause); + + // Assert + expect( + status.toString(), + "Instance of 'DownloadRegionStatus.Error', cause = ${cause.toString()}", + ); + }); + }); +} \ No newline at end of file diff --git a/test/src/global_test.dart b/test/src/global_test.dart new file mode 100644 index 0000000..1ca6cf9 --- /dev/null +++ b/test/src/global_test.dart @@ -0,0 +1,298 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'global_test.mocks.dart'; + +//dart run build_runner build +@GenerateMocks([EventChannel, MethodChannel]) +void main() { + test('verify channel name', () { + expect(globalChannel.name, equals('plugins.flutter.io/nb_maps_flutter')); + }); + group('test gloabl.dart', () { + MethodChannel mockChannel = MockMethodChannel(); + + setUp(() { + setTestingGlobalChannel(mockChannel); + }); + + test('installOfflineMapTiles test', () async { + final tilesDb = 'path/to/tiles.db'; + when(mockChannel.invokeMethod('installOfflineMapTiles', any)) + .thenAnswer((_) async => null); + await installOfflineMapTiles(tilesDb); + verify( + mockChannel.invokeMethod('installOfflineMapTiles', { + 'tilesdb': tilesDb, + })); + }); + + test('setOffline test', () async { + final offline = true; + final accessToken = 'your_access_token'; + + when(mockChannel.invokeMethod('setOffline', any)) + .thenAnswer((_) async => null); + + await setOffline(offline, accessToken: accessToken); + + final args = { + 'offline': offline, + 'accessToken': accessToken, + }; + verify(mockChannel.invokeMethod('setOffline', args)); + }); + + test('setHttpHeaders test', () async { + final headers = {'Content-Type': 'application/json'}; + when(mockChannel.invokeMethod('setHttpHeaders', any)) + .thenAnswer((_) async => null); + await setHttpHeaders(headers); + Map args = { + 'headers': headers, + }; + verify(mockChannel.invokeMethod('setHttpHeaders', args)); + }); + + test('mergeOfflineRegions test', () async { + final path = 'path/to/regions'; + final accessToken = 'your_access_token'; + Map args = { + 'path': path, + 'accessToken': accessToken, + }; + + String regionsJson = ''' + [ + { + "id": 1, + "definition": { + "bounds": [ + [-10.0, -10.0], + [10.0, 10.0] + ], + "mapStyleUrl": "mapstyle1", + "minZoom": 1.0, + "maxZoom": 2.0, + "includeIdeographs": false + }, + "metadata": { + "key1": "value1", + "key2": "value2" + } + } + ] + '''; + + when(mockChannel.invokeMethod('mergeOfflineRegions', args)) + .thenAnswer((_) async => regionsJson); + + final regions = await mergeOfflineRegions(path, accessToken: accessToken); + expect(regions.length, 1); + expect(regions[0].id, 1); + expect(regions[0].metadata, {'key1': 'value1', 'key2': 'value2'}); + }); + + test('getListOfRegions test', () async { + final accessToken = 'your_access_token'; + final args = { + 'accessToken': accessToken, + }; + when(mockChannel.invokeMethod('getListOfRegions', args)) + .thenAnswer((_) async => ''' + [ + { + "id": 1, + "definition": { + "bounds": [ + [-10.0, -10.0], + [10.0, 10.0] + ], + "mapStyleUrl": "mapstyle1", + "minZoom": 1.0, + "maxZoom": 2.0, + "includeIdeographs": false + }, + "metadata": { + "key1": "value1", + "key2": "value2" + } + } + ] + '''); + final regions = await getListOfRegions(accessToken: accessToken); + expect(regions.length, 1); + expect(regions[0].id, 1); + expect(regions[0].metadata, {'key1': 'value1', 'key2': 'value2'}); + }); + + test('updateOfflineRegionMetadata test', () async { + final id = 1; + final metadata = {'name': 'Region 1'}; + final accessToken = 'your_access_token'; + final args = { + 'id': id, + 'accessToken': accessToken, + 'metadata': metadata, + }; + + when(mockChannel.invokeMethod('updateOfflineRegionMetadata', args)) + .thenAnswer((_) async => ''' + { + "id": 1, + "definition": { + "bounds": [ + [-10.0, -10.0], + [10.0, 10.0] + ], + "mapStyleUrl": "mapstyle1", + "minZoom": 1.0, + "maxZoom": 2.0, + "includeIdeographs": false + }, + "metadata": { + "name": "Region 1" + } + } + '''); + final region = await updateOfflineRegionMetadata(id, metadata, + accessToken: accessToken); + + expect(region.id, 1); + expect(region.metadata, {"name": "Region 1"}); + }); + + test('setOfflineTileCountLimit test', () async { + final limit = 1000; + final accessToken = 'your_access_token'; + final args = { + 'limit': limit, + 'accessToken': accessToken, + }; + when(mockChannel.invokeMethod('setOfflineTileCountLimit', args)) + .thenAnswer((_) async => null); + await setOfflineTileCountLimit(limit, accessToken: accessToken); + verify(mockChannel.invokeMethod('setOfflineTileCountLimit', args)); + }); + + test('deleteOfflineRegion test', () async { + final id = 1; + final accessToken = 'your_access_token'; + + final args = { + 'id': id, + 'accessToken': accessToken, + }; + + when(mockChannel.invokeMethod('deleteOfflineRegion', args)) + .thenAnswer((_) async => null); + + await deleteOfflineRegion(id, accessToken: accessToken); + verify(mockChannel.invokeMethod('deleteOfflineRegion', args)); + }); + + test('downloadOfflineRegion test', () async { + final definition = OfflineRegionDefinition( + bounds: LatLngBounds( + southwest: LatLng(37.5, -122.5), + northeast: LatLng(37.9, -122.1), + ), + mapStyleUrl: 'https://example.com/style.json', + minZoom: 10.0, + maxZoom: 15.0, + ); + final metadata = {'name': 'Region 1'}; + final accessToken = 'your_access_token'; + Function(DownloadRegionStatus event) onEvent = (event) => print(event); + + when(mockChannel.invokeMethod('downloadOfflineRegion', any)) + .thenAnswer((_) async => ''' + { + "id": 1, + "definition": { + "bounds": [ + [-10.0, -10.0], + [10.0, 10.0] + ], + "mapStyleUrl": "mapstyle1", + "minZoom": 1.0, + "maxZoom": 2.0, + "includeIdeographs": false + }, + "metadata": { + "name": "Region 1" + } + } + '''); + WidgetsFlutterBinding.ensureInitialized(); + final region = await downloadOfflineRegion( + definition, + metadata: metadata, + accessToken: accessToken, + onEvent: onEvent, + ); + + expect(region.id, 1); + expect(region.metadata, {"name": "Region 1"}); + }); + }); + + test('downloadOfflineRegion with Error', () async { + WidgetsFlutterBinding.ensureInitialized(); + + final definition = OfflineRegionDefinition( + bounds: LatLngBounds( + southwest: LatLng(37.5, -122.5), + northeast: LatLng(37.9, -122.1), + ), + mapStyleUrl: 'https://example.com/style.json', + minZoom: 10.0, + maxZoom: 15.0, + ); + final metadata = {'name': 'Region 1'}; + final accessToken = 'your_access_token'; + + final mockChannel = MockMethodChannel(); + setTestingGlobalChannel(mockChannel); + + // Arrange + final mockEventChannel = MockEventChannel(); + final error = PlatformException(code: 'Error', message: 'Test error'); + + EventChannelCreator eventChannelCreator = (channelName) { + return mockEventChannel; + }; + + Stream stream = Stream.fromFuture(Future.error(error)); + when(mockEventChannel.receiveBroadcastStream()).thenAnswer((_) => stream); + + when(mockChannel.invokeMethod('downloadOfflineRegion', any)) + .thenAnswer((_) async => null); + + when(mockChannel.invokeMethod('downloadOfflineRegion', any)) + .thenAnswer((_) async => null); + + try { + await downloadOfflineRegion( + definition, + metadata: metadata, + accessToken: accessToken, + onEvent: (event) { + // Assert + expect(event, isInstanceOf()); + // expect((event as Error).error, equals(error)); + }, + eventChannelCreator: eventChannelCreator, + ); + } catch (e) { + // This is expected to throw an error + } + + verify(mockChannel.invokeMethod('downloadOfflineRegion', any)).called(1); + verify(mockEventChannel.receiveBroadcastStream()).called(1); + }); +} diff --git a/test/src/global_test.mocks.dart b/test/src/global_test.mocks.dart new file mode 100644 index 0000000..d5eceb3 --- /dev/null +++ b/test/src/global_test.mocks.dart @@ -0,0 +1,187 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in nb_maps_flutter/test/src/global_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; + +import 'package:flutter/src/services/binary_messenger.dart' as _i3; +import 'package:flutter/src/services/message_codec.dart' as _i2; +import 'package:flutter/src/services/platform_channel.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeMethodCodec_0 extends _i1.SmartFake implements _i2.MethodCodec { + _FakeMethodCodec_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeBinaryMessenger_1 extends _i1.SmartFake + implements _i3.BinaryMessenger { + _FakeBinaryMessenger_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [EventChannel]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockEventChannel extends _i1.Mock implements _i4.EventChannel { + MockEventChannel() { + _i1.throwOnMissingStub(this); + } + + @override + String get name => (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#name), + ), + ) as String); + + @override + _i2.MethodCodec get codec => (super.noSuchMethod( + Invocation.getter(#codec), + returnValue: _FakeMethodCodec_0( + this, + Invocation.getter(#codec), + ), + ) as _i2.MethodCodec); + + @override + _i3.BinaryMessenger get binaryMessenger => (super.noSuchMethod( + Invocation.getter(#binaryMessenger), + returnValue: _FakeBinaryMessenger_1( + this, + Invocation.getter(#binaryMessenger), + ), + ) as _i3.BinaryMessenger); + + @override + _i6.Stream receiveBroadcastStream([dynamic arguments]) => + (super.noSuchMethod( + Invocation.method( + #receiveBroadcastStream, + [arguments], + ), + returnValue: _i6.Stream.empty(), + ) as _i6.Stream); +} + +/// A class which mocks [MethodChannel]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { + MockMethodChannel() { + _i1.throwOnMissingStub(this); + } + + @override + String get name => (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#name), + ), + ) as String); + + @override + _i2.MethodCodec get codec => (super.noSuchMethod( + Invocation.getter(#codec), + returnValue: _FakeMethodCodec_0( + this, + Invocation.getter(#codec), + ), + ) as _i2.MethodCodec); + + @override + _i3.BinaryMessenger get binaryMessenger => (super.noSuchMethod( + Invocation.getter(#binaryMessenger), + returnValue: _FakeBinaryMessenger_1( + this, + Invocation.getter(#binaryMessenger), + ), + ) as _i3.BinaryMessenger); + + @override + _i6.Future invokeMethod( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod( + Invocation.method( + #invokeMethod, + [ + method, + arguments, + ], + ), + returnValue: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future?> invokeListMethod( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod( + Invocation.method( + #invokeListMethod, + [ + method, + arguments, + ], + ), + returnValue: _i6.Future?>.value(), + ) as _i6.Future?>); + + @override + _i6.Future?> invokeMapMethod( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod( + Invocation.method( + #invokeMapMethod, + [ + method, + arguments, + ], + ), + returnValue: _i6.Future?>.value(), + ) as _i6.Future?>); + + @override + void setMethodCallHandler( + _i6.Future Function(_i2.MethodCall)? handler) => + super.noSuchMethod( + Invocation.method( + #setMethodCallHandler, + [handler], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/src/layer_properties_test.dart b/test/src/layer_properties_test.dart new file mode 100644 index 0000000..5a21754 --- /dev/null +++ b/test/src/layer_properties_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('SymbolLayerProperties', () { + test('SymbolLayerProperties toJson', () { + final properties = SymbolLayerProperties( + visibility: 'visible', + ); + + final json = properties.toJson(); + + expect(json['visibility'], 'visible'); + }); + + test('SymbolLayerProperties fromJson', () { + final json = { + 'visibility': 'visible', + }; + + final properties = SymbolLayerProperties.fromJson(json); + + expect(properties.visibility, 'visible'); + }); + + test('SymbolLayerProperties copyWith', () { + SymbolLayerProperties properties = SymbolLayerProperties( + visibility: 'visible', + ); + expect(properties.visibility, 'visible'); + + final json = { + 'visibility': 'none', + }; + + final newProperties = SymbolLayerProperties.fromJson(json); + properties = properties.copyWith(newProperties); + expect(properties.visibility, 'none'); + }); + }); + group('CircleLayerProperties', () { + test('CircleLayerProperties toJson', () { + final properties = CircleLayerProperties( + visibility: 'visible', + ); + + final json = properties.toJson(); + + expect(json['visibility'], 'visible'); + }); + + test('CircleLayerProperties fromJson', () { + final json = { + 'visibility': 'visible', + }; + + final properties = CircleLayerProperties.fromJson(json); + + expect(properties.visibility, 'visible'); + }); + + test('CircleLayerProperties copyWith', () { + CircleLayerProperties properties = CircleLayerProperties( + visibility: 'visible', + ); + expect(properties.visibility, 'visible'); + + final json = { + 'visibility': 'none', + }; + + final newProperties = CircleLayerProperties.fromJson(json); + properties = properties.copyWith(newProperties); + expect(properties.visibility, 'none'); + }); + }); + group('FillLayerProperties', () { + test('FillLayerProperties toJson', () { + final properties = FillLayerProperties( + visibility: 'visible', + ); + + final json = properties.toJson(); + + expect(json['visibility'], 'visible'); + }); + + test('FillLayerProperties fromJson', () { + final json = { + 'visibility': 'visible', + }; + + final properties = FillLayerProperties.fromJson(json); + + expect(properties.visibility, 'visible'); + }); + + test('FillLayerProperties copyWith', () { + FillLayerProperties properties = FillLayerProperties( + visibility: 'visible', + ); + expect(properties.visibility, 'visible'); + + final json = { + 'visibility': 'none', + }; + + final newProperties = FillLayerProperties.fromJson(json); + properties = properties.copyWith(newProperties); + expect(properties.visibility, 'none'); + }); + }); + group('LineLayerProperties', () { + test('LineLayerProperties toJson', () { + final properties = LineLayerProperties( + visibility: 'visible', + ); + + final json = properties.toJson(); + + expect(json['visibility'], 'visible'); + }); + + test('LineLayerProperties fromJson', () { + final json = { + 'visibility': 'visible', + }; + + final properties = LineLayerProperties.fromJson(json); + + expect(properties.visibility, 'visible'); + }); + + test('LineLayerProperties copyWith', () { + LineLayerProperties properties = LineLayerProperties( + visibility: 'visible', + ); + expect(properties.visibility, 'visible'); + + final json = { + 'visibility': 'none', + }; + + final newProperties = LineLayerProperties.fromJson(json); + properties = properties.copyWith(newProperties); + expect(properties.visibility, 'none'); + }); + }); + group('RasterLayerProperties', () { + test('RasterLayerProperties toJson', () { + final properties = RasterLayerProperties( + visibility: 'visible', + ); + + final json = properties.toJson(); + + expect(json['visibility'], 'visible'); + }); + + test('RasterLayerProperties fromJson', () { + final json = { + 'visibility': 'visible', + }; + + final properties = RasterLayerProperties.fromJson(json); + + expect(properties.visibility, 'visible'); + }); + + test('RasterLayerProperties copyWith', () { + RasterLayerProperties properties = RasterLayerProperties( + visibility: 'visible', + ); + expect(properties.visibility, 'visible'); + + final json = { + 'visibility': 'none', + }; + + final newProperties = RasterLayerProperties.fromJson(json); + properties = properties.copyWith(newProperties); + expect(properties.visibility, 'none'); + }); + }); +} diff --git a/test/src/nb_map_test.dart b/test/src/nb_map_test.dart new file mode 100644 index 0000000..3862fe0 --- /dev/null +++ b/test/src/nb_map_test.dart @@ -0,0 +1,92 @@ +import 'dart:math'; + +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('_NextBillionMapOptions', () { + test('toMap should convert options to a map correctly', () { + // Arrange + final options = NextBillionMapOptions( + compassEnabled: true, + cameraTargetBounds: CameraTargetBounds( + LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ), + ), + styleString: 'https://example.com/mapstyle', + minMaxZoomPreference: MinMaxZoomPreference(10.0, 15.0), + rotateGesturesEnabled: true, + scrollGesturesEnabled: true, + tiltGesturesEnabled: true, + zoomGesturesEnabled: true, + doubleClickZoomEnabled: true, + trackCameraPosition: true, + myLocationEnabled: true, + myLocationTrackingMode: MyLocationTrackingMode.None, + myLocationRenderMode: MyLocationRenderMode.NORMAL, + logoViewMargins: Point(10, 10), + compassViewPosition: CompassViewPosition.BottomLeft, + compassViewMargins: Point(5, 5), + attributionButtonPosition: AttributionButtonPosition.BottomRight, + attributionButtonMargins: Point(5, 5), + ); + + // Act + final result = options.toMap(); + + // Assert + expect(result['compassEnabled'], equals(true)); + expect(result['cameraTargetBounds'], isNotNull); + expect(result['styleString'], equals('https://example.com/mapstyle')); + expect(result['minMaxZoomPreference'], isNotNull); + expect(result['rotateGesturesEnabled'], equals(true)); + expect(result['scrollGesturesEnabled'], equals(true)); + expect(result['tiltGesturesEnabled'], equals(true)); + expect(result['zoomGesturesEnabled'], equals(true)); + expect(result['doubleClickZoomEnabled'], equals(true)); + expect(result['trackCameraPosition'], equals(true)); + expect(result['myLocationEnabled'], equals(true)); + expect(result['myLocationTrackingMode'], + equals(MyLocationTrackingMode.None.index)); + expect(result['myLocationRenderMode'], + equals(MyLocationRenderMode.NORMAL.index)); + expect(result['logoViewMargins'], equals([10, 10])); + expect(result['compassViewPosition'], + equals(CompassViewPosition.BottomLeft.index)); + expect(result['compassViewMargins'], equals([5, 5])); + expect(result['attributionButtonPosition'], + equals(AttributionButtonPosition.BottomRight.index)); + expect(result['attributionButtonMargins'], equals([5, 5])); + }); + + test('updatesMap should return the updated options as a map', () { + // Arrange + final prevOptions = NextBillionMapOptions( + compassEnabled: true, + rotateGesturesEnabled: true, + scrollGesturesEnabled: true, + tiltGesturesEnabled: true, + zoomGesturesEnabled: true, + doubleClickZoomEnabled: true, + ); + final newOptions = NextBillionMapOptions( + compassEnabled: false, + rotateGesturesEnabled: true, + scrollGesturesEnabled: false, + tiltGesturesEnabled: true, + zoomGesturesEnabled: false, + doubleClickZoomEnabled: true, + ); + + // Act + final result = prevOptions.updatesMap(newOptions); + + // Assert + expect(result['compassEnabled'], equals(false)); + expect(result['scrollGesturesEnabled'], equals(false)); + expect(result['zoomGesturesEnabled'], equals(false)); + }); + }); +} diff --git a/test/src/offline_region_test.dart b/test/src/offline_region_test.dart new file mode 100644 index 0000000..9cf3e57 --- /dev/null +++ b/test/src/offline_region_test.dart @@ -0,0 +1,105 @@ +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:test/test.dart'; + +void main() { + test('OfflineRegionDefinition should convert to map correctly', () { + // Arrange + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + String mapStyleUrl = "https://example.com/mapstyle"; + double minZoom = 10.0; + double maxZoom = 15.0; + bool includeIdeographs = true; + OfflineRegionDefinition definition = OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: mapStyleUrl, + minZoom: minZoom, + maxZoom: maxZoom, + includeIdeographs: includeIdeographs, + ); + + // Act + Map result = definition.toMap(); + + // Assert + expect(result['bounds'], equals(bounds.toList())); + expect(result['mapStyleUrl'], equals(mapStyleUrl)); + expect(result['minZoom'], equals(minZoom)); + expect(result['maxZoom'], equals(maxZoom)); + expect(result['includeIdeographs'], equals(includeIdeographs)); + }); + + test('OfflineRegionDefinition should convert from map correctly', () { + // Arrange + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + String mapStyleUrl = "https://example.com/mapstyle"; + double minZoom = 10.0; + double maxZoom = 15.0; + bool includeIdeographs = true; + Map map = { + 'bounds': bounds.toList(), + 'mapStyleUrl': mapStyleUrl, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + 'includeIdeographs': includeIdeographs, + }; + + // Act + OfflineRegionDefinition result = OfflineRegionDefinition.fromMap(map); + + // Assert + expect(result.bounds, equals(bounds)); + expect(result.mapStyleUrl, equals(mapStyleUrl)); + expect(result.minZoom, equals(minZoom)); + expect(result.maxZoom, equals(maxZoom)); + expect(result.includeIdeographs, equals(includeIdeographs)); + }); + + test('OfflineRegion should convert from map correctly', () { + // Arrange + int id = 123; + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + String mapStyleUrl = "https://example.com/mapstyle"; + double minZoom = 10.0; + double maxZoom = 15.0; + bool includeIdeographs = true; + Map definitionMap = { + 'bounds': bounds.toList(), + 'mapStyleUrl': mapStyleUrl, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + 'includeIdeographs': includeIdeographs, + }; + Map metadata = {"key": "value"}; + Map map = { + 'id': id, + 'definition': definitionMap, + 'metadata': metadata, + }; + + // Act + OfflineRegion result = OfflineRegion.fromMap(map); + + // Assert + expect(result.id, equals(id)); + expect(result.definition.bounds, equals(bounds)); + expect(result.definition.mapStyleUrl, equals(mapStyleUrl)); + expect(result.definition.minZoom, equals(minZoom)); + expect(result.definition.maxZoom, equals(maxZoom)); + expect(result.definition.includeIdeographs, equals(includeIdeographs)); + expect(result.metadata, equals(metadata)); + + expect( + result.toString(), + 'OfflineRegion, id = 123, definition = ${result.definition}, metadata = ${result.metadata}', + ); + }); +} diff --git a/test/src/platform_interface/camera_test.dart b/test/src/platform_interface/camera_test.dart new file mode 100644 index 0000000..94d16d1 --- /dev/null +++ b/test/src/platform_interface/camera_test.dart @@ -0,0 +1,262 @@ +import 'dart:ui'; + +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('CameraPosition', () { + test('toMap should convert CameraPosition to a map correctly', () { + // Arrange + final cameraPosition = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + + // Act + final result = cameraPosition.toMap(); + + // Assert + expect(result['bearing'], equals(90.0)); + expect(result['target'], equals(cameraPosition.target.toJson())); + expect(result['tilt'], equals(45.0)); + expect(result['zoom'], equals(10.0)); + }); + + test('fromMap should convert a map to CameraPosition correctly', () { + // Arrange + final json = { + 'bearing': 90.0, + 'target': LatLng(37.7749, -122.4194).toJson(), + 'tilt': 45.0, + 'zoom': 10.0, + }; + + // Act + final result = CameraPosition.fromMap(json); + + // Assert + expect(result?.bearing, equals(90.0)); + expect(result?.target, equals(LatLng(37.7749, -122.4194))); + expect(result?.tilt, equals(45.0)); + expect(result?.zoom, equals(10.0)); + }); + + test('CameraPosition instances with the same values should be equal', () { + // Arrange + final cameraPosition1 = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + final cameraPosition2 = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + + // Act + final result = cameraPosition1 == cameraPosition2; + + // Assert + expect(result, isTrue); + }); + + test( + 'hashCode should return the same value for equal CameraPosition instances', + () { + // Arrange + final cameraPosition1 = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + final cameraPosition2 = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + + // Act + final hashCode1 = cameraPosition1.hashCode; + final hashCode2 = cameraPosition2.hashCode; + + // Assert + expect(hashCode1, equals(hashCode2)); + }); + + test('toString should return a string representation of CameraPosition', + () { + // Arrange + final cameraPosition = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + + // Act + final result = cameraPosition.toString(); + + // Assert + expect( + result, + equals( + 'CameraPosition(bearing: 90.0, target: LatLng(37.7749, -122.4194), tilt: 45.0, zoom: 10.0)'), + ); + }); + }); + + group('CameraUpdate', () { + test( + 'newCameraPosition should create a CameraUpdate with newCameraPosition action', + () { + // Arrange + final cameraPosition = CameraPosition( + bearing: 90.0, + target: LatLng(37.7749, -122.4194), + tilt: 45.0, + zoom: 10.0, + ); + + // Act + final result = CameraUpdate.newCameraPosition(cameraPosition); + + // Assert + expect(result.toJson(), + equals(['newCameraPosition', cameraPosition.toMap()])); + }); + + test('newLatLng should create a CameraUpdate with newLatLng action', () { + // Arrange + final latLng = LatLng(37.7749, -122.4194); + + // Act + final result = CameraUpdate.newLatLng(latLng); + + // Assert + expect(result.toJson(), equals(['newLatLng', latLng.toJson()])); + }); + + test( + 'newLatLngBounds should create a CameraUpdate with newLatLngBounds action', + () { + // Arrange + final bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + final left = 10.0; + final top = 20.0; + final right = 30.0; + final bottom = 40.0; + + // Act + final result = CameraUpdate.newLatLngBounds(bounds, + left: left, top: top, right: right, bottom: bottom); + + // Assert + expect( + result.toJson(), + equals( + ['newLatLngBounds', bounds.toList(), left, top, right, bottom])); + }); + + test('newLatLngZoom should create a CameraUpdate with newLatLngZoom action', + () { + // Arrange + final latLng = LatLng(37.7749, -122.4194); + final zoom = 10.0; + + // Act + final result = CameraUpdate.newLatLngZoom(latLng, zoom); + + // Assert + expect(result.toJson(), equals(['newLatLngZoom', latLng.toJson(), zoom])); + }); + + test('scrollBy should create a CameraUpdate with scrollBy action', () { + // Arrange + final dx = 50.0; + final dy = 75.0; + + // Act + final result = CameraUpdate.scrollBy(dx, dy); + + // Assert + expect(result.toJson(), equals(['scrollBy', dx, dy])); + }); + + test('zoomBy should create a CameraUpdate with zoomBy action', () { + // Arrange + final amount = 2.0; + final focus = Offset(100.0, 200.0); + + // Act + final result = CameraUpdate.zoomBy(amount, focus); + + // Assert + expect( + result.toJson(), + equals([ + 'zoomBy', + amount, + [focus.dx, focus.dy] + ])); + }); + + test('zoomIn should create a CameraUpdate with zoomIn action', () { + // Act + final result = CameraUpdate.zoomIn(); + + // Assert + expect(result.toJson(), equals(['zoomIn'])); + }); + + test('zoomOut should create a CameraUpdate with zoomOut action', () { + // Act + final result = CameraUpdate.zoomOut(); + + // Assert + expect(result.toJson(), equals(['zoomOut'])); + }); + + test('zoomTo should create a CameraUpdate with zoomTo action', () { + // Arrange + final zoom = 10.0; + + // Act + final result = CameraUpdate.zoomTo(zoom); + + // Assert + expect(result.toJson(), equals(['zoomTo', zoom])); + }); + + test('bearingTo should create a CameraUpdate with bearingTo action', () { + // Arrange + final bearing = 90.0; + + // Act + final result = CameraUpdate.bearingTo(bearing); + + // Assert + expect(result.toJson(), equals(['bearingTo', bearing])); + }); + + test('tiltTo should create a CameraUpdate with tiltTo action', () { + // Arrange + final tilt = 45.0; + + // Act + final result = CameraUpdate.tiltTo(tilt); + + // Assert + expect(result.toJson(), equals(['tiltTo', tilt])); + }); + }); +} diff --git a/test/src/platform_interface/circle_test.dart b/test/src/platform_interface/circle_test.dart new file mode 100644 index 0000000..a7eb365 --- /dev/null +++ b/test/src/platform_interface/circle_test.dart @@ -0,0 +1,109 @@ +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:test/test.dart'; + +void main() { + test('Circle toGeoJson should return a valid GeoJSON representation', () { + // Arrange + CircleOptions options = CircleOptions( + circleRadius: 10.0, + circleColor: "#FF0000", + circleBlur: 0.5, + circleOpacity: 0.8, + circleStrokeWidth: 2.0, + circleStrokeColor: "#000000", + circleStrokeOpacity: 1.0, + geometry: LatLng(37.7749, -122.4194), + draggable: true, + ); + Circle circle = Circle("circle1", options); + + // Act + Map result = circle.toGeoJson(); + + // Assert + expect(circle.data, isNull); + expect(result["type"], equals("Feature")); + expect(result["properties"]["circleRadius"], equals(10.0)); + expect(result["properties"]["circleColor"], equals("#FF0000")); + expect(result["properties"]["circleBlur"], equals(0.5)); + expect(result["properties"]["circleOpacity"], equals(0.8)); + expect(result["properties"]["circleStrokeWidth"], equals(2.0)); + expect(result["properties"]["circleStrokeColor"], equals("#000000")); + expect(result["properties"]["circleStrokeOpacity"], equals(1.0)); + expect(result["geometry"]["type"], equals("Point")); + //toGeoJsonCoordinates: longitute comes first + expect(result["geometry"]["coordinates"], equals([-122.4194, 37.7749])); + }); + + test( + 'test CircleOptions.toJson(addGeometry = true) should contain geometry in the json', + () { + LatLng geometry = LatLng(37.7749, -122.4194); + CircleOptions options = CircleOptions( + circleRadius: 10.0, + circleColor: "#FF0000", + circleBlur: 0.5, + circleOpacity: 0.8, + circleStrokeWidth: 2.0, + circleStrokeColor: "#000000", + circleStrokeOpacity: 1.0, + geometry: geometry, + draggable: true, + ); + + Map result = options.toJson(true); + expect(result["circleRadius"], equals(10.0)); + expect(result["circleColor"], equals("#FF0000")); + expect(result["circleBlur"], equals(0.5)); + expect(result["circleOpacity"], equals(0.8)); + expect(result["circleStrokeWidth"], equals(2.0)); + expect(result["circleStrokeColor"], equals("#000000")); + expect(result["circleStrokeOpacity"], equals(1.0)); + expect(result["geometry"], equals(geometry.toJson())); + }); + + test( + 'test CircleOptions.toJson(addGeometry = false) should not contain geometry in the json', + () { + LatLng geometry = LatLng(37.7749, -122.4194); + CircleOptions options = CircleOptions( + circleRadius: 10.0, + circleColor: "#FF0000", + circleBlur: 0.5, + circleOpacity: 0.8, + circleStrokeWidth: 2.0, + circleStrokeColor: "#000000", + circleStrokeOpacity: 1.0, + geometry: geometry, + draggable: true, + ); + + Map result = options.toJson(false); + expect(result["circleRadius"], equals(10.0)); + expect(result["circleColor"], equals("#FF0000")); + expect(result["circleBlur"], equals(0.5)); + expect(result["circleOpacity"], equals(0.8)); + expect(result["circleStrokeWidth"], equals(2.0)); + expect(result["circleStrokeColor"], equals("#000000")); + expect(result["circleStrokeOpacity"], equals(1.0)); + expect(result.containsKey("geometry"), equals(false)); + }); + + test('Circle translate should update the circle geometry', () { + // Arrange + CircleOptions options = CircleOptions( + geometry: LatLng(37.7749, -122.4194), + ); + Circle circle = Circle("circle1", options); + LatLng delta = LatLng(0.1, 0.2); + + // Act + circle.translate(delta); + + // Assert + // due to the inherent imprecision of floating point arithmetic, we use closeTo instead of equals. + // expect(circle.options.geometry, equals(LatLng(37.8749, -122.2194))); + expect(circle.options.geometry!.latitude, closeTo(37.8749, 0.0001)); + expect(circle.options.geometry!.longitude, closeTo(-122.2194, 0.0001)); + }); +} diff --git a/test/src/platform_interface/fill_test.dart b/test/src/platform_interface/fill_test.dart new file mode 100644 index 0000000..546633f --- /dev/null +++ b/test/src/platform_interface/fill_test.dart @@ -0,0 +1,350 @@ +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('translateFillOptions', () { + test('should translate fill options correctly', () { + // Arrange + FillOptions options = FillOptions( + fillOpacity: 0.5, + fillColor: '#FF0000', + fillOutlineColor: '#000000', + fillPattern: 'pattern', + geometry: [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ], + draggable: true, + ); + LatLng delta = LatLng(1.0, 1.0); + + // Act + FillOptions result = translateFillOptions(options, delta); + + // Assert + expect(result.fillOpacity, equals(options.fillOpacity)); + expect(result.fillColor, equals(options.fillColor)); + expect(result.fillOutlineColor, equals(options.fillOutlineColor)); + expect(result.fillPattern, equals(options.fillPattern)); + expect(result.geometry, isNotNull); + expect(result.geometry!.length, equals(options.geometry!.length)); + expect(result.geometry![0].length, equals(options.geometry![0].length)); + expect(result.geometry![0][0].latitude, + equals(options.geometry![0][0].latitude + delta.latitude)); + expect(result.geometry![0][0].longitude, + equals(options.geometry![0][0].longitude + delta.longitude)); + expect(result.geometry![0][1].latitude, + equals(options.geometry![0][1].latitude + delta.latitude)); + expect(result.geometry![0][1].longitude, + equals(options.geometry![0][1].longitude + delta.longitude)); + expect(result.draggable, equals(options.draggable)); + }); + + test('should return options as is if geometry is null', () { + // Arrange + FillOptions options = FillOptions(); + + // Act + FillOptions result = translateFillOptions(options, LatLng(1.0, 1.0)); + + // Assert + expect(result, equals(options)); + }); + }); + + group('Fill', () { + test('should create Fill instance correctly', () { + // Arrange + String id = 'fill_1'; + FillOptions options = FillOptions( + fillOpacity: 0.5, + fillColor: '#FF0000', + fillOutlineColor: '#000000', + fillPattern: 'pattern', + geometry: [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ], + draggable: true, + ); + Map? data = {'key': 'value'}; + + // Act + Fill fill = Fill(id, options, data); + + // Assert + expect(fill.id, equals(id)); + expect(fill.options, equals(options)); + expect(fill.data, equals(data)); + }); + + test('should translate fill options correctly', () { + // Arrange + String id = 'fill_1'; + FillOptions options = FillOptions( + fillOpacity: 0.5, + fillColor: '#FF0000', + fillOutlineColor: '#000000', + fillPattern: 'pattern', + geometry: [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ], + draggable: true, + ); + Map? data = {'key': 'value'}; + Fill fill = Fill(id, options, data); + LatLng delta = LatLng(1.0, 1.0); + + // Act + fill.translate(delta); + + // Assert + expect(fill.options.fillOpacity, equals(options.fillOpacity)); + expect(fill.options.fillColor, equals(options.fillColor)); + expect(fill.options.fillOutlineColor, equals(options.fillOutlineColor)); + expect(fill.options.fillPattern, equals(options.fillPattern)); + expect(fill.options.geometry, isNotNull); + expect(fill.options.geometry!.length, equals(options.geometry!.length)); + expect(fill.options.geometry![0].length, + equals(options.geometry![0].length)); + expect(fill.options.geometry![0][0].latitude, + equals(options.geometry![0][0].latitude + delta.latitude)); + expect(fill.options.geometry![0][0].longitude, + equals(options.geometry![0][0].longitude + delta.longitude)); + expect(fill.options.geometry![0][1].latitude, + equals(options.geometry![0][1].latitude + delta.latitude)); + expect(fill.options.geometry![0][1].longitude, + equals(options.geometry![0][1].longitude + delta.longitude)); + expect(fill.options.draggable, equals(options.draggable)); + }); + + test('should convert to GeoJSON correctly', () { + // Arrange + String id = 'fill_1'; + FillOptions options = FillOptions( + fillOpacity: 0.5, + fillColor: '#FF0000', + fillOutlineColor: '#000000', + fillPattern: 'pattern', + geometry: [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ], + draggable: true, + ); + Fill fill = Fill(id, options); + + // Act + Map result = fill.toGeoJson(); + + // Assert + expect(result['type'], equals('Feature')); + expect(result['properties']['id'], equals(id)); + expect(result['geometry']['type'], equals('Polygon')); + expect(result['geometry']['coordinates'], isNotNull); + expect(result['geometry']['coordinates'].length, + equals(options.geometry!.length)); + expect(result['geometry']['coordinates'][0].length, + equals(options.geometry![0].length)); + expect(result['geometry']['coordinates'][0][0][0], + equals(options.geometry![0][0].longitude)); + expect(result['geometry']['coordinates'][0][0][1], + equals(options.geometry![0][0].latitude)); + expect(result['geometry']['coordinates'][0][1][0], + equals(options.geometry![0][1].longitude)); + expect(result['geometry']['coordinates'][0][1][1], + equals(options.geometry![0][1].latitude)); + }); + }); + + group('FillOptions', () { + test('should create FillOptions instance correctly', () { + // Arrange + double fillOpacity = 0.5; + String fillColor = '#FF0000'; + String fillOutlineColor = '#000000'; + String fillPattern = 'pattern'; + List> geometry = [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ]; + bool draggable = true; + + // Act + FillOptions options = FillOptions( + fillOpacity: fillOpacity, + fillColor: fillColor, + fillOutlineColor: fillOutlineColor, + fillPattern: fillPattern, + geometry: geometry, + draggable: draggable, + ); + + // Assert + expect(options.fillOpacity, equals(fillOpacity)); + expect(options.fillColor, equals(fillColor)); + expect(options.fillOutlineColor, equals(fillOutlineColor)); + expect(options.fillPattern, equals(fillPattern)); + expect(options.geometry, equals(geometry)); + expect(options.draggable, equals(draggable)); + }); + + test('should create default FillOptions instance correctly', () { + // Arrange + + // Act + FillOptions options = FillOptions.defaultOptions; + + // Assert + expect(options.fillOpacity, isNull); + expect(options.fillColor, isNull); + expect(options.fillOutlineColor, isNull); + expect(options.fillPattern, isNull); + expect(options.geometry, isNull); + expect(options.draggable, isNull); + }); + + test('should copy FillOptions correctly', () { + // Arrange + FillOptions options = FillOptions( + fillOpacity: 0.5, + fillColor: '#FF0000', + fillOutlineColor: '#000000', + fillPattern: 'pattern', + geometry: [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ], + draggable: true, + ); + FillOptions changes = FillOptions( + fillOpacity: 0.8, + fillColor: '#00FF00', + fillOutlineColor: '#FFFFFF', + fillPattern: 'newPattern', + geometry: [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ], + draggable: false, + ); + + // Act + FillOptions result = options.copyWith(changes); + + // Assert + expect(result.fillOpacity, equals(changes.fillOpacity)); + expect(result.fillColor, equals(changes.fillColor)); + expect(result.fillOutlineColor, equals(changes.fillOutlineColor)); + expect(result.fillPattern, equals(changes.fillPattern)); + expect(result.geometry, equals(changes.geometry)); + expect(result.draggable, equals(changes.draggable)); + }); + + test('should convert to JSON correctly', () { + // Arrange + double fillOpacity = 0.5; + String fillColor = '#FF0000'; + String fillOutlineColor = '#000000'; + String fillPattern = 'pattern'; + List> geometry = [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ]; + bool draggable = true; + FillOptions options = FillOptions( + fillOpacity: fillOpacity, + fillColor: fillColor, + fillOutlineColor: fillOutlineColor, + fillPattern: fillPattern, + geometry: geometry, + draggable: draggable, + ); + + // Act + dynamic result = options.toJson(); + + // Assert + expect(result['fillOpacity'], equals(fillOpacity)); + expect(result['fillColor'], equals(fillColor)); + expect(result['fillOutlineColor'], equals(fillOutlineColor)); + expect(result['fillPattern'], equals(fillPattern)); + expect(result['geometry'], isNotNull); + expect(result['geometry'].length, equals(geometry.length)); + expect(result['geometry'][0].length, equals(geometry[0].length)); + expect(result['geometry'][0][0][0], equals(geometry[0][0].latitude)); + expect(result['geometry'][0][0][1], equals(geometry[0][0].longitude)); + expect(result['geometry'][0][1][0], equals(geometry[0][1].latitude)); + expect(result['geometry'][0][1][1], equals(geometry[0][1].longitude)); + expect(result['draggable'], equals(draggable)); + }); + + test('should convert to GeoJSON correctly', () { + // Arrange + double fillOpacity = 0.5; + String fillColor = '#FF0000'; + String fillOutlineColor = '#000000'; + String fillPattern = 'pattern'; + List> geometry = [ + [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ]; + FillOptions options = FillOptions( + fillOpacity: fillOpacity, + fillColor: fillColor, + fillOutlineColor: fillOutlineColor, + fillPattern: fillPattern, + geometry: geometry, + ); + + // Act + Map result = options.toGeoJson(); + + // Assert + expect(result['type'], equals('Feature')); + expect(result['properties'], isNotNull); + expect(result['properties']['fillOpacity'], equals(fillOpacity)); + expect(result['properties']['fillColor'], equals(fillColor)); + expect( + result['properties']['fillOutlineColor'], equals(fillOutlineColor)); + expect(result['properties']['fillPattern'], equals(fillPattern)); + expect(result['geometry'], isNotNull); + expect(result['geometry']['type'], equals('Polygon')); + expect(result['geometry']['coordinates'], isNotNull); + expect(result['geometry']['coordinates'].length, equals(geometry.length)); + expect(result['geometry']['coordinates'][0].length, + equals(geometry[0].length)); + expect(result['geometry']['coordinates'][0][0][0], + equals(geometry[0][0].longitude)); + expect(result['geometry']['coordinates'][0][0][1], + equals(geometry[0][0].latitude)); + expect(result['geometry']['coordinates'][0][1][0], + equals(geometry[0][1].longitude)); + expect(result['geometry']['coordinates'][0][1][1], + equals(geometry[0][1].latitude)); + }); + }); +} diff --git a/test/src/platform_interface/line_test.dart b/test/src/platform_interface/line_test.dart new file mode 100644 index 0000000..21f97d5 --- /dev/null +++ b/test/src/platform_interface/line_test.dart @@ -0,0 +1,114 @@ +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('Line', () { + test('toGeoJson should return the correct GeoJSON representation', () { + // Arrange + final lineOptions = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FF0000', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + final line = Line('line1', lineOptions); + + // Act + final geoJson = line.toGeoJson(); + + // Assert + expect(line.data, isNull); + expect(geoJson['type'], equals('Feature')); + expect(geoJson['properties']['lineJoin'], equals('round')); + expect(geoJson['properties']['lineOpacity'], equals(0.8)); + expect(geoJson['properties']['lineColor'], equals('#FF0000')); + expect(geoJson['properties']['lineWidth'], equals(2.0)); + expect(geoJson['properties']['lineGapWidth'], equals(0.0)); + expect(geoJson['properties']['lineOffset'], equals(0.0)); + expect(geoJson['properties']['lineBlur'], equals(0.0)); + expect(geoJson['properties']['linePattern'], equals('solid')); + expect(geoJson['properties']['draggable'], equals(true)); + expect(geoJson['geometry']['type'], equals('LineString')); + expect( + geoJson['geometry']['coordinates'], + equals([ + [-122.4194, 37.7749], + [-122.3927, 37.8095], + ])); + }); + + test('translate should update the line geometry correctly', () { + // Arrange + final lineOptions = LineOptions( + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + ); + final line = Line('line1', lineOptions); + + // Act + line.translate(LatLng(0.1, 0.1)); + + // Assert + + expect(line.options.geometry![0].latitude, closeTo(37.8749, 0.0001)); + expect(line.options.geometry![0].longitude, closeTo(-122.3194, 0.0001)); + expect(line.options.geometry![1].latitude, closeTo(37.9095, 0.0001)); + expect(line.options.geometry![1].longitude, closeTo(-122.2927, 0.0001)); + }); + + test( + 'LineOption.toJson should contain geometry by default(addGeometry == true)', + () { + final lineOptions = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FF0000', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + + final json = lineOptions.toJson(); + expect(json.containsKey('geometry'), true); + }); + + test('LineOption.toJson should not contain geometry if addGeometry != true', + () { + final lineOptions = LineOptions( + lineJoin: 'round', + lineOpacity: 0.8, + lineColor: '#FF0000', + lineWidth: 2.0, + lineGapWidth: 0.0, + lineOffset: 0.0, + lineBlur: 0.0, + linePattern: 'solid', + geometry: [ + LatLng(37.7749, -122.4194), + LatLng(37.8095, -122.3927), + ], + draggable: true, + ); + final json = lineOptions.toJson(false); + expect(json.containsKey('geometry'), false); + }); + }); +} diff --git a/test/src/platform_interface/location_test.dart b/test/src/platform_interface/location_test.dart new file mode 100644 index 0000000..f7380b3 --- /dev/null +++ b/test/src/platform_interface/location_test.dart @@ -0,0 +1,32 @@ +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:test/test.dart'; + +void main() { + test('test fromList', () { + List list = [ + [1.0, 2.0], + [3.0, 4.0], + [5.0, 6.0], + [7.0, 8.0] + ]; + final location = LatLngQuad.fromList(list); + expect(location, isNotNull); + expect(location?.topLeft, equals(LatLng(list[0][0], list[0][1]))); + expect(location?.topRight, equals(LatLng(list[1][0], list[1][1]))); + expect(location?.bottomLeft, equals(LatLng(list[3][0], list[3][1]))); + expect(location?.bottomRight, equals(LatLng(list[2][0], list[2][1]))); + }); + + test('test fromMultiLatLng', () { + List list = [ + LatLng(1.0, 2.0), + LatLng(3.0, 4.0), + LatLng(5.0, 6.0), + LatLng(7.0, 8.0) + ]; + final location = LatLngBounds.fromMultiLatLng(list); + expect(location, isNotNull); + expect(location.southwest, equals(list[0])); + expect(location.northeast, equals(list[3])); + }); +} diff --git a/test/src/platform_interface/method_channel_nbmaps_test.dart b/test/src/platform_interface/method_channel_nbmaps_test.dart new file mode 100644 index 0000000..08581b6 --- /dev/null +++ b/test/src/platform_interface/method_channel_nbmaps_test.dart @@ -0,0 +1,1202 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'nextbillion_test.mocks.dart'; + +// @GenerateMocks([MethodChannel]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MethodChannelNbMapsGl nbMapsGlChannel; + late MockMethodChannel channel; + + setUp(() async { + // Create a mock channel + channel = MockMethodChannel(); + // Set the mock channel to the platform channel + nbMapsGlChannel = MethodChannelNbMapsGl(); + nbMapsGlChannel.setTestingMethodChanenl(channel); + }); + + test('verify updateMapOptions', () async { + final options = NextBillionMapOptions( + compassEnabled: true, + cameraTargetBounds: CameraTargetBounds( + LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ), + ), + styleString: 'https://example.com/mapstyle', + minMaxZoomPreference: MinMaxZoomPreference(10.0, 15.0), + rotateGesturesEnabled: true, + scrollGesturesEnabled: true, + tiltGesturesEnabled: true, + zoomGesturesEnabled: true, + doubleClickZoomEnabled: true, + trackCameraPosition: true, + myLocationEnabled: true, + myLocationTrackingMode: MyLocationTrackingMode.None, + myLocationRenderMode: MyLocationRenderMode.NORMAL, + logoViewMargins: Point(10, 10), + compassViewPosition: CompassViewPosition.BottomLeft, + compassViewMargins: Point(5, 5), + attributionButtonPosition: AttributionButtonPosition.BottomRight, + attributionButtonMargins: Point(5, 5), + ); + + final expectedCameraPosition = CameraPosition( + target: LatLng(37.7749, -122.4194), + zoom: 15.0, + bearing: 2.0, + tilt: 3.0, + ); + + when(channel.invokeMethod>('map#update', any)) + .thenAnswer((_) async => expectedCameraPosition.toMap()); + + final result = await nbMapsGlChannel.updateMapOptions(options.toMap()); + expect(result, equals(expectedCameraPosition)); + }); + + test('animateCamera', () async { + final cameraUpdate = CameraUpdate.newCameraPosition( + CameraPosition( + target: LatLng(37.7749, -122.4194), + zoom: 15.0, + bearing: 2.0, + tilt: 3.0, + ), + ); + + when(channel.invokeMethod('camera#animate', any)) + .thenAnswer((_) async => true); + + await nbMapsGlChannel.animateCamera(cameraUpdate); + + Map args = { + 'cameraUpdate': cameraUpdate.toJson(), + 'duration': null, + }; + + verify(channel.invokeMethod('camera#animate', args)).called(1); + }); + + test('test moveCamera', () async { + final cameraUpdate = CameraUpdate.newCameraPosition( + CameraPosition( + target: LatLng(37.7749, -122.4194), + zoom: 15.0, + bearing: 2.0, + tilt: 3.0, + ), + ); + + when(channel.invokeMethod('camera#move', any)) + .thenAnswer((_) async => true); + + await nbMapsGlChannel.moveCamera(cameraUpdate); + + Map args = { + 'cameraUpdate': cameraUpdate.toJson(), + }; + + verify(channel.invokeMethod('camera#move', args)).called(1); + }); + + test('updateMyLocationTrackingMode', () async { + final myLocationTrackingMode = MyLocationTrackingMode.None; + + when(channel.invokeMethod('map#updateMyLocationTrackingMode', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.updateMyLocationTrackingMode(myLocationTrackingMode); + + Map args = { + 'mode': myLocationTrackingMode.index, + }; + + verify(channel.invokeMethod('map#updateMyLocationTrackingMode', args)) + .called(1); + }); + + test('matchMapLanguageWithDeviceDefault', () async { + when(channel.invokeMethod('map#matchMapLanguageWithDeviceDefault')) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.matchMapLanguageWithDeviceDefault(); + + verify(channel.invokeMethod('map#matchMapLanguageWithDeviceDefault')) + .called(1); + }); + + test('updateContentInsets', () async { + final insets = EdgeInsets.only( + left: 10.0, + top: 10.0, + right: 10.0, + bottom: 10.0, + ); + + when(channel.invokeMethod('map#updateContentInsets', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.updateContentInsets(insets, false); + + Map args = { + 'bounds': { + 'left': 10.0, + 'top': 10.0, + 'right': 10.0, + 'bottom': 10.0, + }, + 'animated': false, + }; + + verify(channel.invokeMethod('map#updateContentInsets', args)).called(1); + }); + + test('setMapLanguage', () async { + final language = 'en'; + + when(channel.invokeMethod('map#setMapLanguage', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setMapLanguage(language); + + Map args = { + 'language': language, + }; + + verify(channel.invokeMethod('map#setMapLanguage', args)).called(1); + }); + + test('setTelemetryEnabled', () async { + final enabled = true; + + when(channel.invokeMethod('map#setTelemetryEnabled', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setTelemetryEnabled(enabled); + + Map args = { + 'enabled': enabled, + }; + + verify(channel.invokeMethod('map#setTelemetryEnabled', args)).called(1); + }); + + test('getTelemetryEnabled', () async { + final enabled = true; + + when(channel.invokeMethod('map#getTelemetryEnabled')) + .thenAnswer((_) async => enabled); + + final result = await nbMapsGlChannel.getTelemetryEnabled(); + + expect(result, equals(enabled)); + }); + + test('queryRenderedFeatures', () async { + final point = Point(10.0, 10.0); + final layerIds = ['layerId']; + + when(channel.invokeMethod>( + 'map#queryRenderedFeatures', any)) + .thenAnswer((_) async => { + 'features': [], + }); + + await nbMapsGlChannel.queryRenderedFeatures(point, layerIds, null); + + Map args = { + 'x': point.x, + 'y': point.y, + 'layerIds': layerIds, + 'filter': null, + }; + + verify(channel.invokeMethod('map#queryRenderedFeatures', args)).called(1); + }); + + test('queryRenderedFeaturesInRect', () async { + final rect = Rect.fromLTWH(10.0, 10.0, 10.0, 10.0); + final layerIds = ['layerId']; + + when(channel.invokeMethod>( + 'map#queryRenderedFeatures', any)) + .thenAnswer((_) async => { + 'features': [], + }); + + await nbMapsGlChannel.queryRenderedFeaturesInRect(rect, layerIds, null); + + Map args = { + 'left': rect.left, + 'top': rect.top, + 'right': rect.right, + 'bottom': rect.bottom, + 'layerIds': layerIds, + 'filter': null, + }; + + verify(channel.invokeMethod('map#queryRenderedFeatures', args)).called(1); + }); + + test('invalidateAmbientCache', () async { + when(channel.invokeMethod('map#invalidateAmbientCache')) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.invalidateAmbientCache(); + + verify(channel.invokeMethod('map#invalidateAmbientCache')).called(1); + }); + + test('requestMyLocationLatLng', () async { + final latLng = LatLng(37.7749, -122.4194); + + when(channel.invokeMethod>( + 'locationComponent#getLastLocation')) + .thenAnswer((_) async => { + 'latitude': latLng.latitude, + 'longitude': latLng.longitude, + }); + + final result = await nbMapsGlChannel.requestMyLocationLatLng(); + + expect(result, equals(latLng)); + }); + + test('getVisibleRegion', () async { + final bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + + when(channel.invokeMethod>('map#getVisibleRegion')) + .thenAnswer((_) async => { + 'sw': bounds.southwest.toJson(), + 'ne': bounds.northeast.toJson(), + }); + + final result = await nbMapsGlChannel.getVisibleRegion(); + + expect(result, equals(bounds)); + }); + + test('addImage', () async { + final byteData = ByteData(1); + final name = 'name'; + final sdf = false; + final data = byteData.buffer.asUint8List(); + + when(channel.invokeMethod('style#addImage', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addImage(name, data, sdf); + + Map args = { + 'name': name, + 'bytes': data, + 'length': data.length, + 'sdf': sdf, + }; + + verify(channel.invokeMethod('style#addImage', args)).called(1); + }); + + test('test addImageSource', () async { + final sourceId = 'sourceId'; + Uint8List bytes = Uint8List(1); + LatLngQuad coordinates = LatLngQuad( + topLeft: LatLng(37.7749, -122.4194), + topRight: LatLng(37.7749, -122.3927), + bottomRight: LatLng(37.8095, -122.3927), + bottomLeft: LatLng(37.8095, -122.4194), + ); + + when(channel.invokeMethod('style#addImageSource', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addImageSource(sourceId, bytes, coordinates); + + Map args = { + 'imageSourceId': sourceId, + 'bytes': bytes, + 'length': bytes.length, + 'coordinates': coordinates.toList() + }; + + verify(channel.invokeMethod('style#addImageSource', args)).called(1); + }); + + test('test updateImageSource', () async { + final sourceId = 'sourceId'; + Uint8List bytes = Uint8List(1); + LatLngQuad coordinates = LatLngQuad( + topLeft: LatLng(37.7749, -122.4194), + topRight: LatLng(37.7749, -122.3927), + bottomRight: LatLng(37.8095, -122.3927), + bottomLeft: LatLng(37.8095, -122.4194), + ); + + when(channel.invokeMethod('style#updateImageSource', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.updateImageSource(sourceId, bytes, coordinates); + + Map args = { + 'imageSourceId': sourceId, + 'bytes': bytes, + 'length': bytes.length, + 'coordinates': coordinates.toList() + }; + + verify(channel.invokeMethod('style#updateImageSource', args)).called(1); + }); + + test('test toScreenLocation', () async { + final latLng = LatLng(37.7749, -122.4194); + final point = Point(10.0, 10.0); + + when(channel.invokeMethod>( + 'map#toScreenLocation', any)) + .thenAnswer((_) async => { + 'x': point.x, + 'y': point.y, + }); + + final result = await nbMapsGlChannel.toScreenLocation(latLng); + + expect(result, equals(point)); + }); + + test('test toScreenLocationBatch', () async { + final latLngs = [ + LatLng(37.7749, -122.4194), + LatLng(37.7749, -122.3927), + ]; + final points = >[ + Point(10.0, 10.0), + Point(20.0, 20.0), + ]; + + when(channel.invokeMethod>('map#toScreenLocationBatch', any)) + .thenAnswer((_) async => Float64List.fromList([ + points[0].x, + points[0].y, + points[1].x, + points[1].y, + ])); + + final result = await nbMapsGlChannel.toScreenLocationBatch(latLngs); + + expect(result, equals(points)); + }); + + test('removeSource', () async { + final sourceId = 'sourceId'; + + when(channel.invokeMethod('style#removeSource', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.removeSource(sourceId); + + Map args = { + 'sourceId': sourceId, + }; + + verify(channel.invokeMethod('style#removeSource', args)).called(1); + }); + + test('test addLayer', () async { + String imageLayerId = 'imageLayerId'; + String imageSourceId = 'imageSourceId'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + + when(channel.invokeMethod('style#addLayer', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addLayer( + imageLayerId, imageSourceId, minzoom, maxzoom); + + Map args = { + 'imageLayerId': imageLayerId, + 'imageSourceId': imageSourceId, + 'minzoom': minzoom, + 'maxzoom': maxzoom + }; + + verify(channel.invokeMethod('style#addLayer', args)).called(1); + }); + + test('test removeLayer', () async { + final layerId = 'layerId'; + + when(channel.invokeMethod('style#removeLayer', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.removeLayer(layerId); + + Map args = { + 'layerId': layerId, + }; + + verify(channel.invokeMethod('style#removeLayer', args)).called(1); + }); + + test('addLayerBelow', () async { + String imageLayerId = 'imageLayerId'; + String imageSourceId = 'imageSourceId'; + String belowLayerId = 'belowLayerId'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + + when(channel.invokeMethod('style#addLayerBelow', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addLayerBelow( + imageLayerId, imageSourceId, belowLayerId, minzoom, maxzoom); + + Map args = { + 'imageLayerId': imageLayerId, + 'imageSourceId': imageSourceId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom + }; + + verify(channel.invokeMethod('style#addLayerBelow', args)).called(1); + }); + + test('setFilter', () async { + final layerId = 'layerId'; + final filter = ['==', 'name', 'Doe']; + + when(channel.invokeMethod('style#setFilter', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setFilter(layerId, filter); + + Map args = { + 'layerId': layerId, + 'filter': jsonEncode(filter), + }; + + verify(channel.invokeMethod('style#setFilter', args)).called(1); + }); + + test('test setVisibility', () async { + final layerId = 'layerId'; + final isVisible = true; + + when(channel.invokeMethod('style#setVisibility', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setVisibility(layerId, isVisible); + + Map args = { + 'layerId': layerId, + 'isVisible': isVisible, + }; + + verify(channel.invokeMethod('style#setVisibility', args)).called(1); + }); + + test('toLatLng', () async { + final screenLocation = Point(10.0, 10.0); + final latLng = LatLng(37.7749, -122.4194); + Map args = { + 'x': screenLocation.x, + 'y': screenLocation.y, + }; + + when(channel.invokeMethod>('map#toLatLng', any)) + .thenAnswer((_) async => { + 'latitude': latLng.latitude, + 'longitude': latLng.longitude, + }); + + final result = await nbMapsGlChannel.toLatLng(screenLocation); + + expect(result, equals(latLng)); + verify(channel.invokeMethod('map#toLatLng', args)).called(1); + }); + + test('test getMetersPerPixelAtLatitude', () async { + final latitude = 37.7749; + final expectedMetersPerPixel = 152.8740565703525; + + when(channel.invokeMethod>( + 'map#getMetersPerPixelAtLatitude', any)) + .thenAnswer((_) async => {'metersperpixel': expectedMetersPerPixel}); + + final result = await nbMapsGlChannel.getMetersPerPixelAtLatitude( + latitude, + ); + + expect(result, equals(expectedMetersPerPixel)); + }); + + test('test addGeoJsonSource', () async { + final sourceId = 'sourceId'; + final geoJson = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [-122.4194, 37.7749], + }, + 'properties': { + 'name': 'Doe', + }, + }, + ], + }; + + when(channel.invokeMethod('source#addGeoJson', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addGeoJsonSource(sourceId, geoJson); + + Map args = { + 'sourceId': sourceId, + 'geojson': jsonEncode(geoJson), + }; + + verify(channel.invokeMethod('source#addGeoJson', args)).called(1); + }); + + test('test setGeoJsonSource', () async { + final sourceId = 'sourceId'; + final geoJson = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [-122.4194, 37.7749], + }, + 'properties': { + 'name': 'Doe', + }, + }, + ], + }; + + when(channel.invokeMethod('source#setGeoJson', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setGeoJsonSource(sourceId, geoJson); + + Map args = { + 'sourceId': sourceId, + 'geojson': jsonEncode(geoJson), + }; + + verify(channel.invokeMethod('source#setGeoJson', args)).called(1); + }); + + test('test addSymbolLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + dynamic filter = ['==', 'name', 'Doe']; + bool enableInteraction = true; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('symbolLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addSymbolLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction); + + verify(channel.invokeMethod('symbolLayer#add', args)).called(1); + }); + + test('test addLineLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + dynamic filter = ['==', 'name', 'Doe']; + bool enableInteraction = true; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('lineLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addLineLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction); + + verify(channel.invokeMethod('lineLayer#add', args)).called(1); + }); + + test('test addCircleLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + dynamic filter = ['==', 'name', 'Doe']; + bool enableInteraction = true; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('circleLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addCircleLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction); + + verify(channel.invokeMethod('circleLayer#add', args)).called(1); + }); + + test('test addFillLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + dynamic filter = ['==', 'name', 'Doe']; + bool enableInteraction = true; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('fillLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addFillLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction); + + verify(channel.invokeMethod('fillLayer#add', args)).called(1); + }); + + test('test addFillExtrusionLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + dynamic filter = ['==', 'name', 'Doe']; + bool enableInteraction = true; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'sourceLayer': sourceLayer, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'filter': jsonEncode(filter), + 'enableInteraction': enableInteraction, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('fillExtrusionLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addFillExtrusionLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction); + + verify(channel.invokeMethod('fillExtrusionLayer#add', args)).called(1); + }); + + test('test addSource', () async { + String sourceId = 'sourceId'; + SourceProperties properties = VectorSourceProperties(); + + Map args = { + 'sourceId': sourceId, + 'properties': properties.toJson(), + }; + + when(channel.invokeMethod('style#addSource', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addSource(sourceId, properties); + + verify(channel.invokeMethod('style#addSource', args)).called(1); + }); + + test('test addRasterLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('rasterLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addRasterLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + + verify(channel.invokeMethod('rasterLayer#add', args)).called(1); + }); + + test('test addHillshadeLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('hillshadeLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addHillshadeLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + + verify(channel.invokeMethod('hillshadeLayer#add', args)).called(1); + }); + + test('test addHeatmapLayer', () async { + String sourceId = 'sourceId'; + String layerId = 'layerId'; + Map properties = {}; + String? belowLayerId = 'belowLayerId'; + String? sourceLayer = 'sourceLayer'; + double? minzoom = 1.0; + double? maxzoom = 2.0; + + Map args = { + 'sourceId': sourceId, + 'layerId': layerId, + 'belowLayerId': belowLayerId, + 'minzoom': minzoom, + 'maxzoom': maxzoom, + 'properties': properties + .map((key, value) => MapEntry(key, jsonEncode(value))) + }; + + when(channel.invokeMethod('heatmapLayer#add', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.addHeatmapLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + + verify(channel.invokeMethod('heatmapLayer#add', args)).called(1); + }); + + test('test setFeatureForGeoJsonSource', () async { + final sourceId = 'sourceId'; + final feature = { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [-122.4194, 37.7749], + }, + 'properties': { + 'name': 'Doe', + }, + }; + + when(channel.invokeMethod('source#setFeature', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setFeatureForGeoJsonSource(sourceId, feature); + + Map args = { + 'sourceId': sourceId, + 'geojsonFeature': jsonEncode(feature), + }; + + verify(channel.invokeMethod('source#setFeature', args)).called(1); + }); + + test('test findBelowLayerId', () async { + List belowAttrs = ['layerId']; + final belowLayerId = 'belowLayerId'; + + when(channel.invokeMethod('style#findBelowLayer', any)) + .thenAnswer((_) async => belowLayerId); + + final result = await nbMapsGlChannel.findBelowLayerId(belowAttrs); + + expect(result, equals(belowLayerId)); + }); + test('test setStyleString', () async { + final styleString = 'styleString'; + + when(channel.invokeMethod('style#setStyleString', any)) + .thenAnswer((_) async => null); + + await nbMapsGlChannel.setStyleString(styleString); + + Map args = { + 'styleString': styleString, + }; + + verify(channel.invokeMethod('style#setStyleString', args)).called(1); + }); + + test('handleMethodCall infoWindow#onTap', () async { + final arguments = { + 'symbol': 'symbolId', + }; + + nbMapsGlChannel.onInfoWindowTappedPlatform.add((String symbol) { + expect(symbol, 'symbolId'); + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('infoWindow#onTap', arguments)); + }); + + test('handleMethodCall feature#onTap', () async { + String id = 'id'; + double x = 10.0; + double y = 10.0; + double lng = -122.4194; + double lat = 37.7749; + + final arguments = { + 'id': id, + 'x': x, + 'y': y, + 'lng': lng, + 'lat': lat, + }; + + nbMapsGlChannel.onFeatureTappedPlatform.add((Map arg) { + expect(arg['id'], id); + expect(arg['point'], Point(x, y)); + expect(arg['latLng'], LatLng(lat, lng)); + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('feature#onTap', arguments)); + }); + + test('handleMethodCall feature#onDrag', () async { + String id = 'id'; + double x = 10.0; + double y = 10.0; + double originLat = -122.4194; + double originLng = 37.7749; + + final double currentLat = 38.7749; + final double currentLng = -125.4194; + + final double deltaLat = 1.0; + final double deltaLng = 2.0; + final String eventType = 'end'; + + Map methodArgs = { + 'id': id, + 'x': x, + 'y': y, + 'originLat': originLat, + 'originLng': originLng, + 'currentLat': currentLat, + 'currentLng': currentLng, + 'deltaLat': deltaLat, + 'deltaLng': deltaLng, + 'eventType': eventType, + }; + + Map callabckArguments = { + 'id': id, + 'point': Point(x, y), + 'origin': LatLng(originLat, originLng), + 'current': LatLng(currentLat, currentLng), + 'delta': LatLng(deltaLat, deltaLng), + 'eventType': eventType, + }; + + nbMapsGlChannel.onFeatureDraggedPlatform.add((Map arg) { + expect(arg, callabckArguments); + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('feature#onDrag', methodArgs)); + }); + + test('handleMethodCall throws MissingPluginException for unknown method', + () async { + const call = MethodCall('unknownMethod'); + + expect(() async => await nbMapsGlChannel.handleMethodCall(call), + throwsA(isA())); + }); + + test('handleMethodCall camera#onMoveStarted', () async { + bool isCallbackInvoked = false; + nbMapsGlChannel.onCameraMoveStartedPlatform.add((_) { + isCallbackInvoked = true; + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('camera#onMoveStarted')); + + expect(isCallbackInvoked, true); + }); + + test('handleMethodCall camera#onMove', () async { + Map args = { + 'position': { + 'target': [37.7749, -122.4194], + 'zoom': 15.0, + 'bearing': 2.0, + 'tilt': 3.0, + }, + }; + + nbMapsGlChannel.onCameraMovePlatform.add((CameraPosition position) { + expect(position.target, LatLng(37.7749, -122.4194)); + expect(position.zoom, 15.0); + expect(position.bearing, 2.0); + expect(position.tilt, 3.0); + }); + nbMapsGlChannel.handleMethodCall(MethodCall('camera#onMove', args)); + }); + + test('handleMethodCall camera#onIdle', () async { + Map args = { + 'position': { + 'target': [37.7749, -122.4194], + 'zoom': 15.0, + 'bearing': 2.0, + 'tilt': 3.0, + }, + }; + + nbMapsGlChannel.onCameraIdlePlatform.add((CameraPosition? position) { + expect(position?.target, LatLng(37.7749, -122.4194)); + expect(position?.zoom, 15.0); + expect(position?.bearing, 2.0); + expect(position?.tilt, 3.0); + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('camera#onIdle', args)); + }); + + test('handleMethodCall map#onStyleLoaded', () async { + bool isCallbackInvoked = false; + nbMapsGlChannel.onMapStyleLoadedPlatform.add((_) { + isCallbackInvoked = true; + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('map#onStyleLoaded')); + + expect(isCallbackInvoked, true); + }); + + test('handleMethodCall map#onMapClick', () async { + final point = Point(10.0, 10.0); + final latLng = LatLng(37.7749, -122.4194); + + final arguments = { + 'x': point.x, + 'y': point.y, + 'lat': latLng.latitude, + 'lng': latLng.longitude, + }; + + nbMapsGlChannel.onMapClickPlatform.add((Map args) { + expect(args['point'], point); + expect(args['latLng'], latLng); + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('map#onMapClick', arguments)); + }); + + test('handleMethodCall map#onMapLongClick', () async { + final point = Point(10.0, 10.0); + final latLng = LatLng(37.7749, -122.4194); + + final arguments = { + 'x': point.x, + 'y': point.y, + 'lat': latLng.latitude, + 'lng': latLng.longitude, + }; + + nbMapsGlChannel.onMapLongClickPlatform.add((Map args) { + expect(args['point'], point); + expect(args['latLng'], latLng); + }); + + nbMapsGlChannel + .handleMethodCall(MethodCall('map#onMapLongClick', arguments)); + }); + + test('handleMethodCall map#onCameraTrackingChanged', () async { + final int trackingMode = 1; + + final arguments = { + 'mode': trackingMode, + }; + + nbMapsGlChannel.onCameraTrackingChangedPlatform + .add((MyLocationTrackingMode mode) { + expect(mode, MyLocationTrackingMode.values[trackingMode]); + }); + + nbMapsGlChannel + .handleMethodCall(MethodCall('map#onCameraTrackingChanged', arguments)); + }); + + test('handleMethodCall map#onAttributionClick', () async { + bool isCallbackInvoked = false; + nbMapsGlChannel.onAttributionClickPlatform.add((_) { + isCallbackInvoked = true; + }); + + nbMapsGlChannel.handleMethodCall(MethodCall('map#onAttributionClick')); + + expect(isCallbackInvoked, true); + }); + + test('handleMethodCall map#onUserLocationUpdated', () async { + final location = { + 'userLocation': { + 'position': [37.7749, -122.4194], + 'timestamp': DateTime.now().microsecondsSinceEpoch, + 'altitude': 10.0, + 'bearing': 2.0, + 'horizontalAccuracy': 3.0, + 'verticalAccuracy': 4.0, + 'speed': 5.0 + } + }; + + nbMapsGlChannel.onUserLocationUpdatedPlatform.add((UserLocation location) { + expect(location.position.latitude, 37.7749); + expect(location.position.longitude, -122.4194); + expect(location.altitude, 10.0); + expect(location.bearing, 2.0); + expect(location.horizontalAccuracy, 3.0); + expect(location.verticalAccuracy, 4.0); + expect(location.speed, 5.0); + }); + + nbMapsGlChannel + .handleMethodCall(MethodCall('map#onUserLocationUpdated', location)); + }); +} diff --git a/test/src/platform_interface/nbmaps_platform_interface_test.dart b/test/src/platform_interface/nbmaps_platform_interface_test.dart new file mode 100644 index 0000000..f2864b8 --- /dev/null +++ b/test/src/platform_interface/nbmaps_platform_interface_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:test/test.dart'; + +void main() { + test('verify dispose functionalities', () { + WidgetsFlutterBinding.ensureInitialized(); + NbMapsGlPlatform platform = MethodChannelNbMapsGl(); + expect(platform, isNotNull); + platform.onInfoWindowTappedPlatform.add((String str) { + return null; + }); + expect(platform.onInfoWindowTappedPlatform.length, equals(1)); + platform.initPlatform(1); + platform.dispose(); + + expect(platform.onInfoWindowTappedPlatform.length, equals(0)); + + /**å + onInfoWindowTappedPlatform.clear(); + onFeatureTappedPlatform.clear(); + onFeatureDraggedPlatform.clear(); + onCameraMoveStartedPlatform.clear(); + onCameraMovePlatform.clear(); + onCameraIdlePlatform.clear(); + onMapStyleLoadedPlatform.clear(); + + onMapClickPlatform.clear(); + onMapLongClickPlatform.clear(); + onAttributionClickPlatform.clear(); + onCameraTrackingChangedPlatform.clear(); + onCameraTrackingDismissedPlatform.clear(); + onMapIdlePlatform.clear(); + onUserLocationUpdatedPlatform.clear(); + */ + }); +} diff --git a/test/src/platform_interface/nextbillion_test.dart b/test/src/platform_interface/nextbillion_test.dart new file mode 100644 index 0000000..d576182 --- /dev/null +++ b/test/src/platform_interface/nextbillion_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +import 'nextbillion_test.mocks.dart'; + +//dart run build_runner build +@GenerateMocks([MethodChannel]) +void main() { + test('verify default method channel name', () { + expect(NextBillion().channel.name, + equals("plugins.flutter.io/nextbillion_init")); + }); + + group('mock method channel', () { + late MethodChannel channel; + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + channel = MockMethodChannel(); + NextBillion.setMockMethodChannel(channel); + + // 1 nextbillion/init_nextbillion + // 2 nextbillion/get_access_key + // 3 nextbillion/set_access_key + // 4 nextbillion/get_base_uri + // 5 nextbillion/set_base_uri + // 6 nextbillion/set_key_header_name + // 7 nextbillion/get_key_header_name + // 8 nextbillion/get_nb_id + // 9 nextbillion/set_user_id + // 10 nextbillion/get_user_id + }); + + // 1 nextbillion/init_nextbillion + test('NextBillion should initialize correctly', () async { + when(channel.invokeMethod( + 'nextbillion/init_nextbillion', {'accessKey': 'accessKey'})) + .thenAnswer((_) async => null); + + await NextBillion.initNextBillion('accessKey'); + verify(channel.invokeMethod( + 'nextbillion/init_nextbillion', {'accessKey': 'accessKey'})); + }); + + // 2 nextbillion/get_access_key + test('NextBillion should get access key correctly', () async { + const String expectedAccessKey = 'asscessKey'; + + when(channel.invokeMethod('nextbillion/get_access_key')) + .thenAnswer((_) async => expectedAccessKey); + + String accessKey = await NextBillion.getAccessKey(); + expect(accessKey, equals(expectedAccessKey)); + }); + + // 3 nextbillion/set_access_key + test('NextBillion should set access key correctly', () async { + Map arguments = {"accessKey": 'accessKey'}; + when(channel.invokeMethod('nextbillion/set_access_key', arguments)) + .thenAnswer((_) async => null); + + await NextBillion.setAccessKey('accessKey'); + verify(channel.invokeMethod('nextbillion/set_access_key', arguments)); + }); + + // 4 nextbillion/get_base_uri + test('NextBillion should get base URI correctly', () async { + const String expectedBaseUri = 'baseUri'; + + when(channel.invokeMethod('nextbillion/get_base_uri')) + .thenAnswer((_) async => expectedBaseUri); + + String baseUri = await NextBillion.getBaseUri(); + expect(baseUri, equals(expectedBaseUri)); + }); + + // 5 nextbillion/set_base_uri + test('NextBillion should set base URI correctly', () async { + Map arguments = {"baseUri": 'baseUri'}; + when(channel.invokeMethod('nextbillion/set_base_uri', arguments)) + .thenAnswer((_) async => null); + + await NextBillion.setBaseUri('baseUri'); + verify(channel.invokeMethod('nextbillion/set_base_uri', arguments)); + }); + + // 6 nextbillion/set_key_header_name + test('NextBillion should set API key header name correctly', () async { + Map arguments = {"apiKeyHeaderName": 'apiKeyHeaderName'}; + when(channel.invokeMethod('nextbillion/set_key_header_name', arguments)) + .thenAnswer((_) async => null); + + await NextBillion.setApiKeyHeaderName('apiKeyHeaderName'); + + verify( + channel.invokeMethod('nextbillion/set_key_header_name', arguments)); + }); + + // 7 nextbillion/get_key_header_name + test('NextBillion should get API key header name correctly', () async { + const String expectedHeaderName = 'headerName'; + + when(channel.invokeMethod('nextbillion/get_key_header_name')) + .thenAnswer((_) async => expectedHeaderName); + + String apiKeyHeaderName = await NextBillion.getApiKeyHeaderName(); + expect(apiKeyHeaderName, expectedHeaderName); + }); + + // 8 nextbillion/get_nb_id + test('NextBillion should get NB ID correctly', () async { + const String expectedNBID = 'nbid'; + + when(channel.invokeMethod('nextbillion/get_nb_id')) + .thenAnswer((_) async => expectedNBID); + + String nbId = await NextBillion.getNbId(); + expect(nbId, expectedNBID); + }); + + // 9 nextbillion/set_user_id + test('NextBillion should set user ID correctly', () async { + Map arguments = {"userId": 'userId'}; + when(channel.invokeMethod('nextbillion/set_user_id', arguments)) + .thenAnswer((_) async => null); + + await NextBillion.setUserId('userId'); + verify(channel.invokeMethod('nextbillion/set_user_id', arguments)); + }); + + // 10 nextbillion/get_user_id + test('NextBillion should get user ID correctly', () async { + const String expectedUserId = 'userId'; + + when(channel.invokeMethod('nextbillion/get_user_id')) + .thenAnswer((_) async => expectedUserId); + + String? userId = await NextBillion.getUserId(); + expect(userId, expectedUserId); + }); + }); +} diff --git a/test/src/platform_interface/nextbillion_test.mocks.dart b/test/src/platform_interface/nextbillion_test.mocks.dart new file mode 100644 index 0000000..3e25d2f --- /dev/null +++ b/test/src/platform_interface/nextbillion_test.mocks.dart @@ -0,0 +1,141 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in nb_maps_flutter/test/src/platform_interface/nextbillion_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; + +import 'package:flutter/src/services/binary_messenger.dart' as _i3; +import 'package:flutter/src/services/message_codec.dart' as _i2; +import 'package:flutter/src/services/platform_channel.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeMethodCodec_0 extends _i1.SmartFake implements _i2.MethodCodec { + _FakeMethodCodec_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeBinaryMessenger_1 extends _i1.SmartFake + implements _i3.BinaryMessenger { + _FakeBinaryMessenger_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [MethodChannel]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { + MockMethodChannel() { + _i1.throwOnMissingStub(this); + } + + @override + String get name => (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#name), + ), + ) as String); + + @override + _i2.MethodCodec get codec => (super.noSuchMethod( + Invocation.getter(#codec), + returnValue: _FakeMethodCodec_0( + this, + Invocation.getter(#codec), + ), + ) as _i2.MethodCodec); + + @override + _i3.BinaryMessenger get binaryMessenger => (super.noSuchMethod( + Invocation.getter(#binaryMessenger), + returnValue: _FakeBinaryMessenger_1( + this, + Invocation.getter(#binaryMessenger), + ), + ) as _i3.BinaryMessenger); + + @override + _i6.Future invokeMethod( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod( + Invocation.method( + #invokeMethod, + [ + method, + arguments, + ], + ), + returnValue: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future?> invokeListMethod( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod( + Invocation.method( + #invokeListMethod, + [ + method, + arguments, + ], + ), + returnValue: _i6.Future?>.value(), + ) as _i6.Future?>); + + @override + _i6.Future?> invokeMapMethod( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod( + Invocation.method( + #invokeMapMethod, + [ + method, + arguments, + ], + ), + returnValue: _i6.Future?>.value(), + ) as _i6.Future?>); + + @override + void setMethodCallHandler( + _i6.Future Function(_i2.MethodCall)? handler) => + super.noSuchMethod( + Invocation.method( + #setMethodCallHandler, + [handler], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/src/platform_interface/snapshot_test.dart b/test/src/platform_interface/snapshot_test.dart new file mode 100644 index 0000000..3d2fc3e --- /dev/null +++ b/test/src/platform_interface/snapshot_test.dart @@ -0,0 +1,156 @@ +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +class MockPlatformWrapper extends PlatformWrapper { + @override + bool get isAndroid => true; +} + +void main() { + test('SnapshotOptions toJson should convert to map correctly', () { + // Arrange + double width = 500.0; + double height = 300.0; + LatLng centerCoordinate = LatLng(37.7749, -122.4194); + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + double zoomLevel = 10.0; + double pitch = 45.0; + double heading = 90.0; + String styleUri = "https://example.com/mapstyle"; + String styleJson = '{"key": "value"}'; + bool withLogo = true; + bool writeToDisk = false; + + SnapshotOptions options = SnapshotOptions( + width: width, + height: height, + centerCoordinate: centerCoordinate, + bounds: bounds, + zoomLevel: zoomLevel, + pitch: pitch, + heading: heading, + styleUri: styleUri, + styleJson: styleJson, + withLogo: withLogo, + writeToDisk: writeToDisk, + ); + + // Act + Map result = options.toJson(); + + // Assert + expect(result['width'], equals(width)); + expect(result['height'], equals(height)); + expect(result['centerCoordinate'], + equals([centerCoordinate.latitude, centerCoordinate.longitude])); + expect( + result['bounds'], + equals([ + [bounds.southwest.latitude, bounds.southwest.longitude], + [bounds.northeast.latitude, bounds.northeast.longitude] + ])); + expect(result['zoomLevel'], equals(zoomLevel)); + expect(result['pitch'], equals(pitch)); + expect(result['heading'], equals(heading)); + expect(result['styleUri'], equals(styleUri)); + expect(result['styleJson'], equals(styleJson)); + expect(result['withLogo'], equals(withLogo)); + expect(result['writeToDisk'], equals(writeToDisk)); + }); + + test( + 'SnapshotOptions toJson should convert to map correctly when isAndroid == true', + () { + // Arrange + double width = 500.0; + double height = 300.0; + + LatLng centerCoordinate = LatLng(37.7749, -122.4194); + + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + double zoomLevel = 10.0; + double pitch = 45.0; + double heading = 90.0; + String styleUri = "https://example.com/mapstyle"; + String styleJson = '{"key": "value"}'; + bool withLogo = true; + bool writeToDisk = false; + + SnapshotOptions options = SnapshotOptions( + width: width, + height: height, + centerCoordinate: centerCoordinate, + bounds: bounds, + zoomLevel: zoomLevel, + pitch: pitch, + heading: heading, + styleUri: styleUri, + styleJson: styleJson, + withLogo: withLogo, + writeToDisk: writeToDisk, + platformWrapper: MockPlatformWrapper(), + ); + + final featureCollection = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + bounds.northeast.longitude, + bounds.northeast.latitude + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + bounds.southwest.longitude, + bounds.southwest.latitude + ] + } + } + ] + }; + + final feature = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [centerCoordinate.longitude, centerCoordinate.latitude] + } + }; + + // Act + + Map result = options.toJson(); + + // Assert + expect(result['width'], equals(width)); + expect(result['height'], equals(height)); + + //{type: Feature, properties: {}, geometry: {type: Point, coordinates: [-122.4194, 37.7749]}} + expect(result['centerCoordinate'], equals(feature.toString())); + expect(result['bounds'], equals(featureCollection.toString())); + expect(result['zoomLevel'], equals(zoomLevel)); + expect(result['pitch'], equals(pitch)); + expect(result['heading'], equals(heading)); + expect(result['styleUri'], equals(styleUri)); + expect(result['styleJson'], equals(styleJson)); + expect(result['withLogo'], equals(withLogo)); + expect(result['writeToDisk'], equals(writeToDisk)); + }); +} diff --git a/test/src/platform_interface/source_properties_test.dart b/test/src/platform_interface/source_properties_test.dart new file mode 100644 index 0000000..54a80cb --- /dev/null +++ b/test/src/platform_interface/source_properties_test.dart @@ -0,0 +1,727 @@ +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('VectorSourceProperties', () { + test('toJson should convert VectorSourceProperties to a map correctly', () { + // Arrange + VectorSourceProperties properties = VectorSourceProperties( + url: 'https://example.com/vector', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + scheme: 'tms', + minzoom: 5, + maxzoom: 10, + attribution: 'Map data © OpenStreetMap contributors', + promoteId: 'sourceLayer.propertyName', + ); + + // Act + Map result = properties.toJson(); + + // Assert + expect(result['type'], equals('vector')); + expect(result['url'], equals('https://example.com/vector')); + expect(result['tiles'], + equals(['https://example.com/tile1', 'https://example.com/tile2'])); + expect(result['bounds'], equals([-90, -180, 90, 180])); + expect(result['scheme'], equals('tms')); + expect(result['minzoom'], equals(5)); + expect(result['maxzoom'], equals(10)); + expect(result['attribution'], + equals('Map data © OpenStreetMap contributors')); + expect(result['promoteId'], equals('sourceLayer.propertyName')); + }); + + test('fromJson should convert a map to VectorSourceProperties correctly', + () { + // Arrange + Map json = { + 'type': 'vector', + 'url': 'https://example.com/vector', + 'tiles': ['https://example.com/tile1', 'https://example.com/tile2'], + 'bounds': [-90.0, -180.0, 90.0, 180.0], + 'scheme': 'tms', + 'minzoom': 5.0, + 'maxzoom': 10.0, + 'attribution': 'Map data © OpenStreetMap contributors', + 'promoteId': 'sourceLayer.propertyName', + }; + + // Act + VectorSourceProperties result = VectorSourceProperties.fromJson(json); + + // Assert + expect(result.url, equals('https://example.com/vector')); + expect(result.tiles, + equals(['https://example.com/tile1', 'https://example.com/tile2'])); + expect(result.bounds, equals([-90.0, -180.0, 90.0, 180.0])); + expect(result.scheme, equals('tms')); + expect(result.minzoom, equals(5)); + expect(result.maxzoom, equals(10)); + expect( + result.attribution, equals('Map data © OpenStreetMap contributors')); + expect(result.promoteId, equals('sourceLayer.propertyName')); + }); + + test('copyWith returns a copy with updated values', () { + VectorSourceProperties properties = VectorSourceProperties( + url: 'https://example.com/vector', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + scheme: 'tms', + minzoom: 5, + maxzoom: 10, + attribution: 'Map data © OpenStreetMap contributors', + promoteId: 'sourceLayer.propertyName', + ); + + final copy = properties.copyWith( + url: "https://example.com/vector_new", + tiles: ['https://example.com/tile3', 'https://example.com/tile4'], + bounds: [-91, -181, 89, 179], + scheme: 'xyz', + minzoom: 6, + maxzoom: 11, + attribution: 'Map data © Nextbillion contributors', + promoteId: 'sourceLayer.propertyName_new', + ); + + expect(copy.url, isNot(properties.url)); + expect(copy.tiles, isNot(properties.tiles)); + expect(copy.bounds, isNot(properties.bounds)); + expect(copy.scheme, isNot(properties.scheme)); + expect(copy.minzoom, isNot(properties.minzoom)); + expect(copy.maxzoom, isNot(properties.maxzoom)); + expect(copy.attribution, isNot(properties.attribution)); + expect(copy.promoteId, isNot(properties.promoteId)); + }); + + test('copyWith returns a copy with unchanged values when not provided', () { + VectorSourceProperties properties = VectorSourceProperties( + url: 'https://example.com/vector', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + scheme: 'tms', + minzoom: 5, + maxzoom: 10, + attribution: 'Map data © OpenStreetMap contributors', + promoteId: 'sourceLayer.propertyName', + ); + + final copy = properties.copyWith(); + + expect(copy.url, equals(properties.url)); + }); + }); + + group('RasterSourceProperties', () { + test('toJson should convert RasterSourceProperties to a map correctly', () { + // Arrange + RasterSourceProperties properties = RasterSourceProperties( + url: 'https://example.com/raster', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + minzoom: 5, + maxzoom: 10, + tileSize: 256, + scheme: 'tms', + attribution: 'Map data © OpenStreetMap contributors', + ); + + // Act + Map result = properties.toJson(); + + // Assert + expect(result['type'], equals('raster')); + expect(result['url'], equals('https://example.com/raster')); + expect(result['tiles'], + equals(['https://example.com/tile1', 'https://example.com/tile2'])); + expect(result['bounds'], equals([-90, -180, 90, 180])); + expect(result['minzoom'], equals(5)); + expect(result['maxzoom'], equals(10)); + expect(result['tileSize'], equals(256)); + expect(result['scheme'], equals('tms')); + expect(result['attribution'], + equals('Map data © OpenStreetMap contributors')); + }); + + test('fromJson should convert a map to RasterSourceProperties correctly', + () { + // Arrange + Map json = { + 'type': 'raster', + 'url': 'https://example.com/raster', + 'tiles': ['https://example.com/tile1', 'https://example.com/tile2'], + 'bounds': [-90, -180, 90, 180], + 'minzoom': 5.0, + 'maxzoom': 10.0, + 'tileSize': 256.toDouble(), + 'scheme': 'tms', + 'attribution': 'Map data © OpenStreetMap contributors', + }; + + // Act + RasterSourceProperties result = RasterSourceProperties.fromJson(json); + + // Assert + expect(result.url, equals('https://example.com/raster')); + expect(result.tiles, + equals(['https://example.com/tile1', 'https://example.com/tile2'])); + expect(result.bounds, equals([-90, -180, 90, 180])); + expect(result.minzoom, equals(5)); + expect(result.maxzoom, equals(10)); + expect(result.tileSize, equals(256)); + expect(result.scheme, equals('tms')); + expect( + result.attribution, equals('Map data © OpenStreetMap contributors')); + }); + + test('copyWith returns a copy with updated values', () { + RasterSourceProperties properties = RasterSourceProperties( + url: 'https://example.com/raster', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + minzoom: 5, + maxzoom: 10, + tileSize: 256, + scheme: 'tms', + attribution: 'Map data © OpenStreetMap contributors', + ); + + final copy = properties.copyWith( + url: "https://example.com/raster_new", + tiles: ['https://example.com/tile3', 'https://example.com/tile4'], + bounds: [-91, -181, 89, 179], + minzoom: 6, + maxzoom: 11, + tileSize: 512, + scheme: 'xyz', + attribution: 'Map data © Nextbillion contributors', + ); + + expect(copy.url, isNot(properties.url)); + expect(copy.bounds, isNot(properties.bounds)); + }); + + test('copyWith returns a copy with unchanged values when not provided', () { + RasterSourceProperties properties = RasterSourceProperties( + url: 'https://example.com/raster', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + minzoom: 5, + maxzoom: 10, + tileSize: 256, + scheme: 'tms', + attribution: 'Map data © OpenStreetMap contributors', + ); + + final copy = properties.copyWith(); + + expect(copy.url, equals(properties.url)); + }); + }); + + group('RasterDemSourceProperties', () { + test('toJson should convert RasterDemSourceProperties to a map correctly', + () { + // Arrange + RasterDemSourceProperties properties = RasterDemSourceProperties( + url: 'https://example.com/raster-dem', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + minzoom: 5, + maxzoom: 10, + tileSize: 256, + attribution: 'Map data © OpenStreetMap contributors', + encoding: 'terrarium', + ); + + // Act + Map result = properties.toJson(); + + // Assert + expect(result['type'], equals('raster-dem')); + expect(result['url'], equals('https://example.com/raster-dem')); + expect(result['tiles'], + equals(['https://example.com/tile1', 'https://example.com/tile2'])); + expect(result['bounds'], equals([-90, -180, 90, 180])); + expect(result['minzoom'], equals(5)); + expect(result['maxzoom'], equals(10)); + expect(result['tileSize'], equals(256)); + expect(result['attribution'], + equals('Map data © OpenStreetMap contributors')); + expect(result['encoding'], equals('terrarium')); + }); + + test('fromJson should convert a map to RasterDemSourceProperties correctly', + () { + // Arrange + Map json = { + 'type': 'raster-dem', + 'url': 'https://example.com/raster-dem', + 'tiles': ['https://example.com/tile1', 'https://example.com/tile2'], + 'bounds': [-90, -180, 90, 180], + 'minzoom': 5.toDouble(), + 'maxzoom': 10.toDouble(), + 'tileSize': 256.toDouble(), + 'attribution': 'Map data © OpenStreetMap contributors', + 'encoding': 'terrarium', + }; + + // Act + RasterDemSourceProperties result = + RasterDemSourceProperties.fromJson(json); + + // Assert + expect(result.url, equals('https://example.com/raster-dem')); + expect(result.tiles, + equals(['https://example.com/tile1', 'https://example.com/tile2'])); + expect(result.bounds, equals([-90, -180, 90, 180])); + expect(result.minzoom, equals(5)); + expect(result.maxzoom, equals(10)); + expect(result.tileSize, equals(256)); + expect( + result.attribution, equals('Map data © OpenStreetMap contributors')); + expect(result.encoding, equals('terrarium')); + }); + + test('copyWith returns a copy with updated values', () { + RasterDemSourceProperties properties = RasterDemSourceProperties( + url: 'https://example.com/raster-dem', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + minzoom: 5, + maxzoom: 10, + tileSize: 256, + attribution: 'Map data © OpenStreetMap contributors', + encoding: 'terrarium', + ); + + final copy = properties.copyWith( + url: "https://example.com/raster-dem_new", + tiles: ['https://example.com/tile3', 'https://example.com/tile4'], + bounds: [-91, -181, 89, 179], + minzoom: 6, + maxzoom: 11, + tileSize: 512, + attribution: 'Map data © Nextbillion contributors', + encoding: 'mapbox', + ); + + expect(copy.url, isNot(properties.url)); + expect(copy.tiles, isNot(properties.tiles)); + }); + + test('copyWith returns a copy with unchanged values when not provided', () { + RasterDemSourceProperties properties = RasterDemSourceProperties( + url: 'https://example.com/raster-dem', + tiles: ['https://example.com/tile1', 'https://example.com/tile2'], + bounds: [-90, -180, 90, 180], + minzoom: 5, + maxzoom: 10, + tileSize: 256, + attribution: 'Map data © OpenStreetMap contributors', + encoding: 'terrarium', + ); + + final copy = properties.copyWith(); + + expect(copy.url, equals(properties.url)); + }); + }); + + group('GeojsonSourceProperties', () { + test('toJson should convert GeojsonSourceProperties to a map correctly', + () { + // Arrange + GeojsonSourceProperties properties = GeojsonSourceProperties( + data: { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [0, 0] + } + }, + maxzoom: 15, + attribution: 'Map data © OpenStreetMap contributors', + buffer: 256, + tolerance: 0.5, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + clusterProperties: { + 'sum': [ + '+', + ['accumulated'], + ['get', 'scalerank'] + ] + }, + lineMetrics: true, + generateId: true, + promoteId: 'sourceLayer.propertyName', + ); + + // Act + Map result = properties.toJson(); + + // Assert + expect(result['type'], equals('geojson')); + expect( + result['data'], + equals({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [0, 0] + } + })); + expect(result['maxzoom'], equals(15)); + expect(result['attribution'], + equals('Map data © OpenStreetMap contributors')); + expect(result['buffer'], equals(256)); + expect(result['tolerance'], equals(0.5)); + expect(result['cluster'], equals(true)); + expect(result['clusterRadius'], equals(50)); + expect(result['clusterMaxZoom'], equals(14)); + expect( + result['clusterProperties'], + equals({ + 'sum': [ + '+', + ['accumulated'], + ['get', 'scalerank'] + ] + })); + expect(result['lineMetrics'], equals(true)); + expect(result['generateId'], equals(true)); + expect(result['promoteId'], equals('sourceLayer.propertyName')); + }); + + test('fromJson should convert a map to GeojsonSourceProperties correctly', + () { + // Arrange + Map json = { + 'type': 'geojson', + 'data': { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [0, 0] + } + }, + 'maxzoom': 15.toDouble(), + 'attribution': 'Map data © OpenStreetMap contributors', + 'buffer': 256.toDouble(), + 'tolerance': 0.5, + 'cluster': true, + 'clusterRadius': 50.toDouble(), + 'clusterMaxZoom': 14.toDouble(), + 'clusterProperties': { + 'sum': [ + '+', + ['accumulated'], + ['get', 'scalerank'] + ] + }, + 'lineMetrics': true, + 'generateId': true, + 'promoteId': 'sourceLayer.propertyName', + }; + + // Act + GeojsonSourceProperties result = GeojsonSourceProperties.fromJson(json); + + // Assert + expect( + result.data, + equals({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [0, 0] + } + })); + expect(result.maxzoom, equals(15)); + expect( + result.attribution, equals('Map data © OpenStreetMap contributors')); + expect(result.buffer, equals(256)); + expect(result.tolerance, equals(0.5)); + expect(result.cluster, equals(true)); + expect(result.clusterRadius, equals(50)); + expect(result.clusterMaxZoom, equals(14)); + expect( + result.clusterProperties, + equals({ + 'sum': [ + '+', + ['accumulated'], + ['get', 'scalerank'] + ] + })); + expect(result.lineMetrics, equals(true)); + expect(result.generateId, equals(true)); + expect(result.promoteId, equals('sourceLayer.propertyName')); + }); + + test('copyWith returns a copy with updated values', () { + GeojsonSourceProperties properties = GeojsonSourceProperties( + data: { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [0, 0] + } + }, + maxzoom: 15, + attribution: 'Map data © OpenStreetMap contributors', + buffer: 256, + tolerance: 0.5, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + clusterProperties: { + 'sum': [ + '+', + ['accumulated'], + ['get', 'scalerank'] + ] + }, + lineMetrics: true, + generateId: true, + promoteId: 'sourceLayer.propertyName', + ); + + final copy = properties.copyWith( + data: {}, + maxzoom: 18, + attribution: 'Map data © Nextbillion contributors', + buffer: 512, + tolerance: 0.8, + cluster: false, + clusterRadius: 40, + clusterMaxZoom: 16, + clusterProperties: '{}', + lineMetrics: false, + generateId: false, + promoteId: 'sourceLayer.propertyName_new'); + + expect(copy.data, isNot(properties.data)); + expect(copy.attribution, isNot(properties.attribution)); + expect(copy.buffer, isNot(properties.buffer)); + expect(copy.tolerance, isNot(properties.tolerance)); + expect(copy.cluster, isNot(properties.cluster)); + expect(copy.clusterRadius, isNot(properties.clusterRadius)); + expect(copy.clusterMaxZoom, isNot(properties.clusterMaxZoom)); + expect(copy.promoteId, isNot(properties.promoteId)); + }); + + test('copyWith returns a copy with unchanged values when not provided', () { + GeojsonSourceProperties properties = GeojsonSourceProperties( + data: { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [0, 0] + } + }, + maxzoom: 15, + attribution: 'Map data © OpenStreetMap contributors', + buffer: 256, + tolerance: 0.5, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + clusterProperties: { + 'sum': [ + '+', + ['accumulated'], + ['get', 'scalerank'] + ] + }, + lineMetrics: true, + generateId: true, + promoteId: 'sourceLayer.propertyName', + ); + + final copy = properties.copyWith(); + + // Check that the copy is equal to the original + expect(copy.data, equals(properties.data)); + }); + }); + + group('VideoSourceProperties', () { + test('toJson should convert VideoSourceProperties to a map correctly', () { + // Arrange + VideoSourceProperties properties = VideoSourceProperties( + urls: ['https://example.com/video1', 'https://example.com/video2'], + coordinates: [ + [-90, -180], + [90, 180] + ], + ); + + // Act + Map result = properties.toJson(); + + // Assert + expect(result['type'], equals('video')); + expect(result['urls'], + equals(['https://example.com/video1', 'https://example.com/video2'])); + expect( + result['coordinates'], + equals([ + [-90, -180], + [90, 180] + ])); + }); + + test('fromJson should convert a map to VideoSourceProperties correctly', + () { + // Arrange + Map json = { + 'type': 'video', + 'urls': ['https://example.com/video1', 'https://example.com/video2'], + 'coordinates': [ + [-90, -180], + [90, 180] + ], + }; + + // Act + VideoSourceProperties result = VideoSourceProperties.fromJson(json); + + // Assert + expect(result.urls, + equals(['https://example.com/video1', 'https://example.com/video2'])); + expect( + result.coordinates, + equals([ + [-90, -180], + [90, 180] + ])); + }); + + test('copyWith returns a copy with updated values', () { + VideoSourceProperties properties = VideoSourceProperties( + urls: ['https://example.com/video1', 'https://example.com/video2'], + coordinates: [ + [-90, -180], + [90, 180] + ], + ); + + final copy = properties.copyWith( + urls: ['https://example.com/video3', 'https://example.com/video4'], + coordinates: [ + [-91, -181], + [89, 179] + ], + ); + + expect(copy.urls, isNot(properties.urls)); + expect(copy.coordinates, isNot(properties.coordinates)); + }); + + test('copyWith returns a copy with unchanged values when not provided', () { + VideoSourceProperties properties = VideoSourceProperties( + urls: ['https://example.com/video1', 'https://example.com/video2'], + coordinates: [ + [-90, -180], + [90, 180] + ], + ); + + final copy = properties.copyWith(); + + expect(copy.coordinates, equals(properties.coordinates)); + }); + }); + + group('ImageSourceProperties', () { + test('toJson should convert ImageSourceProperties to a map correctly', () { + // Arrange + ImageSourceProperties properties = ImageSourceProperties( + url: 'https://example.com/image', + coordinates: [ + [-90, -180], + [90, 180] + ], + ); + + // Act + Map result = properties.toJson(); + + // Assert + expect(result['type'], equals('image')); + expect(result['url'], equals('https://example.com/image')); + expect( + result['coordinates'], + equals([ + [-90, -180], + [90, 180] + ])); + }); + + test('fromJson should convert a map to ImageSourceProperties correctly', + () { + // Arrange + Map json = { + 'type': 'image', + 'url': 'https://example.com/image', + 'coordinates': [ + [-90, -180], + [90, 180] + ], + }; + + // Act + ImageSourceProperties result = ImageSourceProperties.fromJson(json); + + // Assert + expect(result.url, equals('https://example.com/image')); + expect( + result.coordinates, + equals([ + [-90, -180], + [90, 180] + ])); + }); + + test('copyWith returns a copy with updated values', () { + ImageSourceProperties properties = ImageSourceProperties( + url: 'https://example.com/image', + coordinates: [ + [-90, -180], + [90, 180] + ], + ); + + final copy = properties.copyWith( + url: "https://example.com/image_new", + coordinates: [ + [-91, -181], + [89, 179] + ], + ); + + expect(copy.url, isNot(properties.url)); + expect(copy.coordinates, isNot(properties.coordinates)); + }); + + test('copyWith returns a copy with unchanged values when not provided', () { + ImageSourceProperties properties = ImageSourceProperties( + url: 'https://example.com/image', + coordinates: [ + [-90, -180], + [90, 180] + ], + ); + + final copy = properties.copyWith(); + + expect(copy.coordinates, equals(properties.coordinates)); + }); + }); +} diff --git a/test/src/platform_interface/symbol_test.dart b/test/src/platform_interface/symbol_test.dart new file mode 100644 index 0000000..dd4cd94 --- /dev/null +++ b/test/src/platform_interface/symbol_test.dart @@ -0,0 +1,105 @@ +import 'package:test/test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + group('Symbol', () { + test('toGeoJson should return the correct GeoJSON representation', () { + // Arrange + final options = SymbolOptions( + iconSize: 1.0, + iconImage: 'symbol_icon', + geometry: LatLng(37.7749, -122.4194), + ); + final symbol = Symbol('symbol_id', options); + + // Act + final geojson = symbol.toGeoJson(); + + // Assert + expect(symbol.data, isNull); + expect(geojson['type'], equals('Feature')); + expect(geojson['properties']['iconSize'], equals(1.0)); + expect(geojson['properties']['iconImage'], equals('symbol_icon')); + expect(geojson['properties']['id'], equals('symbol_id')); + expect(geojson['geometry']['type'], equals('Point')); + expect(geojson['geometry']['coordinates'], equals([-122.4194, 37.7749])); + }); + + test('translate should update the symbol options geometry', () { + // Arrange + final options = SymbolOptions( + geometry: LatLng(37.7749, -122.4194), + ); + final symbol = Symbol('symbol_id', options); + final delta = LatLng(0.1, 0.1); + + // Act + symbol.translate(delta); + + // Assert + // expect(symbol.options.geometry, equals(LatLng(37.8749, -122.3194))); + expect(symbol.options.geometry!.latitude, closeTo(37.8749, 0.00001)); + expect(symbol.options.geometry!.longitude, closeTo(-122.3194, 0.00001)); + }); + }); + + group('SymbolOptions', () { + test('toJson should return the correct JSON representation', () { + // Arrange + final options = SymbolOptions( + iconSize: 1.0, + iconImage: 'symbol_icon', + geometry: LatLng(37.7749, -122.4194), + ); + + // Act + final json = options.toJson(); + + // Assert + expect(json['iconSize'], equals(1.0)); + expect(json['iconImage'], equals('symbol_icon')); + expect(json['geometry'], equals([37.7749, -122.4194])); + }); + + test('toGeoJson should return the correct GeoJSON representation', () { + // Arrange + final options = SymbolOptions( + iconSize: 1.0, + iconImage: 'symbol_icon', + geometry: LatLng(37.7749, -122.4194), + ); + + // Act + final geojson = options.toGeoJson(); + + // Assert + expect(geojson['type'], equals('Feature')); + expect(geojson['properties']['iconSize'], equals(1.0)); + expect(geojson['properties']['iconImage'], equals('symbol_icon')); + expect(geojson['geometry']['type'], equals('Point')); + expect(geojson['geometry']['coordinates'], equals([-122.4194, 37.7749])); + }); + + test('copyWith should create a new SymbolOptions with the given changes', + () { + // Arrange + final options = SymbolOptions( + iconSize: 1.0, + iconImage: 'symbol_icon', + geometry: LatLng(37.7749, -122.4194), + ); + final changes = SymbolOptions( + iconSize: 2.0, + iconImage: 'new_symbol_icon', + ); + + // Act + final newOptions = options.copyWith(changes); + + // Assert + expect(newOptions.iconSize, equals(2.0)); + expect(newOptions.iconImage, equals('new_symbol_icon')); + expect(newOptions.geometry, equals(LatLng(37.7749, -122.4194))); + }); + }); +} diff --git a/test/src/platform_interface/ui_test.dart b/test/src/platform_interface/ui_test.dart new file mode 100644 index 0000000..b42f662 --- /dev/null +++ b/test/src/platform_interface/ui_test.dart @@ -0,0 +1,228 @@ +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:test/test.dart'; + +void main() { + test('NbMapStyles constants should have correct URLs', () { + expect( + NbMapStyles.NBMAP_STREETS, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light")); + expect( + NbMapStyles.OUTDOORS, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light")); + expect( + NbMapStyles.LIGHT, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light")); + expect( + NbMapStyles.EMPTY, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light")); + expect( + NbMapStyles.DARK, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-dark")); + expect( + NbMapStyles.SATELLITE, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-satellite")); + expect( + NbMapStyles.SATELLITE_STREETS, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-satellite")); + expect( + NbMapStyles.TRAFFIC_DAY, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-light&traffic_incidents=2/incidents_light&traffic_flow=2/flow_relative-light")); + expect( + NbMapStyles.TRAFFIC_NIGHT, + equals( + "https://api.nextbillion.io/tt/style/1/style/22.2.1-9?map=2/basic_street-dark&traffic_incidents=2/incidents_dark&traffic_flow=2/flow_relative-dark")); + }); + + test('CameraTargetBounds should have correct values', () { + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + CameraTargetBounds cameraTargetBounds = CameraTargetBounds(bounds); + + expect(cameraTargetBounds.bounds, equals(bounds)); + expect(cameraTargetBounds.toJson(), equals([bounds.toList()])); + expect(cameraTargetBounds.toString(), + equals('CameraTargetBounds(bounds: $bounds)')); + }); + + test('CameraTargetBounds should have unbounded value', () { + CameraTargetBounds cameraTargetBounds = CameraTargetBounds.unbounded; + + expect(cameraTargetBounds.bounds, isNull); + expect(cameraTargetBounds.toJson(), equals([null])); + expect(cameraTargetBounds.toString(), + equals('CameraTargetBounds(bounds: null)')); + }); + + test('MinMaxZoomPreference should have correct values', () { + double minZoom = 10.0; + double maxZoom = 15.0; + MinMaxZoomPreference minMaxZoomPreference = + MinMaxZoomPreference(minZoom, maxZoom); + + expect(minMaxZoomPreference.minZoom, equals(minZoom)); + expect(minMaxZoomPreference.maxZoom, equals(maxZoom)); + expect(minMaxZoomPreference.toJson(), equals([minZoom, maxZoom])); + expect(minMaxZoomPreference.toString(), + equals('MinMaxZoomPreference(minZoom: $minZoom, maxZoom: $maxZoom)')); + }); + + test('MinMaxZoomPreference should have unbounded values', () { + MinMaxZoomPreference minMaxZoomPreference = MinMaxZoomPreference.unbounded; + + expect(minMaxZoomPreference.minZoom, isNull); + expect(minMaxZoomPreference.maxZoom, isNull); + expect(minMaxZoomPreference.toJson(), equals([null, null])); + expect(minMaxZoomPreference.toString(), + equals('MinMaxZoomPreference(minZoom: null, maxZoom: null)')); + }); + + group('CameraTargetBounds', () { + test('== returns true for identical objects', () { + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + CameraTargetBounds bounds1 = CameraTargetBounds(bounds); + final bounds2 = bounds1; + + expect(bounds1 == bounds2, isTrue); + }); + + test('== returns false for different types', () { + final bounds = CameraTargetBounds.unbounded; + final other = 'not a CameraTargetBounds'; + + expect(bounds == other, isFalse); + }); + + test('== returns true for equal bounds', () { + LatLngBounds bounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + final bounds1 = CameraTargetBounds(bounds); + final bounds2 = CameraTargetBounds(bounds); + + expect(bounds1 == bounds2, isTrue); + }); + + test('== returns false for unequal bounds', () { + LatLngBounds latLngBounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + final bounds1 = CameraTargetBounds(latLngBounds); + + LatLngBounds latLngBounds2 = LatLngBounds( + southwest: LatLng(37.7649, -122.5194), + northeast: LatLng(37.8095, -122.3727)); + final bounds2 = CameraTargetBounds(latLngBounds2); + + expect(bounds1 == bounds2, isFalse); + }); + + test('hashCode returns consistent value', () { + LatLngBounds latLngBounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + final bounds = CameraTargetBounds(latLngBounds); + + final hashCode1 = bounds.hashCode; + final hashCode2 = bounds.hashCode; + + expect(hashCode1, hashCode2); + }); + + test('hashCode returns different values for different objects', () { + LatLngBounds latLngBounds = LatLngBounds( + southwest: LatLng(37.7749, -122.4194), + northeast: LatLng(37.8095, -122.3927), + ); + final bounds1 = CameraTargetBounds(latLngBounds); + + LatLngBounds latLngBounds2 = LatLngBounds( + southwest: LatLng(37.7649, -122.5194), + northeast: LatLng(37.8095, -122.3727)); + final bounds2 = CameraTargetBounds(latLngBounds2); + + expect(bounds1.hashCode, isNot(equals(bounds2.hashCode))); + }); + }); + + group('MinMaxZoomPreference', () { + test('== returns true for identical objects', () { + double minZoom = 10.0; + double maxZoom = 15.0; + MinMaxZoomPreference zoomPreference1 = + MinMaxZoomPreference(minZoom, maxZoom); + + final zoomPreference2 = zoomPreference1; + + expect(zoomPreference1 == zoomPreference2, isTrue); + }); + + test('== returns false for different types', () { + double minZoom = 10.0; + double maxZoom = 15.0; + MinMaxZoomPreference zoomPreference = + MinMaxZoomPreference(minZoom, maxZoom); + + final other = 'not a MinMaxZoomPreference'; + + expect(zoomPreference == other, isFalse); + }); + + test('== returns true for equal minZoom and maxZoom', () { + double minZoom = 10.0; + double maxZoom = 15.0; + final zoomPreference1 = MinMaxZoomPreference(minZoom, maxZoom); + final zoomPreference2 = MinMaxZoomPreference(minZoom, maxZoom); + expect(zoomPreference1 == zoomPreference2, isTrue); + }); + + test('== returns false for unequal minZoom and maxZoom', () { + double minZoom = 10.0; + double maxZoom = 15.0; + final MinMaxZoomPreference zoomPreference1 = + MinMaxZoomPreference(minZoom, maxZoom); + + final zoomPreference2 = + MinMaxZoomPreference(minZoom + 1.0, maxZoom + 1.0); + + expect(zoomPreference1 == zoomPreference2, isFalse); + }); + test('hashCode returns consistent value', () { + double minZoom = 10.0; + double maxZoom = 15.0; + final zoomPreference = MinMaxZoomPreference(minZoom, maxZoom); + + final hashCode1 = zoomPreference.hashCode; + final hashCode2 = zoomPreference.hashCode; + + expect(hashCode1, hashCode2); + }); + + test('hashCode returns different values for different objects', () { + double minZoom = 10.0; + double maxZoom = 15.0; + final MinMaxZoomPreference zoomPreference1 = + MinMaxZoomPreference(minZoom, maxZoom); + + final zoomPreference2 = + MinMaxZoomPreference(minZoom + 1.0, maxZoom + 1.0); + + expect(zoomPreference1.hashCode, isNot(equals(zoomPreference2.hashCode))); + }); + }); +} diff --git a/test/src/util_test.dart b/test/src/util_test.dart new file mode 100644 index 0000000..ce9346f --- /dev/null +++ b/test/src/util_test.dart @@ -0,0 +1,31 @@ +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; +import 'package:test/test.dart'; + +void main() { + test('buildFeatureCollection should return a valid feature collection', () { + // Arrange + List> features = [ + {"name": "Feature 1", "type": "Point"}, + {"name": "Feature 2", "type": "Polygon"}, + {"name": "Feature 3", "type": "LineString"}, + ]; + + // Act + Map result = buildFeatureCollection(features); + + // Assert + expect(result["type"], equals("FeatureCollection")); + expect(result["features"], equals(features)); + }); + + test('getRandomString should return a random string of specified length', () { + // Arrange + int length = 8; + + // Act + String result = getRandomString(length); + + // Assert + expect(result.length, equals(length)); + }); +} diff --git a/test/widget/nb_map_widget_test.dart b/test/widget/nb_map_widget_test.dart new file mode 100644 index 0000000..9649703 --- /dev/null +++ b/test/widget/nb_map_widget_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nb_maps_flutter/nb_maps_flutter.dart'; + +void main() { + testWidgets('NBMap Widget test', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: NBMap( + initialCameraPosition: + CameraPosition(target: LatLng(1.3, 108.2), zoom: 15.0), + ), + ), + )); + + expect(find.byType(NBMap), findsOne); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: NBMap( + initialCameraPosition: + CameraPosition(target: LatLng(37, 128.2), zoom: 14.0), + ), + ), + )); + + expect(find.byType(NBMap), findsOne); + }); +}