diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 046e9c88c..3ea91b44e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/flutter_beta.yml b/.github/workflows/flutter_beta.yml index da40e3db7..a9901ffff 100644 --- a/.github/workflows/flutter_beta.yml +++ b/.github/workflows/flutter_beta.yml @@ -15,7 +15,7 @@ jobs: run: working-directory: maplibre_gl_example steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: java-version: '17' @@ -29,7 +29,7 @@ jobs: - name: Build example APK run: flutter build apk - name: Upload apk as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: maplibre-flutter-demo.apk path: maplibre_gl_example/build/app/outputs/flutter-apk/app-release.apk @@ -41,7 +41,7 @@ jobs: run: working-directory: maplibre_gl_example steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: ${{ env.FLUTTER_CHANNEL }} @@ -53,7 +53,7 @@ jobs: - name: Build iOS package run: flutter build ios --simulator - name: Upload Runner.app as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: maplibre-flutter-demo.app path: maplibre_gl_example/build/ios/iphonesimulator @@ -65,7 +65,7 @@ jobs: run: working-directory: maplibre_gl_example steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: ${{ env.FLUTTER_CHANNEL }} diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml index 6167b7130..c052acd17 100644 --- a/.github/workflows/flutter_ci.yml +++ b/.github/workflows/flutter_ci.yml @@ -12,7 +12,7 @@ jobs: name: "Check formatting" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -27,7 +27,7 @@ jobs: name: "Static code analysis" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -42,7 +42,7 @@ jobs: name: "Run tests" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -56,7 +56,7 @@ jobs: name: "Run web tests" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -70,7 +70,7 @@ jobs: name: "Generate code from templates" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -90,7 +90,7 @@ jobs: run: working-directory: maplibre_gl_example steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -106,7 +106,7 @@ jobs: - name: Build example APK run: flutter build apk - name: Upload apk as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: maplibre-flutter-demo.apk path: maplibre_gl_example/build/app/outputs/flutter-apk/app-release.apk @@ -118,7 +118,7 @@ jobs: run: working-directory: maplibre_gl_example steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable @@ -132,7 +132,7 @@ jobs: - name: Build iOS package run: flutter build ios --simulator - name: Upload Runner.app as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: maplibre-flutter-demo.app path: maplibre_gl_example/build/ios/iphonesimulator @@ -144,7 +144,7 @@ jobs: run: working-directory: maplibre_gl_example steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: channel: stable diff --git a/.github/workflows/publish-single.yml b/.github/workflows/publish-single.yml index e274cedce..137c08552 100644 --- a/.github/workflows/publish-single.yml +++ b/.github/workflows/publish-single.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'maplibre' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: dart-lang/setup-dart@v1 # --force skips the y/N confirmation # --skip-validation because of "Because maplibre_gl requires the Flutter SDK, version solving failed. Flutter users should use `flutter pub` instead of `dart pub`." diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e33c34f4..e1e0e920f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,61 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +### Added +* Logo customization options including visibility and position settings (#b4fb174). +* Explicit annotation manager initialization with clear error handling (#668). +* Web: Implemented `getStyle()` to return map style as JSON string. +* Web: Implemented `getSourceIds()` to return list of source IDs. +* Web: Improved `getLayers()` with safe null handling. + +### Changed +* **BREAKING**: Web implementation migrated from deprecated `dart:js_util` to modern `dart:js_interop` API (#687). + - **WASM compatible**: Now supports Flutter's upcoming WASM compilation target + - Required for Flutter 3.38.4+ compatibility + - All JS interop classes updated to `@staticInterop` pattern with extension methods + - Improved type safety for JS ↔ Dart conversions + - No public API changes for users +* MapLibre Android SDK upgraded from `11.13.5` to `12.3.0` (#690). + - Includes synchronous GeoJSON source updates + - Support for MLT-format vector tile sources + - Better frustum offset support + - See [MapLibre Native Android 12.3.0 release notes](https://github.com/maplibre/maplibre-native/releases/tag/android-v12.3.0) +* OkHttp updated from `4.12.0` to `5.3.2` for Node.js 24 compatibility (#676, #700). +* Kotlin updated to `2.3.0` (#697, #698). +* Android Gradle Plugin updated to `8.13.2` (#695, #674). +* Android Application Plugin updated to `8.13.2` (#696, #689). +* GitHub Actions: `actions/checkout` updated from v5 to v6 (#672, #693). +* GitHub Actions: `actions/upload-artifact` updated from v4 to v6 (#688, #694). + +### Fixed +* Min/max zoom preference on iOS (#5230fab). +* `queryRenderedFeatures` now returns all targets when supplying empty layers list on iOS, aligning behavior with Android (#680). +* iOS: Enhanced LayerPropertyConverter to handle null values and improve expression parsing (#98660dc). + - Better handling of null values in layer properties + - Improved expression parsing for complex layer configurations +* Web: Fixed `setPaintProperty` and `setLayoutProperty` to handle nullable `JSAny` values correctly (#12dfad2). +* Web: Improved `jsify` function to create JS arrays correctly (#2b550ed). +* Fixed `lineDasharray` and patterns reset to null in layer properties (#2b550ed). +* Improved MapLibreMapController disposing to prevent memory leaks (#2b550ed). +* Removed unnecessary disposing of mapController in example app (#f989797). + - Cleaned up example code with minor fixes + - Improved example app stability +* Fixed `setLayerProperties` and pattern images on web and Android (#9ce52a6). + - Pattern images now correctly converted to RGBA format on web + - Fixed mismatched image size error when loading pattern images + +### Refactor +* Complete refactor of example app with new UI and improved user experience (#ac877a4). + - Improved map sizing with responsive layouts (50-60% of screen height) + - Better button and control layouts across different screen sizes + - Enhanced visual design and usability +* Refactored `cameraTargetBounds` implementation on Android and iOS for consistent behavior (#8bcd74a). +* Refactored image upload on web - all images now converted to RGBA format for consistency (#9ce52a6). + +**Full Changelog**: [v0.24.1...v0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) + ## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a65300d8b..66667b698 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,12 +163,14 @@ Found a vulnerability or sensitive exposure vector? ## Release process (Maintainers) A high-level outline (subject to change): 1. Ensure `main` (or release branch) is green (CI, tests, analyzer). -2. Update package versions & root/individual `CHANGELOG.md` sections. -3. Tag the release (`vX.Y.Z`) and publish packages to pub.dev in dependency order. +2. Update package versions & root/individual `CHANGELOG.md` sections following the pre-1.0 versioning policy in [RELEASE.md](RELEASE.md): + - **MINOR** version for breaking changes, significant features, or new functionality + - **PATCH** version for backward-compatible bug fixes +3. Tag the release (`v0.X.Y`) and publish packages to pub.dev in dependency order. 4. Merge back any release branch into `main`. 5. Announce in discussions (optional). -For more information, see [RELEASE](RELEASE.md) instructions. +For more information, see [RELEASE.md](RELEASE.md) instructions. ## Community epectations Be respectful and constructive. We follow the project's `CODE_OF_CONDUCT.md`. Harassment, discrimination, or unprofessional behavior is not tolerated. diff --git a/RELEASE.md b/RELEASE.md index dff452be3..39cb91fd6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -74,12 +74,12 @@ Edit `pubspec.yaml` in each package: Ensure internal dependencies point to the new version (same number across all three). Example snippet: ```yaml dependencies: - maplibre_gl_platform_interface: ^0.23.0 + maplibre_gl_platform_interface: ^0.25.0 ``` ## Update the Changelog In root `CHANGELOG.md`: -1. Move items from `Unreleased` (if present) under a new heading: `## 0.23.0 - YYYY-MM-DD`. +1. Move items from `Unreleased` (if present) under a new heading: `## 0.25.0 - YYYY-MM-DD`. 2. Group by sections (Suggested): - Added - Changed @@ -116,8 +116,8 @@ After the release PR has been merged, create & push the version tag (format `vX. ```bash git checkout main git pull origin main -git tag v0.23.0 -git push origin v0.23.0 +git tag v0.24.0 +git push origin v0.24.0 ``` No further manual publish commands are required unless the pipeline fails. In that case, resolve the issue and re-push (delete & recreate tag only if absolutely necessary and before broad adoption). @@ -140,15 +140,28 @@ If a critical issue is discovered shortly after release: - If native SDK version bumps are included, link upstream release notes in the PR description. - Run `flutter clean` in example only if weird build artifacts appear (avoid unnecessary noise in instructions). -## Example Flow (Patch Fix) -``` +## Example Flows + +### Patch Fix (Bug Fix) +```bash # After merging a simple fix -# decide version 0.23.1 -edit pubspecs -> 0.23.1 +# decide version 0.25.1 (was 0.25.0) +edit pubspecs -> 0.25.1 +update CHANGELOG +commit & PR -> chore: release 0.25.1 +merge +git tag v0.25.1 && git push origin v0.25.1 +``` + +### Minor Release (New Feature) +```bash +# After merging a new feature +# decide version 0.25.0 (was 0.24.1) +edit pubspecs -> 0.25.0 update CHANGELOG -commit & PR -> chore: release 0.23.1 +commit & PR -> chore: release 0.25.0 merge -git tag v0.23.1 && push tag +git tag v0.25.0 && git push origin v0.25.0 ``` --- diff --git a/maplibre_gl/CHANGELOG.md b/maplibre_gl/CHANGELOG.md index 45582986c..806122f13 100644 --- a/maplibre_gl/CHANGELOG.md +++ b/maplibre_gl/CHANGELOG.md @@ -1,3 +1,40 @@ +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +### Added +* Logo customization options including visibility and position settings (#b4fb174). +* Explicit annotation manager initialization with clear error handling (#668). +* iOS: Attribution support for tile and raster sources with HTML link parsing. + +### Changed +* MapLibre Android SDK upgraded from `11.13.5` to `12.3.0` (#690). + - Includes synchronous GeoJSON source updates + - Support for MLT-format vector tile sources + - Better frustum offset support + - See [MapLibre Native Android 12.3.0 release notes](https://github.com/maplibre/maplibre-native/releases/tag/android-v12.3.0) +* OkHttp updated from `4.12.0` to `5.3.2` for Node.js 24 compatibility (#676, #700). +* Kotlin updated to `2.3.0` (#697, #698). +* Android Gradle Plugin updated to `8.13.2` (#695, #674). +* Android Application Plugin updated to `8.13.2` (#696, #689). +* GitHub Actions: `actions/checkout` updated from v5 to v6 (#672, #693). +* GitHub Actions: `actions/upload-artifact` updated from v4 to v6 (#688, #694). + +### Fixed +* Min/max zoom preference on iOS (#5230fab). +* `queryRenderedFeatures` now returns all targets when supplying empty layers list on iOS, aligning behavior with Android (#680). +* iOS: Enhanced LayerPropertyConverter to handle null values and improve expression parsing (#98660dc). +* Fixed `lineDasharray` and patterns reset to null in layer properties (#2b550ed). +* Improved MapLibreMapController disposing to prevent memory leaks. +* Removed unnecessary disposing of mapController in example app (#f989797). +* Fixed `setLayerProperties` and pattern images on web and Android (#9ce52a6). + - Pattern images now correctly converted to RGBA format on web + - Fixed mismatched image size error when loading pattern images + +### Refactor +* Complete refactor of example app with new UI and improved user experience (#ac877a4). +* Refactored `cameraTargetBounds` implementation on Android and iOS for consistent behavior (#8bcd74a). + +**Full Changelog**: [v0.24.1...v0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) + ## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) ### Fixed diff --git a/maplibre_gl/android/build.gradle b/maplibre_gl/android/build.gradle index 0f4f6d3f2..114f4ec22 100644 --- a/maplibre_gl/android/build.gradle +++ b/maplibre_gl/android/build.gradle @@ -2,14 +2,14 @@ group 'org.maplibre.maplibregl' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '2.1.0' + ext.kotlin_version = '2.3.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.android.tools.build:gradle:8.13.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -50,10 +50,10 @@ android { jvmTarget = JavaVersion.VERSION_21.toString() } dependencies { - implementation 'org.maplibre.gl:android-sdk:11.13.5' + implementation 'org.maplibre.gl:android-sdk:12.3.1' implementation 'org.maplibre.gl:android-plugin-annotation-v9:3.0.2' implementation 'org.maplibre.gl:android-plugin-offline-v9:3.0.2' - implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' } } diff --git a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java index e41df5ddd..7e070eccf 100644 --- a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java +++ b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java @@ -279,6 +279,14 @@ static void interpretMapLibreMapOptions(Object o, MapLibreMapOptionsSink sink, C if (myLocationRenderMode != null) { sink.setMyLocationRenderMode(toInt(myLocationRenderMode)); } + final Object logoEnabled = data.get("logoEnabled"); + if (logoEnabled != null) { + sink.setLogoEnabled(toBoolean(logoEnabled)); + } + final Object logoViewGravity = data.get("logoViewPosition"); + if (logoViewGravity != null) { + sink.setLogoViewGravity(toInt(logoViewGravity)); + } final Object logoViewMargins = data.get("logoViewMargins"); if (logoViewMargins != null) { final List logoViewMarginsData = toList(logoViewMargins); diff --git a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java index b4b08ddce..2531e908e 100644 --- a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java +++ b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java @@ -373,7 +373,11 @@ static PropertyValue[] interpretLineLayerProperties(Object o) { } break; case "line-pattern": - properties.add(PropertyFactory.linePattern(expression)); + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.linePattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.linePattern(expression)); + } break; case "line-gradient": properties.add(PropertyFactory.lineGradient(expression)); @@ -441,7 +445,11 @@ static PropertyValue[] interpretFillLayerProperties(Object o) { properties.add(PropertyFactory.fillTranslateAnchor(expression)); break; case "fill-pattern": - properties.add(PropertyFactory.fillPattern(expression)); + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.fillPattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.fillPattern(expression)); + } break; case "fill-sort-key": properties.add(PropertyFactory.fillSortKey(expression)); @@ -488,7 +496,11 @@ static PropertyValue[] interpretFillExtrusionLayerProperties(Object o) { properties.add(PropertyFactory.fillExtrusionTranslateAnchor(expression)); break; case "fill-extrusion-pattern": - properties.add(PropertyFactory.fillExtrusionPattern(expression)); + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.fillExtrusionPattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.fillExtrusionPattern(expression)); + } break; case "fill-extrusion-height": properties.add(PropertyFactory.fillExtrusionHeight(expression)); diff --git a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java index 26f97f74d..98cc2dad8 100644 --- a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java +++ b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java @@ -121,6 +121,29 @@ public void setMyLocationRenderMode(int myLocationRenderMode) { this.myLocationRenderMode = myLocationRenderMode; } + @Override + public void setLogoEnabled(boolean logoEnabled) { + options.logoEnabled(logoEnabled); + } + + @Override + public void setLogoViewGravity(int gravity) { + switch (gravity) { + case 0: + options.logoGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.logoGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.logoGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.logoGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + public void setLogoViewMargins(int x, int y) { options.logoMargins( new int[] { diff --git a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java index 372b0e7a3..873a4d8be 100644 --- a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java +++ b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java @@ -165,7 +165,7 @@ public void onStyleLoaded(@NonNull Style style) { updateMyLocationEnabled(); if (null != bounds) { - mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + setCameraTargetBounds(bounds); } mapLibreMap.addOnMapClickListener(MapLibreMapController.this); @@ -236,6 +236,11 @@ public void onMapReady(MapLibreMap mapLibreMap) { mapLibreMap.addOnCameraMoveListener(this); mapLibreMap.addOnCameraIdleListener(this); + // Apply camera target bounds if set during initialization + if (bounds != null) { + mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + } + if (androidGesturesManager != null) { androidGesturesManager.setMoveGestureListener(new MoveGestureListener()); mapView.setOnTouchListener( @@ -1481,9 +1486,21 @@ public void onFailure(@NonNull Exception exception) { "The style is null. Has onStyleLoaded() already been invoked?", null); } + // Configure bitmap options to prevent density-based scaling + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = false; // Disable automatic scaling + options.inDensity = 0; // No source density + options.inTargetDensity = 0; // No target density + + Bitmap bitmap = BitmapFactory.decodeByteArray( + call.argument("bytes"), + 0, + call.argument("length"), + options); + style.addImage( call.argument("name"), - BitmapFactory.decodeByteArray(call.argument("bytes"), 0, call.argument("length")), + bitmap, call.argument("sdf")); result.success(null); break; @@ -2087,6 +2104,9 @@ public void onDestroy(@NonNull LifecycleOwner owner) { @Override public void setCameraTargetBounds(LatLngBounds bounds) { this.bounds = bounds; + if (mapLibreMap != null) { + mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + } } @Override @@ -2167,6 +2187,30 @@ public void setMyLocationRenderMode(int myLocationRenderMode) { } } + @Override + public void setLogoEnabled(boolean logoEnabled) { + mapLibreMap.getUiSettings().setLogoEnabled(logoEnabled); + } + + @Override + public void setLogoViewGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.TOP | Gravity.START); + break; + case 1: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.TOP | Gravity.END); + break; + default: + case 2: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + public void setLogoViewMargins(int x, int y) { mapLibreMap.getUiSettings().setLogoMargins(x, 0, 0, y); } diff --git a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt index e0432f612..bc1575c8a 100644 --- a/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt +++ b/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt @@ -34,6 +34,10 @@ internal interface MapLibreMapOptionsSink { fun setMyLocationRenderMode(myLocationRenderMode: Int) + fun setLogoEnabled(logoEnabled: Boolean) + + fun setLogoViewGravity(gravity: Int) + fun setLogoViewMargins(x: Int, y: Int) fun setCompassGravity(gravity: Int) diff --git a/maplibre_gl/ios/maplibre_gl.podspec b/maplibre_gl/ios/maplibre_gl.podspec index 00a7aba7e..a322e7900 100644 --- a/maplibre_gl/ios/maplibre_gl.podspec +++ b/maplibre_gl/ios/maplibre_gl.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'maplibre_gl' - s.version = '0.24.1' + s.version = '0.25.0' s.summary = 'MapLibre GL Flutter plugin' s.description = <<-DESC MapLibre GL Flutter plugin. diff --git a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift index 4f1a71c87..b7fb0c046 100644 --- a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift +++ b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift @@ -3,17 +3,27 @@ import MapLibre class Convert { class func interpretMapLibreMapOptions(options: Any?, delegate: MapLibreMapOptionsSink) { guard let options = options as? [String: Any] else { return } - if let cameraTargetBounds = options["cameraTargetBounds"] as? [[[Double]]] { - delegate - .setCameraTargetBounds(bounds: MLNCoordinateBounds.fromArray(cameraTargetBounds[0])) + if let cameraTargetBounds = options["cameraTargetBounds"] as? [Any?] { + // Handle both [[[Double]]] and [nil] (for unbounded) + if let boundsArray = cameraTargetBounds[0] as? [[Double]] { + let bounds = MLNCoordinateBounds.fromArray(boundsArray) + delegate.setCameraTargetBounds(bounds: bounds) + } else { + // Unbounded - clear the bounds restriction + delegate.setCameraTargetBounds(bounds: nil) + } } if let compassEnabled = options["compassEnabled"] as? Bool { delegate.setCompassEnabled(compassEnabled: compassEnabled) } - if let minMaxZoomPreference = options["minMaxZoomPreference"] as? [Double] { + if let minMaxZoomPreference = options["minMaxZoomPreference"] as? [Any] { + // Handle both [Double] and [NSNull] (for unbounded zoom) + let minZoom: Double? = (minMaxZoomPreference[0] is NSNull) ? nil : minMaxZoomPreference[0] as? Double + let maxZoom: Double? = (minMaxZoomPreference[1] is NSNull) ? nil : minMaxZoomPreference[1] as? Double + delegate.setMinMaxZoomPreference( - min: minMaxZoomPreference[0], - max: minMaxZoomPreference[1] + min: minZoom, + max: maxZoom ) } if let styleString = options["styleString"] as? String { @@ -47,6 +57,14 @@ class Convert { { delegate.setMyLocationRenderMode(myLocationRenderMode: renderMode) } + if let logoEnabled = options["logoEnabled"] as? Bool { + delegate.setLogoEnabled(logoEnabled: logoEnabled) + } + if let logoViewPosition = options["logoViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: logoViewPosition) + { + delegate.setLogoViewPosition(position: position) + } if let logoViewMargins = options["logoViewMargins"] as? [Double] { delegate.setLogoViewMargins(x: logoViewMargins[0], y: logoViewMargins[1]) } diff --git a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift index 992ba230d..e80a7272d 100644 --- a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift +++ b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift @@ -6,7 +6,20 @@ import MapLibre class LayerPropertyConverter { class func addSymbolProperties(symbolLayer: MLNSymbolStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "icon-opacity": symbolLayer.iconOpacity = expression @@ -119,8 +132,10 @@ class LayerPropertyConverter { case "text-optional": symbolLayer.textOptional = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - symbolLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + symbolLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -130,7 +145,20 @@ class LayerPropertyConverter { class func addCircleProperties(circleLayer: MLNCircleStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "circle-radius": circleLayer.circleRadius = expression @@ -157,8 +185,10 @@ class LayerPropertyConverter { case "circle-sort-key": circleLayer.circleSortKey = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - circleLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + circleLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -168,7 +198,20 @@ class LayerPropertyConverter { class func addLineProperties(lineLayer: MLNLineStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "line-opacity": lineLayer.lineOpacity = expression @@ -203,8 +246,10 @@ class LayerPropertyConverter { case "line-sort-key": lineLayer.lineSortKey = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - lineLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + lineLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -214,7 +259,20 @@ class LayerPropertyConverter { class func addFillProperties(fillLayer: MLNFillStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "fill-antialias": fillLayer.fillAntialiased = expression @@ -233,8 +291,10 @@ class LayerPropertyConverter { case "fill-sort-key": fillLayer.fillSortKey = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - fillLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -244,7 +304,20 @@ class LayerPropertyConverter { class func addFillExtrusionProperties(fillExtrusionLayer: MLNFillExtrusionStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "fill-extrusion-opacity": fillExtrusionLayer.fillExtrusionOpacity = expression @@ -263,8 +336,10 @@ class LayerPropertyConverter { case "fill-extrusion-vertical-gradient": fillExtrusionLayer.fillExtrusionHasVerticalGradient = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - fillExtrusionLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillExtrusionLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -274,7 +349,20 @@ class LayerPropertyConverter { class func addRasterProperties(rasterLayer: MLNRasterStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "raster-opacity": rasterLayer.rasterOpacity = expression @@ -293,8 +381,10 @@ class LayerPropertyConverter { case "raster-fade-duration": rasterLayer.rasterFadeDuration = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - rasterLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + rasterLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -304,7 +394,20 @@ class LayerPropertyConverter { class func addHillshadeProperties(hillshadeLayer: MLNHillshadeStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "hillshade-illumination-direction": hillshadeLayer.hillshadeIlluminationDirection = expression @@ -319,8 +422,10 @@ class LayerPropertyConverter { case "hillshade-accent-color": hillshadeLayer.hillshadeAccentColor = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - hillshadeLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + hillshadeLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -330,7 +435,20 @@ class LayerPropertyConverter { class func addHeatmapProperties(heatmapLayer: MLNHeatmapStyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { case "heatmap-radius": heatmapLayer.heatmapRadius = expression @@ -343,8 +461,10 @@ class LayerPropertyConverter { case "heatmap-opacity": heatmapLayer.heatmapOpacity = expression case "visibility": - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - heatmapLayer.isVisible = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + heatmapLayer.isVisible = trimmedPropertyValue == "visible" + } default: break @@ -359,6 +479,12 @@ class LayerPropertyConverter { do { let json = try JSONSerialization.jsonObject(with: expression.data(using: .utf8)!, options: .fragmentsAllowed) + + // Check if JSON contains NSNull - this would create an invalid NSExpression + if json is NSNull { + return nil + } + // this is required because NSExpression.init(mglJSONObject: json) fails to create // a proper Expression if the data of is a hexString if isColor { @@ -392,6 +518,13 @@ class LayerPropertyConverter { // this is required because NSExpression.init(mglJSONObject: json) fails to create // a proper Expression if the data is an array of double return NSExpression(forConstantValue: [NSNumber(value: x), NSNumber(value: y)]) + } else { + // Handle arrays with any number of elements (e.g., dash arrays with 3+ elements) + // Convert to array of NSNumbers for proper expression creation + let numbers = offset.compactMap { $0 as? Double }.map { NSNumber(value: $0) } + if numbers.count == offset.count { + return NSExpression(forConstantValue: numbers) + } } } diff --git a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift index dcdb0bdfd..e2db567b6 100644 --- a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift +++ b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift @@ -18,10 +18,10 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, private var dragFeature: MLNFeature? private var initialTilt: CGFloat? - private var cameraTargetBounds: MLNCoordinateBounds? private var trackCameraPosition = false private var myLocationEnabled = false private var scrollingEnabled = true + private var isAdjustingCameraProgrammatically = false private var interactiveFeatureLayerIds = Set() private var addedShapesByLayer = [String: MLNShape]() @@ -78,7 +78,6 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, ) mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - mapView.logoView.isHidden = true self.registrar = registrar super.init() @@ -254,7 +253,7 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, case "map#queryRenderedFeatures": guard let arguments = methodCall.arguments as? [String: Any] else { return } var styleLayerIdentifiers: Set? - if let layerIds = arguments["layerIds"] as? [String] { + if let layerIds = arguments["layerIds"] as? [String], !layerIds.isEmpty { styleLayerIdentifiers = Set(layerIds) } var filterExpression: NSPredicate? @@ -1853,7 +1852,7 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, } } - func mapView(_ mapView: MLNMapView, regionDidChangeAnimated _: Bool) { + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { let arguments = trackCameraPosition ? [ "position": getCamera()?.toDict(mapView: mapView) ] : [:] @@ -1954,7 +1953,11 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, * MapLibreMapOptionsSink */ func setCameraTargetBounds(bounds: MLNCoordinateBounds?) { - cameraTargetBounds = bounds + let bounds = bounds ?? MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: -90, longitude: -180), + ne: CLLocationCoordinate2D(latitude: 90, longitude: 180) + ) + mapView.maximumScreenBounds = bounds; } func setCompassEnabled(compassEnabled: Bool) { @@ -1962,9 +1965,13 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, mapView.compassView.isHidden = !compassEnabled } - func setMinMaxZoomPreference(min: Double, max: Double) { - mapView.minimumZoomLevel = min - mapView.maximumZoomLevel = max + func setMinMaxZoomPreference(min: Double?, max: Double?) { + // Use MapLibre defaults (0 for min, 22 for max) when unbounded (nil) + let minZoom = min ?? 0.0 + let maxZoom = max ?? 22.0 + + mapView.minimumZoomLevel = minZoom + mapView.maximumZoomLevel = maxZoom } private static func styleStringIsJSON(_ styleString: String) -> Bool { @@ -2065,6 +2072,14 @@ class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, } } + func setLogoEnabled(logoEnabled: Bool) { + mapView.logoView.isHidden = !logoEnabled + } + + func setLogoViewPosition(position: MLNOrnamentPosition) { + mapView.logoViewPosition = position + } + func setLogoViewMargins(x: Double, y: Double) { mapView.logoViewMargins = CGPoint(x: x, y: y) } diff --git a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift index c0468c06d..4625b1276 100644 --- a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift +++ b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift @@ -4,7 +4,7 @@ protocol MapLibreMapOptionsSink { func setCameraTargetBounds(bounds: MLNCoordinateBounds?) func setCompassEnabled(compassEnabled: Bool) func setStyleString(styleString: String) - func setMinMaxZoomPreference(min: Double, max: Double) + func setMinMaxZoomPreference(min: Double?, max: Double?) func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) @@ -13,6 +13,8 @@ protocol MapLibreMapOptionsSink { func setMyLocationEnabled(myLocationEnabled: Bool) func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) + func setLogoEnabled(logoEnabled: Bool) + func setLogoViewPosition(position: MLNOrnamentPosition) func setLogoViewMargins(x: Double, y: Double) func setCompassViewPosition(position: MLNOrnamentPosition) func setCompassViewMargins(x: Double, y: Double) diff --git a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift index 01b8f1473..a4c5ec9fc 100644 --- a/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift +++ b/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift @@ -22,8 +22,72 @@ class SourcePropertyConverter { let system: MLNTileCoordinateSystem = (scheme == "tms" ? .TMS : .XYZ) options[.tileCoordinateSystem] = system.rawValue } + if let attribution = properties["attribution"] as? String { + options[.attributionInfos] = parseAttributionHTML(attribution) + } return options - // TODO: attribution not implemneted for IOS + } + + class func parseAttributionHTML(_ html: String) -> [MLNAttributionInfo] { + // Parse HTML attribution string and create a single attributed string with clickable links + let pattern = #"]*>([^<]+)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + // Regex failed, use plain text + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + let nsString = html as NSString + let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) + + if matches.isEmpty { + // No links found, create simple attribution + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + // Build a single attributed string with all text and apply link attributes + let attributedString = NSMutableAttributedString() + var lastLocation = 0 + var primaryURL: URL? + + for match in matches { + // Add text before the link + if match.range.location > lastLocation { + let plainRange = NSRange(location: lastLocation, length: match.range.location - lastLocation) + let plainText = nsString.substring(with: plainRange) + attributedString.append(NSAttributedString(string: plainText)) + } + + // Add the link text with link attribute + if match.numberOfRanges >= 3 { + let urlRange = match.range(at: 1) + let textRange = match.range(at: 2) + + let urlString = nsString.substring(with: urlRange) + let linkText = nsString.substring(with: textRange) + + if let url = URL(string: urlString) { + if primaryURL == nil { + primaryURL = url + } + let linkAttributedString = NSMutableAttributedString(string: linkText) + linkAttributedString.addAttribute(.link, value: url, range: NSRange(location: 0, length: linkText.count)) + attributedString.append(linkAttributedString) + } + } + + lastLocation = match.range.location + match.range.length + } + + // Add any remaining text after the last link + if lastLocation < nsString.length { + let remainingText = nsString.substring(from: lastLocation) + attributedString.append(NSAttributedString(string: remainingText)) + } + + return [MLNAttributionInfo(title: attributedString, url: primaryURL)] } class func buildRasterTileSource(identifier: String, diff --git a/maplibre_gl/lib/maplibre_gl.dart b/maplibre_gl/lib/maplibre_gl.dart index 2591386ca..f7e930443 100644 --- a/maplibre_gl/lib/maplibre_gl.dart +++ b/maplibre_gl/lib/maplibre_gl.dart @@ -69,6 +69,7 @@ export 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.da LocationEngineAndroidProperties, LocationEnginePlatforms, LocationPriority, + LogoViewPosition, MapLibreMethodChannel, MapLibrePlatform, MinMaxZoomPreference, diff --git a/maplibre_gl/lib/src/annotation_manager.dart b/maplibre_gl/lib/src/annotation_manager.dart index c15bdb78b..8ab46870d 100644 --- a/maplibre_gl/lib/src/annotation_manager.dart +++ b/maplibre_gl/lib/src/annotation_manager.dart @@ -4,6 +4,10 @@ part of '../maplibre_gl.dart'; /// owning their backing style source(s)/layer(s) and performing efficient /// batched updates. /// +/// The [initialize] method must be called before [AnnotationManager] instance +/// can be used. Once [AnnotationManager] is initialized, the [isInitialized] +/// getter will return true. +/// /// An [AnnotationManager] keeps an internal mapping from annotation id to its /// model object and mirrors the collection into one or more GeoJSON sources; /// each source is bound to a style layer whose visual properties come from @@ -13,12 +17,18 @@ part of '../maplibre_gl.dart'; /// underlying annotation is translated & re-set. abstract class AnnotationManager { final MapLibreMapController controller; + + bool _isInitializing = false; + bool _isInitialized = false; final _idToAnnotation = {}; final _idToLayerIndex = {}; /// Base identifier of the manager. Use [layerIds] for concrete layer ids. final String id; + /// Tracks whether the manager and its layers were initialized. + bool get isInitialized => _isInitialized; + List get layerIds => [for (int i = 0; i < allLayerProperties.length; i++) _makeLayerId(i)]; @@ -45,20 +55,42 @@ abstract class AnnotationManager { this.controller, { this.selectLayer, required this.enableInteraction, - }) : id = getRandomString() { - for (var i = 0; i < allLayerProperties.length; i++) { - final layerId = _makeLayerId(i); - unawaited(controller.addGeoJsonSource(layerId, buildFeatureCollection([]), - promoteId: "id")); - unawaited(controller.addLayer( - layerId, - layerId, - allLayerProperties[i], - enableInteraction: enableInteraction, - )); + }) : id = getRandomString(); + + @mustCallSuper + Future initialize() async { + if (_isInitializing || _isInitialized) { + return; } - controller.onFeatureDrag.add(_onDrag); + // Mark initialization process start, so that it cannot be entered again + _isInitializing = true; + + try { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + + await controller.addGeoJsonSource( + layerId, + buildFeatureCollection([]), + promoteId: "id", + ); + await controller.addLayer( + layerId, + layerId, + allLayerProperties[i], + enableInteraction: enableInteraction, + ); + } + + controller.onFeatureDrag.add(_onDrag); + + // Mark as initialized + _isInitialized = true; + } finally { + // Mark initialization process end + _isInitializing = false; + } } /// Rebuilds all backing style layers (e.g. after overlap settings changed). @@ -264,24 +296,32 @@ class SymbolManager extends AnnotationManager { /// If true, the icon will be visible even if it collides with other previously drawn symbols. Future setIconAllowOverlap(bool value) async { + if (value == _iconAllowOverlap) return; + _iconAllowOverlap = value; await _rebuildLayers(); } /// If true, other symbols can be visible even if they collide with the icon. Future setTextAllowOverlap(bool value) async { + if (value == _textAllowOverlap) return; + _textAllowOverlap = value; await _rebuildLayers(); } /// If true, the text will be visible even if it collides with other previously drawn symbols. Future setIconIgnorePlacement(bool value) async { + if (value == _iconIgnorePlacement) return; + _iconIgnorePlacement = value; await _rebuildLayers(); } /// If true, other symbols can be visible even if they collide with the text. Future setTextIgnorePlacement(bool value) async { + if (value == _textIgnorePlacement) return; + _textIgnorePlacement = value; await _rebuildLayers(); } diff --git a/maplibre_gl/lib/src/controller.dart b/maplibre_gl/lib/src/controller.dart index 0ebf3552b..f511b3cd0 100644 --- a/maplibre_gl/lib/src/controller.dart +++ b/maplibre_gl/lib/src/controller.dart @@ -176,13 +176,13 @@ class MapLibreMapController extends ChangeNotifier { _maplibrePlatform.onCameraMoveStartedPlatform.add((_) { _isCameraMoving = true; - notifyListeners(); + if (!isDisposed) notifyListeners(); }); _maplibrePlatform.onCameraMovePlatform.add((cameraPosition) { _cameraPosition = cameraPosition; onCameraMove?.call(cameraPosition); - notifyListeners(); + if (!isDisposed) notifyListeners(); }); _maplibrePlatform.onCameraIdlePlatform.add((cameraPosition) { @@ -191,10 +191,10 @@ class MapLibreMapController extends ChangeNotifier { _cameraPosition = cameraPosition; } onCameraIdle?.call(); - notifyListeners(); + if (!isDisposed) notifyListeners(); }); - _maplibrePlatform.onMapStyleLoadedPlatform.add((_) { + _maplibrePlatform.onMapStyleLoadedPlatform.add((_) async { final interactionEnabled = annotationConsumeTapEvents.toSet(); for (final type in annotationOrder.toSet()) { final enableInteraction = interactionEnabled.contains(type); @@ -204,21 +204,25 @@ class MapLibreMapController extends ChangeNotifier { this, enableInteraction: enableInteraction, ); + await fillManager!.initialize(); case AnnotationType.line: lineManager = LineManager( this, enableInteraction: enableInteraction, ); + await lineManager!.initialize(); case AnnotationType.circle: circleManager = CircleManager( this, enableInteraction: enableInteraction, ); + await circleManager!.initialize(); case AnnotationType.symbol: symbolManager = SymbolManager( this, enableInteraction: enableInteraction, ); + await symbolManager!.initialize(); } } onStyleLoadedCallback?.call(); @@ -526,9 +530,16 @@ class MapLibreMapController extends ChangeNotifier { /// /// The returned [Future] completes after the change has been made on the /// platform side. + /// + /// NOTE: The [properties] will not skip null values, so setting a property to null will potentially reset it to default. Future setLayerProperties( - String layerId, LayerProperties properties) async { - await _maplibrePlatform.setLayerProperties(layerId, properties.toJson()); + String layerId, + LayerProperties properties, + ) async { + await _maplibrePlatform.setLayerProperties( + layerId, + properties.toJson(skipNulls: false), + ); } /// Add a fill layer to the map with the given properties @@ -1700,8 +1711,8 @@ class MapLibreMapController extends ChangeNotifier { /// Ensures that the given manager is initialized. /// If not, throws an [Exception]. - void _ensureManagerInitialized(Object? manager) { - if (manager == null) { + void _ensureManagerInitialized(AnnotationManager? manager) { + if (manager == null || !manager.isInitialized) { throw Exception( "This Annotation Manager has not been initialized. Make sure that the map style has been loaded.", ); diff --git a/maplibre_gl/lib/src/layer_properties.dart b/maplibre_gl/lib/src/layer_properties.dart index f4e5735aa..7e0e38d28 100644 --- a/maplibre_gl/lib/src/layer_properties.dart +++ b/maplibre_gl/lib/src/layer_properties.dart @@ -4,7 +4,7 @@ part of '../maplibre_gl.dart'; abstract class LayerProperties { - Map toJson(); + Map toJson({bool skipNulls = true}); } class SymbolLayerProperties implements LayerProperties { @@ -903,13 +903,12 @@ class SymbolLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('icon-opacity', iconOpacity); @@ -1228,13 +1227,12 @@ class CircleLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('circle-radius', circleRadius); @@ -1532,13 +1530,12 @@ class LineLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('line-opacity', lineOpacity); @@ -1719,13 +1716,12 @@ class FillLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('fill-antialias', fillAntialias); @@ -1900,13 +1896,12 @@ class FillExtrusionLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('fill-extrusion-opacity', fillExtrusionOpacity); @@ -2077,13 +2072,12 @@ class RasterLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('raster-opacity', rasterOpacity); @@ -2227,13 +2221,12 @@ class HillshadeLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent( @@ -2357,13 +2350,12 @@ class HeatmapLayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } addIfPresent('heatmap-radius', heatmapRadius); diff --git a/maplibre_gl/lib/src/maplibre_map.dart b/maplibre_gl/lib/src/maplibre_map.dart index e105ec5af..87f4ba509 100644 --- a/maplibre_gl/lib/src/maplibre_map.dart +++ b/maplibre_gl/lib/src/maplibre_map.dart @@ -35,6 +35,8 @@ class MapLibreMap extends StatefulWidget { this.myLocationEnabled = false, this.myLocationTrackingMode = MyLocationTrackingMode.none, this.myLocationRenderMode = MyLocationRenderMode.normal, + this.logoEnabled = false, + this.logoViewPosition, this.logoViewMargins, this.compassViewPosition, this.compassViewMargins, @@ -88,9 +90,10 @@ class MapLibreMap extends StatefulWidget { /// **Available only on Android. Has no effect on iOS or Web.** final bool translucentTextureSurface; - /// Defines the layer order of annotations displayed on map + /// Defines the layer order of annotations displayed on map. + /// Order them from bottom to top. Bottom annotation will be rendered first. /// - /// Any annotation type can only be contained once, so 0 to 4 types + /// Any annotation type can only be contained once, so 0 to 4 types. /// /// Note that setting this to be empty gives a big perfomance boost for /// android. However if you do so annotations will not work. @@ -207,6 +210,13 @@ class MapLibreMap extends StatefulWidget { /// If this is set to a value other than [MyLocationRenderMode.normal], [myLocationEnabled] needs to be true. final MyLocationRenderMode myLocationRenderMode; + /// True if the MapLibre logo should be shown on the map. + /// Defaults to false. + final bool logoEnabled; + + /// Set the position for the Logo + final LogoViewPosition? logoViewPosition; + /// Set the layout margins for the Logo final Point? logoViewMargins; @@ -281,6 +291,7 @@ class MapLibreMap extends StatefulWidget { class _MapLibreMapState extends State { final Completer _controller = Completer(); + MapLibreMapController? _mapController; late _MapLibreMapOptions _maplibreMapOptions; final MapLibrePlatform _maplibrePlatform = MapLibrePlatform.createInstance(); @@ -312,12 +323,12 @@ class _MapLibreMapState extends State { } @override - Future dispose() async { - super.dispose(); + void dispose() { if (_controller.isCompleted) { - final controller = await _controller.future; - controller.dispose(); + _mapController?.dispose(); } + + super.dispose(); } @override @@ -365,6 +376,7 @@ class _MapLibreMapState extends State { annotationConsumeTapEvents: widget.annotationConsumeTapEvents, ); await _maplibrePlatform.initPlatform(id); + _mapController = controller; _controller.complete(controller); widget.onMapCreated?.call(controller); } @@ -389,6 +401,8 @@ class _MapLibreMapOptions { this.myLocationEnabled, this.myLocationTrackingMode, this.myLocationRenderMode, + this.logoEnabled, + this.logoViewPosition, this.logoViewMargins, this.compassViewPosition, this.compassViewMargins, @@ -415,6 +429,8 @@ class _MapLibreMapOptions { myLocationEnabled: map.myLocationEnabled, myLocationTrackingMode: map.myLocationTrackingMode, myLocationRenderMode: map.myLocationRenderMode, + logoEnabled: map.logoEnabled, + logoViewPosition: map.logoViewPosition, logoViewMargins: map.logoViewMargins, compassViewPosition: map.compassViewPosition, compassViewMargins: map.compassViewMargins, @@ -450,6 +466,10 @@ class _MapLibreMapOptions { final MyLocationRenderMode? myLocationRenderMode; + final bool? logoEnabled; + + final LogoViewPosition? logoViewPosition; + final Point? logoViewMargins; final CompassViewPosition? compassViewPosition; @@ -506,6 +526,8 @@ class _MapLibreMapOptions { addIfNonNull('myLocationEnabled', myLocationEnabled); addIfNonNull('myLocationTrackingMode', myLocationTrackingMode?.index); addIfNonNull('myLocationRenderMode', myLocationRenderMode?.index); + addIfNonNull('logoEnabled', logoEnabled); + addIfNonNull('logoViewPosition', logoViewPosition?.index); addIfNonNull('logoViewMargins', pointToArray(logoViewMargins)); addIfNonNull('compassViewPosition', compassViewPosition?.index); addIfNonNull('compassViewMargins', pointToArray(compassViewMargins)); diff --git a/maplibre_gl/pubspec.yaml b/maplibre_gl/pubspec.yaml index 4a0b77950..f5d99da71 100644 --- a/maplibre_gl/pubspec.yaml +++ b/maplibre_gl/pubspec.yaml @@ -1,6 +1,6 @@ name: maplibre_gl description: A Flutter plugin for integrating MapLibre Maps inside a Flutter application on Android, iOS and web platforms. -version: 0.24.1 +version: 0.25.0 repository: https://github.com/maplibre/flutter-maplibre-gl issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues resolution: workspace @@ -12,8 +12,8 @@ environment: dependencies: flutter: sdk: flutter - maplibre_gl_platform_interface: ^0.24.1 - maplibre_gl_web: ^0.24.1 + maplibre_gl_platform_interface: ^0.25.0 + maplibre_gl_web: ^0.25.0 dev_dependencies: very_good_analysis: ^10.0.0 diff --git a/maplibre_gl_example/android/settings.gradle b/maplibre_gl_example/android/settings.gradle index 64a0deb89..23b1cc95b 100644 --- a/maplibre_gl_example/android/settings.gradle +++ b/maplibre_gl_example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.12.0" apply false - id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "com.android.application" version "8.13.2" apply false + id "org.jetbrains.kotlin.android" version "2.3.0" apply false } include ":app" \ No newline at end of file diff --git a/maplibre_gl_example/assets/fill/cat_silhouette_pattern.png b/maplibre_gl_example/assets/pattern/cat_silhouette_pattern.png similarity index 100% rename from maplibre_gl_example/assets/fill/cat_silhouette_pattern.png rename to maplibre_gl_example/assets/pattern/cat_silhouette_pattern.png diff --git a/maplibre_gl_example/assets/pattern/marker_pattern.png b/maplibre_gl_example/assets/pattern/marker_pattern.png new file mode 100644 index 000000000..aa069df60 Binary files /dev/null and b/maplibre_gl_example/assets/pattern/marker_pattern.png differ diff --git a/maplibre_gl_example/lib/animate_camera.dart b/maplibre_gl_example/lib/animate_camera.dart deleted file mode 100644 index 4ba60672d..000000000 --- a/maplibre_gl_example/lib/animate_camera.dart +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class AnimateCameraPage extends ExamplePage { - const AnimateCameraPage({super.key}) - : super(const Icon(Icons.map), 'Camera control, animated'); - - @override - Widget build(BuildContext context) { - return const AnimateCamera(); - } -} - -class AnimateCamera extends StatefulWidget { - const AnimateCamera({super.key}); - - @override - State createState() => AnimateCameraState(); -} - -class AnimateCameraState extends State { - late MapLibreMapController mapController; - var _fps = 30; - - void _onMapCreated(MapLibreMapController controller) { - mapController = controller; - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - onMapCreated: _onMapCreated, - initialCameraPosition: - const CameraPosition(target: LatLng(0.0, 0.0)), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - TextButton( - onPressed: () async { - await mapController - .animateCamera( - CameraUpdate.newCameraPosition( - const CameraPosition( - bearing: 270.0, - target: LatLng(51.5160895, -0.1294527), - tilt: 30.0, - zoom: 17.0, - ), - ), - ) - .then( - (result) => debugPrint( - "mapController.animateCamera() returned $result"), - ); - }, - child: const Text('newCameraPosition'), - ), - if (!kIsWeb) - TextButton( - onPressed: () async { - await mapController - .easeCamera( - CameraUpdate.newCameraPosition( - const CameraPosition( - bearing: 270.0, - target: LatLng(46.233487, 14.363610), - tilt: 30.0, - zoom: 17.0, - ), - ), - duration: const Duration(seconds: 2), - ) - .then( - (result) => debugPrint( - "mapController.easeCamera() returned $result"), - ); - }, - child: const Text('easeCamera'), - ), - TextButton( - onPressed: () async { - await mapController - .animateCamera( - CameraUpdate.newLatLng( - const LatLng(56.1725505, 10.1850512), - ), - duration: const Duration(seconds: 5), - ) - .then((result) => debugPrint( - "mapController.animateCamera() returned $result")); - }, - child: const Text('newLatLng'), - ), - TextButton( - onPressed: () async { - await 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: () async { - await mapController.animateCamera( - CameraUpdate.newLatLngZoom( - const LatLng(37.4231613, -122.087159), - 11.0, - ), - ); - }, - child: const Text('newLatLngZoom'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.scrollBy(150.0, -225.0), - ); - }, - child: const Text('scrollBy'), - ), - if (!kIsWeb) - TextButton( - onPressed: () async { - await mapController.queryCameraPosition().then( - (result) => debugPrint( - "queryCameraPosition() returned $result"), - ); - }, - child: const Text('queryCameraPosition'), - ), - TextButton( - onPressed: () async { - _fps = _fps == 30 ? 3 : 30; - await mapController.setMaximumFps(_fps); - }, - child: const Text('setMaximumFps'), - ), - ], - ), - Column( - children: [ - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.zoomBy( - -0.5, - const Offset(30.0, 20.0), - ), - ); - }, - child: const Text('zoomBy with focus'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.newLatLngZoom(const LatLng(48, 11), 5), - duration: const Duration(milliseconds: 300), - ); - }, - child: const Text('latlngZoom'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.zoomBy(-0.5), - ); - }, - child: const Text('zoomBy'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.zoomIn(), - ); - }, - child: const Text('zoomIn'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.zoomOut(), - ); - }, - child: const Text('zoomOut'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.zoomTo(16.0), - ); - }, - child: const Text('zoomTo'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.bearingTo(45.0), - ); - }, - child: const Text('bearingTo'), - ), - TextButton( - onPressed: () async { - await mapController.animateCamera( - CameraUpdate.tiltTo(30.0), - ); - }, - child: const Text('tiltTo'), - ), - ], - ), - ], - ) - ], - ); - } -} diff --git a/maplibre_gl_example/lib/annotation_order_maps.dart b/maplibre_gl_example/lib/annotation_order_maps.dart deleted file mode 100644 index ad6526c5d..000000000 --- a/maplibre_gl_example/lib/annotation_order_maps.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; -import 'util.dart'; - -class AnnotationOrderPage extends ExamplePage { - const AnnotationOrderPage({super.key}) - : super(const Icon(Icons.layers), 'Annotation order maps'); - - @override - Widget build(BuildContext context) => const AnnotationOrderBody(); -} - -class AnnotationOrderBody extends StatefulWidget { - const AnnotationOrderBody({super.key}); - - @override - State createState() => _AnnotationOrderBodyState(); -} - -class _AnnotationOrderBodyState extends State { - late MapLibreMapController controllerOne; - late MapLibreMapController controllerTwo; - - final LatLng center = const LatLng(36.580664, 32.5563837); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Card( - 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: MapLibreMap( - onMapCreated: onMapCreatedOne, - onStyleLoadedCallback: () => onStyleLoaded(controllerOne), - initialCameraPosition: CameraPosition( - target: center, - zoom: 5.0, - ), - annotationOrder: const [ - AnnotationType.line, - AnnotationType.symbol, - AnnotationType.circle, - AnnotationType.fill, - ], - ), - ), - ), - ], - ), - ), - Card( - 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: MapLibreMap( - onMapCreated: onMapCreatedTwo, - onStyleLoadedCallback: () => onStyleLoaded(controllerTwo), - initialCameraPosition: CameraPosition( - target: center, - zoom: 5.0, - ), - annotationOrder: const [ - AnnotationType.fill, - AnnotationType.line, - AnnotationType.symbol, - AnnotationType.circle, - ], - ), - ), - ), - ], - ), - ), - ], - ); - } - - void onMapCreatedOne(MapLibreMapController controller) { - controllerOne = controller; - } - - void onMapCreatedTwo(MapLibreMapController controller) { - controllerTwo = controller; - } - - Future onStyleLoaded(MapLibreMapController controller) async { - await addImageFromAsset( - controller, "custom-marker", "assets/symbols/custom-marker.png"); - controller.addSymbol( - SymbolOptions( - geometry: LatLng( - center.latitude, - center.longitude, - ), - iconImage: "custom-marker", // "airport-15", - ), - ); - controller.addLine( - const 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( - const 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/maplibre_gl_example/lib/attribution.dart b/maplibre_gl_example/lib/attribution.dart deleted file mode 100644 index 893da03a6..000000000 --- a/maplibre_gl_example/lib/attribution.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class AttributionPage extends ExamplePage { - const AttributionPage({super.key}) - : super(const Icon(Icons.thumb_up), 'Attribution'); - - @override - Widget build(BuildContext context) { - return const AttributionBody(); - } -} - -class AttributionBody extends StatefulWidget { - const AttributionBody({super.key}); - - @override - State createState() => _AttributionBodyState(); -} - -class _AttributionBodyState extends State { - AttributionButtonPosition? attributionButtonPosition; - bool useDefaultAttributionPosition = true; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - const Text("Set attribution position"), - Wrap( - children: [ - buildDefaultPositionButton(), - buildPositionButton(null), - buildPositionButton(AttributionButtonPosition.topRight), - buildPositionButton(AttributionButtonPosition.topLeft), - buildPositionButton(AttributionButtonPosition.bottomRight), - buildPositionButton(AttributionButtonPosition.bottomLeft), - ], - ), - Expanded( - child: buildMap( - attributionButtonPosition, - useDefaultAttributionPosition, - ), - ), - ], - ); - } - - ElevatedButton buildDefaultPositionButton() { - return ElevatedButton( - onPressed: () { - setState(() { - attributionButtonPosition = null; - useDefaultAttributionPosition = true; - }); - }, - child: const Text("Default"), - ); - } - - ElevatedButton buildPositionButton(AttributionButtonPosition? position) { - return ElevatedButton( - onPressed: () { - setState(() { - attributionButtonPosition = position; - useDefaultAttributionPosition = false; - }); - }, - child: Text(position?.name ?? "Null (=platform default)"), - ); - } - - MapLibreMap buildMap( - AttributionButtonPosition? attributionButtonPosition, - bool useDefaultAttributionPosition, - ) { - if (useDefaultAttributionPosition) { - return MapLibreMap( - key: UniqueKey(), - initialCameraPosition: const CameraPosition( - target: LatLng(-33.852, 151.211), - zoom: 11.0, - ), - styleString: "assets/osm_style.json", - ); - } else { - return MapLibreMap( - key: UniqueKey(), - initialCameraPosition: const CameraPosition( - target: LatLng(-33.852, 151.211), - zoom: 11.0, - ), - styleString: "assets/osm_style.json", - attributionButtonPosition: attributionButtonPosition, - ); - } - } -} diff --git a/maplibre_gl_example/lib/click_annotations.dart b/maplibre_gl_example/lib/click_annotations.dart deleted file mode 100644 index 7e0a13d3e..000000000 --- a/maplibre_gl_example/lib/click_annotations.dart +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; -import 'util.dart'; - -class ClickAnnotationPage extends ExamplePage { - const ClickAnnotationPage({super.key}) - : super(const Icon(Icons.check_circle), 'Annotation tap'); - - @override - Widget build(BuildContext context) { - return const ClickAnnotationBody(); - } -} - -class ClickAnnotationBody extends StatefulWidget { - const ClickAnnotationBody({super.key}); - - @override - State createState() => ClickAnnotationBodyState(); -} - -class ClickAnnotationBodyState extends State { - ClickAnnotationBodyState(); - - static const LatLng center = LatLng(-33.88, 151.16); - - MapLibreMapController? controller; - - void _onMapCreated(MapLibreMapController 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(); - } - - void _showSnackBar(String type, String id) { - final snackBar = SnackBar( - content: Text( - 'Tapped $type $id', - style: const 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); - } - - Future _onStyleLoaded() async { - await addImageFromAsset( - controller!, "custom-marker", "assets/symbols/custom-marker.png"); - controller!.addCircle( - const CircleOptions( - geometry: LatLng(-33.881979408447314, 151.171361438502117), - circleStrokeColor: "#00FF00", - circleStrokeWidth: 2, - circleRadius: 16, - ), - ); - controller!.addCircle( - const CircleOptions( - geometry: LatLng(-33.894372606072309, 151.17576679759523), - circleStrokeColor: "#00FF00", - circleStrokeWidth: 2, - circleRadius: 30, - ), - ); - controller!.addSymbol( - const SymbolOptions( - geometry: LatLng(-33.894372606072309, 151.17576679759523), - iconImage: "custom-marker", //"fast-food-15", - iconSize: 2), - ); - controller!.addLine( - const 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( - const 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 MapLibreMap( - annotationOrder: const [ - AnnotationType.fill, - AnnotationType.line, - AnnotationType.circle, - AnnotationType.symbol, - ], - onMapCreated: _onMapCreated, - onStyleLoadedCallback: _onStyleLoaded, - initialCameraPosition: const CameraPosition( - target: center, - zoom: 12.0, - ), - ); - } -} diff --git a/maplibre_gl_example/lib/offline_region_map.dart b/maplibre_gl_example/lib/examples/advanced/offline_region_map.dart similarity index 100% rename from maplibre_gl_example/lib/offline_region_map.dart rename to maplibre_gl_example/lib/examples/advanced/offline_region_map.dart diff --git a/maplibre_gl_example/lib/offline_regions.dart b/maplibre_gl_example/lib/examples/advanced/offline_regions.dart similarity index 51% rename from maplibre_gl_example/lib/offline_regions.dart rename to maplibre_gl_example/lib/examples/advanced/offline_regions.dart index c09a2a0fe..5d714aa3a 100644 --- a/maplibre_gl_example/lib/offline_regions.dart +++ b/maplibre_gl_example/lib/examples/advanced/offline_regions.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'offline_region_map.dart'; -import 'page.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; final LatLngBounds hawaiiBounds = LatLngBounds( southwest: const LatLng(17.26672, -161.14746), @@ -101,22 +102,24 @@ final List allRegions = [ class OfflineRegionsPage extends ExamplePage { const OfflineRegionsPage({super.key}) - : super(const Icon(Icons.map), 'Offline Regions'); + : super( + const Icon(Icons.cloud_off), + 'Offline Regions', + category: ExampleCategory.advanced, + ); @override - Widget build(BuildContext context) { - return const OfflineRegionBody(); - } + Widget build(BuildContext context) => const _OfflineRegionBody(); } -class OfflineRegionBody extends StatefulWidget { - const OfflineRegionBody({super.key}); +class _OfflineRegionBody extends StatefulWidget { + const _OfflineRegionBody(); @override - State createState() => _OfflineRegionsBodyState(); + State<_OfflineRegionBody> createState() => _OfflineRegionsBodyState(); } -class _OfflineRegionsBodyState extends State { +class _OfflineRegionsBodyState extends State<_OfflineRegionBody> { final List _items = []; @override @@ -127,60 +130,105 @@ class _OfflineRegionsBodyState extends State { @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: const Icon(Icons.map), - onPressed: () => _goToMap(_items[index]), - ), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _items[index].name, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, + return _items.isEmpty + ? const Center( + child: CircularProgressIndicator(), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _items.length, + itemBuilder: (context, index) { + final item = _items[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: item.isDownloaded + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + item.isDownloaded + ? Icons.cloud_done + : Icons.cloud_download, + color: item.isDownloaded + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + title: Text( + item.name, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'Estimated tiles: ${item.estimatedTiles}', + style: Theme.of(context).textTheme.bodyMedium, + ), + trailing: item.isDownloading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : null, ), - ), - Text( - 'Est. tiles: ${_items[index].estimatedTiles}', - style: const TextStyle( - fontSize: 16, + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: ExampleButton( + label: 'View Map', + icon: Icons.map_outlined, + onPressed: () => _goToMap(item), + style: ExampleButtonStyle.outlined, + ), + ), + const SizedBox(width: 8), + Expanded( + child: item.isDownloaded + ? ExampleButton( + label: 'Delete', + icon: Icons.delete_outline, + onPressed: item.isDownloading + ? null + : () => _deleteRegion(item, index), + style: ExampleButtonStyle.destructive, + ) + : ExampleButton( + label: 'Download', + icon: Icons.download, + onPressed: item.isDownloading + ? null + : () => _downloadRegion(item, index), + style: ExampleButtonStyle.filled, + ), + ), + ], + ), ), - ), - ], - ), - const Spacer(), - if (_items[index].isDownloading) - const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(), - ) - else - IconButton( - icon: Icon( - _items[index].isDownloaded - ? Icons.delete - : Icons.file_download, - ), - onPressed: _items[index].isDownloaded - ? () => _deleteRegion(_items[index], index) - : () => _downloadRegion(_items[index], index), + ], ), - ], - ), - ), - ], - ); + ); + }, + ); } Future _updateListOfRegions() async { diff --git a/maplibre_gl_example/lib/examples/advanced/pmtiles.dart b/maplibre_gl_example/lib/examples/advanced/pmtiles.dart new file mode 100644 index 000000000..adb337153 --- /dev/null +++ b/maplibre_gl_example/lib/examples/advanced/pmtiles.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +const _nullIsland = CameraPosition(target: LatLng(0, 0), zoom: 4.0); + +/// Example demonstrating PMTiles vector tiles format +class PMTilesPage extends ExamplePage { + const PMTilesPage({super.key}) + : super( + const Icon(Icons.map_outlined), + 'PMTiles', + category: ExampleCategory.advanced, + ); + + @override + Widget build(BuildContext context) => const _PMTilesBody(); +} + +class _PMTilesBody extends StatefulWidget { + const _PMTilesBody(); + + @override + State<_PMTilesBody> createState() => _PMTilesBodyState(); +} + +class _PMTilesBodyState extends State<_PMTilesBody> { + MapLibreMapController? _mapController; + bool _canInteractWithMap = false; + bool _canReset = false; + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + } + + void _onStyleLoaded() { + setState(() => _canInteractWithMap = true); + } + + Future _moveCameraToLondon() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition(ExampleConstants.londonCameraPosition), + ); + setState(() => _canReset = true); + } + + Future _resetCamera() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition(_nullIsland), + ); + setState(() => _canReset = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: MapLibreMap( + styleString: 'assets/pmtiles_style.json', + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: _nullIsland, + logoEnabled: true, + trackCameraPosition: true, + compassEnabled: true, + ), + floatingActionButton: ExampleButton( + label: _canReset ? 'Reset camera' : 'Go to London', + icon: _canReset ? Icons.refresh : Icons.flight_takeoff, + onPressed: _canInteractWithMap + ? _canReset + ? _resetCamera + : _moveCameraToLondon + : null, + style: ExampleButtonStyle.tonal, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + } +} diff --git a/maplibre_gl_example/lib/examples/advanced/translucent_full_map.dart b/maplibre_gl_example/lib/examples/advanced/translucent_full_map.dart new file mode 100644 index 000000000..fe6f6d18f --- /dev/null +++ b/maplibre_gl_example/lib/examples/advanced/translucent_full_map.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating a translucent map with content underneath +class TranslucentFullMapPage extends ExamplePage { + const TranslucentFullMapPage({super.key}) + : super( + const Icon(Icons.layers), + 'Translucent map', + category: ExampleCategory.advanced, + ); + + @override + Widget build(BuildContext context) => const _TranslucentMapBody(); +} + +class _TranslucentMapBody extends StatefulWidget { + const _TranslucentMapBody(); + + @override + State<_TranslucentMapBody> createState() => _TranslucentMapBodyState(); +} + +class _TranslucentMapBodyState extends State<_TranslucentMapBody> { + MapLibreMapController? _mapController; + bool _canInteractWithMap = false; + bool _canReset = false; + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + } + + void _onStyleLoaded() { + setState(() => _canInteractWithMap = true); + } + + Future _moveCameraToTokyo() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition(ExampleConstants.tokyoCameraPosition), + ); + setState(() => _canReset = true); + } + + Future _resetCamera() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition(ExampleConstants.defaultCameraPosition), + ); + setState(() => _canReset = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + const ColoredBox( + color: Colors.blue, + child: Center( + child: Text( + 'Any widget can be here', + style: TextStyle( + fontSize: 20, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + MapLibreMap( + styleString: 'assets/translucent_style.json', + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + logoEnabled: true, + trackCameraPosition: true, + compassEnabled: true, + // This is a random color, for example purposes. + foregroundLoadColor: Colors.purple, + // This sets the map to be translucent. + translucentTextureSurface: true, + ), + ], + ), + floatingActionButton: ExampleButton( + label: _canReset ? 'Reset camera' : 'Go to Tokyo', + icon: _canReset ? Icons.refresh : Icons.location_searching, + onPressed: _canInteractWithMap + ? _canReset + ? _resetCamera + : _moveCameraToTokyo + : null, + style: ExampleButtonStyle.tonal, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + } +} diff --git a/maplibre_gl_example/lib/examples/annotations/annotation_order_example.dart b/maplibre_gl_example/lib/examples/annotations/annotation_order_example.dart new file mode 100644 index 000000000..00627d48a --- /dev/null +++ b/maplibre_gl_example/lib/examples/annotations/annotation_order_example.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; +import '../../util.dart'; + +/// Example demonstrating annotation rendering order control +class AnnotationOrderExample extends ExamplePage { + const AnnotationOrderExample({super.key}) + : super( + const Icon(Icons.layers), + 'Annotation Order', + category: ExampleCategory.annotations, + ); + + @override + Widget build(BuildContext context) => const _AnnotationOrderBody(); +} + +class _AnnotationOrderBody extends StatefulWidget { + const _AnnotationOrderBody(); + + @override + State<_AnnotationOrderBody> createState() => _AnnotationOrderBodyState(); +} + +class _AnnotationOrderBodyState extends State<_AnnotationOrderBody> { + MapLibreMapController? _controller; + + // Default order: bottom to top + final List _annotationOrder = [ + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + AnnotationType.symbol, + ]; + + final LatLng _center = const LatLng(36.580664, 32.5563837); + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + if (_controller == null) return; + + await addImageFromAsset( + _controller!, + "custom-marker", + "assets/symbols/custom-marker.png", + ); + + await _controller!.addSymbol( + const SymbolOptions( + geometry: LatLng(35.5, 31.8), + iconImage: "custom-marker", + iconSize: 2.5, + iconOpacity: 1.0, + ), + ); + + await _controller!.addLine( + const LineOptions( + lineColor: "#ff0000", + lineWidth: 8.0, + lineOpacity: 1.0, + geometry: [ + LatLng(37.5, 31.0), + LatLng(36.5, 31.3), + LatLng(36.0, 31.5), + LatLng(35.5, 31.8), + LatLng(35.0, 32.0), + LatLng(34.5, 31.5), + ], + ), + ); + + await _controller!.addFill( + const FillOptions( + fillColor: "#00aa88", + fillOpacity: 1.0, + fillOutlineColor: "#008866", + geometry: [ + [ + LatLng(35.3649902, 32.0593003), + LatLng(34.9475098, 31.1187944), + LatLng(36.7108154, 30.7040582), + LatLng(37.6995850, 33.6512083), + LatLng(35.3814697, 32.0546447), + ] + ], + ), + ); + + await _controller!.addCircle( + const CircleOptions( + geometry: LatLng(36.0, 31.5), + circleRadius: 13.0, + circleColor: "#4169E1", + circleOpacity: 1.0, + ), + ); + } + + void _recreateMap() { + setState(() { + _controller = null; + }); + } + + IconData _getIconForType(AnnotationType type) { + switch (type) { + case AnnotationType.fill: + return Icons.square; + case AnnotationType.line: + return Icons.show_chart; + case AnnotationType.symbol: + return Icons.place; + case AnnotationType.circle: + return Icons.circle; + } + } + + String _getNameForType(AnnotationType type) { + switch (type) { + case AnnotationType.fill: + return 'Fill'; + case AnnotationType.line: + return 'Line'; + case AnnotationType.symbol: + return 'Symbol'; + case AnnotationType.circle: + return 'Circle'; + } + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + mapHeightRatio: 0.4, + map: MapLibreMap( + key: ValueKey(_annotationOrder.toString()), + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: CameraPosition( + target: _center, + zoom: 5.5, + ), + annotationOrder: _annotationOrder, + ), + controls: [ + const InfoCard( + title: 'Drag to reorder Annotations', + subtitle: 'Change rendering order from bottom to top', + icon: Icons.layers, + ), + Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.drag_indicator, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Rendering order (Last on Top)', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 4), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _annotationOrder.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _annotationOrder.removeAt(oldIndex); + _annotationOrder.insert(newIndex, item); + _recreateMap(); + }); + }, + itemBuilder: (context, index) { + final type = _annotationOrder[index]; + return Container( + key: ValueKey(type), + margin: const EdgeInsets.only(bottom: 8), + child: Card( + elevation: 2, + child: ListTile( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + ), + const SizedBox(width: 12), + Icon( + _getIconForType(type), + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + title: Text( + _getNameForType(type), + style: Theme.of(context).textTheme.bodyLarge, + ), + trailing: Icon( + Icons.drag_handle, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/maplibre_gl_example/lib/examples/annotations/annotation_properties_example.dart b/maplibre_gl_example/lib/examples/annotations/annotation_properties_example.dart new file mode 100644 index 000000000..c3b81469d --- /dev/null +++ b/maplibre_gl_example/lib/examples/annotations/annotation_properties_example.dart @@ -0,0 +1,684 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl_example/util.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating dynamic annotation property updates +class AnnotationPropertiesExample extends ExamplePage { + const AnnotationPropertiesExample({super.key}) + : super( + const Icon(Icons.tune), + 'Annotation Properties', + category: ExampleCategory.annotations, + ); + + @override + Widget build(BuildContext context) => const _AnnotationPropertiesBody(); +} + +enum PropertyAnnotationType { symbol, circle, fill, line } + +class _AnnotationPropertiesBody extends StatefulWidget { + const _AnnotationPropertiesBody(); + + @override + State<_AnnotationPropertiesBody> createState() => + _AnnotationPropertiesBodyState(); +} + +class _AnnotationPropertiesBodyState extends State<_AnnotationPropertiesBody> { + MapLibreMapController? _controller; + PropertyAnnotationType _currentType = PropertyAnnotationType.symbol; + + // Single annotation of each type + Symbol? _symbol; + Circle? _circle; + Fill? _fill; + Line? _line; + + // Symbol properties + double _iconSize = 2.0; + double _iconRotate = 0.0; + double _textSize = 12.0; + String _textField = 'Symbol'; + + // Circle properties + double _circleRadius = 20.0; + double _circleOpacity = 0.8; + double _circleStrokeWidth = 2.0; + String _circleColor = '#3498DB'; + + // Fill properties + double _fillOpacity = 0.6; + String _fillColor = '#E74C3C'; + String _fillOutlineColor = '#C0392B'; + + // Line properties + double _lineWidth = 4.0; + double _lineOpacity = 0.9; + String _lineColor = '#2ECC71'; + double _lineBlur = 0.0; + + @override + void initState() { + super.initState(); + } + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + await _addInitialAnnotations(); + } + + Future _addInitialAnnotations() async { + if (_controller == null) return; + + try { + await addImageFromAsset( + _controller!, + "custom-marker", + "assets/symbols/custom-marker.png", + ); + + const center = ExampleConstants.sydneyCenter; + + // Add one of each annotation type at different positions + _symbol = await _controller!.addSymbol( + SymbolOptions( + geometry: LatLng(center.latitude + 0.15, center.longitude), + iconImage: 'custom-marker', + iconSize: _iconSize, + iconRotate: _iconRotate, + textField: _textField, + textSize: _textSize, + textOffset: const Offset(0, 2), + ), + ); + + _circle = await _controller!.addCircle( + CircleOptions( + geometry: LatLng(center.latitude + 0.05, center.longitude), + circleRadius: _circleRadius, + circleColor: _circleColor, + circleOpacity: _circleOpacity, + circleStrokeWidth: _circleStrokeWidth, + circleStrokeColor: '#FFFFFF', + ), + ); + + _fill = await _controller!.addFill( + FillOptions( + geometry: [ + _generatePolygon( + LatLng(center.latitude - 0.05, center.longitude), + ) + ], + fillColor: _fillColor, + fillOpacity: _fillOpacity, + fillOutlineColor: _fillOutlineColor, + ), + ); + + _line = await _controller!.addLine( + LineOptions( + geometry: _generateLineString( + LatLng(center.latitude - 0.15, center.longitude), + ), + lineColor: _lineColor, + lineWidth: _lineWidth, + lineOpacity: _lineOpacity, + lineBlur: _lineBlur, + ), + ); + + setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding annotations: $e')), + ); + } + } + } + + List _generatePolygon(LatLng center) { + final points = []; + const size = 0.025; + points.add(LatLng(center.latitude - size, center.longitude - size)); + points.add(LatLng(center.latitude - size, center.longitude + size)); + points.add(LatLng(center.latitude + size, center.longitude + size)); + points.add(LatLng(center.latitude + size, center.longitude - size)); + points.add(LatLng(center.latitude - size, center.longitude - size)); + return points; + } + + List _generateLineString(LatLng center) { + final points = []; + const step = 0.02; + for (var i = 0; i < 5; i++) { + points.add(LatLng( + center.latitude + (i * step * 0.3), + center.longitude - (step * 2) + (i * step), + )); + } + return points; + } + + Future _updateSymbolProperties() async { + if (_controller == null || _symbol == null) return; + + try { + await _controller!.updateSymbol( + _symbol!, + SymbolOptions( + iconSize: _iconSize, + iconRotate: _iconRotate, + textField: _textField, + textSize: _textSize, + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating symbol: $e')), + ); + } + } + } + + Future _updateCircleProperties() async { + if (_controller == null || _circle == null) return; + + try { + await _controller!.updateCircle( + _circle!, + CircleOptions( + circleRadius: _circleRadius, + circleColor: _circleColor, + circleOpacity: _circleOpacity, + circleStrokeWidth: _circleStrokeWidth, + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating circle: $e')), + ); + } + } + } + + Future _updateFillProperties() async { + if (_controller == null || _fill == null) return; + + try { + await _controller!.updateFill( + _fill!, + FillOptions( + fillColor: _fillColor, + fillOpacity: _fillOpacity, + fillOutlineColor: _fillOutlineColor, + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating fill: $e')), + ); + } + } + } + + Future _updateLineProperties() async { + if (_controller == null || _line == null) return; + + try { + await _controller!.updateLine( + _line!, + LineOptions( + lineColor: _lineColor, + lineWidth: _lineWidth, + lineOpacity: _lineOpacity, + lineBlur: _lineBlur, + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating line: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: ExampleConstants.sydneyCenter, + zoom: 9, + ), + trackCameraPosition: true, + ), + controls: _controller == null + ? [] + : [ + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Annotation Type', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: PropertyAnnotationType.symbol, + label: 'Symbol', + icon: Icons.place, + ), + ExampleSegment( + value: PropertyAnnotationType.circle, + label: 'Circle', + icon: Icons.circle_outlined, + ), + ExampleSegment( + value: PropertyAnnotationType.fill, + label: 'Fill', + icon: Icons.square, + ), + ExampleSegment( + value: PropertyAnnotationType.line, + label: 'Line', + icon: Icons.timeline, + ), + ], + selected: _currentType, + onSelectionChanged: (type) { + setState(() => _currentType = type); + }, + ), + ], + ), + ), + ), + if (_currentType == PropertyAnnotationType.symbol) + ..._buildSymbolControls(), + if (_currentType == PropertyAnnotationType.circle) + ..._buildCircleControls(), + if (_currentType == PropertyAnnotationType.fill) + ..._buildFillControls(), + if (_currentType == PropertyAnnotationType.line) + ..._buildLineControls(), + ], + ); + } + + List _buildSymbolControls() { + return [ + ControlGroup( + title: 'Icon Properties', + children: [ + ListTile( + title: Text('Icon Size: ${_iconSize.toStringAsFixed(1)}'), + subtitle: Slider( + value: _iconSize, + min: 0.5, + max: 4.0, + divisions: 35, + onChanged: (value) async { + setState(() => _iconSize = value); + await _updateSymbolProperties(); + }, + ), + ), + ListTile( + title: Text('Icon Rotation: ${_iconRotate.toStringAsFixed(0)}°'), + subtitle: Slider( + value: _iconRotate, + min: 0.0, + max: 360.0, + divisions: 72, + onChanged: (value) async { + setState(() => _iconRotate = value); + await _updateSymbolProperties(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Text Properties', + children: [ + ListTile( + title: const Text('Text Content'), + subtitle: TextField( + controller: TextEditingController(text: _textField) + ..selection = + TextSelection.collapsed(offset: _textField.length), + onChanged: (value) async { + setState(() => _textField = value); + await _updateSymbolProperties(); + }, + decoration: const InputDecoration( + hintText: 'Enter text', + border: OutlineInputBorder(), + ), + ), + ), + ListTile( + title: Text('Text Size: ${_textSize.toStringAsFixed(1)}'), + subtitle: Slider( + value: _textSize, + min: 8.0, + max: 24.0, + divisions: 32, + onChanged: (value) async { + setState(() => _textSize = value); + await _updateSymbolProperties(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _iconSize = 2.0; + _iconRotate = 0.0; + _textSize = 12.0; + _textField = 'Symbol'; + }); + await _updateSymbolProperties(); + }, + ), + ], + ), + ]; + } + + List _buildCircleControls() { + return [ + ControlGroup( + title: 'Circle Appearance', + children: [ + ListTile( + title: Text('Radius: ${_circleRadius.toStringAsFixed(1)}'), + subtitle: Slider( + value: _circleRadius, + min: 5.0, + max: 50.0, + divisions: 45, + onChanged: (value) async { + setState(() => _circleRadius = value); + await _updateCircleProperties(); + }, + ), + ), + ListTile( + title: + Text('Opacity: ${(_circleOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _circleOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _circleOpacity = value); + await _updateCircleProperties(); + }, + ), + ), + ListTile( + title: + Text('Stroke Width: ${_circleStrokeWidth.toStringAsFixed(1)}'), + subtitle: Slider( + value: _circleStrokeWidth, + min: 0.0, + max: 10.0, + divisions: 20, + onChanged: (value) async { + setState(() => _circleStrokeWidth = value); + await _updateCircleProperties(); + }, + ), + ), + ListTile( + title: const Text('Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(int.parse(_circleColor.substring(1), radix: 16) + + 0xFF000000), + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('circle'), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _circleRadius = 20.0; + _circleOpacity = 0.8; + _circleStrokeWidth = 2.0; + _circleColor = '#3498DB'; + }); + await _updateCircleProperties(); + }, + ), + ], + ), + ]; + } + + List _buildFillControls() { + return [ + ControlGroup( + title: 'Fill Appearance', + children: [ + ListTile( + title: Text('Opacity: ${(_fillOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _fillOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _fillOpacity = value); + await _updateFillProperties(); + }, + ), + ), + ListTile( + title: const Text('Fill Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color( + int.parse(_fillColor.substring(1), radix: 16) + 0xFF000000), + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('fill'), + ), + ListTile( + title: const Text('Outline Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color( + int.parse(_fillOutlineColor.substring(1), radix: 16) + + 0xFF000000), + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('fillOutline'), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _fillOpacity = 0.6; + _fillColor = '#E74C3C'; + _fillOutlineColor = '#C0392B'; + }); + await _updateFillProperties(); + }, + ), + ], + ), + ]; + } + + List _buildLineControls() { + return [ + ControlGroup( + title: 'Line Appearance', + children: [ + ListTile( + title: Text('Width: ${_lineWidth.toStringAsFixed(1)}'), + subtitle: Slider( + value: _lineWidth, + min: 1.0, + max: 20.0, + divisions: 38, + onChanged: (value) async { + setState(() => _lineWidth = value); + await _updateLineProperties(); + }, + ), + ), + ListTile( + title: Text('Opacity: ${(_lineOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _lineOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _lineOpacity = value); + await _updateLineProperties(); + }, + ), + ), + ListTile( + title: Text('Blur: ${_lineBlur.toStringAsFixed(1)}'), + subtitle: Slider( + value: _lineBlur, + min: 0.0, + max: 10.0, + divisions: 20, + onChanged: (value) async { + setState(() => _lineBlur = value); + await _updateLineProperties(); + }, + ), + ), + ListTile( + title: const Text('Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color( + int.parse(_lineColor.substring(1), radix: 16) + 0xFF000000), + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('line'), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _lineWidth = 4.0; + _lineOpacity = 0.9; + _lineColor = '#2ECC71'; + _lineBlur = 0.0; + }); + await _updateLineProperties(); + }, + ), + ], + ), + ]; + } + + Future _pickColor(String type) async { + String? currentHexColor; + switch (type) { + case 'circle': + currentHexColor = _circleColor; + case 'fill': + currentHexColor = _fillColor; + case 'fillOutline': + currentHexColor = _fillOutlineColor; + case 'line': + currentHexColor = _lineColor; + } + + final selectedColor = await ColorPickerModal.showForHex( + context: context, + title: 'Select Color', + currentHexColor: currentHexColor, + ); + + if (selectedColor != null) { + switch (type) { + case 'circle': + setState(() => _circleColor = selectedColor); + await _updateCircleProperties(); + case 'fill': + setState(() => _fillColor = selectedColor); + await _updateFillProperties(); + case 'fillOutline': + setState(() => _fillOutlineColor = selectedColor); + await _updateFillProperties(); + case 'line': + setState(() => _lineColor = selectedColor); + await _updateLineProperties(); + } + } + } +} diff --git a/maplibre_gl_example/lib/examples/annotations/annotations_example.dart b/maplibre_gl_example/lib/examples/annotations/annotations_example.dart new file mode 100644 index 000000000..c58c8db5d --- /dev/null +++ b/maplibre_gl_example/lib/examples/annotations/annotations_example.dart @@ -0,0 +1,545 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl_example/util.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Unified example for all annotation types (symbols, circles, fills, lines) +class AnnotationsExample extends ExamplePage { + const AnnotationsExample({super.key}) + : super( + const Icon(Icons.place), + 'Annotations', + category: ExampleCategory.annotations, + ); + + @override + Widget build(BuildContext context) => const _AnnotationsBody(); +} + +enum AnnotationType { symbol, circle, fill, line } + +class _AnnotationsBody extends StatefulWidget { + const _AnnotationsBody(); + + @override + State<_AnnotationsBody> createState() => _AnnotationsBodyState(); +} + +class _AnnotationsBodyState extends State<_AnnotationsBody> { + MapLibreMapController? _controller; + AnnotationType _currentType = AnnotationType.symbol; + + final Map _symbols = {}; + final Map _circles = {}; + final Map _fills = {}; + final Map _lines = {}; + + int _counter = 0; + String? _lastTappedAnnotation; + + void _onMapCreated(MapLibreMapController controller) { + controller.onSymbolTapped.add(_onSymbolTapped); + controller.onCircleTapped.add(_onCircleTapped); + controller.onFillTapped.add(_onFillTapped); + controller.onLineTapped.add(_onLineTapped); + + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + await addImageFromAsset( + _controller!, + "custom-marker", + "assets/symbols/custom-marker.png", + ); + } + + void _onSymbolTapped(Symbol symbol) { + final symbolId = _symbols.entries + .firstWhere((entry) => entry.value == symbol, + orElse: () => MapEntry('', symbol)) + .key; + setState(() => _lastTappedAnnotation = 'Selected: $symbolId'); + } + + void _onCircleTapped(Circle circle) { + final circleId = _circles.entries + .firstWhere((entry) => entry.value == circle, + orElse: () => MapEntry('', circle)) + .key; + setState(() => _lastTappedAnnotation = 'Selected: $circleId'); + } + + void _onFillTapped(Fill fill) { + final fillId = _fills.entries + .firstWhere((entry) => entry.value == fill, + orElse: () => MapEntry('', fill)) + .key; + setState(() => _lastTappedAnnotation = 'Fill: $fillId'); + } + + void _onLineTapped(Line line) { + final lineId = _lines.entries + .firstWhere((entry) => entry.value == line, + orElse: () => MapEntry('', line)) + .key; + setState(() => _lastTappedAnnotation = 'Line: $lineId'); + } + + Future _addAnnotation() async { + if (_controller == null) return; + + final random = Random(); + const center = ExampleConstants.sydneyCenter; + + // Use different spread patterns for different annotation types + // to prevent overlapping and ensure visibility + double latOffset; + double lngOffset; + // Base Y offset for vertical separation + double baseLatOffset; + + switch (_currentType) { + case AnnotationType.symbol: + baseLatOffset = 0.25; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.25; + case AnnotationType.circle: + baseLatOffset = 0.08; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.3; + case AnnotationType.fill: + baseLatOffset = -0.08; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.35; + case AnnotationType.line: + baseLatOffset = -0.25; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.35; + } + + final lat = center.latitude + latOffset; + final lng = center.longitude + lngOffset; + final position = LatLng(lat, lng); + + _counter++; + + switch (_currentType) { + case AnnotationType.symbol: + final symbol = await _controller!.addSymbol( + SymbolOptions( + geometry: position, + iconImage: 'custom-marker', + iconSize: 2.0, + textField: 'Symbol $_counter', + textOffset: const Offset(0, 2), + ), + ); + setState(() => _symbols['symbol_$_counter'] = symbol); + await _controller?.setSymbolIconAllowOverlap(true); + await _controller?.setSymbolTextAllowOverlap(true); + + case AnnotationType.circle: + final circle = await _controller!.addCircle( + CircleOptions( + geometry: position, + circleRadius: 18.0, + circleColor: _randomColor(), + circleOpacity: 0.7, + ), + ); + setState(() => _circles['circle_$_counter'] = circle); + + case AnnotationType.fill: + final fill = await _controller!.addFill( + FillOptions( + geometry: _generatePolygon(position), + fillColor: _randomColor(), + fillOpacity: 0.6, + fillOutlineColor: '#000000', + ), + ); + setState(() => _fills['fill_$_counter'] = fill); + + case AnnotationType.line: + final line = await _controller!.addLine( + LineOptions( + geometry: _generateLineString(position), + lineColor: _randomColor(), + lineWidth: 8.0, + lineOpacity: 0.9, + ), + ); + setState(() => _lines['line_$_counter'] = line); + } + } + + Future _clearAnnotations() async { + if (_controller == null) return; + + switch (_currentType) { + case AnnotationType.symbol: + if (_symbols.isNotEmpty) { + await _controller!.removeSymbols(_symbols.values.toList()); + setState(() => _symbols.clear()); + } + case AnnotationType.circle: + if (_circles.isNotEmpty) { + await _controller!.removeCircles(_circles.values.toList()); + setState(() => _circles.clear()); + } + case AnnotationType.fill: + if (_fills.isNotEmpty) { + await _controller!.removeFills(_fills.values.toList()); + setState(() => _fills.clear()); + } + case AnnotationType.line: + if (_lines.isNotEmpty) { + await _controller!.removeLines(_lines.values.toList()); + setState(() => _lines.clear()); + } + } + } + + Future _batchAdd({int count = 5}) async { + if (_controller == null) return; + + final random = Random(); + const center = ExampleConstants.sydneyCenter; + + for (var i = 0; i < count; i++) { + double latOffset; + double lngOffset; + double baseLatOffset; + + switch (_currentType) { + case AnnotationType.symbol: + baseLatOffset = 0.25; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.25; + case AnnotationType.circle: + baseLatOffset = 0.08; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.3; + case AnnotationType.fill: + baseLatOffset = -0.08; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.35; + case AnnotationType.line: + baseLatOffset = -0.25; + latOffset = baseLatOffset + (random.nextDouble() - 0.5) * 0.06; + lngOffset = (random.nextDouble() - 0.5) * 0.35; + } + + final lat = center.latitude + latOffset; + final lng = center.longitude + lngOffset; + final position = LatLng(lat, lng); + + _counter++; + + switch (_currentType) { + case AnnotationType.symbol: + final symbol = await _controller!.addSymbol( + SymbolOptions( + geometry: position, + iconImage: 'custom-marker', + iconSize: 2.0, + textField: 'Symbol $_counter', + textOffset: const Offset(0, 2), + ), + ); + _symbols['symbol_$_counter'] = symbol; + + case AnnotationType.circle: + final circle = await _controller!.addCircle( + CircleOptions( + geometry: position, + circleRadius: 18.0, + circleColor: _randomColor(), + circleOpacity: 0.7, + ), + ); + _circles['circle_$_counter'] = circle; + + case AnnotationType.fill: + final fill = await _controller!.addFill( + FillOptions( + geometry: _generatePolygon(position), + fillColor: _randomColor(), + fillOpacity: 0.6, + fillOutlineColor: '#000000', + ), + ); + _fills['fill_$_counter'] = fill; + + case AnnotationType.line: + final line = await _controller!.addLine( + LineOptions( + geometry: _generateLineString(position), + lineColor: _randomColor(), + lineWidth: 8.0, + lineOpacity: 0.9, + ), + ); + _lines['line_$_counter'] = line; + } + } + + if (_currentType == AnnotationType.symbol) { + await _controller?.setSymbolIconAllowOverlap(true); + await _controller?.setSymbolTextAllowOverlap(true); + } + + setState(() {}); + } + + Future _batchRemove({int count = 5}) async { + if (_controller == null) return; + + switch (_currentType) { + case AnnotationType.symbol: + if (_symbols.isEmpty) return; + final toRemove = + _symbols.values.take(count.clamp(0, _symbols.length)).toList(); + final keysToRemove = _symbols.entries + .where((e) => toRemove.contains(e.value)) + .map((e) => e.key) + .toList(); + await _controller!.removeSymbols(toRemove); + setState(() { + keysToRemove.forEach(_symbols.remove); + }); + + case AnnotationType.circle: + if (_circles.isEmpty) return; + final toRemove = + _circles.values.take(count.clamp(0, _circles.length)).toList(); + final keysToRemove = _circles.entries + .where((e) => toRemove.contains(e.value)) + .map((e) => e.key) + .toList(); + await _controller!.removeCircles(toRemove); + setState(() { + keysToRemove.forEach(_circles.remove); + }); + + case AnnotationType.fill: + if (_fills.isEmpty) return; + final toRemove = + _fills.values.take(count.clamp(0, _fills.length)).toList(); + final keysToRemove = _fills.entries + .where((e) => toRemove.contains(e.value)) + .map((e) => e.key) + .toList(); + await _controller!.removeFills(toRemove); + setState(() { + keysToRemove.forEach(_fills.remove); + }); + + case AnnotationType.line: + if (_lines.isEmpty) return; + final toRemove = + _lines.values.take(count.clamp(0, _lines.length)).toList(); + final keysToRemove = _lines.entries + .where((e) => toRemove.contains(e.value)) + .map((e) => e.key) + .toList(); + await _controller!.removeLines(toRemove); + setState(() { + keysToRemove.forEach(_lines.remove); + }); + } + } + + String _randomColor() { + final colors = [ + '#FF6B6B', // Red + '#4ECDC4', // Teal + '#45B7D1', // Blue + '#FFA07A', // Orange + '#98D8C8', // Green + '#F7DC6F', // Yellow + '#BB8FCE', // Purple + ]; + return colors[Random().nextInt(colors.length)]; + } + + List> _generatePolygon(LatLng center) { + // Make polygon larger and more visible + const offset = 0.06; + return [ + [ + LatLng(center.latitude + offset, center.longitude - offset), + LatLng(center.latitude + offset, center.longitude + offset), + LatLng(center.latitude - offset, center.longitude + offset), + LatLng(center.latitude - offset, center.longitude - offset), + LatLng(center.latitude + offset, center.longitude - offset), + ] + ]; + } + + List _generateLineString(LatLng center) { + // Make line longer and more visible with more points for a curved appearance + const offset = 0.08; + return [ + LatLng(center.latitude - offset, center.longitude - offset), + LatLng(center.latitude - offset * 0.3, center.longitude), + LatLng(center.latitude + offset * 0.3, center.longitude + offset * 0.5), + LatLng(center.latitude + offset, center.longitude + offset), + ]; + } + + int _getCurrentCount() { + switch (_currentType) { + case AnnotationType.symbol: + return _symbols.length; + case AnnotationType.circle: + return _circles.length; + case AnnotationType.fill: + return _fills.length; + case AnnotationType.line: + return _lines.length; + } + } + + int _getTotalCount() { + return _symbols.length + _circles.length + _fills.length + _lines.length; + } + + @override + Widget build(BuildContext context) { + final hasController = _controller != null; + final count = _getCurrentCount(); + final totalCount = _getTotalCount(); + + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: ExampleConstants.sydneyCenter, + zoom: 8, + ), + trackCameraPosition: true, + ), + controls: [ + InfoCard( + title: _lastTappedAnnotation ?? + '${_currentType.name.capitalize()}s on Map', + subtitle: + '$count annotation${count == 1 ? "" : "s"}.${_lastTappedAnnotation == null ? " Tap an annotation to select." : ""}', + icon: _lastTappedAnnotation != null + ? Icons.touch_app + : Icons.info_outline, + ), + + // Type Selector + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Annotation Type', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: AnnotationType.symbol, + label: 'Symbol', + icon: Icons.place, + ), + ExampleSegment( + value: AnnotationType.circle, + label: 'Circle', + icon: Icons.circle, + ), + ExampleSegment( + value: AnnotationType.fill, + label: 'Fill', + icon: Icons.square, + ), + ExampleSegment( + value: AnnotationType.line, + label: 'Line', + icon: Icons.show_chart, + ), + ], + selected: _currentType, + onSelectionChanged: (type) { + setState(() => _currentType = type); + }, + ), + ], + ), + ), + ), + + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Add ${_currentType.name.capitalize()}', + icon: Icons.add, + onPressed: hasController ? _addAnnotation : null, + ), + ExampleButton( + label: 'Clear ${_currentType.name.capitalize()}s', + icon: Icons.delete_sweep, + onPressed: hasController && count > 0 ? _clearAnnotations : null, + style: ExampleButtonStyle.destructive, + ), + ], + ), + + ControlGroup( + title: 'Batch Actions', + children: [ + ExampleButton( + label: 'Add 10', + icon: Icons.add_box, + onPressed: hasController ? () => _batchAdd(count: 10) : null, + ), + ExampleButton( + label: 'Remove 10', + icon: Icons.remove_circle_outline, + onPressed: hasController && count >= 10 + ? () => _batchRemove(count: 10) + : null, + style: ExampleButtonStyle.outlined, + ), + ExampleButton( + label: 'Clear all annotations', + icon: Icons.clear, + onPressed: hasController && totalCount > 0 + ? () async { + _lastTappedAnnotation = null; + await _controller!.clearSymbols(); + await _controller!.clearCircles(); + await _controller!.clearFills(); + await _controller!.clearLines(); + setState(() { + _symbols.clear(); + _circles.clear(); + _fills.clear(); + _lines.clear(); + }); + } + : null, + style: ExampleButtonStyle.destructive, + ), + ], + ), + ], + ); + } +} diff --git a/maplibre_gl_example/lib/custom_marker.dart b/maplibre_gl_example/lib/examples/annotations/custom_marker.dart similarity index 73% rename from maplibre_gl_example/lib/custom_marker.dart rename to maplibre_gl_example/lib/examples/annotations/custom_marker.dart index ecbaf2fdc..aa2384a9e 100644 --- a/maplibre_gl_example/lib/custom_marker.dart +++ b/maplibre_gl_example/lib/examples/annotations/custom_marker.dart @@ -4,13 +4,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'page.dart'; +import '../../page.dart'; const randomMarkerNum = 100; class CustomMarkerPage extends ExamplePage { const CustomMarkerPage({super.key}) - : super(const Icon(Icons.place), 'Custom marker'); + : super(const Icon(Icons.place), 'Custom marker', + category: ExampleCategory.annotations); @override Widget build(BuildContext context) { @@ -28,7 +29,7 @@ class CustomMarker extends StatefulWidget { class CustomMarkerState extends State { final _rnd = Random(); - late MapLibreMapController _mapController; + MapLibreMapController? _mapController; final _markers = []; final _markerStates = []; @@ -37,7 +38,8 @@ class CustomMarkerState extends State { } void _onMapCreated(MapLibreMapController controller) { - _mapController = controller; + setState(() => _mapController = controller); + controller.addListener(() async { if (controller.isCameraMoving) { await _updateMarkerPosition(); @@ -64,7 +66,7 @@ class CustomMarkerState extends State { coordinates.add(markerState.getCoordinate()); } - await _mapController.toScreenLocationBatch(coordinates).then((points) { + await _mapController?.toScreenLocationBatch(coordinates).then((points) { _markerStates.asMap().forEach((i, value) { _markerStates[i].updatePosition(points[i]); }); @@ -116,7 +118,7 @@ class CustomMarkerState extends State { param.add(LatLng(lat, lng)); } - await _mapController.toScreenLocationBatch(param).then((value) { + await _mapController?.toScreenLocationBatch(param).then((value) { for (var i = 0; i < randomMarkerNum; i++) { final point = Point(value[i].x as double, value[i].y as double); @@ -128,57 +130,6 @@ class CustomMarkerState extends State { ), ); } - - // ignore: unused_element --- IGNORE --- - Future _measurePerformance() async { - const trial = 10; - final batches = [500, 1000, 1500, 2000, 2500, 3000]; - final results = >{}; - for (final batch in batches) { - results[batch] = [0.0, 0.0]; - } - - await _mapController.toScreenLocation(const LatLng(0, 0)); - final sw = Stopwatch(); - - for (final batch in batches) { - // - // primitive - // - for (var i = 0; i < trial; i++) { - sw.start(); - final list = >>[]; - for (var j = 0; j < batch; j++) { - final 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(); - final 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(); - } - - debugPrint( - 'batch=$batch,primitive=${results[batch]![0] / trial}ms, batch=${results[batch]![1] / trial}ms', - ); - } - } } class Marker extends StatefulWidget { @@ -226,12 +177,6 @@ class MarkerState extends State with TickerProviderStateMixin { ); } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { var ratio = 1.0; diff --git a/maplibre_gl_example/lib/examples/basics/full_map_example.dart b/maplibre_gl_example/lib/examples/basics/full_map_example.dart new file mode 100644 index 000000000..e12c669fd --- /dev/null +++ b/maplibre_gl_example/lib/examples/basics/full_map_example.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating basic full-screen map +class FullMapExample extends ExamplePage { + const FullMapExample({super.key}) + : super( + const Icon(Icons.map), + 'Full screen map', + category: ExampleCategory.basics, + ); + + @override + Widget build(BuildContext context) => const _FullMapBody(); +} + +class _FullMapBody extends StatefulWidget { + const _FullMapBody(); + + @override + State<_FullMapBody> createState() => _FullMapBodyState(); +} + +class _FullMapBodyState extends State<_FullMapBody> { + MapLibreMapController? _mapController; + bool _canInteractWithMap = false; + bool canReset = false; + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + setState(() => _canInteractWithMap = true); + } + + Future _moveCameraToSanFrancisco() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition( + ExampleConstants.sanFranciscoCameraPosition, + ), + ); + setState(() => canReset = true); + } + + Future _resetCamera() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition( + ExampleConstants.defaultCameraPosition, + ), + ); + setState(() => canReset = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + logoEnabled: true, + trackCameraPosition: true, + compassEnabled: true, + myLocationEnabled: true, + ), + floatingActionButton: ExampleButton( + label: canReset ? 'Reset camera' : 'Go to San Francisco', + icon: canReset ? Icons.refresh : Icons.flight_takeoff, + onPressed: _canInteractWithMap + ? canReset + ? _resetCamera + : _moveCameraToSanFrancisco + : null, + style: ExampleButtonStyle.tonal, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + } +} diff --git a/maplibre_gl_example/lib/examples/basics/get_map_state.dart b/maplibre_gl_example/lib/examples/basics/get_map_state.dart new file mode 100644 index 000000000..6b3bf51ce --- /dev/null +++ b/maplibre_gl_example/lib/examples/basics/get_map_state.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../../page.dart'; +import '../../shared/shared.dart'; + +class GetMapInfoPage extends ExamplePage { + const GetMapInfoPage({super.key}) + : super(const Icon(Icons.info), 'Get map state', + category: ExampleCategory.basics); + + @override + Widget build(BuildContext context) => const _GetMapInfoBody(); +} + +class _GetMapInfoBody extends StatefulWidget { + const _GetMapInfoBody(); + + @override + State<_GetMapInfoBody> createState() => _GetMapInfoBodyState(); +} + +class _GetMapInfoBodyState extends State<_GetMapInfoBody> { + MapLibreMapController? _controller; + String _displayData = ''; + bool _isLoading = false; + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _displaySources() async { + if (_controller == null) return; + + setState(() => _isLoading = true); + + try { + final sources = await _controller!.getSourceIds(); + setState(() { + _displayData = 'Sources:\n${sources.map((e) => '• $e').join('\n')}'; + _isLoading = false; + }); + } catch (e) { + setState(() { + _displayData = 'Error: $e'; + _isLoading = false; + }); + } + } + + Future _displayLayers() async { + if (_controller == null) return; + + setState(() => _isLoading = true); + + try { + final layers = (await _controller!.getLayerIds()).cast(); + setState(() { + _displayData = 'Layers:\n${layers.map((e) => '• $e').join('\n')}'; + _isLoading = false; + }); + } catch (e) { + setState(() { + _displayData = 'Error: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final hasController = _controller != null; + + return MapExampleScaffold( + map: MapLibreMap( + onMapCreated: _onMapCreated, + styleString: ExampleConstants.demoMapStyle, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + ), + controls: [ + if (!hasController) + const InfoCard( + title: 'Waiting for map', + subtitle: 'Map is initializing...', + icon: Icons.hourglass_empty, + ), + if (hasController) ...[ + const SizedBox(height: 8), + ControlGroup( + title: 'Map Information', + vertical: false, + children: [ + ExampleButton( + label: 'Get Layers', + onPressed: hasController ? _displayLayers : null, + icon: Icons.layers, + style: ExampleButtonStyle.filled, + ), + ExampleButton( + label: 'Get Sources', + onPressed: hasController ? _displaySources : null, + icon: Icons.source, + style: ExampleButtonStyle.filled, + ), + ], + ), + if (_isLoading) + const Card( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ) + else if (_displayData.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Results', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + _displayData, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ), + ], + ], + ); + } +} diff --git a/maplibre_gl_example/lib/examples/basics/gps_location_page.dart b/maplibre_gl_example/lib/examples/basics/gps_location_page.dart new file mode 100644 index 000000000..acf9eac5c --- /dev/null +++ b/maplibre_gl_example/lib/examples/basics/gps_location_page.dart @@ -0,0 +1,248 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:location/location.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating GPS location tracking +class GpsLocationPage extends ExamplePage { + const GpsLocationPage({super.key}) + : super( + const Icon(Icons.gps_fixed), + 'GPS Location', + needsLocationPermission: false, + category: ExampleCategory.basics, + ); + + @override + Widget build(BuildContext context) => const _GpsLocationBody(); +} + +class _GpsLocationBody extends StatefulWidget { + const _GpsLocationBody(); + + @override + State<_GpsLocationBody> createState() => _GpsLocationBodyState(); +} + +class _GpsLocationBodyState extends State<_GpsLocationBody> { + MapLibreMapController? _controller; + LocationData? _currentLocation; + bool _useHighAccuracy = false; + PermissionStatus? _permissionStatus; + MyLocationTrackingMode _trackingMode = MyLocationTrackingMode.none; + final Location _location = Location(); + + @override + void initState() { + super.initState(); + unawaited(_checkPermission()); + } + + Future _checkPermission() async { + final status = await _location.hasPermission(); + if (mounted) { + setState(() => _permissionStatus = status); + } + } + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _requestPermission() async { + final status = await _location.requestPermission(); + if (mounted) { + setState(() => _permissionStatus = status); + } + } + + Future _toggleAccuracy() async { + setState(() => _useHighAccuracy = !_useHighAccuracy); + } + + Future _cycleTrackingMode() async { + const modes = MyLocationTrackingMode.values; + final currentIndex = modes.indexOf(_trackingMode); + final nextMode = modes[(currentIndex + 1) % modes.length]; + + if (_controller != null) { + await _controller!.updateMyLocationTrackingMode(nextMode); + setState(() => _trackingMode = nextMode); + } + } + + String _getTrackingModeLabel() { + switch (_trackingMode) { + case MyLocationTrackingMode.none: + return 'None'; + case MyLocationTrackingMode.tracking: + return 'Tracking'; + case MyLocationTrackingMode.trackingCompass: + return 'Tracking + Compass'; + case MyLocationTrackingMode.trackingGps: + return 'Tracking GPS'; + } + } + + String _getTrackingModeDescription() { + switch (_trackingMode) { + case MyLocationTrackingMode.none: + return 'No tracking active'; + case MyLocationTrackingMode.tracking: + return 'Follow user location'; + case MyLocationTrackingMode.trackingCompass: + return 'Follow location and rotation'; + case MyLocationTrackingMode.trackingGps: + return 'GPS-based tracking'; + } + } + + String _formatCoordinate(double? value, {int decimals = 6}) { + if (value == null) return '--'; + return value.toStringAsFixed(decimals); + } + + @override + Widget build(BuildContext context) { + final hasController = _controller != null; + final hasPermission = _permissionStatus == PermissionStatus.granted; + final hasLocation = _currentLocation != null; + + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(37.3, -121.8), + zoom: 7, + ), + trackCameraPosition: true, + myLocationEnabled: hasPermission, + myLocationTrackingMode: _trackingMode, + locationEnginePlatforms: _useHighAccuracy + ? const LocationEnginePlatforms( + androidPlatform: LocationEngineAndroidProperties( + interval: 1000, + displacement: 1, + priority: LocationPriority.highAccuracy, + ), + ) + : LocationEnginePlatforms.defaultPlatform, + ), + controls: [ + InfoCard( + title: 'GPS Location Tracking', + subtitle: hasPermission + ? 'Track your device location on the map' + : 'Location permission required', + icon: hasPermission ? Icons.gps_fixed : Icons.gps_off, + color: hasPermission ? null : Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 8), + if (!hasPermission) + ControlGroup( + title: 'Permission Required', + vertical: false, + children: [ + ExampleButton( + label: 'Grant Permission', + onPressed: _requestPermission, + icon: Icons.lock_open, + style: ExampleButtonStyle.filled, + ), + ], + ), + if (hasPermission) ...[ + ControlGroup( + title: 'Tracking Mode', + vertical: true, + children: [ + ListTile( + title: Text(_getTrackingModeLabel()), + subtitle: Text(_getTrackingModeDescription()), + trailing: FilledButton.tonal( + onPressed: hasController ? _cycleTrackingMode : null, + child: const Text('Change'), + ), + contentPadding: EdgeInsets.zero, + ), + ], + ), + const SizedBox(height: 8), + ControlGroup( + title: 'Settings', + vertical: true, + children: [ + SwitchListTile( + title: const Text('High Accuracy Mode'), + subtitle: Text(_useHighAccuracy + ? 'GPS with high accuracy (Android only)' + : 'Default location settings'), + value: _useHighAccuracy, + onChanged: (_) => _toggleAccuracy(), + contentPadding: EdgeInsets.zero, + ), + ], + ), + if (hasLocation) ...[ + const SizedBox(height: 8), + ControlGroup( + title: 'Current Location', + vertical: true, + children: [ + _buildLocationTile( + 'Latitude', + _formatCoordinate(_currentLocation?.latitude), + Icons.pin_drop, + ), + _buildLocationTile( + 'Longitude', + _formatCoordinate(_currentLocation?.longitude), + Icons.pin_drop, + ), + if (_currentLocation?.accuracy != null) + _buildLocationTile( + 'Accuracy', + '${_currentLocation!.accuracy!.toStringAsFixed(1)} m', + Icons.radar, + ), + if (_currentLocation?.altitude != null) + _buildLocationTile( + 'Altitude', + '${_currentLocation!.altitude!.toStringAsFixed(1)} m', + Icons.terrain, + ), + if (_currentLocation?.speed != null && + _currentLocation!.speed! > 0) + _buildLocationTile( + 'Speed', + '${_currentLocation!.speed!.toStringAsFixed(1)} m/s', + Icons.speed, + ), + ], + ), + ], + ], + ], + ); + } + + Widget _buildLocationTile(String label, String value, IconData icon) { + return ListTile( + leading: Icon(icon, size: 20), + title: Text(label), + trailing: SelectableText( + value, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), + ), + contentPadding: EdgeInsets.zero, + ); + } +} diff --git a/maplibre_gl_example/lib/multi_style_switch.dart b/maplibre_gl_example/lib/examples/basics/multi_style_switch.dart similarity index 96% rename from maplibre_gl_example/lib/multi_style_switch.dart rename to maplibre_gl_example/lib/examples/basics/multi_style_switch.dart index 8ce9b3a9c..541de815d 100644 --- a/maplibre_gl_example/lib/multi_style_switch.dart +++ b/maplibre_gl_example/lib/examples/basics/multi_style_switch.dart @@ -3,7 +3,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'page.dart'; +import '../../page.dart'; /// A page that demonstrates switching between multiple map styles in MapLibre GL. /// @@ -14,7 +14,8 @@ import 'page.dart'; /// The styles include a remote style, an embedded minimal style, and styles loaded from assets. class MultiStyleSwitchPage extends ExamplePage { const MultiStyleSwitchPage({super.key}) - : super(const Icon(Icons.style), 'Multi style switch'); + : super(const Icon(Icons.style), 'Multi style switch', + category: ExampleCategory.basics); @override Widget build(BuildContext context) => const _MultiStyleSwitchBody(); @@ -111,9 +112,10 @@ class _MultiStyleSwitchBodyState extends State<_MultiStyleSwitchBody> { onMapCreated: _onMapCreated, onStyleLoadedCallback: _onStyleLoaded, onCameraIdle: _onCameraIdle, + attributionButtonPosition: AttributionButtonPosition.topRight, ), Positioned( - top: 0, + bottom: 0, left: 0, right: 0, child: SafeArea( diff --git a/maplibre_gl_example/lib/examples/camera/camera_bounds_example.dart b/maplibre_gl_example/lib/examples/camera/camera_bounds_example.dart new file mode 100644 index 000000000..c30ec1508 --- /dev/null +++ b/maplibre_gl_example/lib/examples/camera/camera_bounds_example.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating camera bounds and constraints +class CameraBoundsExample extends ExamplePage { + const CameraBoundsExample({super.key}) + : super( + const Icon(Icons.crop), + 'Camera Bounds & Constraints', + category: ExampleCategory.camera, + ); + + @override + Widget build(BuildContext context) => const _CameraBoundsBody(); +} + +class _CameraBoundsBody extends StatefulWidget { + const _CameraBoundsBody(); + + @override + State<_CameraBoundsBody> createState() => _CameraBoundsBodyState(); +} + +class _CameraBoundsBodyState extends State<_CameraBoundsBody> { + MapLibreMapController? _controller; + LatLngBounds? _currentBounds; + LatLngBounds? _constrainedBounds; + double? _minZoom; + double? _maxZoom; + + // Predefined bounds + static final _sydneyBounds = LatLngBounds( + southwest: const LatLng(-34.022631, 150.620685), + northeast: const LatLng(-33.571835, 151.325952), + ); + + static final _sanFranciscoBounds = LatLngBounds( + southwest: const LatLng(37.7, -122.5), + northeast: const LatLng(37.8, -122.4), + ); + + static final _europeBounds = LatLngBounds( + southwest: const LatLng(36.0, -10.0), + northeast: const LatLng(71.0, 40.0), + ); + + Future _onMapCreated(MapLibreMapController controller) async { + _controller = controller; + await _updateInfo(); + } + + Future _updateInfo() async { + if (_controller == null) return; + final bounds = await _controller!.getVisibleRegion(); + if (mounted) { + setState(() => _currentBounds = bounds); + } + } + + Future _setBounds(LatLngBounds bounds, String name) async { + if (_controller == null) return; + var padding = 150.0; + if (bounds == _europeBounds) padding = 50.0; + + await _controller!.animateCamera( + CameraUpdate.newLatLngBounds( + bounds, + left: padding, + top: padding, + right: padding, + bottom: padding, + ), + ); + + _updateInfo(); + } + + Future _setMinZoom(double zoom) async { + if (_controller == null) return; + // Note: min/max zoom can be set via style, initial confing or options. + setState(() => _minZoom = zoom); + } + + Future _setMaxZoom(double zoom) async { + if (_controller == null) return; + setState(() => _maxZoom = zoom); + } + + Future _clearZoomConstraints() async { + if (_controller == null) return; + setState(() { + _minZoom = null; + _maxZoom = null; + }); + } + + Future _setCameraBounds(LatLngBounds bounds, String name) async { + if (_controller == null) return; + + final adjustedBounds = LatLngBounds( + southwest: LatLng( + bounds.southwest.latitude - 0.2, + bounds.southwest.longitude - 0.2, + ), + northeast: LatLng( + bounds.northeast.latitude + 0.2, + bounds.northeast.longitude + 0.2, + ), + ); + + setState(() => _constrainedBounds = adjustedBounds); + } + + Future _clearCameraBounds() async { + if (_controller == null) return; + setState(() => _constrainedBounds = null); + } + + @override + Widget build(BuildContext context) { + final hasController = _controller != null; + final boundsInfo = _currentBounds != null + ? 'SW: ${_currentBounds!.southwest.latitude.toStringAsFixed(2)}, ${_currentBounds!.southwest.longitude.toStringAsFixed(2)}\n' + 'NE: ${_currentBounds!.northeast.latitude.toStringAsFixed(2)}, ${_currentBounds!.northeast.longitude.toStringAsFixed(2)}' + : 'Loading...'; + + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onCameraIdle: _updateInfo, + trackCameraPosition: true, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + minMaxZoomPreference: MinMaxZoomPreference(_minZoom, _maxZoom), + cameraTargetBounds: _constrainedBounds != null + ? CameraTargetBounds(_constrainedBounds) + : CameraTargetBounds.unbounded, + ), + controls: [ + InfoCard( + title: 'Visible Bounds', + subtitle: boundsInfo, + icon: Icons.crop, + ), + if (_minZoom != null || _maxZoom != null) + InfoCard( + title: 'Zoom Constraints', + subtitle: + 'Min: ${_minZoom?.toStringAsFixed(0) ?? "None"} | Max: ${_maxZoom?.toStringAsFixed(0) ?? "None"}', + icon: Icons.lock, + color: Colors.orange.shade100, + ), + if (_constrainedBounds != null) + InfoCard( + title: 'Camera Target Bounds', + subtitle: 'Panning restricted to defined region', + icon: Icons.lock_outline, + color: Colors.red.shade100, + ), + const SizedBox(height: 8), + ControlGroup( + title: 'Move to Bounds', + children: [ + ExampleButton( + label: 'Sydney', + icon: Icons.map, + onPressed: hasController + ? () => _setBounds(_sydneyBounds, 'Sydney') + : null, + ), + ExampleButton( + label: 'San Francisco', + icon: Icons.map, + onPressed: hasController + ? () => _setBounds(_sanFranciscoBounds, 'San Francisco') + : null, + ), + ExampleButton( + label: 'Europe', + icon: Icons.map, + onPressed: hasController + ? () => _setBounds(_europeBounds, 'Europe') + : null, + ), + ], + ), + const SizedBox(height: 8), + ControlGroup( + title: 'Zoom Limits', + children: [ + ExampleButton( + label: 'Min Zoom: 3', + icon: Icons.zoom_out, + onPressed: hasController ? () => _setMinZoom(3) : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'Max Zoom: 8', + icon: Icons.zoom_in, + onPressed: hasController ? () => _setMaxZoom(8) : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'Clear', + icon: Icons.clear, + onPressed: hasController && (_minZoom != null || _maxZoom != null) + ? _clearZoomConstraints + : null, + style: ExampleButtonStyle.outlined, + ), + ], + ), + ControlGroup( + title: 'Camera Target Bounds', + children: [ + ExampleButton( + label: 'Lock to Sydney', + icon: Icons.lock, + onPressed: hasController + ? () => _setCameraBounds(_sydneyBounds, 'Sydney') + : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'Lock to SF', + icon: Icons.lock, + onPressed: hasController + ? () => _setCameraBounds(_sanFranciscoBounds, 'San Francisco') + : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'Lock to Europe', + icon: Icons.lock, + onPressed: hasController + ? () => _setCameraBounds(_europeBounds, 'Europe') + : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'Clear', + icon: Icons.clear, + onPressed: hasController && _constrainedBounds != null + ? _clearCameraBounds + : null, + style: ExampleButtonStyle.outlined, + ), + ], + ), + ], + ); + } +} diff --git a/maplibre_gl_example/lib/examples/camera/camera_controls_example.dart b/maplibre_gl_example/lib/examples/camera/camera_controls_example.dart new file mode 100644 index 000000000..67a4560c0 --- /dev/null +++ b/maplibre_gl_example/lib/examples/camera/camera_controls_example.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Comprehensive camera control example with animated and instant movements +class CameraControlsExample extends ExamplePage { + const CameraControlsExample({super.key}) + : super( + const Icon(Icons.videocam), + 'Camera Controls', + category: ExampleCategory.camera, + ); + + @override + Widget build(BuildContext context) => const _CameraControlsBody(); +} + +class _CameraControlsBody extends StatefulWidget { + const _CameraControlsBody(); + + @override + State<_CameraControlsBody> createState() => _CameraControlsBodyState(); +} + +class _CameraControlsBodyState extends State<_CameraControlsBody> { + MapLibreMapController? _controller; + bool _isAnimated = true; + CameraPosition? _currentPosition; + + void _onMapCreated(MapLibreMapController controller) { + _controller = controller; + } + + void _onCameraIdle() { + final position = _controller?.cameraPosition; + if (mounted && position != null) { + setState(() => _currentPosition = position); + } + } + + Future _moveCamera(CameraUpdate update) async { + if (_controller == null) return; + + if (_isAnimated) { + await _controller!.animateCamera(update, + duration: ExampleConstants.cameraAnimationDuration); + } else { + await _controller!.moveCamera(update); + } + } + + Future _zoomIn() => _moveCamera(CameraUpdate.zoomIn()); + Future _zoomOut() => _moveCamera(CameraUpdate.zoomOut()); + + Future _tiltUp() async { + final current = _currentPosition?.tilt ?? 0; + await _moveCamera(CameraUpdate.tiltTo(current + 15)); + } + + Future _tiltDown() async { + final current = _currentPosition?.tilt ?? 0; + await _moveCamera(CameraUpdate.tiltTo((current - 15).clamp(0, 60))); + } + + Future _rotateLeft() async { + final current = _currentPosition?.bearing ?? 0; + await _moveCamera(CameraUpdate.bearingTo(current - 30)); + } + + Future _rotateRight() async { + final current = _currentPosition?.bearing ?? 0; + await _moveCamera(CameraUpdate.bearingTo(current + 30)); + } + + Future _resetCamera() => _moveCamera( + CameraUpdate.newCameraPosition(ExampleConstants.defaultCameraPosition), + ); + + Future _goToSydney() => _moveCamera( + CameraUpdate.newCameraPosition(ExampleConstants.sydneyCameraPosition), + ); + Future _goToSanFrancisco() => _moveCamera( + CameraUpdate.newCameraPosition( + ExampleConstants.sanFranciscoCameraPosition), + ); + + Future _goToLondon() => _moveCamera( + CameraUpdate.newCameraPosition( + ExampleConstants.londonCameraPosition, + ), + ); + + @override + Widget build(BuildContext context) { + final hasController = _controller != null; + final zoom = _currentPosition?.zoom.toStringAsFixed(1) ?? '--'; + final tilt = _currentPosition?.tilt.toStringAsFixed(0) ?? '--'; + final bearing = _currentPosition?.bearing.toStringAsFixed(0) ?? '--'; + + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onCameraIdle: _onCameraIdle, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + trackCameraPosition: true, + myLocationEnabled: true, + compassEnabled: true, + ), + controls: [ + // Animation Mode Toggle + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + _isAnimated ? Icons.play_circle : Icons.skip_next, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Text( + 'Animation', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + Switch( + value: _isAnimated, + onChanged: (value) => setState(() => _isAnimated = value), + ), + ], + ), + ), + ), + + // Camera Info + InfoCard( + title: 'Camera Position', + subtitle: 'Zoom: $zoom | Tilt: $tilt° | Bearing: $bearing°', + icon: Icons.info_outline, + ), + + const SizedBox(height: 8), + + // Zoom Controls + ControlGroup( + title: 'Zoom', + children: [ + ExampleButton( + label: 'Zoom In', + icon: Icons.add, + onPressed: hasController ? _zoomIn : null, + ), + ExampleButton( + label: 'Zoom Out', + icon: Icons.remove, + onPressed: hasController ? _zoomOut : null, + ), + ], + ), + + const SizedBox(height: 8), + + // Tilt Controls + ControlGroup( + title: 'Tilt (Pitch)', + children: [ + ExampleButton( + label: 'Tilt Up', + icon: Icons.arrow_upward, + onPressed: hasController ? _tiltUp : null, + ), + ExampleButton( + label: 'Tilt Down', + icon: Icons.arrow_downward, + onPressed: hasController ? _tiltDown : null, + ), + ], + ), + + const SizedBox(height: 8), + + // Rotation Controls + ControlGroup( + title: 'Rotation (Bearing)', + children: [ + ExampleButton( + label: 'Rotate Left', + icon: Icons.rotate_left, + onPressed: hasController ? _rotateLeft : null, + ), + ExampleButton( + label: 'Rotate Right', + icon: Icons.rotate_right, + onPressed: hasController ? _rotateRight : null, + ), + ], + ), + + const SizedBox(height: 8), + + // Location Presets + ControlGroup( + title: 'Go To Location', + children: [ + ExampleButton( + label: 'Null Island', + icon: Icons.place, + onPressed: hasController ? _resetCamera : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'Sydney', + icon: Icons.place, + onPressed: hasController ? _goToSydney : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'San Francisco', + icon: Icons.place, + onPressed: hasController ? _goToSanFrancisco : null, + style: ExampleButtonStyle.tonal, + ), + ExampleButton( + label: 'London', + icon: Icons.place, + onPressed: hasController ? _goToLondon : null, + style: ExampleButtonStyle.tonal, + ), + ], + ), + ], + ); + } +} diff --git a/maplibre_gl_example/lib/examples/interaction/map_controls_example.dart b/maplibre_gl_example/lib/examples/interaction/map_controls_example.dart new file mode 100644 index 000000000..f9af8a58d --- /dev/null +++ b/maplibre_gl_example/lib/examples/interaction/map_controls_example.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating map UI controls and settings +class MapControlsExample extends ExamplePage { + const MapControlsExample({super.key}) + : super( + const Icon(Icons.settings), + 'Map Controls', + category: ExampleCategory.interaction, + ); + + @override + Widget build(BuildContext context) => const _MapControlsBody(); +} + +class _MapControlsBody extends StatefulWidget { + const _MapControlsBody(); + + @override + State<_MapControlsBody> createState() => _MapControlsBodyState(); +} + +class _MapControlsBodyState extends State<_MapControlsBody> { + MapLibreMapController? _controller; + + // UI Settings + bool _compassEnabled = true; + CompassViewPosition _compassPosition = CompassViewPosition.topRight; + + bool _logoEnabled = true; + LogoViewPosition _logoPosition = LogoViewPosition.bottomLeft; + + AttributionButtonPosition _attributionPosition = + AttributionButtonPosition.bottomRight; + + // Current Map State + CameraPosition? _currentPosition; + + void _onMapCreated(MapLibreMapController controller) { + _controller = controller; + controller.addListener(_onMapChanged); + } + + void _onMapChanged() { + final position = _controller?.cameraPosition; + if (mounted && position != null) { + setState(() => _currentPosition = position); + } + } + + Future _updateCompass(bool enabled) async { + setState(() => _compassEnabled = enabled); + } + + void _cycleCompassPosition() { + const positions = CompassViewPosition.values; + final currentIndex = positions.indexOf(_compassPosition); + setState(() { + _compassPosition = positions[(currentIndex + 1) % positions.length]; + }); + } + + Future _updateLogo(bool enabled) async { + setState(() => _logoEnabled = enabled); + } + + void _cycleLogoPosition() { + const positions = LogoViewPosition.values; + final currentIndex = positions.indexOf(_logoPosition); + setState(() { + _logoPosition = positions[(currentIndex + 1) % positions.length]; + }); + } + + void _cycleAttributionPosition() { + const positions = AttributionButtonPosition.values; + final currentIndex = positions.indexOf(_attributionPosition); + setState(() { + _attributionPosition = positions[(currentIndex + 1) % positions.length]; + }); + } + + @override + Widget build(BuildContext context) { + final zoom = _currentPosition?.zoom.toStringAsFixed(1) ?? '--'; + final lat = _currentPosition?.target.latitude.toStringAsFixed(4) ?? '--'; + final lng = _currentPosition?.target.longitude.toStringAsFixed(4) ?? '--'; + + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + trackCameraPosition: true, + compassEnabled: _compassEnabled, + compassViewPosition: _compassPosition, + logoEnabled: _logoEnabled, + logoViewPosition: _logoPosition, + attributionButtonPosition: _attributionPosition, + ), + controls: [ + InfoCard( + title: 'Camera Info', + subtitle: 'Zoom: $zoom\nLat: $lat, Lng: $lng', + icon: Icons.camera, + ), + _buildControlCard( + title: 'Logo', + icon: Icons.image, + enabled: _logoEnabled, + position: _logoPosition.name.capitalize(), + onToggle: _updateLogo, + onChangePosition: _cycleLogoPosition, + ), + _buildControlCard( + title: 'Attribution', + icon: Icons.info_outline, + enabled: true, + position: _attributionPosition.name.capitalize(), + onChangePosition: _cycleAttributionPosition, + showToggle: false, + ), + _buildControlCard( + title: 'Compass', + icon: Icons.explore, + enabled: _compassEnabled, + position: _compassPosition.name.capitalize(), + onToggle: _updateCompass, + onChangePosition: _cycleCompassPosition, + ), + ], + ); + } + + Widget _buildControlCard({ + required String title, + required IconData icon, + required bool enabled, + required String position, + required VoidCallback onChangePosition, + void Function(bool)? onToggle, + bool showToggle = true, + }) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + if (showToggle && onToggle != null) + Switch( + value: enabled, + onChanged: onToggle, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Position', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 2), + Text( + position, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + FilledButton.tonal( + onPressed: (!showToggle || enabled) ? onChangePosition : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Change', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _controller?.removeListener(_onMapChanged); + super.dispose(); + } +} diff --git a/maplibre_gl_example/lib/examples/interaction/map_gestures_example.dart b/maplibre_gl_example/lib/examples/interaction/map_gestures_example.dart new file mode 100644 index 000000000..7327dd6b2 --- /dev/null +++ b/maplibre_gl_example/lib/examples/interaction/map_gestures_example.dart @@ -0,0 +1,204 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating gesture and interaction configuration +class MapGesturesExample extends ExamplePage { + const MapGesturesExample({super.key}) + : super( + const Icon(Icons.touch_app), + 'Map Gestures', + category: ExampleCategory.interaction, + ); + + @override + Widget build(BuildContext context) => const _MapGesturesBody(); +} + +class _MapGesturesBody extends StatefulWidget { + const _MapGesturesBody(); + + @override + State<_MapGesturesBody> createState() => _MapGesturesBodyState(); +} + +class _MapGesturesBodyState extends State<_MapGesturesBody> { + MapLibreMapController? _controller; + + // Gesture Settings + bool _rotateGesturesEnabled = true; + bool _scrollGesturesEnabled = true; + bool _tiltGesturesEnabled = true; + bool _zoomGesturesEnabled = true; + bool _doubleClickToZoomEnabled = true; + + // Movement State + bool _isMoving = false; + String _lastGesture = 'None'; + + void _onMapCreated(MapLibreMapController controller) { + _controller = controller; + controller.addListener(_onMapChanged); + } + + void _onMapChanged() { + final moving = _controller?.isCameraMoving ?? false; + if (mounted && moving != _isMoving) { + setState(() { + _isMoving = moving; + if (moving) { + _lastGesture = 'Camera moving...'; + } + }); + } + } + + void _onCameraIdle() { + if (mounted) { + setState(() => _lastGesture = 'Camera idle'); + } + } + + void _onMapClick(math.Point point, LatLng coordinates) { + setState(() => _lastGesture = 'Map clicked'); + } + + void _onMapLongClick(math.Point point, LatLng coordinates) { + setState(() => _lastGesture = 'Map long pressed'); + } + + Future _updateRotateGestures(bool enabled) async { + setState(() => _rotateGesturesEnabled = enabled); + } + + Future _updateScrollGestures(bool enabled) async { + setState(() => _scrollGesturesEnabled = enabled); + } + + Future _updateTiltGestures(bool enabled) async { + setState(() => _tiltGesturesEnabled = enabled); + } + + Future _updateZoomGestures(bool enabled) async { + setState(() => _zoomGesturesEnabled = enabled); + } + + Future _updateDoubleClickZoom(bool enabled) async { + setState(() => _doubleClickToZoomEnabled = enabled); + } + + Future _enableAllGestures() async { + setState(() { + _rotateGesturesEnabled = true; + _scrollGesturesEnabled = true; + _tiltGesturesEnabled = true; + _zoomGesturesEnabled = true; + _doubleClickToZoomEnabled = true; + }); + } + + Future _disableAllGestures() async { + setState(() { + _rotateGesturesEnabled = false; + _scrollGesturesEnabled = false; + _tiltGesturesEnabled = false; + _zoomGesturesEnabled = false; + _doubleClickToZoomEnabled = false; + }); + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onMapClick: _onMapClick, + onMapLongClick: _onMapLongClick, + onCameraIdle: _onCameraIdle, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + trackCameraPosition: true, + rotateGesturesEnabled: _rotateGesturesEnabled, + scrollGesturesEnabled: _scrollGesturesEnabled, + tiltGesturesEnabled: _tiltGesturesEnabled, + zoomGesturesEnabled: _zoomGesturesEnabled, + doubleClickZoomEnabled: _doubleClickToZoomEnabled, + ), + controls: [ + InfoCard( + title: 'Gesture Status', + subtitle: _lastGesture, + icon: _isMoving ? Icons.touch_app : Icons.check_circle, + color: _isMoving ? Colors.blue.shade100 : Colors.green.shade100, + ), + ControlGroup( + title: 'Quick Actions', + children: [ + ExampleButton( + label: 'Enable All', + icon: Icons.check_circle, + onPressed: _enableAllGestures, + style: ExampleButtonStyle.filled, + ), + ExampleButton( + label: 'Disable All', + icon: Icons.block, + onPressed: _disableAllGestures, + style: ExampleButtonStyle.destructive, + ), + ], + ), + ControlGroup( + title: 'Gesture Settings', + vertical: false, + children: [ + SwitchListTile( + title: const Text('Rotate Gestures'), + subtitle: const Text('Two-finger rotation'), + value: _rotateGesturesEnabled, + onChanged: _updateRotateGestures, + contentPadding: EdgeInsets.zero, + ), + SwitchListTile( + title: const Text('Scroll Gestures'), + subtitle: const Text('Pan to move map'), + value: _scrollGesturesEnabled, + onChanged: _updateScrollGestures, + contentPadding: EdgeInsets.zero, + ), + SwitchListTile( + title: const Text('Tilt Gestures'), + subtitle: const Text('Two-finger tilt/pitch'), + value: _tiltGesturesEnabled, + onChanged: _updateTiltGestures, + contentPadding: EdgeInsets.zero, + ), + SwitchListTile( + title: const Text('Zoom Gestures'), + subtitle: const Text('Pinch to zoom'), + value: _zoomGesturesEnabled, + onChanged: _updateZoomGestures, + contentPadding: EdgeInsets.zero, + ), + SwitchListTile( + title: const Text('Double-Click Zoom'), + subtitle: const Text('Double tap to zoom in'), + value: _doubleClickToZoomEnabled, + onChanged: _updateDoubleClickZoom, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ], + ); + } + + @override + void dispose() { + _controller?.removeListener(_onMapChanged); + super.dispose(); + } +} diff --git a/maplibre_gl_example/lib/examples/layers/circle_layer_example.dart b/maplibre_gl_example/lib/examples/layers/circle_layer_example.dart new file mode 100644 index 000000000..39d10cb1d --- /dev/null +++ b/maplibre_gl_example/lib/examples/layers/circle_layer_example.dart @@ -0,0 +1,380 @@ +import 'dart:developer' as dev; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating circle layer properties +class CircleLayerExample extends ExamplePage { + const CircleLayerExample({super.key}) + : super( + const Icon(Icons.circle_outlined), + 'Circle Layer', + category: ExampleCategory.layers, + ); + + @override + Widget build(BuildContext context) => const _CircleLayerBody(); +} + +class _CircleLayerBody extends StatefulWidget { + const _CircleLayerBody(); + + @override + State<_CircleLayerBody> createState() => _CircleLayerBodyState(); +} + +class _CircleLayerBodyState extends State<_CircleLayerBody> { + MapLibreMapController? _controller; + + static const _sourceId = 'circle_source'; + static const _layerId = 'circle_layer'; + + // Circle properties + double _circleRadius = 20.0; + double _circleOpacity = 0.8; + double _circleStrokeWidth = 2.0; + double _circleStrokeOpacity = 1.0; + Color _circleColor = Colors.blue; + Color _circleStrokeColor = Colors.white; + double _circleBlur = 0.0; + double _circlePitchAlignment = 0.0; // 0 = viewport, 1 = map + double _circleTranslateX = 0.0; + double _circleTranslateY = 0.0; + + @override + void initState() { + super.initState(); + } + + Future _onMapCreated(MapLibreMapController controller) async { + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + await _addCircleLayer(); + } + + Future _addCircleLayer() async { + if (_controller == null) return; + + try { + // Add GeoJSON source with multiple points + await _controller!.addGeoJsonSource( + _sourceId, + { + 'type': 'FeatureCollection', + 'features': _generateRandomPoints(20), + }, + ); + + // Add circle layer + await _controller!.addCircleLayer( + _sourceId, + _layerId, + CircleLayerProperties( + circleRadius: _circleRadius, + circleColor: + '#${_circleColor.toARGB32().toRadixString(16).substring(2)}', + circleOpacity: _circleOpacity, + circleStrokeWidth: _circleStrokeWidth, + circleStrokeColor: + '#${_circleStrokeColor.toARGB32().toRadixString(16).substring(2)}', + circleStrokeOpacity: _circleStrokeOpacity, + circleBlur: _circleBlur, + circlePitchAlignment: _circlePitchAlignment == 0 ? 'viewport' : 'map', + circleTranslate: [_circleTranslateX, _circleTranslateY], + ), + ); + + setState(() {}); + } catch (e) { + dev.log('Error adding circle layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding circle layer: $e')), + ); + } + } + } + + List> _generateRandomPoints(int count) { + final random = Random(); + final points = >[]; + + for (var i = 0; i < count; i++) { + final lat = -33.87 + (random.nextDouble() - 0.5) * 0.2; + final lng = 151.21 + (random.nextDouble() - 0.5) * 0.2; + + points.add({ + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'Point', + 'coordinates': [lng, lat], + }, + }); + } + + return points; + } + + Future _updateLayer() async { + if (_controller == null) return; + + try { + await _controller!.setLayerProperties( + _layerId, + CircleLayerProperties( + circleRadius: _circleRadius, + circleColor: + '#${_circleColor.toARGB32().toRadixString(16).substring(2)}', + circleOpacity: _circleOpacity, + circleStrokeWidth: _circleStrokeWidth, + circleStrokeColor: + '#${_circleStrokeColor.toARGB32().toRadixString(16).substring(2)}', + circleStrokeOpacity: _circleStrokeOpacity, + circleBlur: _circleBlur, + circlePitchAlignment: _circlePitchAlignment == 0 ? 'viewport' : 'map', + circleTranslate: [_circleTranslateX, _circleTranslateY], + ), + ); + } catch (e) { + dev.log('Error updating circle layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating circle layer: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: ExampleConstants.sydneyCenter, + zoom: 10, + ), + trackCameraPosition: true, + ), + controls: _controller == null + ? [] + : [ + ControlGroup( + title: 'Circle Size', + children: [ + ListTile( + title: Text('Radius: ${_circleRadius.toStringAsFixed(1)}'), + subtitle: Slider( + value: _circleRadius, + min: 5.0, + max: 50.0, + divisions: 45, + onChanged: (value) async { + setState(() => _circleRadius = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text('Blur: ${_circleBlur.toStringAsFixed(1)}'), + subtitle: Slider( + value: _circleBlur, + min: 0.0, + max: 4.0, + divisions: 20, + onChanged: (value) async { + setState(() => _circleBlur = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Circle Color', + children: [ + ListTile( + title: const Text('Fill Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _circleColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('fill'), + ), + ListTile( + title: Text( + 'Opacity: ${(_circleOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _circleOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _circleOpacity = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Circle Stroke', + children: [ + ListTile( + title: Text( + 'Stroke Width: ${_circleStrokeWidth.toStringAsFixed(1)}'), + subtitle: Slider( + value: _circleStrokeWidth, + min: 0.0, + max: 10.0, + divisions: 20, + onChanged: (value) async { + setState(() => _circleStrokeWidth = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: const Text('Stroke Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _circleStrokeColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('stroke'), + ), + ListTile( + title: Text( + 'Stroke Opacity: ${(_circleStrokeOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _circleStrokeOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _circleStrokeOpacity = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Circle Transform', + children: [ + ListTile( + title: const Text('Pitch Alignment'), + subtitle: ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: 0.0, + label: 'Viewport', + ), + ExampleSegment( + value: 1.0, + label: 'Map', + ), + ], + selected: _circlePitchAlignment, + onSelectionChanged: (value) async { + setState(() => _circlePitchAlignment = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Translate X: ${_circleTranslateX.toStringAsFixed(0)}'), + subtitle: Slider( + value: _circleTranslateX, + min: -50.0, + max: 50.0, + divisions: 100, + onChanged: (value) async { + setState(() => _circleTranslateX = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Translate Y: ${_circleTranslateY.toStringAsFixed(0)}'), + subtitle: Slider( + value: _circleTranslateY, + min: -50.0, + max: 50.0, + divisions: 100, + onChanged: (value) async { + setState(() => _circleTranslateY = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _circleRadius = 20.0; + _circleOpacity = 0.8; + _circleStrokeWidth = 2.0; + _circleStrokeOpacity = 1.0; + _circleColor = Colors.blue; + _circleStrokeColor = Colors.white; + _circleBlur = 0.0; + _circlePitchAlignment = 0.0; + _circleTranslateX = 0.0; + _circleTranslateY = 0.0; + }); + await _updateLayer(); + }, + ), + ], + ), + ], + ); + } + + Future _pickColor(String type) async { + final currentColor = type == 'fill' ? _circleColor : _circleStrokeColor; + final title = type == 'fill' ? 'Select Fill Color' : 'Select Stroke Color'; + + final selectedColor = await ColorPickerModal.show( + context: context, + title: title, + currentColor: currentColor, + ); + + if (selectedColor != null) { + setState(() { + if (type == 'fill') { + _circleColor = selectedColor; + } else { + _circleStrokeColor = selectedColor; + } + }); + await _updateLayer(); + } + } +} diff --git a/maplibre_gl_example/lib/examples/layers/fill_layer_example.dart b/maplibre_gl_example/lib/examples/layers/fill_layer_example.dart new file mode 100644 index 000000000..01a582975 --- /dev/null +++ b/maplibre_gl_example/lib/examples/layers/fill_layer_example.dart @@ -0,0 +1,359 @@ +import 'dart:developer' as dev; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl_example/util.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating fill layer properties +class FillLayerExample extends ExamplePage { + const FillLayerExample({super.key}) + : super( + const Icon(Icons.square), + 'Fill Layer', + category: ExampleCategory.layers, + ); + + @override + Widget build(BuildContext context) => const _FillLayerBody(); +} + +class _FillLayerBody extends StatefulWidget { + const _FillLayerBody(); + + @override + State<_FillLayerBody> createState() => _FillLayerBodyState(); +} + +class _FillLayerBodyState extends State<_FillLayerBody> { + MapLibreMapController? _controller; + + static const _sourceId = 'fill_source'; + static const _layerId = 'fill_layer'; + + // Fill properties + double _fillOpacity = 0.6; + Color _fillColor = const Color(0xFF3498DB); + Color _fillOutlineColor = const Color(0xFF2C3E50); + double _fillTranslateX = 0.0; + double _fillTranslateY = 0.0; + String _fillTranslateAnchor = 'map'; + bool _fillAntialias = true; + String? _fillPattern; + + @override + void initState() { + super.initState(); + } + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + await _loadPatternImages(); + await _addFillLayer(); + } + + Future _loadPatternImages() async { + if (_controller == null) return; + + try { + // Load marker pattern from assets + await addImageFromAsset( + _controller!, + 'marker-pattern', + ExampleConstants.markerPatternPath, + ); + dev.log('Pattern images loaded successfully', name: 'FillLayerExample'); + } catch (e) { + dev.log('Error loading pattern images: $e'); + } + } + + Future _addFillLayer() async { + if (_controller == null) return; + + try { + // Add GeoJSON source with multiple polygons + await _controller!.addGeoJsonSource( + _sourceId, + { + 'type': 'FeatureCollection', + 'features': _generateRandomPolygons(5), + }, + ); + + // Add fill layer + await _controller!.addFillLayer( + _sourceId, + _layerId, + FillLayerProperties( + fillOpacity: _fillOpacity, + fillColor: '#${_fillColor.toARGB32().toRadixString(16).substring(2)}', + fillOutlineColor: + '#${_fillOutlineColor.toARGB32().toRadixString(16).substring(2)}', + fillTranslate: [_fillTranslateX, _fillTranslateY], + fillTranslateAnchor: _fillTranslateAnchor, + fillAntialias: _fillAntialias, + fillPattern: _fillPattern, + ), + ); + + setState(() {}); + } catch (e) { + dev.log('Error adding fill layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding fill layer: $e')), + ); + } + } + } + + List> _generateRandomPolygons(int count) { + final random = Random(); + final polygons = >[]; + + for (var i = 0; i < count; i++) { + final centerLat = -33.87 + (random.nextDouble() - 0.5) * 0.2; + final centerLng = 151.21 + (random.nextDouble() - 0.5) * 0.2; + final size = 0.01 + random.nextDouble() * 0.02; + + polygons.add({ + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ + [ + [centerLng - size, centerLat + size], + [centerLng + size, centerLat + size], + [centerLng + size, centerLat - size], + [centerLng - size, centerLat - size], + [centerLng - size, centerLat + size], + ] + ], + }, + }); + } + + return polygons; + } + + Future _updateLayer() async { + if (_controller == null) return; + + try { + await _controller!.setLayerProperties( + _layerId, + FillLayerProperties( + fillOpacity: _fillOpacity, + fillColor: '#${_fillColor.toARGB32().toRadixString(16).substring(2)}', + fillOutlineColor: + '#${_fillOutlineColor.toARGB32().toRadixString(16).substring(2)}', + fillTranslate: [_fillTranslateX, _fillTranslateY], + fillTranslateAnchor: _fillTranslateAnchor, + fillAntialias: _fillAntialias, + fillPattern: _fillPattern, + ), + ); + } catch (e) { + dev.log('Error updating fill layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating fill layer: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: ExampleConstants.sydneyCenter, + zoom: 10, + ), + trackCameraPosition: true, + ), + controls: _controller == null + ? [] + : [ + ControlGroup( + title: 'Fill Color', + children: [ + ListTile( + title: const Text('Fill Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _fillColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('fill'), + ), + ListTile( + title: Text( + 'Opacity: ${(_fillOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _fillOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _fillOpacity = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Fill Outline', + children: [ + ListTile( + title: const Text('Outline Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _fillOutlineColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor('outline'), + ), + SwitchListTile( + title: const Text('Antialias'), + subtitle: const Text('Smooth edges'), + value: _fillAntialias, + onChanged: (value) async { + setState(() => _fillAntialias = value); + await _updateLayer(); + }, + ), + ], + ), + ControlGroup( + title: 'Fill Transform', + children: [ + ListTile( + title: Text( + 'Translate X: ${_fillTranslateX.toStringAsFixed(0)}'), + subtitle: Slider( + value: _fillTranslateX, + min: -50.0, + max: 50.0, + divisions: 100, + onChanged: (value) async { + setState(() => _fillTranslateX = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Translate Y: ${_fillTranslateY.toStringAsFixed(0)}'), + subtitle: Slider( + value: _fillTranslateY, + min: -50.0, + max: 50.0, + divisions: 100, + onChanged: (value) async { + setState(() => _fillTranslateY = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: const Text('Translate Anchor'), + subtitle: const Text('Reference frame for translation'), + trailing: SegmentedButton( + segments: const [ + ButtonSegment(value: 'map', label: Text('Map')), + ButtonSegment( + value: 'viewport', label: Text('Viewport')), + ], + selected: {_fillTranslateAnchor}, + onSelectionChanged: (Set selected) async { + setState(() => _fillTranslateAnchor = selected.first); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Pattern', + children: [ + SwitchListTile( + value: _fillPattern != null, + title: const Text('Fill Pattern'), + subtitle: Text(_fillPattern ?? 'None'), + onChanged: (bool value) async { + setState(() { + _fillPattern = value ? 'marker-pattern' : null; + }); + await _updateLayer(); + }, + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _fillOpacity = 0.6; + _fillColor = const Color(0xFF3498DB); + _fillOutlineColor = const Color(0xFF2C3E50); + _fillTranslateX = 0.0; + _fillTranslateY = 0.0; + _fillTranslateAnchor = 'map'; + _fillAntialias = true; + _fillPattern = null; + }); + await _updateLayer(); + }, + ), + ], + ), + ], + ); + } + + Future _pickColor(String type) async { + final currentColor = type == 'fill' ? _fillColor : _fillOutlineColor; + final title = type == 'fill' ? 'Select Fill Color' : 'Select Outline Color'; + + final selectedColor = await ColorPickerModal.show( + context: context, + title: title, + currentColor: currentColor, + ); + + if (selectedColor != null) { + setState(() { + if (type == 'fill') { + _fillColor = selectedColor; + } else { + _fillOutlineColor = selectedColor; + } + }); + await _updateLayer(); + } + } +} diff --git a/maplibre_gl_example/lib/examples/layers/line_layer_example.dart b/maplibre_gl_example/lib/examples/layers/line_layer_example.dart new file mode 100644 index 000000000..db5b144ff --- /dev/null +++ b/maplibre_gl_example/lib/examples/layers/line_layer_example.dart @@ -0,0 +1,599 @@ +import 'dart:developer' as dev; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl_example/util.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating line layer properties +class LineLayerExample extends ExamplePage { + const LineLayerExample({super.key}) + : super( + const Icon(Icons.timeline), + 'Line Layer', + category: ExampleCategory.layers, + ); + + @override + Widget build(BuildContext context) => const _LineLayerBody(); +} + +class _LineLayerBody extends StatefulWidget { + const _LineLayerBody(); + + @override + State<_LineLayerBody> createState() => _LineLayerBodyState(); +} + +class _LineLayerBodyState extends State<_LineLayerBody> { + MapLibreMapController? _controller; + + static const _sourceId = 'line_source'; + static const _layerId = 'line_layer'; + + // Line properties + double _lineWidth = 4.0; + double _lineOpacity = 0.9; + Color _lineColor = const Color(0xFFE74C3C); + double _lineBlur = 0.0; + double _lineGapWidth = 0.0; + double _lineOffset = 0.0; + double _lineTranslateX = 0.0; + double _lineTranslateY = 0.0; + String _lineCap = 'round'; // butt, round, square + String _lineJoin = 'round'; // bevel, round, miter + double _lineMiterLimit = 2.0; + double _lineRoundLimit = 1.05; + String? _linePattern; + _LineDashStyle _lineDasharray = _LineDashStyle.solid; + + @override + void initState() { + super.initState(); + } + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + await _loadPatternImages(); + + // Add GeoJSON source with multiple lines + await _controller!.addGeoJsonSource( + _sourceId, + { + 'type': 'FeatureCollection', + 'features': _generateRandomLines(5), + }, + ); + await _addLineLayer(); + } + + Future _loadPatternImages() async { + if (_controller == null) return; + + try { + // Load marker pattern from assets + await addImageFromAsset( + _controller!, + 'marker-pattern', + ExampleConstants.markerPatternPath, + ); + } catch (e) { + dev.log('Error loading pattern images: $e'); + } + } + + Future _addLineLayer() async { + if (_controller == null) return; + + try { + // Add line layer + await _controller!.addLineLayer( + _sourceId, + _layerId, + LineLayerProperties( + lineWidth: _lineWidth, + lineOpacity: _lineOpacity, + lineColor: '#${_lineColor.toARGB32().toRadixString(16).substring(2)}', + lineBlur: _lineBlur, + lineGapWidth: _lineGapWidth, + lineOffset: _lineOffset, + lineTranslate: [_lineTranslateX, _lineTranslateY], + lineCap: _lineCap, + lineJoin: _lineJoin, + lineMiterLimit: _lineMiterLimit, + lineRoundLimit: _lineRoundLimit, + linePattern: _linePattern, + lineDasharray: _lineDasharray.dashArray, + ), + ); + + setState(() {}); + } catch (e) { + dev.log('Error adding line layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding line layer: $e')), + ); + } + } + } + + List> _generateRandomLines(int count) { + final random = Random(); + final lines = >[]; + + for (var i = 0; i < count; i++) { + final startLat = -33.87 + (random.nextDouble() - 0.5) * 0.2; + final startLng = 151.21 + (random.nextDouble() - 0.5) * 0.2; + + // Generate a curved line with multiple points + final coordinates = >[]; + for (var j = 0; j < 5; j++) { + coordinates.add([ + startLng + j * 0.02 + (random.nextDouble() - 0.5) * 0.01, + startLat + (random.nextDouble() - 0.5) * 0.04, + ]); + } + + lines.add({ + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'LineString', + 'coordinates': coordinates, + }, + }); + } + + return lines; + } + + Future _updateLayer() async { + if (_controller == null) return; + + try { + await _controller!.setLayerProperties( + _layerId, + LineLayerProperties( + lineWidth: _lineWidth, + lineOpacity: _lineOpacity, + lineColor: '#${_lineColor.toARGB32().toRadixString(16).substring(2)}', + lineBlur: _lineBlur, + lineGapWidth: _lineGapWidth, + lineOffset: _lineOffset, + lineTranslate: [_lineTranslateX, _lineTranslateY], + lineCap: _lineCap, + lineJoin: _lineJoin, + lineMiterLimit: _lineMiterLimit, + lineRoundLimit: _lineRoundLimit, + linePattern: _linePattern, + lineDasharray: _lineDasharray.dashArray, + ), + ); + } catch (e) { + dev.log('Error updating line layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating layer: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: ExampleConstants.sydneyCenter, + zoom: 10, + ), + trackCameraPosition: true, + ), + controls: _controller == null + ? [] + : [ + ControlGroup( + title: 'Line Appearance', + children: [ + ListTile( + title: Text('Width: ${_lineWidth.toStringAsFixed(1)}'), + subtitle: Slider( + value: _lineWidth, + min: 1.0, + max: 20.0, + divisions: 38, + onChanged: (value) async { + setState(() => _lineWidth = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text('Blur: ${_lineBlur.toStringAsFixed(1)}'), + subtitle: Slider( + value: _lineBlur, + min: 0.0, + max: 10.0, + divisions: 20, + onChanged: (value) async { + setState(() => _lineBlur = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: const Text('Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _lineColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: _pickColor, + ), + ListTile( + title: Text( + 'Opacity: ${(_lineOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _lineOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _lineOpacity = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Line Style', + children: [ + ListTile( + title: const Text('Cap Style'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('End of line appearance'), + const SizedBox(height: 8), + ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: 'butt', + label: 'Butt', + ), + ExampleSegment( + value: 'round', + label: 'Round', + ), + ExampleSegment( + value: 'square', + label: 'Square', + ), + ], + selected: _lineCap, + onSelectionChanged: (value) async { + setState(() => _lineCap = value); + await _updateLayer(); + }, + ), + ], + ), + ), + ListTile( + title: const Text('Join Style'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Line corner appearance'), + const SizedBox(height: 8), + ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: 'bevel', + label: 'Bevel', + ), + ExampleSegment( + value: 'round', + label: 'Round', + ), + ExampleSegment( + value: 'miter', + label: 'Miter', + ), + ], + selected: _lineJoin, + onSelectionChanged: (value) async { + setState(() => _lineJoin = value); + await _updateLayer(); + }, + ), + ], + ), + ), + if (_lineJoin == 'miter') + ListTile( + title: Text( + 'Miter Limit: ${_lineMiterLimit.toStringAsFixed(1)}'), + subtitle: const Text('Sharp corner threshold'), + trailing: SizedBox( + width: 200, + child: Slider( + value: _lineMiterLimit, + min: 1.0, + max: 10.0, + divisions: 18, + onChanged: (value) async { + setState(() => _lineMiterLimit = value); + await _updateLayer(); + }, + ), + ), + ), + if (_lineJoin == 'round') + ListTile( + title: Text( + 'Round Limit: ${_lineRoundLimit.toStringAsFixed(2)}'), + subtitle: const Text('Round corner threshold'), + trailing: SizedBox( + width: 200, + child: Slider( + value: _lineRoundLimit, + min: 1.0, + max: 2.0, + divisions: 20, + onChanged: (value) async { + setState(() => _lineRoundLimit = value); + await _updateLayer(); + }, + ), + ), + ), + ], + ), + ControlGroup( + title: 'Line Layout', + children: [ + ListTile( + title: + Text('Gap Width: ${_lineGapWidth.toStringAsFixed(1)}'), + subtitle: const Text('Space between parallel lines'), + trailing: SizedBox( + width: 200, + child: Slider( + value: _lineGapWidth, + min: 0.0, + max: 20.0, + divisions: 20, + onChanged: (value) async { + setState(() => _lineGapWidth = value); + await _updateLayer(); + }, + ), + ), + ), + ListTile( + title: Text('Offset: ${_lineOffset.toStringAsFixed(1)}'), + subtitle: const Text('Perpendicular shift'), + trailing: SizedBox( + width: 200, + child: Slider( + value: _lineOffset, + min: -20.0, + max: 20.0, + divisions: 40, + onChanged: (value) async { + setState(() => _lineOffset = value); + await _updateLayer(); + }, + ), + ), + ), + ], + ), + ControlGroup( + title: 'Line Transform', + children: [ + ListTile( + title: Text( + 'Translate X: ${_lineTranslateX.toStringAsFixed(0)}'), + subtitle: Slider( + value: _lineTranslateX, + min: -50.0, + max: 50.0, + divisions: 100, + onChanged: (value) async { + setState(() => _lineTranslateX = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Translate Y: ${_lineTranslateY.toStringAsFixed(0)}'), + subtitle: Slider( + value: _lineTranslateY, + min: -50.0, + max: 50.0, + divisions: 100, + onChanged: (value) async { + setState(() => _lineTranslateY = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Pattern', + children: [ + SwitchListTile( + value: _linePattern != null, + title: const Text('Line Pattern'), + subtitle: Text(_linePattern ?? 'None'), + onChanged: (bool value) async { + setState(() { + _linePattern = value ? 'marker-pattern' : null; + }); + await _updateLayer(); + }, + ), + ], + ), + ControlGroup( + title: 'Dash Array', + children: [ + ListTile( + title: const Text('Dash Style'), + subtitle: Text(_lineDasharray.label), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => _selectDashArray(), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + _lineWidth = 4.0; + _lineOpacity = 0.9; + _lineColor = const Color(0xFFE74C3C); + _lineBlur = 0.0; + _lineGapWidth = 0.0; + _lineOffset = 0.0; + _lineTranslateX = 0.0; + _lineTranslateY = 0.0; + _lineCap = 'round'; + _lineJoin = 'round'; + _lineMiterLimit = 2.0; + _lineRoundLimit = 1.05; + _linePattern = null; + _lineDasharray = _LineDashStyle.solid; + }); + await _updateLayer(); + }, + ), + ], + ), + ], + ); + } + + Future _pickColor() async { + final selectedColor = await ColorPickerModal.show( + context: context, + title: 'Select Line Color', + currentColor: _lineColor, + ); + + if (selectedColor != null) { + setState(() => _lineColor = selectedColor); + await _updateLayer(); + } + } + + Future _selectDashArray() async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Dash Style'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(_LineDashStyle.solid.label), + subtitle: const Text('No dashes'), + leading: Icon( + _lineDasharray == _LineDashStyle.solid + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + onTap: () => Navigator.pop(context, _LineDashStyle.solid), + ), + ListTile( + title: Text(_LineDashStyle.dotted.label), + subtitle: Text(_LineDashStyle.dotted.dashArray.toString()), + leading: Icon( + _lineDasharray == _LineDashStyle.dotted + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + onTap: () => Navigator.pop(context, _LineDashStyle.dotted), + ), + ListTile( + title: Text(_LineDashStyle.dashed.label), + subtitle: Text(_LineDashStyle.dashed.dashArray.toString()), + leading: Icon( + _lineDasharray == _LineDashStyle.dashed + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + onTap: () => Navigator.pop(context, _LineDashStyle.dashed), + ), + ListTile( + title: Text(_LineDashStyle.dashDot.label), + subtitle: Text(_LineDashStyle.dashDot.dashArray.toString()), + leading: Icon( + _lineDasharray == _LineDashStyle.dashDot + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + onTap: () => Navigator.pop(context, _LineDashStyle.dashDot), + ), + ListTile( + title: Text(_LineDashStyle.custom.label), + subtitle: Text(_LineDashStyle.custom.dashArray.toString()), + leading: Icon( + _lineDasharray == _LineDashStyle.custom + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + onTap: () => Navigator.pop(context, _LineDashStyle.custom), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + + // Only update if a selection was made (not cancelled) + if (result != null && result is _LineDashStyle) { + dev.log('Selected dash style: ${result.label}'); + setState(() => _lineDasharray = result); + await _updateLayer(); + } + } +} + +enum _LineDashStyle { + solid(label: 'Solid', dashArray: null), + dotted(label: 'Dotted', dashArray: [0.1, 2]), + dashed(label: 'Dashed', dashArray: [6, 3]), + dashDot(label: 'Dash Dot', dashArray: [6, 2, 0.1, 2]), + custom(label: 'Custom', dashArray: [10.0, 5.0, 2.0, 5.0]); + + final String label; + final List? dashArray; + const _LineDashStyle({required this.label, required this.dashArray}); +} diff --git a/maplibre_gl_example/lib/examples/layers/symbol_layer_example.dart b/maplibre_gl_example/lib/examples/layers/symbol_layer_example.dart new file mode 100644 index 000000000..45cb02723 --- /dev/null +++ b/maplibre_gl_example/lib/examples/layers/symbol_layer_example.dart @@ -0,0 +1,654 @@ +import 'dart:developer' as dev; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl_example/util.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; + +/// Example demonstrating symbol layer properties +class SymbolLayerExample extends ExamplePage { + const SymbolLayerExample({super.key}) + : super( + const Icon(Icons.place), + 'Symbol Layer', + category: ExampleCategory.layers, + ); + + @override + Widget build(BuildContext context) => const _SymbolLayerBody(); +} + +class _SymbolLayerBody extends StatefulWidget { + const _SymbolLayerBody(); + + @override + State<_SymbolLayerBody> createState() => _SymbolLayerBodyState(); +} + +class _SymbolLayerBodyState extends State<_SymbolLayerBody> { + MapLibreMapController? _controller; + + static const _sourceId = 'symbol_source'; + static const _layerId = 'symbol_layer'; + + // Text properties + double _textSize = 14.0; + Color _textColor = const Color(0xFF2C3E50); + double _textOpacity = 1.0; + double _textHaloWidth = 2.0; + Color _textHaloColor = Colors.white; + double _textHaloBlur = 1.0; + double _textRotate = 0.0; + double _textOffsetX = 0.0; + double _textOffsetY = 0.0; + String _textAnchor = 'center'; // center, left, right, top, bottom + String _textJustify = 'center'; // left, center, right + bool _textAllowOverlap = false; + bool _textIgnorePlacement = false; + + // Icon properties + bool _showIcon = true; + double _iconSize = 1.0; + double _iconRotate = 0.0; + double _iconOffsetX = 0.0; + double _iconOffsetY = -1.5; + bool _iconAllowOverlap = false; + bool _iconIgnorePlacement = false; + + // Symbol layout + String _symbolPlacement = 'point'; // point, line + double _symbolSpacing = 250.0; + bool _symbolAvoidEdges = false; + + @override + void initState() { + super.initState(); + } + + void _onMapCreated(MapLibreMapController controller) { + setState(() => _controller = controller); + } + + Future _onStyleLoaded() async { + await _addSymbolLayer(); + } + + Future _addSymbolLayer() async { + if (_controller == null) return; + + try { + await addImageFromAsset( + _controller!, + "custom-marker", + "assets/symbols/custom-marker.png", + ); + + // Add GeoJSON source with points + await _controller!.addGeoJsonSource( + _sourceId, + { + 'type': 'FeatureCollection', + 'features': _generateRandomPoints(10), + }, + ); + + // Add symbol layer + await _controller!.addSymbolLayer( + _sourceId, + _layerId, + SymbolLayerProperties( + // Icon properties + iconImage: _showIcon ? 'custom-marker' : null, + iconSize: _iconSize, + iconRotate: _iconRotate, + iconOffset: [_iconOffsetX, _iconOffsetY], + iconAllowOverlap: _iconAllowOverlap, + iconIgnorePlacement: _iconIgnorePlacement, + // Text properties + textField: '{name}', + textSize: _textSize, + textColor: '#${_textColor.toARGB32().toRadixString(16).substring(2)}', + textOpacity: _textOpacity, + textHaloWidth: _textHaloWidth, + textHaloColor: + '#${_textHaloColor.toARGB32().toRadixString(16).substring(2)}', + textHaloBlur: _textHaloBlur, + textRotate: _textRotate, + textOffset: [_textOffsetX, _textOffsetY], + textAnchor: _textAnchor, + textJustify: _textJustify, + textAllowOverlap: _textAllowOverlap, + textIgnorePlacement: _textIgnorePlacement, + symbolPlacement: _symbolPlacement, + symbolSpacing: _symbolSpacing, + symbolAvoidEdges: _symbolAvoidEdges, + ), + ); + + setState(() {}); + } catch (e) { + dev.log('Error adding symbol layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding symbol layer: $e')), + ); + } + } + } + + List> _generateRandomPoints(int count) { + final random = Random(); + final points = >[]; + final cities = [ + 'Sydney', + 'Melbourne', + 'Brisbane', + 'Perth', + 'Adelaide', + 'Canberra', + 'Hobart', + 'Darwin', + 'Newcastle', + 'Wollongong', + ]; + + for (var i = 0; i < count; i++) { + points.add({ + 'type': 'Feature', + 'properties': { + 'name': cities[i % cities.length], + }, + 'geometry': { + 'type': 'Point', + 'coordinates': [ + 151.21 + (random.nextDouble() - 0.5) * 0.3, + -33.87 + (random.nextDouble() - 0.5) * 0.3, + ], + }, + }); + } + + return points; + } + + Future _updateLayer() async { + if (_controller == null) return; + + try { + await _controller!.setLayerProperties( + _layerId, + SymbolLayerProperties( + // Icon properties + iconImage: _showIcon ? 'custom-marker' : null, + iconSize: _iconSize, + iconRotate: _iconRotate, + iconOffset: [_iconOffsetX, _iconOffsetY], + iconAllowOverlap: _iconAllowOverlap, + iconIgnorePlacement: _iconIgnorePlacement, + // Text properties + textField: '{name}', + textSize: _textSize, + textColor: '#${_textColor.toARGB32().toRadixString(16).substring(2)}', + textOpacity: _textOpacity, + textHaloWidth: _textHaloWidth, + textHaloColor: + '#${_textHaloColor.toARGB32().toRadixString(16).substring(2)}', + textHaloBlur: _textHaloBlur, + textRotate: _textRotate, + textOffset: [_textOffsetX, _textOffsetY], + textAnchor: _textAnchor, + textJustify: _textJustify, + textAllowOverlap: _textAllowOverlap, + textIgnorePlacement: _textIgnorePlacement, + symbolPlacement: _symbolPlacement, + symbolSpacing: _symbolSpacing, + symbolAvoidEdges: _symbolAvoidEdges, + ), + ); + } catch (e) { + dev.log('Error updating symbol layer: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating symbol layer: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return MapExampleScaffold( + map: MapLibreMap( + styleString: ExampleConstants.demoMapStyle, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: const CameraPosition( + target: ExampleConstants.sydneyCenter, + zoom: 10, + ), + trackCameraPosition: true, + ), + controls: _controller == null + ? [] + : [ + ControlGroup( + title: 'Icon Properties', + children: [ + ListTile( + title: Text('Icon Size: ${_iconSize.toStringAsFixed(1)}'), + subtitle: Slider( + value: _iconSize, + min: 0.5, + max: 3.0, + divisions: 25, + onChanged: (value) async { + setState(() => _iconSize = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Icon Rotation: ${_iconRotate.toStringAsFixed(0)}°'), + subtitle: Slider( + value: _iconRotate, + min: 0.0, + max: 360.0, + divisions: 72, + onChanged: (value) async { + setState(() => _iconRotate = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Icon Offset X: ${_iconOffsetX.toStringAsFixed(1)}'), + subtitle: Slider( + value: _iconOffsetX, + min: -3.0, + max: 3.0, + divisions: 60, + onChanged: (value) async { + setState(() => _iconOffsetX = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: Text( + 'Icon Offset Y: ${_iconOffsetY.toStringAsFixed(1)}'), + subtitle: Slider( + value: _iconOffsetY, + min: -3.0, + max: 3.0, + divisions: 60, + onChanged: (value) async { + setState(() => _iconOffsetY = value); + await _updateLayer(); + }, + ), + ), + SwitchListTile( + title: const Text('Icon Allow Overlap'), + subtitle: const Text('Allow icons to overlap'), + value: _iconAllowOverlap, + onChanged: (value) async { + setState(() => _iconAllowOverlap = value); + await _updateLayer(); + }, + ), + SwitchListTile( + title: const Text('Icon Ignore Placement'), + subtitle: const Text('Ignore icon collision detection'), + value: _iconIgnorePlacement, + onChanged: (value) async { + setState(() => _iconIgnorePlacement = value); + await _updateLayer(); + }, + ), + ], + ), + ControlGroup( + title: 'Text Appearance', + children: [ + ListTile( + title: Text('Size: ${_textSize.toStringAsFixed(1)}'), + subtitle: Slider( + value: _textSize, + min: 8.0, + max: 32.0, + divisions: 48, + onChanged: (value) async { + setState(() => _textSize = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: const Text('Text Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _textColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor(true), + ), + ListTile( + title: Text( + 'Opacity: ${(_textOpacity * 100).toStringAsFixed(0)}%'), + subtitle: Slider( + value: _textOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) async { + setState(() => _textOpacity = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Text Halo', + children: [ + ListTile( + title: Text( + 'Halo Width: ${_textHaloWidth.toStringAsFixed(1)}'), + subtitle: Slider( + value: _textHaloWidth, + min: 0.0, + max: 4.0, + divisions: 20, + onChanged: (value) async { + setState(() => _textHaloWidth = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: const Text('Halo Color'), + trailing: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _textHaloColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => _pickColor(false), + ), + ListTile( + title: + Text('Halo Blur: ${_textHaloBlur.toStringAsFixed(1)}'), + subtitle: Slider( + value: _textHaloBlur, + min: 0.0, + max: 4.0, + divisions: 20, + onChanged: (value) async { + setState(() => _textHaloBlur = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Text Layout', + children: [ + ListTile( + title: const Text('Anchor Position'), + subtitle: const Text('Text alignment relative to point'), + trailing: DropdownButton( + value: _textAnchor, + items: const [ + DropdownMenuItem( + value: 'center', child: Text('Center')), + DropdownMenuItem(value: 'top', child: Text('Top')), + DropdownMenuItem( + value: 'bottom', child: Text('Bottom')), + DropdownMenuItem(value: 'left', child: Text('Left')), + DropdownMenuItem(value: 'right', child: Text('Right')), + DropdownMenuItem( + value: 'top-left', child: Text('Top Left')), + DropdownMenuItem( + value: 'top-right', child: Text('Top Right')), + DropdownMenuItem( + value: 'bottom-left', child: Text('Bottom Left')), + DropdownMenuItem( + value: 'bottom-right', child: Text('Bottom Right')), + ], + onChanged: (value) async { + if (value != null) { + setState(() => _textAnchor = value); + await _updateLayer(); + } + }, + ), + ), + ListTile( + title: const Text('Text Justify'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Horizontal text alignment'), + const SizedBox(height: 8), + ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: 'left', + label: 'Left', + ), + ExampleSegment( + value: 'center', + label: 'Center', + ), + ExampleSegment( + value: 'right', + label: 'Right', + ), + ], + selected: _textJustify, + onSelectionChanged: (value) async { + setState(() => _textJustify = value); + await _updateLayer(); + }, + ), + ], + ), + ), + ListTile( + title: Text('Rotation: ${_textRotate.toStringAsFixed(0)}°'), + subtitle: Slider( + value: _textRotate, + min: 0.0, + max: 360.0, + divisions: 72, + onChanged: (value) async { + setState(() => _textRotate = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Text Position', + children: [ + ListTile( + title: + Text('Offset X: ${_textOffsetX.toStringAsFixed(1)} em'), + subtitle: Slider( + value: _textOffsetX, + min: -2.0, + max: 2.0, + divisions: 40, + onChanged: (value) async { + setState(() => _textOffsetX = value); + await _updateLayer(); + }, + ), + ), + ListTile( + title: + Text('Offset Y: ${_textOffsetY.toStringAsFixed(1)} em'), + subtitle: Slider( + value: _textOffsetY, + min: -2.0, + max: 2.0, + divisions: 40, + onChanged: (value) async { + setState(() => _textOffsetY = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Symbol Behavior', + children: [ + SwitchListTile( + title: const Text('Allow Overlap'), + subtitle: const Text('Allow symbols to overlap'), + value: _textAllowOverlap, + onChanged: (value) async { + setState(() => _textAllowOverlap = value); + await _updateLayer(); + }, + ), + SwitchListTile( + title: const Text('Ignore Placement'), + subtitle: const Text('Ignore collision detection'), + value: _textIgnorePlacement, + onChanged: (value) async { + setState(() => _textIgnorePlacement = value); + await _updateLayer(); + }, + ), + SwitchListTile( + title: const Text('Avoid Edges'), + subtitle: const Text('Keep symbols from map edges'), + value: _symbolAvoidEdges, + onChanged: (value) async { + setState(() => _symbolAvoidEdges = value); + await _updateLayer(); + }, + ), + ListTile( + title: const Text('Placement Type'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Point or line placement'), + const SizedBox(height: 8), + ExampleSegmentedButton( + segments: const [ + ExampleSegment( + value: 'point', + label: 'Point', + ), + ExampleSegment( + value: 'line', + label: 'Line', + ), + ], + selected: _symbolPlacement, + onSelectionChanged: (value) async { + setState(() => _symbolPlacement = value); + await _updateLayer(); + }, + ), + ], + ), + ), + if (_symbolPlacement == 'line') + ListTile( + title: Text( + 'Symbol Spacing: ${_symbolSpacing.toStringAsFixed(0)} px'), + subtitle: Slider( + value: _symbolSpacing, + min: 50.0, + max: 500.0, + divisions: 45, + onChanged: (value) async { + setState(() => _symbolSpacing = value); + await _updateLayer(); + }, + ), + ), + ], + ), + ControlGroup( + title: 'Actions', + children: [ + ExampleButton( + label: 'Reset Properties', + onPressed: () async { + setState(() { + // Icon properties + _showIcon = true; + _iconSize = 1.0; + _iconRotate = 0.0; + _iconOffsetX = 0.0; + _iconOffsetY = -1.5; + _iconAllowOverlap = false; + _iconIgnorePlacement = false; + // Text properties + _textSize = 14.0; + _textColor = const Color(0xFF2C3E50); + _textOpacity = 1.0; + _textHaloWidth = 2.0; + _textHaloColor = Colors.white; + _textHaloBlur = 1.0; + _textRotate = 0.0; + _textOffsetX = 0.0; + _textOffsetY = 0.0; + _textAnchor = 'center'; + _textJustify = 'center'; + _textAllowOverlap = false; + _textIgnorePlacement = false; + _symbolPlacement = 'point'; + _symbolSpacing = 250.0; + _symbolAvoidEdges = false; + }); + await _updateLayer(); + }, + ), + ], + ), + ], + ); + } + + Future _pickColor(bool isTextColor) async { + final currentColor = isTextColor ? _textColor : _textHaloColor; + final title = isTextColor ? 'Select Text Color' : 'Select Halo Color'; + + final selectedColor = await ColorPickerModal.show( + context: context, + title: title, + currentColor: currentColor, + ); + + if (selectedColor != null) { + setState(() { + if (isTextColor) { + _textColor = selectedColor; + } else { + _textHaloColor = selectedColor; + } + }); + await _updateLayer(); + } + } +} diff --git a/maplibre_gl_example/lib/sources.dart b/maplibre_gl_example/lib/examples/layers/various_sources.dart similarity index 54% rename from maplibre_gl_example/lib/sources.dart rename to maplibre_gl_example/lib/examples/layers/various_sources.dart index 4ea55ed1e..6082b0a21 100644 --- a/maplibre_gl_example/lib/sources.dart +++ b/maplibre_gl_example/lib/examples/layers/various_sources.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'page.dart'; +import '../../page.dart'; class StyleInfo { final String name; @@ -17,8 +17,10 @@ class StyleInfo { required this.position}); } -class Sources extends ExamplePage { - const Sources({super.key}) : super(const Icon(Icons.map), 'Various Sources'); +class VariousSources extends ExamplePage { + const VariousSources({super.key}) + : super(const Icon(Icons.map), 'Various Sources', + category: ExampleCategory.layers); @override Widget build(BuildContext context) { @@ -39,36 +41,40 @@ class FullMapState extends State { int selectedStyleId = 0; void _onMapCreated(MapLibreMapController controller) { - this.controller = controller; + setState(() => this.controller = controller); } static Future addRaster(MapLibreMapController controller) async { await controller.addSource( - "watercolor", + "osm-raster", const RasterSourceProperties( tiles: [ - 'https://stamen-tiles.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg' + 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png' ], tileSize: 256, attribution: - 'Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under CC BY SA'), + '© OpenStreetMap contributors'), ); await controller.addLayer( - "watercolor", "watercolor", const RasterLayerProperties()); + "osm-raster", "osm-raster", const RasterLayerProperties()); } static Future addGeojsonCluster( MapLibreMapController controller) async { await controller.addSource( - "earthquakes", - const GeojsonSourceProperties( - data: - 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson', - cluster: true, - clusterMaxZoom: 14, // Max zoom to cluster points on - clusterRadius: - 50 // Radius of each cluster when clustering points (defaults to 50) - )); + "earthquakes", + const GeojsonSourceProperties( + data: + 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson', + cluster: true, + clusterMaxZoom: 14, // Max zoom to cluster points on + clusterRadius: + 50, // Radius of each cluster when clustering points (defaults to 50) + attribution: + 'Earthquake data © MapLibre'), + ); await controller.addLayer( "earthquakes", "earthquakes-circles", @@ -101,34 +107,50 @@ class FullMapState extends State { static Future addVector(MapLibreMapController controller) async { await controller.addSource( - "terrain", + "openmaptiles", const VectorSourceProperties( - url: MapLibreStyles.demo, + tiles: [ + 'https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf', + ], + maxzoom: 14, + attribution: + '© MapLibre contributors addImage(MapLibreMapController controller) async { await controller.addSource( - "radar", - const ImageSourceProperties( - url: "https://docs.mapbox.com/mapbox-gl-js/assets/radar.gif", - coordinates: [ - [-80.425, 46.437], - [-71.516, 46.437], - [-71.516, 37.936], - [-80.425, 37.936] - ])); + "radar", + const ImageSourceProperties( + url: "https://maplibre.org/maplibre-gl-js/docs/assets/radar.gif", + coordinates: [ + [-80.425, 46.437], + [-71.516, 46.437], + [-71.516, 37.936], + [-80.425, 37.936] + ], + ), + ); await controller.addRasterLayer( "radar", @@ -139,16 +161,20 @@ class FullMapState extends State { static Future addVideo(MapLibreMapController controller) async { await controller.addSource( - "video", - const VideoSourceProperties(urls: [ + "video", + const VideoSourceProperties( + urls: [ 'https://static-assets.mapbox.com/mapbox-gl-js/drone.mp4', 'https://static-assets.mapbox.com/mapbox-gl-js/drone.webm' - ], coordinates: [ + ], + coordinates: [ [-122.51596391201019, 37.56238816766053], [-122.51467645168304, 37.56410183312965], [-122.51309394836426, 37.563391708549425], [-122.51423120498657, 37.56161849366671] - ])); + ], + ), + ); await controller.addRasterLayer( "video", @@ -159,10 +185,14 @@ class FullMapState extends State { static Future addHeatMap(MapLibreMapController controller) async { await controller.addSource( - 'earthquakes-heatmap-source', - const GeojsonSourceProperties( - data: - 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson')); + 'earthquakes-heatmap-source', + const GeojsonSourceProperties( + data: + 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson', + attribution: + 'Earthquake data © MapLibre', + ), + ); await controller.addLayer( 'earthquakes-heatmap-source', @@ -234,20 +264,94 @@ class FullMapState extends State { ); } - static Future addDem(MapLibreMapController controller) async { - // TODO: adapt example? - // await controller.addSource( - // "dem", - // RasterDemSourceProperties( - // url: "mapbox://mapbox.mapbox-terrain-dem-v1")); + static Future addCountries(MapLibreMapController controller) async { + // Add a simple additional layer to demonstrate layering on the default style + // Remove existing layers/source if they exist (in case of re-loading) + try { + await controller.removeLayer("countries-fill"); + } catch (e) { + // Layer doesn't exist, ignore + } + try { + await controller.removeLayer("countries-outline"); + } catch (e) { + // Layer doesn't exist, ignore + } + try { + await controller.removeSource("countries-highlight"); + } catch (e) { + // Source doesn't exist, ignore + } - // await controller.addLayer( - // "dem", - // "hillshade", - // HillshadeLayerProperties( - // hillshadeExaggeration: 1, - // hillshadeShadowColor: Colors.blue.toHexStringRGB()), - // ); + // Source: Natural Earth public domain data + // Free vector and raster map data @ naturalearthdata.com + await controller.addSource( + "countries-highlight", + const GeojsonSourceProperties( + data: + 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_110m_admin_0_countries.geojson', + attribution: + 'GeoJSON data courtesy of Natural Earth', + ), + ); + + await controller.addLayer( + "countries-highlight", + "countries-fill", + const FillLayerProperties( + fillColor: "#627BC1", + fillOpacity: 0.3, + ), + ); + + await controller.addLayer( + "countries-highlight", + "countries-outline", + const LineLayerProperties( + lineColor: "#627BC1", + lineWidth: 2, + ), + ); + } + + static Future addDemHillshade(MapLibreMapController controller) async { + // Remove existing layers/source if they exist + try { + await controller.removeLayer("hillshade-layer"); + } catch (e) { + // Layer doesn't exist, ignore + } + try { + await controller.removeSource("terrarium-dem"); + } catch (e) { + // Source doesn't exist, ignore + } + + // Source: Terrarium terrain tiles + await controller.addSource( + "terrarium-dem", + const RasterDemSourceProperties( + tiles: [ + 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png' + ], + minzoom: 0, + maxzoom: 15, + tileSize: 256, + encoding: 'terrarium', + attribution: + 'Elevation data © AWS Terrain Tiles', + ), + ); + + await controller.addLayer( + "terrarium-dem", + "hillshade-layer", + HillshadeLayerProperties( + hillshadeExaggeration: 0.8, + hillshadeShadowColor: Colors.blue.shade900.toHexStringRGB(), + hillshadeHighlightColor: Colors.white.toHexStringRGB(), + ), + ); } final _stylesAndLoaders = [ @@ -258,14 +362,20 @@ class FullMapState extends State { position: CameraPosition(target: LatLng(33.3832, -118.4333), zoom: 6), ), const StyleInfo( - name: "Default style", + name: "Countries GeoJSON", // Using the raw github file version of MapLibreStyles.DEMO here, because we need to // specify a different baseStyle for consecutive elements in this list, // otherwise the map will not update - baseStyle: - "https://raw.githubusercontent.com/maplibre/demotiles/gh-pages/style.json", - addDetails: addDem, - position: CameraPosition(target: LatLng(33.5, -118.1), zoom: 8), + baseStyle: MapLibreStyles.demo, + addDetails: addCountries, + position: CameraPosition(target: LatLng(20, 0), zoom: 2), + ), + const StyleInfo( + name: "DEM Hillshade", + baseStyle: MapLibreStyles.demo, + addDetails: addDemHillshade, + position: CameraPosition( + target: LatLng(46.5, 8.0), zoom: 8, bearing: 80, tilt: 60), ), const StyleInfo( name: "Geojson cluster", @@ -275,22 +385,19 @@ class FullMapState extends State { ), const StyleInfo( name: "Raster", - baseStyle: - "https://raw.githubusercontent.com/maplibre/demotiles/gh-pages/style.json", + baseStyle: MapLibreStyles.demo, addDetails: addRaster, position: CameraPosition(target: LatLng(40, -100), zoom: 3), ), const StyleInfo( name: "Image", - baseStyle: - "https://raw.githubusercontent.com/maplibre/demotiles/gh-pages/style.json?", + baseStyle: MapLibreStyles.demo, addDetails: addImage, position: CameraPosition(target: LatLng(43, -75), zoom: 6), ), const StyleInfo( name: "Heatmap", - baseStyle: - "https://raw.githubusercontent.com/maplibre/demotiles/gh-pages/style.json", + baseStyle: MapLibreStyles.demo, addDetails: addHeatMap, position: CameraPosition(target: LatLng(33.5, -118.1), zoom: 2), ), @@ -298,18 +405,27 @@ class FullMapState extends State { if (kIsWeb) const StyleInfo( name: "Video", - baseStyle: - "https://raw.githubusercontent.com/maplibre/demotiles/gh-pages/style.json", + baseStyle: MapLibreStyles.demo, addDetails: addVideo, position: CameraPosition( target: LatLng(37.562984, -122.514426), zoom: 17, bearing: -96), ), ]; + Future _loadCurrentSource() async { + if (controller == null) return; + final styleInfo = _stylesAndLoaders[selectedStyleId]; + // Reload the style to clear all previous layers/sources + await controller!.setStyle(styleInfo.baseStyle); + // Wait for style to load, then add the new source details + // The onStyleLoadedCallback will be triggered again, but we need to prevent recursion + } + Future _onStyleLoadedCallback() async { + if (controller == null) return; final styleInfo = _stylesAndLoaders[selectedStyleId]; - styleInfo.addDetails(controller!); - controller! + await styleInfo.addDetails(controller!); + await controller! .animateCamera(CameraUpdate.newCameraPosition(styleInfo.position)); } @@ -320,17 +436,17 @@ class FullMapState extends State { _stylesAndLoaders[(selectedStyleId + 1) % _stylesAndLoaders.length] .name; return Scaffold( - floatingActionButton: Padding( - padding: const EdgeInsets.all(32.0), - child: FloatingActionButton.extended( - icon: const Icon(Icons.swap_horiz), - label: SizedBox( - width: 120, child: Center(child: Text("To $nextName"))), - onPressed: () => setState( - () => selectedStyleId = - (selectedStyleId + 1) % _stylesAndLoaders.length, - ), - ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.swap_horiz), + label: + SizedBox(width: 120, child: Center(child: Text("To $nextName"))), + onPressed: () async { + setState(() { + selectedStyleId = + (selectedStyleId + 1) % _stylesAndLoaders.length; + }); + await _loadCurrentSource(); + }, ), body: Stack( children: [ @@ -339,6 +455,8 @@ class FullMapState extends State { onMapCreated: _onMapCreated, initialCameraPosition: styleInfo.position, onStyleLoadedCallback: _onStyleLoadedCallback, + logoEnabled: true, + attributionButtonPosition: AttributionButtonPosition.bottomLeft, ), Container( padding: const EdgeInsets.all(8), diff --git a/maplibre_gl_example/lib/full_map.dart b/maplibre_gl_example/lib/full_map.dart deleted file mode 100644 index 729b95813..000000000 --- a/maplibre_gl_example/lib/full_map.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -const _nullIsland = CameraPosition(target: LatLng(0, 0), zoom: 4.0); - -class FullMapPage extends ExamplePage { - const FullMapPage({super.key}) - : super(const Icon(Icons.map), 'Full screen map'); - - @override - Widget build(BuildContext context) { - return const FullMap(); - } -} - -class FullMap extends StatefulWidget { - const FullMap({super.key}); - - @override - State createState() => FullMapState(); -} - -class FullMapState extends State { - final Completer mapController = Completer(); - bool canInteractWithMap = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - floatingActionButtonLocation: - FloatingActionButtonLocation.miniCenterFloat, - floatingActionButton: canInteractWithMap - ? FloatingActionButton( - onPressed: _moveCameraToNullIsland, - mini: true, - child: const Icon(Icons.restore), - ) - : null, - body: MapLibreMap( - onMapCreated: (controller) => mapController.complete(controller), - initialCameraPosition: _nullIsland, - onStyleLoadedCallback: () => setState(() => canInteractWithMap = true), - ), - ); - } - - void _moveCameraToNullIsland() => mapController.future.then( - (c) => c.animateCamera(CameraUpdate.newCameraPosition(_nullIsland))); -} diff --git a/maplibre_gl_example/lib/get_map_informations.dart b/maplibre_gl_example/lib/get_map_informations.dart deleted file mode 100644 index 5819f741d..000000000 --- a/maplibre_gl_example/lib/get_map_informations.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class GetMapInfoPage extends ExamplePage { - const GetMapInfoPage({super.key}) - : super(const Icon(Icons.info), 'Get map state'); - - @override - Widget build(BuildContext context) { - return const GetMapInfoBody(); - } -} - -class GetMapInfoBody extends StatefulWidget { - const GetMapInfoBody({super.key}); - - @override - State createState() => _GetMapInfoBodyState(); -} - -class _GetMapInfoBodyState extends State { - MapLibreMapController? controller; - String data = ''; - - void onMapCreated(MapLibreMapController controller) { - setState(() { - this.controller = controller; - }); - } - - Future displaySources() async { - if (controller == null) { - return; - } - final sources = await controller!.getSourceIds(); - setState(() { - data = 'Sources: ${sources.map((e) => '"$e"').join(', ')}'; - }); - } - - Future displayLayers() async { - if (controller == null) { - return; - } - final layers = (await controller!.getLayerIds()).cast(); - setState(() { - data = 'Layers: ${layers.map((e) => '"$e"').join(', ')}'; - }); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: SizedBox( - width: 300.0, - height: 200.0, - child: MapLibreMap( - initialCameraPosition: const CameraPosition( - target: LatLng(-33.852, 151.211), - zoom: 11.0, - ), - onMapCreated: onMapCreated, - compassEnabled: false, - annotationOrder: const [], - myLocationEnabled: false, - styleString: '''{ - "version": 8, - "sources": { - "OSM": { - "type": "raster", - "tiles": [ - "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", - "https://b.tile.openstreetmap.org/{z}/{x}/{y}.png", - "https://c.tile.openstreetmap.org/{z}/{x}/{y}.png" - ], - "tileSize": 256, - "attribution": "© OpenStreetMap contributors", - "maxzoom": 18 - } - }, - "layers": [ - { - "id": "OSM-layer", - "source": "OSM", - "type": "raster" - } - ] - }''', - ), - ), - ), - const Center(child: Text('© OpenStreetMap contributors')), - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 30), - Center(child: Text(data)), - const SizedBox(height: 30), - ElevatedButton( - onPressed: controller == null ? null : displayLayers, - child: const Text('Get map layers'), - ), - ElevatedButton( - onPressed: controller == null ? null : displaySources, - child: const Text('Get map sources'), - ) - ], - ), - )), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/given_bounds.dart b/maplibre_gl_example/lib/given_bounds.dart deleted file mode 100644 index ba3f277ee..000000000 --- a/maplibre_gl_example/lib/given_bounds.dart +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class GivenBoundsPage extends ExamplePage { - const GivenBoundsPage({super.key}) - : super(const Icon(Icons.map_sharp), 'Changing given bounds'); - - @override - Widget build(BuildContext context) { - return const GivenBounds(); - } -} - -class GivenBounds extends StatefulWidget { - const GivenBounds({super.key}); - - @override - State createState() => GivenBoundsState(); -} - -class GivenBoundsState extends State { - late MapLibreMapController mapController; - - void _onMapCreated(MapLibreMapController controller) { - mapController = controller; - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - onMapCreated: _onMapCreated, - initialCameraPosition: - const CameraPosition(target: LatLng(0.0, 0.0)), - ), - ), - TextButton( - onPressed: () async { - await mapController.setCameraBounds( - west: 5.98865807458, - south: 47.3024876979, - east: 15.0169958839, - north: 54.983104153, - padding: 25, - ); - }, - child: const Text('Set bounds to Germany'), - ), - TextButton( - onPressed: () async { - await mapController.setCameraBounds( - west: -18, - south: -40, - east: 54, - north: 40, - padding: 25, - ); - }, - child: const Text('Set bounds to Africa'), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/layer.dart b/maplibre_gl_example/lib/layer.dart deleted file mode 100644 index 266d3265b..000000000 --- a/maplibre_gl_example/lib/layer.dart +++ /dev/null @@ -1,469 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:maplibre_gl_example/page.dart'; - -import 'util.dart'; - -class LayerPage extends ExamplePage { - const LayerPage({super.key}) : super(const Icon(Icons.share), 'Layer'); - - @override - Widget build(BuildContext context) => const LayerBody(); -} - -class LayerBody extends StatefulWidget { - const LayerBody({super.key}); - - @override - State createState() => LayerState(); -} - -class LayerState extends State { - static const LatLng center = LatLng(-33.86711, 151.1947171); - - late MapLibreMapController controller; - Timer? bikeTimer; - Timer? filterTimer; - int filteredId = 0; - bool linesVisible = true; - bool fillsVisible = true; - bool symbolsVisible = true; - bool circlesVisible = true; - bool linesRed = false; - bool fillsRed = true; - bool symbolsRed = false; - bool circlesRed = false; - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - dragEnabled: false, - myLocationEnabled: true, - onMapCreated: _onMapCreated, - onMapClick: (point, latLong) { - debugPrint(point.toString() + latLong.toString()); - }, - onStyleLoadedCallback: _onStyleLoadedCallback, - initialCameraPosition: const CameraPosition( - target: center, - zoom: 11.0, - ), - )), - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "lines", - LineLayerProperties.fromJson( - {"visibility": linesVisible ? "none" : "visible"}, - ), - ) - .then( - (value) => - setState(() => linesVisible = !linesVisible), - ); - }, - child: const Text('toggle line visibility'), - ), - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "lines", - LineLayerProperties.fromJson( - {"line-color": linesRed ? "#0000ff" : "#ff0000"}, - ), - ) - .then((value) => setState(() => linesRed = !linesRed)); - }, - child: const Text('toggle line color'), - ), - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "fills", - FillLayerProperties.fromJson( - {"visibility": fillsVisible ? "none" : "visible"}, - ), - ) - .then( - (value) => - setState(() => fillsVisible = !fillsVisible), - ); - }, - child: const Text('toggle fill visibility'), - ), - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "fills", - FillLayerProperties.fromJson( - {"fill-color": fillsRed ? "#0000ff" : "#ff0000"}, - ), - ) - .then( - (value) => setState(() => fillsRed = !fillsRed), - ); - }, - child: const Text('toggle fill color'), - ), - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "circles", - CircleLayerProperties.fromJson( - {"visibility": circlesVisible ? "none" : "visible"}, - ), - ) - .then( - (value) => - setState(() => circlesVisible = !circlesVisible), - ); - }, - child: const Text('toggle circle visibility'), - ), - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "circles", - CircleLayerProperties.fromJson( - { - "circle-color": circlesRed ? "#0000ff" : "#ff0000" - }, - ), - ) - .then( - (value) => setState(() => circlesRed = !circlesRed), - ); - }, - child: const Text('toggle circle color'), - ), - TextButton( - onPressed: () async { - await controller - .setLayerProperties( - "symbols", - SymbolLayerProperties.fromJson( - {"visibility": symbolsVisible ? "none" : "visible"}, - ), - ) - .then( - (value) => - setState(() => symbolsVisible = !symbolsVisible), - ); - }, - child: const Text('toggle (non-moving) symbols visibility'), - ), - ], - ), - ), - ), - ], - ); - } - - void _onMapCreated(MapLibreMapController controller) { - this.controller = controller; - controller.onFeatureTapped.add(onFeatureTap); - } - - void onFeatureTap( - Point point, - LatLng latLng, - String id, - String layerId, - Annotation? annotation, - ) { - final snackBar = SnackBar( - content: Text( - 'Tapped feature with id $id on layer $layerId at $latLng.\nAnnotation is ${annotation != null ? 'present' : 'null'}', - style: const TextStyle(fontSize: 14), - ), - backgroundColor: Theme.of(context).primaryColor, - ); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } - - Future _onStyleLoadedCallback() async { - await addImageFromAsset( - controller, "custom-marker", "assets/symbols/custom-marker.png"); - 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", - const FillLayerProperties(fillColor: [ - Expressions.interpolate, - ['exponential', 0.5], - [Expressions.zoom], - 11, - 'red', - 18, - 'green' - ], fillOpacity: 0.4), - filter: ['==', 'id', filteredId], - ); - - await controller.addFillExtrusionLayer( - "fills", - "fills-extrusion", - const FillExtrusionLayerProperties( - fillExtrusionHeight: 300, - fillExtrusionColor: [ - Expressions.interpolate, - ['exponential', 0.5], - [Expressions.zoom], - 11, - 'red', - 18, - 'blue' - ], - ), - belowLayerId: "water", - filter: ['==', 'id', 2], - ); - - 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", - const SymbolLayerProperties( - iconImage: "custom-marker", // "{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: "custom-marker", - // "bicycle-15", - iconSize: 2, - iconAllowOverlap: true, - textAllowOverlap: true, - ), - minzoom: 11, - ); - - bikeTimer = Timer.periodic(const Duration(milliseconds: 10), (t) async { - await controller.setGeoJsonSource( - "moving", _movingFeature(t.tick / 2000)); - }); - - filterTimer = Timer.periodic(const Duration(seconds: 3), (t) async { - filteredId = filteredId == 0 ? 1 : 0; - await controller.setFilter('fills', ['==', 'id', filteredId]); - }); - } - - @override - void dispose() { - bikeTimer?.cancel(); - filterTimer?.cancel(); - super.dispose(); - } -} - -Map _movingFeature(double t) { - List makeLatLong(double t) { - final angle = t * 2 * pi; - const r = 0.025; - const centerX = 151.1849; - const centerY = -33.8748; - return [ - centerX + r * sin(angle), - centerY + 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 Map _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": 2, - "properties": {'id': 2}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [151.121824791363, -33.885947459842846], - [151.121824791363, -33.89768020458625], - [151.13561641336742, -33.89768020458625], - [151.13561641336742, -33.885947459842846], - [151.121824791363, -33.885947459842846] - ] - ], - } - }, - { - "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 Map _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/maplibre_gl_example/lib/layer_manipulation.dart b/maplibre_gl_example/lib/layer_manipulation.dart deleted file mode 100644 index 7af7d1339..000000000 --- a/maplibre_gl_example/lib/layer_manipulation.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class LayerManipulationPage extends ExamplePage { - const LayerManipulationPage({super.key}) - : super(const Icon(Icons.layers), 'Layer Manipulation'); - - @override - Widget build(BuildContext context) { - return const LayerManipulation(); - } -} - -class LayerManipulation extends StatefulWidget { - const LayerManipulation({super.key}); - - @override - State createState() => _LayerManipulationState(); -} - -class _LayerManipulationState extends State { - MapLibreMapController? mapController; - String currentStyle = ''; - int currentFilterId = 0; - bool isGeoJsonSourceMode = true; - int animationStep = 0; - Timer? _animationTimer; - bool _isAnimating = false; - - // Sample GeoJSON data for editGeoJsonSource - final Map _sampleGeoJsonData = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [14.363610, 46.233487, 0.0] - } - }, - ] - }; - - // Animated GeoJSON data that changes over time - Map _getAnimatedGeoJsonData(int step) { - final baseData = Map.from(_sampleGeoJsonData); - final features = List.from(baseData['features']); - - for (var i = 0; i < features.length; i++) { - final feature = features[i]; - final coords = List.from(feature['geometry']['coordinates']); - final angle = (step * 0.1) + (i * 0.5); - final radius = 0.5 + (i * 0.1); - - coords[0] = coords[0] + radius * cos(angle); - coords[1] = coords[1] + radius * sin(angle); - - feature['geometry']['coordinates'] = coords; - } - - baseData['features'] = features; - return baseData; - } - - void _onMapCreated(MapLibreMapController controller) { - mapController = controller; - } - - Future _onStyleLoadedCallback() async { - if (mapController == null) return; - - // Add a GeoJSON source - await mapController!.addSource( - "sample-data", - GeojsonSourceProperties(data: _sampleGeoJsonData), - ); - - // Add a circle layer - await mapController!.addCircleLayer( - "sample-data", - "sample-circles", - const CircleLayerProperties( - circleRadius: 8, - circleColor: "#ff0000", - circleStrokeColor: "#ffffff", - circleStrokeWidth: 2, - ), - ); - - // Add a symbol layer for labels - await mapController!.addSymbolLayer( - "sample-data", - "sample-labels", - const SymbolLayerProperties( - textField: ["get", "name"], - textFont: ["Open Sans Regular"], - textSize: 12, - textColor: "#000000", - textHaloColor: "#ffffff", - textHaloWidth: 1, - ), - ); - - // Get and display current style - await _getCurrentStyle(); - } - - Future _getCurrentStyle() async { - if (mapController == null) return; - - try { - final style = await mapController!.getStyle(); - setState(() { - currentStyle = style ?? 'No style available'; - }); - } catch (e) { - setState(() { - currentStyle = 'Error getting style: $e'; - }); - } - } - - void _toggleAnimation() { - if (_isAnimating) { - _stopAnimation(); - } else { - _startAnimation(); - } - } - - void _startAnimation() { - if (_animationTimer != null) return; - - setState(() { - isGeoJsonSourceMode = true; - _isAnimating = true; - }); - - _animationTimer = - Timer.periodic(const Duration(milliseconds: 100), (timer) async { - if (mapController != null) { - final newData = _getAnimatedGeoJsonData(animationStep); - await mapController!.setGeoJsonSource("sample-data", newData); - - setState(() { - animationStep++; - }); - } - }); - } - - void _stopAnimation() { - _animationTimer?.cancel(); - _animationTimer = null; - - setState(() { - _isAnimating = false; - }); - } - - Future _editGeoJsonUrl() async { - if (mapController == null) return; - - // Stop animation when switching to URL mode - _stopAnimation(); - - setState(() { - isGeoJsonSourceMode = false; - }); - - // Use a public GeoJSON URL (earthquakes data) - await mapController!.editGeoJsonUrl( - "sample-data", - "https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson", - ); - } - - Future _setLayerFilter() async { - if (mapController == null) return; - - // Cycle through different filters - final filters = [ - '["all", ["==", "name", "International Date Line"]]', // Don't show International Date Line - '["all", ["!=", "name", "International Date Line"]]', // Do show International Date Line - ]; - - final filter = filters[currentFilterId % filters.length]; - await mapController!.setLayerFilter("countries-fill", filter); - - setState(() { - currentFilterId++; - }); - } - - @override - void dispose() { - _animationTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - children: [ - // Map - Expanded( - flex: 3, - child: MapLibreMap( - styleString: MapLibreStyles.demo, - onMapCreated: _onMapCreated, - onStyleLoadedCallback: _onStyleLoadedCallback, - initialCameraPosition: const CameraPosition( - target: LatLng(50.0, 10.0), - zoom: 3.0, - ), - ), - ), - - // Controls - Expanded( - flex: 2, - child: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Layer Manipulation Demo', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - - // Control buttons - Wrap( - spacing: 8.0, - runSpacing: 8.0, - children: [ - ElevatedButton( - onPressed: _toggleAnimation, - style: ElevatedButton.styleFrom( - backgroundColor: - _isAnimating ? Colors.red : Colors.green, - foregroundColor: Colors.white, - ), - child: Text(_isAnimating - ? 'Stop Animation' - : 'Start Animation'), - ), - if (!kIsWeb) ...[ - ElevatedButton( - onPressed: _editGeoJsonUrl, - child: const Text('Show Earthquakes'), - ), - ElevatedButton( - onPressed: _setLayerFilter, - child: const Text('Toggle Country Fill'), - ), - ElevatedButton( - onPressed: _getCurrentStyle, - child: const Text('Get Style'), - ), - ], - ], - ), - - const SizedBox(height: 16), - - // Status information - Container( - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(4.0), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Current Mode: ${isGeoJsonSourceMode ? "GeoJSON Source" : "GeoJSON URL"}', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text('Filter ID: $currentFilterId'), - Text('Animation Step: $animationStep'), - Text( - 'Animation Status: ${_isAnimating ? "Running" : "Stopped"}'), - ], - ), - ), - - const SizedBox(height: 8), - - // Style information - if (currentStyle.isNotEmpty) ...[ - const Text( - 'Current Style:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Container( - height: 100, - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: Colors.grey[50], - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(4.0), - ), - child: SingleChildScrollView( - child: Text( - currentStyle, - style: const TextStyle( - fontFamily: 'monospace', fontSize: 10), - ), - ), - ), - ], - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/maplibre_gl_example/lib/line.dart b/maplibre_gl_example/lib/line.dart deleted file mode 100644 index 1e061f788..000000000 --- a/maplibre_gl_example/lib/line.dart +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class LinePage extends ExamplePage { - const LinePage({super.key}) : super(const Icon(Icons.share), 'Line'); - - @override - Widget build(BuildContext context) { - return const LineBody(); - } -} - -class LineBody extends StatefulWidget { - const LineBody({super.key}); - - @override - State createState() => LineBodyState(); -} - -class LineBodyState extends State { - LineBodyState(); - - static const LatLng center = LatLng(-33.86711, 151.1947171); - - MapLibreMapController? controller; - int _lineCount = 0; - Line? _selectedLine; - final String _linePatternImage = "assets/fill/cat_silhouette_pattern.png"; - - void _onMapCreated(MapLibreMapController controller) { - this.controller = controller; - controller.onLineTapped.add(_onLineTapped); - controller.onFeatureHover.add(_onFeatureHover); - } - - @override - void dispose() { - controller?.onLineTapped.remove(_onLineTapped); - controller?.onFeatureHover.remove(_onFeatureHover); - super.dispose(); - } - - /// Adds an asset image to the currently displayed style - Future addImageFromAsset(String name, String assetName) async { - final bytes = await rootBundle.load(assetName); - final list = bytes.buffer.asUint8List(); - return controller!.addImage(name, list); - } - - Future _onLineTapped(Line line) async { - await _updateSelectedLine( - const LineOptions(lineColor: "#ff0000"), - ); - setState(() { - _selectedLine = line; - }); - await _updateSelectedLine( - const LineOptions(lineColor: "#ffe100"), - ); - } - - Future _onFeatureHover( - Point point, - LatLng latLng, - String id, - Annotation? annotation, - HoverEventType eventType, - ) async { - if (annotation is! Line) return; - if (eventType == HoverEventType.enter) { - controller!.updateLine( - annotation, - const LineOptions( - lineWidth: 16, - lineColor: "#8B0000", - )); - } else if (eventType == HoverEventType.leave) { - controller!.updateLine( - annotation, - const LineOptions( - lineWidth: 14, - lineColor: "#ff0000", - )); - } - } - - Future _updateSelectedLine(LineOptions changes) async { - if (_selectedLine != null) controller!.updateLine(_selectedLine!, changes); - } - - Future _add() async { - await controller!.addLine( - const 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; - }); - } - - Future _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])); - } - - Future _remove() async { - await controller!.removeLine(_selectedLine!); - setState(() { - _selectedLine = null; - _lineCount -= 1; - }); - } - - Future _changeLinePattern() async { - final current = - _selectedLine!.options.linePattern == null ? "assetImage" : null; - await _updateSelectedLine( - LineOptions(linePattern: current), - ); - } - - Future _changeAlpha() async { - var current = _selectedLine!.options.lineOpacity; - current ??= 1.0; - - await _updateSelectedLine( - LineOptions(lineOpacity: current < 0.1 ? 1.0 : current * 0.75), - ); - } - - Future _toggleVisible() async { - var current = _selectedLine!.options.lineOpacity; - current ??= 1.0; - await _updateSelectedLine( - LineOptions(lineOpacity: current == 0.0 ? 1.0 : 0.0), - ); - } - - Future _onStyleLoadedCallback() async { - addImageFromAsset("assetImage", _linePatternImage); - await controller!.addLine( - const 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) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - 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( - onPressed: (_lineCount == 12) ? null : _add, - child: const Text('add'), - ), - TextButton( - onPressed: (_selectedLine == null) ? null : _remove, - child: const Text('remove'), - ), - TextButton( - onPressed: (_selectedLine == null) - ? null - : () async { - await _move(); - }, - child: const Text('move'), - ), - TextButton( - onPressed: (_selectedLine == null) - ? null - : _changeLinePattern, - child: const Text('change line-pattern'), - ), - ], - ), - Row( - children: [ - TextButton( - onPressed: - (_selectedLine == null) ? null : _changeAlpha, - child: const Text('change alpha'), - ), - TextButton( - onPressed: - (_selectedLine == null) ? null : _toggleVisible, - child: const Text('toggle visible'), - ), - TextButton( - onPressed: (_selectedLine == null) - ? null - : () { - final latLngs = controller! - .getLineLatLngs(_selectedLine!); - debugPrint('Current geometry: $latLngs'); - }, - child: const Text('print current LatLng'), - ), - ], - ), - ], - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/localized_map.dart b/maplibre_gl_example/lib/localized_map.dart deleted file mode 100644 index cd80d9995..000000000 --- a/maplibre_gl_example/lib/localized_map.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class LocalizedMapPage extends ExamplePage { - const LocalizedMapPage({super.key}) - : super(const Icon(Icons.map), 'Localized screen map'); - - @override - Widget build(BuildContext context) { - return const LocalizedMap(); - } -} - -class LocalizedMap extends StatefulWidget { - const LocalizedMap({super.key}); - - @override - State createState() => LocalizedMapState(); -} - -class LocalizedMapState extends State { - final _mapReadyCompleter = Completer(); - - var _mapLanguage = "en"; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - children: [ - DropdownButton( - value: _mapLanguage, - icon: const Icon(Icons.arrow_drop_down), - elevation: 16, - onChanged: (value) async { - if (value == null) return; - - setState(() => _mapLanguage = value); - await _setMapLanguage(); - }, - items: ["en", "de", "es", "pl"] - .map>((String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - ), - Expanded( - child: MapLibreMap( - onMapCreated: _onMapCreated, - initialCameraPosition: - const CameraPosition(target: LatLng(0.0, 0.0)), - onStyleLoadedCallback: _onStyleLoadedCallback, - ), - ), - ], - ), - ); - } - - void _onMapCreated(MapLibreMapController controller) { - _mapReadyCompleter.complete(controller); - } - - Future _onStyleLoadedCallback() async { - await _setMapLanguage(); - } - - Future _setMapLanguage() async { - final controller = await _mapReadyCompleter.future; - controller.setMapLanguage(_mapLanguage); - } -} diff --git a/maplibre_gl_example/lib/main.dart b/maplibre_gl_example/lib/main.dart index 00fff994d..bce2815c4 100644 --- a/maplibre_gl_example/lib/main.dart +++ b/maplibre_gl_example/lib/main.dart @@ -1,70 +1,110 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +import 'dart:async' show unawaited; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:location/location.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:maplibre_gl_example/attribution.dart'; -import 'package:maplibre_gl_example/get_map_informations.dart'; -import 'package:maplibre_gl_example/given_bounds.dart'; -import 'package:maplibre_gl_example/localized_map.dart'; -import 'package:maplibre_gl_example/no_location_permission_page.dart'; -import 'package:maplibre_gl_example/pmtiles.dart'; -import 'package:maplibre_gl_example/presentation/gps_location/gps_location_page.dart'; -import 'package:maplibre_gl_example/translucent_full_map.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 'layer_manipulation.dart'; -import 'line.dart'; -import 'map_ui.dart'; -import 'move_camera.dart'; -import 'offline_regions.dart'; + +// Page system 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 'multi_style_switch.dart'; + +// Basics examples +import 'examples/basics/full_map_example.dart'; +import 'examples/basics/multi_style_switch.dart'; +import 'examples/basics/get_map_state.dart'; +import 'examples/basics/gps_location_page.dart'; + +// Camera examples +import 'examples/camera/camera_controls_example.dart'; +import 'examples/camera/camera_bounds_example.dart'; + +// Interaction examples +import 'examples/interaction/map_controls_example.dart'; +import 'examples/interaction/map_gestures_example.dart'; + +// Annotations examples +import 'examples/annotations/annotations_example.dart'; +import 'examples/annotations/annotation_order_example.dart'; +import 'examples/annotations/annotation_properties_example.dart'; +import 'examples/annotations/custom_marker.dart'; + +// Layers examples +import 'examples/layers/circle_layer_example.dart'; +import 'examples/layers/fill_layer_example.dart'; +import 'examples/layers/line_layer_example.dart'; +import 'examples/layers/symbol_layer_example.dart'; +import 'examples/layers/various_sources.dart'; + +// Advanced examples +import 'examples/advanced/offline_regions.dart'; +import 'examples/advanced/pmtiles.dart'; +import 'examples/advanced/translucent_full_map.dart'; + +void main() { + runApp(const MapLibreExampleApp()); +} + +class MapLibreExampleApp extends StatelessWidget { + const MapLibreExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'MapLibre Examples', + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1976D2), + brightness: Brightness.light, + ), + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1976D2), + brightness: Brightness.dark, + ), + ), + themeMode: ThemeMode.system, + home: const MapsDemo(), + ); + } +} final List _allPages = [ - const MapUiPage(), - const FullMapPage(), - const TranslucentFullMapPage(), - const PMTilesPage(), + // Basics + const FullMapExample(), const MultiStyleSwitchPage(), - const LocalizedMapPage(), - const AnimateCameraPage(), - const MoveCameraPage(), - const PlaceSymbolPage(), - const PlaceSourcePage(), - const LinePage(), - const LayerPage(), - const LayerManipulationPage(), - const PlaceCirclePage(), - const PlaceFillPage(), - const ScrollingMapPage(), - const OfflineRegionsPage(), - const AnnotationOrderPage(), - const CustomMarkerPage(), - const BatchAddPage(), - const ClickAnnotationPage(), - const Sources(), - const GivenBoundsPage(), - const GetMapInfoPage(), - const NoLocationPermissionPage(), - const AttributionPage(), const GpsLocationPage(), + const GetMapInfoPage(), + + // Camera + const CameraControlsExample(), + const CameraBoundsExample(), + + // Interaction + const MapControlsExample(), + const MapGesturesExample(), + + // Annotations + const AnnotationsExample(), + const AnnotationPropertiesExample(), + const AnnotationOrderExample(), + const CustomMarkerPage(), + + // Layers + const SymbolLayerExample(), + const CircleLayerExample(), + const FillLayerExample(), + const LineLayerExample(), + const VariousSources(), + + // Advanced + const PMTilesPage(), + const OfflineRegionsPage(), + const TranslucentFullMapPage(), ]; class MapsDemo extends StatefulWidget { @@ -75,6 +115,12 @@ class MapsDemo extends StatefulWidget { } class _MapsDemoState extends State { + @override + void initState() { + super.initState(); + unawaited(initHybridComposition()); + } + /// Determine the android version of the phone and turn off HybridComposition /// on older sdk versions to improve performance for these /// @@ -109,26 +155,133 @@ class _MapsDemoState extends State { } } + Map> _groupByCategory() { + final grouped = >{}; + for (final page in _allPages) { + grouped.putIfAbsent(page.category, () => []).add(page); + } + return grouped; + } + @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final groupedPages = _groupByCategory(); + return Scaffold( - appBar: AppBar(title: const Text('MapLibre examples')), - body: ListView.builder( - itemCount: _allPages.length + 1, - itemBuilder: (_, int index) => index == _allPages.length - ? const AboutListTile( - applicationName: "flutter-maplibre-gl example", - ) - : ListTile( - leading: _allPages[index].leading, - title: Text(_allPages[index].title), - onTap: () => _pushPage(context, _allPages[index]), + body: CustomScrollView( + slivers: [ + const SliverAppBar.large( + title: Text('MapLibre Examples'), + floating: true, + snap: true, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 16.0, + ), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Explore ${_allPages.length} interactive examples', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Learn how to use MapLibre GL with Flutter through categorized examples.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + const categories = ExampleCategory.values; + if (index >= categories.length) { + // About tile at the end + return const Padding( + padding: + EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: AboutListTile( + icon: Icon(Icons.info), + applicationName: "MapLibre GL Flutter", + aboutBoxChildren: [ + Text( + 'MapLibre GL Flutter is an open-source Flutter plugin for embedding interactive maps using the MapLibre GL Native library.'), + SizedBox(height: 8), + Text( + 'This example app showcases various features and capabilities of the MapLibre GL Flutter plugin through interactive examples.'), + ], + ), + ); + } + + final category = categories[index]; + final pages = groupedPages[category] ?? []; + + if (pages.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 4.0), + child: Card( + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + leading: Icon( + category.icon, + color: theme.colorScheme.primary, + ), + title: Text( + category.label, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text('${pages.length} examples'), + children: pages + .map((page) => ListTile( + leading: page.leading, + title: Text(page.title), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pushPage(context, page), + )) + .toList(), + ), + ), + ); + }, + childCount: ExampleCategory.values.length + 1, + ), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 16)), + ], ), ); } } - -void main() { - runApp(const MaterialApp(home: MapsDemo())); -} diff --git a/maplibre_gl_example/lib/map_ui.dart b/maplibre_gl_example/lib/map_ui.dart deleted file mode 100644 index b4014636b..000000000 --- a/maplibre_gl_example/lib/map_ui.dart +++ /dev/null @@ -1,490 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; -import 'package:maplibre_gl/maplibre_gl.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 { - const MapUiPage({super.key}) : super(const Icon(Icons.map), 'User interface'); - - @override - Widget build(BuildContext context) { - return const MapUiBody(); - } -} - -class MapUiBody extends StatefulWidget { - const MapUiBody({super.key}); - - @override - State createState() => MapUiBodyState(); -} - -class MapUiBodyState extends State { - MapUiBodyState(); - - static const CameraPosition _kInitialPosition = CameraPosition( - target: LatLng(-33.852, 151.211), - zoom: 11.0, - ); - - MapLibreMapController? 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. - final List _styleStrings = [MapLibreStyles.demo, "assets/style.json"]; - final List _styleStringLabels = [ - "MapLibre demo style", - "Local style file" - ]; - bool _rotateGesturesEnabled = true; - bool _scrollGesturesEnabled = true; - bool? _doubleClickToZoomEnabled; - bool _tiltGesturesEnabled = true; - bool _zoomGesturesEnabled = true; - bool _myLocationEnabled = true; - bool _telemetryEnabled = true; - bool _countriesVisible = true; - MyLocationTrackingMode _myLocationTrackingMode = MyLocationTrackingMode.none; - MyLocationRenderMode _myLocationRenderMode = MyLocationRenderMode.normal; - List? _featureQueryFilter; - Fill? _selectedFill; - - 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 nextType = MyLocationTrackingMode.values[ - (_myLocationTrackingMode.index + 1) % - MyLocationTrackingMode.values.length]; - return TextButton( - child: Text('change to $nextType'), - onPressed: () { - setState(() { - _myLocationTrackingMode = nextType; - }); - }, - ); - } - - Widget _myLocationRenderModeCycler() { - final nextType = MyLocationRenderMode.values[ - (_myLocationRenderMode.index + 1) % MyLocationRenderMode.values.length]; - return TextButton( - onPressed: _myLocationEnabled || nextType == MyLocationRenderMode.normal - ? () { - setState(() { - _myLocationRenderMode = nextType; - }); - } - : null, - child: Text('change to $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( - onPressed: _myLocationRenderMode == MyLocationRenderMode.normal - ? () { - setState(() { - _myLocationEnabled = !_myLocationEnabled; - }); - } - : null, - child: Text('${_myLocationEnabled ? 'disable' : 'enable'} my location'), - ); - } - - Widget _telemetryToggler() { - return TextButton( - child: Text('${_telemetryEnabled ? 'disable' : 'enable'} telemetry'), - onPressed: () async { - setState(() { - _telemetryEnabled = !_telemetryEnabled; - }); - await mapController?.setTelemetryEnabled(_telemetryEnabled); - }, - ); - } - - Widget _visibleRegionGetter() { - return TextButton( - child: const Text('get currently visible region'), - onPressed: () async { - final result = await mapController!.getVisibleRegion(); - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text("SW: ${result.southwest} NE: ${result.northeast}"), - )); - }, - ); - } - - Widget _sourceFeaturesGetter() { - return TextButton( - child: const Text('get source features (maplibre)'), - onPressed: () async { - final result = await mapController! - .querySourceFeatures("maplibre", "centroids", null); - debugPrint(result.toString()); - }, - ); - } - - Widget _layerVisibilityToggler() { - return TextButton( - child: const Text('toggle layer visibility'), - onPressed: () async { - _countriesVisible = !_countriesVisible; - mapController?.setLayerVisibility('countries-fill', _countriesVisible); - }, - ); - } - - Future _clearFill() async { - if (_selectedFill != null) { - await mapController!.removeFill(_selectedFill!); - setState(() { - _selectedFill = null; - }); - } - } - - Future _drawFill(List features) async { - final Map? feature = - features.firstWhereOrNull((f) => f['geometry']['type'] == 'Polygon'); - - if (feature != null) { - final List> geometry = feature['geometry']['coordinates'] - .map( - (ll) => ll.map((l) => LatLng(l[1], l[0])).toList().cast()) - .toList() - .cast>(); - final fill = await mapController!.addFill(FillOptions( - geometry: geometry, - fillColor: "#FF0000", - fillOutlineColor: "#FF0000", - fillOpacity: 0.6, - )); - setState(() { - _selectedFill = fill; - }); - } - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = - _mapExpanded ? MediaQuery.of(context).size.height / 2 : 200.0; - - final maplibreMap = MapLibreMap( - 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, - onMapClick: (point, latLng) async { - debugPrint( - "Map click: ${point.x},${point.y} ${latLng.latitude}/${latLng.longitude}"); - debugPrint("Filter $_featureQueryFilter"); - final features = await mapController! - .queryRenderedFeatures(point, [], _featureQueryFilter); - if (!mounted) return; - - debugPrint('# features: ${features.length}'); - _clearFill(); - if (features.isEmpty && _featureQueryFilter != null) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('QueryRenderedFeatures: No features found!'))); - } - } else if (features.isNotEmpty) { - _drawFill(features); - } - }, - onMapLongClick: (point, latLng) async { - debugPrint( - "Map long press: ${point.x},${point.y} ${latLng.latitude}/${latLng.longitude}"); - final convertedPoint = await mapController!.toScreenLocation(latLng); - final convertedLatLng = await mapController!.toLatLng(point); - debugPrint( - "Map long press converted: ${convertedPoint.x},${convertedPoint.y} ${convertedLatLng.latitude}/${convertedLatLng.longitude}"); - final metersPerPixel = - await mapController!.getMetersPerPixelAtLatitude(latLng.latitude); - - debugPrint( - "Map long press The distance measured in meters at latitude ${latLng.latitude} is $metersPerPixel m"); - - final features = - await mapController!.queryRenderedFeatures(point, [], null); - if (features.isNotEmpty) { - debugPrint("Features in map: ${features[0]}"); - } - }, - onCameraTrackingDismissed: () { - setState(() { - _myLocationTrackingMode = MyLocationTrackingMode.none; - }); - }, - onUserLocationUpdated: (location) { - debugPrint( - "new location: ${location.position}, alt.: ${location.altitude}, bearing: ${location.bearing}, speed: ${location.speed}, horiz. accuracy: ${location.horizontalAccuracy}, vert. accuracy: ${location.verticalAccuracy}"); - }, - ); - - final 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(), - _myLocationRenderModeCycler(), - _latLngBoundsToggler(), - _setStyleToSatellite(), - _zoomBoundsToggler(), - _rotateToggler(), - _scrollToggler(), - _doubleClickToZoomToggler(), - _tiltToggler(), - _zoomToggler(), - _myLocationToggler(), - _telemetryToggler(), - _visibleRegionGetter(), - _layerVisibilityToggler(), - _sourceFeaturesGetter(), - ], - ); - } - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: SizedBox( - width: width, - height: height, - child: maplibreMap, - ), - ), - Expanded( - child: ListView( - children: listViewChildren, - ), - ) - ], - ); - } - - Future onMapCreated(MapLibreMapController controller) async { - mapController = controller; - mapController!.addListener(_onMapChanged); - _extractMapInfo(); - - await mapController!.getTelemetryEnabled().then((isEnabled) => setState(() { - _telemetryEnabled = isEnabled; - })); - } -} diff --git a/maplibre_gl_example/lib/move_camera.dart b/maplibre_gl_example/lib/move_camera.dart deleted file mode 100644 index 72e4371a5..000000000 --- a/maplibre_gl_example/lib/move_camera.dart +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class MoveCameraPage extends ExamplePage { - const MoveCameraPage({super.key}) - : super(const Icon(Icons.map), 'Camera control'); - - @override - Widget build(BuildContext context) { - return const MoveCamera(); - } -} - -class MoveCamera extends StatefulWidget { - const MoveCamera({super.key}); - - @override - State createState() => MoveCameraState(); -} - -class MoveCameraState extends State { - late MapLibreMapController mapController; - - void _onMapCreated(MapLibreMapController controller) { - mapController = controller; - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - onMapCreated: _onMapCreated, - onCameraIdle: () => debugPrint("onCameraIdle"), - initialCameraPosition: - const CameraPosition(target: LatLng(0.0, 0.0)), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - TextButton( - onPressed: () async { - await 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: () async { - await mapController.moveCamera( - CameraUpdate.newLatLng( - const LatLng(56.1725505, 10.1850512), - ), - ); - }, - child: const Text('newLatLng'), - ), - TextButton( - onPressed: () async { - await 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: () async { - await mapController.moveCamera( - CameraUpdate.newLatLngZoom( - const LatLng(37.4231613, -122.087159), - 11.0, - ), - ); - }, - child: const Text('newLatLngZoom'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.scrollBy(150.0, -225.0), - ); - }, - child: const Text('scrollBy'), - ), - ], - ), - Column( - children: [ - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.zoomBy( - -0.5, - const Offset(30.0, 20.0), - ), - ); - }, - child: const Text('zoomBy with focus'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.zoomBy(-0.5), - ); - }, - child: const Text('zoomBy'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.zoomIn(), - ); - }, - child: const Text('zoomIn'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.zoomOut(), - ); - }, - child: const Text('zoomOut'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.zoomTo(16.0), - ); - }, - child: const Text('zoomTo'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.bearingTo(45.0), - ); - }, - child: const Text('bearingTo'), - ), - TextButton( - onPressed: () async { - await mapController.moveCamera( - CameraUpdate.tiltTo(30.0), - ); - }, - child: const Text('tiltTo'), - ), - ], - ), - ], - ) - ], - ); - } -} diff --git a/maplibre_gl_example/lib/no_location_permission_page.dart b/maplibre_gl_example/lib/no_location_permission_page.dart deleted file mode 100644 index f0c283ca5..000000000 --- a/maplibre_gl_example/lib/no_location_permission_page.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class NoLocationPermissionPage extends ExamplePage { - const NoLocationPermissionPage({super.key}) - : super( - const Icon(Icons.gps_off), - 'Using a map without user location/permission', - needsLocationPermission: false, - ); - - @override - Widget build(BuildContext context) { - return const NoLocationPermissionBody(); - } -} - -class NoLocationPermissionBody extends StatefulWidget { - const NoLocationPermissionBody({super.key}); - - @override - State createState() => - _NoLocationPermissionBodyState(); -} - -class _NoLocationPermissionBodyState extends State { - @override - Widget build(BuildContext context) { - return MapLibreMap( - initialCameraPosition: const CameraPosition( - target: LatLng(-33.852, 151.211), - zoom: 11.0, - ), - styleString: "assets/osm_style.json", - ); - } -} diff --git a/maplibre_gl_example/lib/page.dart b/maplibre_gl_example/lib/page.dart index 45b88af19..caab76694 100644 --- a/maplibre_gl_example/lib/page.dart +++ b/maplibre_gl_example/lib/page.dart @@ -1,18 +1,31 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - import 'package:flutter/material.dart'; +/// Category for organizing examples +enum ExampleCategory { + basics('Basics', Icons.map), + camera('Camera', Icons.videocam), + interaction('Interaction', Icons.touch_app), + annotations('Annotations', Icons.location_on), + layers('Layers & Sources', Icons.layers), + advanced('Advanced', Icons.science); + + const ExampleCategory(this.label, this.icon); + + final String label; + final IconData icon; +} + abstract class ExamplePage extends StatelessWidget { const ExamplePage( this.leading, this.title, { this.needsLocationPermission = true, + this.category = ExampleCategory.basics, super.key, }); final Widget leading; final String title; final bool needsLocationPermission; + final ExampleCategory category; } diff --git a/maplibre_gl_example/lib/place_batch.dart b/maplibre_gl_example/lib/place_batch.dart deleted file mode 100644 index 4ab2bd71e..000000000 --- a/maplibre_gl_example/lib/place_batch.dart +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; -import 'util.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 { - const BatchAddPage({super.key}) - : super(const Icon(Icons.check_circle), 'Batch add/remove'); - - @override - Widget build(BuildContext context) { - return const BatchAddBody(); - } -} - -class BatchAddBody extends StatefulWidget { - const BatchAddBody({super.key}); - - @override - State createState() => BatchAddBodyState(); -} - -class BatchAddBodyState extends State { - BatchAddBodyState(); - - List _fills = []; - List _circles = []; - List _lines = []; - List _symbols = []; - - static const LatLng center = LatLng(-33.86711, 151.1947171); - - late MapLibreMapController controller; - - void _onMapCreated(MapLibreMapController 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: 'custom-marker', geometry: latLng)); - } - } - } - return symbolOptions; - } - - Future _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)); - } - } - - Future _remove() async { - await controller.removeFills(_fills); - await controller.removeLines(_lines); - await controller.removeCircles(_circles); - await controller.removeSymbols(_symbols); - _fills.clear(); - _lines.clear(); - _circles.clear(); - _symbols.clear(); - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - onMapCreated: _onMapCreated, - onStyleLoadedCallback: () => addImageFromAsset(controller, - "custom-marker", "assets/symbols/custom-marker.png"), - 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( - onPressed: _add, child: const Text('batch add')), - TextButton( - onPressed: _remove, - child: const Text('batch remove')), - ], - ), - ], - ) - ], - ), - ), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/place_circle.dart b/maplibre_gl_example/lib/place_circle.dart deleted file mode 100644 index f07f9c165..000000000 --- a/maplibre_gl_example/lib/place_circle.dart +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class PlaceCirclePage extends ExamplePage { - const PlaceCirclePage({super.key}) - : super(const Icon(Icons.check_circle), 'Place circle'); - - @override - Widget build(BuildContext context) { - return const PlaceCircleBody(); - } -} - -class PlaceCircleBody extends StatefulWidget { - const PlaceCircleBody({super.key}); - - @override - State createState() => PlaceCircleBodyState(); -} - -class PlaceCircleBodyState extends State { - PlaceCircleBodyState(); - - static const LatLng center = LatLng(-33.86711, 151.1947171); - - MapLibreMapController? controller; - int _circleCount = 0; - Circle? _selectedCircle; - - void _onMapCreated(MapLibreMapController controller) { - this.controller = controller; - controller.onCircleTapped.add(_onCircleTapped); - } - - @override - void dispose() { - controller?.onCircleTapped.remove(_onCircleTapped); - super.dispose(); - } - - Future _onCircleTapped(Circle circle) async { - if (_selectedCircle != null) { - await _updateSelectedCircle( - const CircleOptions(circleRadius: 60), - ); - } - setState(() { - _selectedCircle = circle; - }); - await _updateSelectedCircle( - const CircleOptions( - circleRadius: 30, - ), - ); - } - - Future _updateSelectedCircle(CircleOptions changes) async { - await controller!.updateCircle(_selectedCircle!, changes); - } - - Future _add() async { - await 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; - }); - } - - Future _remove() async { - await controller!.removeCircle(_selectedCircle!); - setState(() { - _selectedCircle = null; - _circleCount -= 1; - }); - } - - Future _changePosition() async { - final current = _selectedCircle!.options.geometry!; - final offset = Offset( - center.latitude - current.latitude, - center.longitude - current.longitude, - ); - await _updateSelectedCircle( - CircleOptions( - geometry: LatLng( - center.latitude + offset.dy, - center.longitude + offset.dx, - ), - ), - ); - } - - Future _changeDraggable() async { - var draggable = _selectedCircle!.options.draggable; - draggable ??= false; - await _updateSelectedCircle( - CircleOptions( - draggable: !draggable, - ), - ); - } - - void _getLatLng() { - final latLng = controller!.getCircleLatLng(_selectedCircle!); - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(latLng.toString()), - ), - ); - } - - Future _changeCircleStrokeOpacity() async { - var current = _selectedCircle!.options.circleStrokeOpacity; - current ??= 1.0; - - await _updateSelectedCircle( - CircleOptions(circleStrokeOpacity: current < 0.1 ? 1.0 : current * 0.75), - ); - } - - Future _changeCircleStrokeWidth() async { - var current = _selectedCircle!.options.circleStrokeWidth; - current ??= 0; - await _updateSelectedCircle( - CircleOptions(circleStrokeWidth: current == 0 ? 5.0 : 0)); - } - - Future _changeCircleStrokeColor() async { - var current = _selectedCircle!.options.circleStrokeColor; - current ??= "#FFFFFF"; - - await _updateSelectedCircle( - CircleOptions( - circleStrokeColor: current == "#FFFFFF" ? "#FF0000" : "#FFFFFF"), - ); - } - - Future _changeCircleOpacity() async { - var current = _selectedCircle!.options.circleOpacity; - current ??= 1.0; - - await _updateSelectedCircle( - CircleOptions(circleOpacity: current < 0.1 ? 1.0 : current * 0.75), - ); - } - - Future _changeCircleRadius() async { - var current = _selectedCircle!.options.circleRadius; - current ??= 0; - await _updateSelectedCircle( - CircleOptions(circleRadius: current == 120.0 ? 30.0 : current + 30.0), - ); - } - - Future _changeCircleColor() async { - var current = _selectedCircle!.options.circleColor; - current ??= "#FF0000"; - - await _updateSelectedCircle( - const CircleOptions(circleColor: "#FFFF00"), - ); - } - - Future _changeCircleBlur() async { - var current = _selectedCircle!.options.circleBlur; - current ??= 0; - await _updateSelectedCircle( - CircleOptions(circleBlur: current == 0.75 ? 0 : 0.75), - ); - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - 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( - onPressed: (_circleCount == 12) ? null : _add, - child: const Text('add'), - ), - TextButton( - onPressed: (_selectedCircle == null) ? null : _remove, - child: const Text('remove'), - ), - ], - ), - Column( - children: [ - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleOpacity, - child: const Text('change circle-opacity'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleRadius, - child: const Text('change circle-radius'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleColor, - child: const Text('change circle-color'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleBlur, - child: const Text('change circle-blur'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleStrokeWidth, - child: const Text('change circle-stroke-width'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleStrokeColor, - child: const Text('change circle-stroke-color'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeCircleStrokeOpacity, - child: const Text('change circle-stroke-opacity'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changePosition, - child: const Text('change position'), - ), - TextButton( - onPressed: (_selectedCircle == null) - ? null - : _changeDraggable, - child: const Text('toggle draggable'), - ), - TextButton( - onPressed: - (_selectedCircle == null) ? null : _getLatLng, - child: const Text('get current LatLng'), - ), - ], - ), - ], - ) - ], - ), - ), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/place_fill.dart b/maplibre_gl_example/lib/place_fill.dart deleted file mode 100644 index ea719a0ce..000000000 --- a/maplibre_gl_example/lib/place_fill.dart +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class PlaceFillPage extends ExamplePage { - const PlaceFillPage({super.key}) - : super(const Icon(Icons.check_circle), 'Place fill'); - - @override - Widget build(BuildContext context) { - return const PlaceFillBody(); - } -} - -class PlaceFillBody extends StatefulWidget { - const PlaceFillBody({super.key}); - - @override - State createState() => PlaceFillBodyState(); -} - -class PlaceFillBodyState extends State { - PlaceFillBodyState(); - - static const LatLng center = LatLng(-33.86711, 151.1947171); - final String _fillPatternImage = "assets/fill/cat_silhouette_pattern.png"; - - final List> _defaultGeometry = [ - [ - const LatLng(-33.719, 151.150), - const LatLng(-33.858, 151.150), - const LatLng(-33.866, 151.401), - const LatLng(-33.747, 151.328), - const LatLng(-33.719, 151.150), - ], - [ - const LatLng(-33.762, 151.250), - const LatLng(-33.827, 151.250), - const LatLng(-33.833, 151.347), - const LatLng(-33.762, 151.250), - ] - ]; - - MapLibreMapController? controller; - int _fillCount = 0; - Fill? _selectedFill; - - void _onMapCreated(MapLibreMapController controller) { - this.controller = controller; - controller.onFillTapped.add(_onFillTapped); - } - - Future _onStyleLoaded() async { - await addImageFromAsset("assetImage", _fillPatternImage); - } - - /// Adds an asset image to the currently displayed style - Future addImageFromAsset(String name, String assetName) async { - final bytes = await rootBundle.load(assetName); - final 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; - }); - } - - Future _updateSelectedFill(FillOptions changes) async { - await controller!.updateFill(_selectedFill!, changes); - } - - Future _add() async { - await controller!.addFill( - FillOptions( - geometry: _defaultGeometry, - fillColor: "#FF0000", - fillOutlineColor: "#FF0000"), - ); - setState(() { - _fillCount += 1; - }); - } - - Future _remove() async { - await controller!.removeFill(_selectedFill!); - setState(() { - _selectedFill = null; - _fillCount -= 1; - }); - } - - Future _changePosition() async { - var geometry = _selectedFill!.options.geometry; - - geometry ??= _defaultGeometry; - - await _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())); - } - - Future _changeDraggable() async { - var draggable = _selectedFill!.options.draggable; - draggable ??= false; - await _updateSelectedFill( - FillOptions(draggable: !draggable), - ); - } - - Future _changeFillOpacity() async { - var current = _selectedFill!.options.fillOpacity; - current ??= 1.0; - - await _updateSelectedFill( - FillOptions(fillOpacity: current < 0.1 ? 1.0 : current * 0.75), - ); - } - - Future _changeFillColor() async { - var current = _selectedFill!.options.fillColor; - current ??= "#FF0000"; - - await _updateSelectedFill( - const FillOptions(fillColor: "#FFFF00"), - ); - } - - Future _changeFillOutlineColor() async { - var current = _selectedFill!.options.fillOutlineColor; - current ??= "#FF0000"; - - await _updateSelectedFill( - const FillOptions(fillOutlineColor: "#FFFF00"), - ); - } - - Future _changeFillPattern() async { - final current = - _selectedFill!.options.fillPattern == null ? "assetImage" : null; - await _updateSelectedFill( - FillOptions(fillPattern: current), - ); - } - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - 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( - onPressed: (_fillCount == 12) ? null : _add, - child: const Text('add'), - ), - TextButton( - onPressed: (_selectedFill == null) ? null : _remove, - child: const Text('remove'), - ), - ], - ), - Column( - children: [ - TextButton( - onPressed: (_selectedFill == null) - ? null - : _changeFillOpacity, - child: const Text('change fill-opacity'), - ), - TextButton( - onPressed: - (_selectedFill == null) ? null : _changeFillColor, - child: const Text('change fill-color'), - ), - TextButton( - onPressed: (_selectedFill == null) - ? null - : _changeFillOutlineColor, - child: const Text('change fill-outline-color'), - ), - TextButton( - onPressed: (_selectedFill == null) - ? null - : _changeFillPattern, - child: const Text('change fill-pattern'), - ), - TextButton( - onPressed: - (_selectedFill == null) ? null : _changePosition, - child: const Text('change position'), - ), - TextButton( - onPressed: - (_selectedFill == null) ? null : _changeDraggable, - child: const Text('toggle draggable'), - ), - ], - ), - ], - ) - ], - ), - ), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/place_source.dart b/maplibre_gl_example/lib/place_source.dart deleted file mode 100644 index 9cfe6fcdf..000000000 --- a/maplibre_gl_example/lib/place_source.dart +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class PlaceSourcePage extends ExamplePage { - const PlaceSourcePage({super.key}) - : super(const Icon(Icons.place), 'Place source'); - - @override - Widget build(BuildContext context) { - return const PlaceSymbolBody(); - } -} - -class PlaceSymbolBody extends StatefulWidget { - const PlaceSymbolBody({super.key}); - - @override - State createState() => PlaceSymbolBodyState(); -} - -class PlaceSymbolBodyState extends State { - PlaceSymbolBodyState(); - - static const sourceId = 'sydney_source'; - static const layerId = 'sydney_layer'; - - bool sourceAdded = false; - bool layerAdded = false; - bool imageFlag = false; - late MapLibreMapController controller; - - void _onMapCreated(MapLibreMapController 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 bytes = await rootBundle.load(assetName); - final 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), - ), - ); - } - - Future removeImageSource(String imageSourceId) { - return controller.removeSource(imageSourceId); - } - - Future addLayer(String imageLayerId, String imageSourceId) async { - if (layerAdded) { - await removeLayer(imageLayerId); - } - setState(() => layerAdded = true); - return controller.addImageLayer(imageLayerId, imageSourceId); - } - - Future addLayerBelow( - String imageLayerId, String imageSourceId, String belowLayerId) async { - if (layerAdded) { - await removeLayer(imageLayerId); - } - setState(() => layerAdded = true); - return controller.addImageLayerBelow( - imageLayerId, imageSourceId, belowLayerId); - } - - Future removeLayer(String imageLayerId) { - setState(() => layerAdded = false); - return controller.removeLayer(imageLayerId); - } - - Future updateImageSourceFromAsset( - String imageSourceId, String assetName) async { - final bytes = await rootBundle.load(assetName); - final list = bytes.buffer.asUint8List(); - return controller.updateImageSource(imageSourceId, list, null); - } - - String pickImage() { - return imageFlag ? 'assets/sydney0.png' : 'assets/sydney1.png'; - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: SizedBox( - width: 300.0, - height: 200.0, - child: MapLibreMap( - onMapCreated: _onMapCreated, - initialCameraPosition: const CameraPosition( - target: LatLng(-33.852, 151.211), - zoom: 11.0, - ), - ), - ), - ), - Expanded( - child: SingleChildScrollView( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - TextButton( - onPressed: sourceAdded - ? null - : () async { - await addImageSourceFromAsset( - sourceId, pickImage()) - .then((value) { - setState(() => sourceAdded = true); - }); - }, - child: const Text('Add source (asset image)'), - ), - TextButton( - onPressed: sourceAdded - ? () async { - await removeLayer(layerId); - await removeImageSource(sourceId).then((value) { - setState(() => sourceAdded = false); - }); - } - : null, - child: const Text('Remove source (asset image)'), - ), - TextButton( - onPressed: sourceAdded - ? () => addLayer(layerId, sourceId) - : null, - child: const Text('Show layer'), - ), - TextButton( - onPressed: sourceAdded - ? () => addLayerBelow(layerId, sourceId, 'water') - : null, - child: const Text('Show layer below water'), - ), - TextButton( - onPressed: - sourceAdded ? () => removeLayer(layerId) : null, - child: const Text('Hide layer'), - ), - TextButton( - onPressed: sourceAdded - ? () async { - setState(() => imageFlag = !imageFlag); - await updateImageSourceFromAsset( - sourceId, pickImage()); - } - : null, - child: const Text('Change image'), - ), - ], - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/place_symbol.dart b/maplibre_gl_example/lib/place_symbol.dart deleted file mode 100644 index 38fb96efe..000000000 --- a/maplibre_gl_example/lib/place_symbol.dart +++ /dev/null @@ -1,435 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:core'; -import 'dart:developer' as dev; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:http/http.dart' as http; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -class PlaceSymbolPage extends ExamplePage { - const PlaceSymbolPage({super.key}) - : super(const Icon(Icons.place), 'Place symbol'); - - @override - Widget build(BuildContext context) { - return const PlaceSymbolBody(); - } -} - -class PlaceSymbolBody extends StatefulWidget { - const PlaceSymbolBody({super.key}); - - @override - State createState() => PlaceSymbolBodyState(); -} - -class PlaceSymbolBodyState extends State { - PlaceSymbolBodyState(); - - static const LatLng center = LatLng(-33.86711, 151.1947171); - - MapLibreMapController? controller; - int _symbolCount = 0; - Symbol? _selectedSymbol; - bool _iconAllowOverlap = false; - - void _onMapCreated(MapLibreMapController controller) { - this.controller = controller; - controller.onSymbolTapped.add(_onSymbolTapped); - controller.onFeatureDrag.add(_onFeatureDrag); - } - - Future _onStyleLoaded() async { - await addImageFromAsset( - "custom-marker", "assets/symbols/custom-marker.png"); - await addImageFromAsset("assetImage", "assets/symbols/custom-icon.png"); - await addImageFromUrl( - "networkImage", Uri.parse("https://dummyimage.com/50x50")); - } - - @override - void dispose() { - controller?.onSymbolTapped.remove(_onSymbolTapped); - controller?.onFeatureDrag.remove(_onFeatureDrag); - super.dispose(); - } - - /// Adds an asset image to the currently displayed style - Future addImageFromAsset(String name, String assetName) async { - final bytes = await rootBundle.load(assetName); - final 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 { - final response = await http.get(uri); - dev.log( - "response.statusCode: ${response.statusCode} for uri: $uri, bodyBytes length: ${response.bodyBytes.length}"); - return controller!.addImage(name, response.bodyBytes); - } - - Future _onSymbolTapped(Symbol symbol) async { - if (_selectedSymbol != null) { - await _updateSelectedSymbol( - const SymbolOptions(iconSize: 1.0), - ); - } - setState(() { - _selectedSymbol = symbol; - }); - await _updateSelectedSymbol( - const SymbolOptions(iconSize: 1.4), - ); - } - - void _onFeatureDrag( - Point point, - LatLng origin, - LatLng current, - LatLng delta, - String id, - Annotation? annotation, - DragEventType eventType, - ) { - if (annotation is! Symbol) return; - if (eventType == DragEventType.end) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Symbol #${annotation.data?['count'] ?? ''} was dragged to ${annotation.options.geometry}'), - ), - ); - } - } - - Future _updateSelectedSymbol(SymbolOptions changes) async { - await controller!.updateSymbol(_selectedSymbol!, changes); - } - - Future _add(String iconImage) async { - final availableNumbers = Iterable.generate(12).toList(); - for (final s in controller!.symbols) { - availableNumbers.removeWhere((i) => i == s.data!['count']); - } - if (availableNumbers.isNotEmpty) { - await controller!.addSymbol( - _getSymbolOptions(iconImage, availableNumbers.first), - {'count': availableNumbers.first}); - setState(() { - _symbolCount += 1; - }); - } - } - - SymbolOptions _getSymbolOptions(String iconImage, int symbolCount) { - final 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: 'custom-marker', - //'airport-15', - fontNames: ['DIN Offc Pro Bold', 'Arial Unicode MS Regular'], - textField: 'Airport', - textSize: 12.5, - textOffset: const Offset(0, 0.8), - textAnchor: 'top', - textColor: '#000000', - textHaloBlur: 1, - textHaloColor: '#ffffff', - textHaloWidth: 0.8, - ) - : SymbolOptions( - geometry: geometry, - textField: 'Airport', - textOffset: const Offset(0, 0.8), - iconImage: iconImage, - ); - } - - Future _addAll(String iconImage) async { - final symbolsToAddNumbers = Iterable.generate(12).toList(); - for (final s in controller!.symbols) { - symbolsToAddNumbers.removeWhere((i) => i == s.data!['count']); - } - - if (symbolsToAddNumbers.isNotEmpty) { - final symbolOptionsList = symbolsToAddNumbers - .map((i) => _getSymbolOptions(iconImage, i)) - .toList(); - controller!.addSymbols(symbolOptionsList, - symbolsToAddNumbers.map((i) => {'count': i}).toList()); - - setState(() { - _symbolCount += symbolOptionsList.length; - }); - } - } - - Future _remove() async { - await controller!.removeSymbol(_selectedSymbol!); - setState(() { - _selectedSymbol = null; - _symbolCount -= 1; - }); - } - - Future _removeAll() async { - await controller!.removeSymbols(controller!.symbols); - setState(() { - _selectedSymbol = null; - _symbolCount = 0; - }); - } - - Future _changePosition() async { - final current = _selectedSymbol!.options.geometry!; - final offset = Offset( - center.latitude - current.latitude, - center.longitude - current.longitude, - ); - await _updateSelectedSymbol( - SymbolOptions( - geometry: LatLng( - center.latitude + offset.dy, - center.longitude + offset.dx, - ), - ), - ); - } - - Future _changeIconOffset() async { - var currentAnchor = _selectedSymbol!.options.iconOffset; - currentAnchor ??= Offset.zero; - final newAnchor = Offset(1.0 - currentAnchor.dy, currentAnchor.dx); - await _updateSelectedSymbol(SymbolOptions(iconOffset: newAnchor)); - } - - Future _changeIconAnchor() async { - var current = _selectedSymbol!.options.iconAnchor; - if (current == null || current == 'center') { - current = 'bottom'; - } else { - current = 'center'; - } - await _updateSelectedSymbol( - SymbolOptions(iconAnchor: current), - ); - } - - Future _toggleDraggable() async { - var draggable = _selectedSymbol!.options.draggable; - draggable ??= false; - - await _updateSelectedSymbol( - SymbolOptions(draggable: !draggable), - ); - } - - Future _changeAlpha() async { - var current = _selectedSymbol!.options.iconOpacity; - current ??= 1.0; - - await _updateSelectedSymbol( - SymbolOptions(iconOpacity: current < 0.1 ? 1.0 : current * 0.75), - ); - } - - Future _changeRotation() async { - var current = _selectedSymbol!.options.iconRotate; - current ??= 0; - await _updateSelectedSymbol( - SymbolOptions(iconRotate: current == 330.0 ? 0.0 : current + 30.0), - ); - } - - Future _toggleVisible() async { - var current = _selectedSymbol!.options.iconOpacity; - current ??= 1.0; - - await _updateSelectedSymbol( - SymbolOptions(iconOpacity: current == 0.0 ? 1.0 : 0.0), - ); - } - - Future _changeZIndex() async { - var current = _selectedSymbol!.options.zIndex; - current ??= 0; - await _updateSelectedSymbol( - SymbolOptions(zIndex: current == 12 ? 0 : current + 1), - ); - } - - void _getLatLng() { - final latLng = controller!.getSymbolLatLng(_selectedSymbol!); - if (!mounted) return; - - 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) { - final width = MediaQuery.of(context).size.width; - final height = MediaQuery.of(context).size.height; - - return Column( - children: [ - SizedBox( - width: width, - height: height * 0.5, - child: MapLibreMap( - onMapCreated: _onMapCreated, - onStyleLoadedCallback: _onStyleLoaded, - 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("custom-marker"), - ), - TextButton( - child: const Text('add all'), - onPressed: () => (_symbolCount == 12) - ? null - : _addAll("custom-marker"), - ), - TextButton( - child: const Text('add (custom icon)'), - onPressed: () => (_symbolCount == 12) - ? null - : _add("assets/symbols/custom-icon.png"), - ), - TextButton( - onPressed: (_selectedSymbol == null) ? null : _remove, - child: const Text('remove'), - ), - TextButton( - onPressed: _changeIconOverlap, - child: Text( - '${_iconAllowOverlap ? 'disable' : 'enable'} icon overlap'), - ), - TextButton( - onPressed: (_symbolCount == 0) ? null : _removeAll, - child: const Text('remove all'), - ), - 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( - onPressed: - (_selectedSymbol == null) ? null : _changeAlpha, - child: const Text('change alpha'), - ), - TextButton( - onPressed: (_selectedSymbol == null) - ? null - : _changeIconOffset, - child: const Text('change icon offset'), - ), - TextButton( - onPressed: (_selectedSymbol == null) - ? null - : _changeIconAnchor, - child: const Text('change icon anchor'), - ), - TextButton( - onPressed: (_selectedSymbol == null) - ? null - : _toggleDraggable, - child: const Text('toggle draggable'), - ), - TextButton( - onPressed: (_selectedSymbol == null) - ? null - : _changePosition, - child: const Text('change position'), - ), - TextButton( - onPressed: (_selectedSymbol == null) - ? null - : _changeRotation, - child: const Text('change rotation'), - ), - TextButton( - onPressed: - (_selectedSymbol == null) ? null : _toggleVisible, - child: const Text('toggle visible'), - ), - TextButton( - onPressed: - (_selectedSymbol == null) ? null : _changeZIndex, - child: const Text('change zIndex'), - ), - TextButton( - onPressed: - (_selectedSymbol == null) ? null : _getLatLng, - child: const Text('get current LatLng'), - ), - ], - ), - ], - ) - ], - ), - ), - ), - ], - ); - } -} diff --git a/maplibre_gl_example/lib/pmtiles.dart b/maplibre_gl_example/lib/pmtiles.dart deleted file mode 100644 index 578cc6b62..000000000 --- a/maplibre_gl_example/lib/pmtiles.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; - -const _nullIsland = CameraPosition(target: LatLng(0, 0), zoom: 4.0); - -class PMTilesPage extends ExamplePage { - const PMTilesPage({super.key}) - : super(const Icon(Icons.map), 'PMTiles example'); - - @override - Widget build(BuildContext context) { - return const PMTilesMap(); - } -} - -class PMTilesMap extends StatefulWidget { - const PMTilesMap({super.key}); - - @override - State createState() => PMTilesMapState(); -} - -class PMTilesMapState extends State { - final Completer mapController = Completer(); - bool canInteractWithMap = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - floatingActionButtonLocation: - FloatingActionButtonLocation.miniCenterFloat, - floatingActionButton: canInteractWithMap - ? FloatingActionButton( - onPressed: _moveCameraToNullIsland, - mini: true, - child: const Icon(Icons.restore), - ) - : null, - body: MapLibreMap( - onMapCreated: (controller) => mapController.complete(controller), - initialCameraPosition: _nullIsland, - onStyleLoadedCallback: () => setState(() => canInteractWithMap = true), - styleString: "assets/pmtiles_style.json", - ), - ); - } - - void _moveCameraToNullIsland() => mapController.future.then( - (c) => c.animateCamera(CameraUpdate.newCameraPosition(_nullIsland))); -} diff --git a/maplibre_gl_example/lib/presentation/gps_location/gps_location_page.dart b/maplibre_gl_example/lib/presentation/gps_location/gps_location_page.dart deleted file mode 100644 index fecf07bef..000000000 --- a/maplibre_gl_example/lib/presentation/gps_location/gps_location_page.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:location/location.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:maplibre_gl_example/page.dart' show ExamplePage; - -class GpsLocationPage extends ExamplePage { - const GpsLocationPage({super.key}) - : super( - const Icon(Icons.gps_fixed), - 'GPS Location', - needsLocationPermission: false, - ); - - @override - Widget build(BuildContext context) { - return const GpsLocationMap(); - } -} - -class GpsLocationMap extends HookWidget { - const GpsLocationMap({super.key}); - - CameraPosition get initialLocation => const CameraPosition( - target: LatLng(37.3, -121.8), - zoom: 7, - ); - - @override - Widget build(BuildContext context) { - final mapController = useState(null); - final useDefaultLocationSettings = useState(true); - return Scaffold( - floatingActionButtonLocation: - FloatingActionButtonLocation.miniCenterFloat, - body: Stack( - children: [ - MapLibreMap( - onMapCreated: (controller) { - mapController.value = controller; - }, - styleString: "assets/osm_style.json", - compassEnabled: true, - myLocationEnabled: true, - trackCameraPosition: true, - locationEnginePlatforms: switch (useDefaultLocationSettings.value) { - false => const LocationEnginePlatforms( - androidPlatform: LocationEngineAndroidProperties( - interval: 1000, - displacement: 1, - priority: LocationPriority.highAccuracy, - ), - ), - true => LocationEnginePlatforms.defaultPlatform, - }, - initialCameraPosition: initialLocation, - ), - Column( - children: [ - GpsIcon(onTapAndPermissionGranted: () async { - final currentLocation = await Location().getLocation(); - print("Current location: $currentLocation"); - mapController.value?.animateCamera( - CameraUpdate.newLatLngZoom( - LatLng( - currentLocation.latitude!, - currentLocation.longitude!, - ), - 9), - ); - }), - FloatingActionButton( - heroTag: "accuracy", - onPressed: () { - useDefaultLocationSettings.value = - !useDefaultLocationSettings.value; - }, - child: Text(switch (useDefaultLocationSettings.value) { - true => "Default", - false => "high", - }), - ), - ], - ) - ], - ), - ); - } -} - -class GpsIcon extends HookWidget { - final Future Function() onTapAndPermissionGranted; - - const GpsIcon({super.key, required this.onTapAndPermissionGranted}); - - @override - Widget build(BuildContext context) { - final locationLoading = useState(false); - return FloatingActionButton.small( - heroTag: "gps", - onPressed: () async { - if (locationLoading.value) { - return; - } - locationLoading.value = true; - var hasPermissions = await Location().hasPermission(); - if (hasPermissions != PermissionStatus.granted) { - hasPermissions = await Location().requestPermission(); - } - if (hasPermissions == PermissionStatus.granted) { - await onTapAndPermissionGranted(); - } - locationLoading.value = false; - }, - child: switch (locationLoading.value) { - true => const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2)), - false => const Icon(Icons.gps_fixed), - }, - ); - } -} diff --git a/maplibre_gl_example/lib/scrolling_map.dart b/maplibre_gl_example/lib/scrolling_map.dart deleted file mode 100644 index 2a6e72890..000000000 --- a/maplibre_gl_example/lib/scrolling_map.dart +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:maplibre_gl_example/util.dart'; - -import 'page.dart'; - -class ScrollingMapPage extends ExamplePage { - const ScrollingMapPage({super.key}) - : super(const Icon(Icons.map), 'Scrolling map'); - - @override - Widget build(BuildContext context) { - return const ScrollingMapBody(); - } -} - -class ScrollingMapBody extends StatefulWidget { - const ScrollingMapBody({super.key}); - - @override - State createState() => _ScrollingMapBodyState(); -} - -class _ScrollingMapBodyState extends State { - late MapLibreMapController controllerOne; - late MapLibreMapController 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: MapLibreMap( - onMapCreated: onMapCreatedOne, - onStyleLoadedCallback: () => onStyleLoaded(controllerOne), - initialCameraPosition: CameraPosition( - target: center, - zoom: 11.0, - ), - gestureRecognizers: >{ - Factory( - () => EagerGestureRecognizer(), - ), - }, - ), - ), - ), - ], - ), - ), - ), - 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: MapLibreMap( - onMapCreated: onMapCreatedTwo, - onStyleLoadedCallback: () => onStyleLoaded(controllerTwo), - initialCameraPosition: CameraPosition( - target: center, - zoom: 11.0, - ), - gestureRecognizers: >{ - Factory( - () => ScaleGestureRecognizer(), - ), - }, - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - void onMapCreatedOne(MapLibreMapController controller) { - controllerOne = controller; - } - - void onMapCreatedTwo(MapLibreMapController controller) { - controllerTwo = controller; - } - - Future onStyleLoaded(MapLibreMapController controller) async { - await addImageFromAsset( - controller, "custom-marker", "assets/symbols/custom-marker.png"); - controller.addSymbol(SymbolOptions( - geometry: LatLng( - center.latitude, - center.longitude, - ), - iconImage: "custom-marker")); - controller.addLine( - const 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/maplibre_gl_example/lib/shared/constants.dart b/maplibre_gl_example/lib/shared/constants.dart new file mode 100644 index 000000000..c83d5499d --- /dev/null +++ b/maplibre_gl_example/lib/shared/constants.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Common constants used across examples +class ExampleConstants { + ExampleConstants._(); + + // ============================================================================ + // Map Coordinates + // ============================================================================ + + /// Default map center (Sydney, Australia) + static const LatLng sydneyCenter = LatLng(-33.86711, 151.1947171); + + /// Alternative coordinates for variety + static const LatLng sanFrancisco = LatLng(37.7749, -122.4194); + static const LatLng london = LatLng(51.5074, -0.1278); + static const LatLng tokyo = LatLng(35.6762, 139.6503); + + // ============================================================================ + // Map Configuration + // ============================================================================ + + /// Default zoom level + static const double defaultZoom = 4.0; + + /// Default map bounds (Australia region) + static final LatLngBounds defaultBounds = LatLngBounds( + southwest: const LatLng(-34.0, 150.5), + northeast: const LatLng(-33.5, 151.5), + ); + + // ============================================================================ + // Spacing & Layout + // ============================================================================ + + /// Standard padding for content + static const double paddingStandard = 16.0; + + /// Small padding + static const double paddingSmall = 8.0; + + /// Tiny padding for tight spaces + static const double paddingTiny = 4.0; + + /// Spacing between buttons in control panels + static const double buttonSpacing = 8.0; + + /// Run spacing for wrapped button groups + static const double buttonRunSpacing = 8.0; + + /// Map height ratio (portion of screen height) + static const double mapHeightRatio = 0.5; + + /// Border radius for cards and containers + static const double borderRadius = 12.0; + + // ============================================================================ + // Map Styles + // ============================================================================ + + /// Demo map style URL (default) + static const String demoMapStyle = + 'https://demotiles.maplibre.org/style.json'; + + /// Style asset paths + static const String osmStyleAsset = 'assets/osm_style.json'; + static const String pmtilesStyleAsset = 'assets/pmtiles_style.json'; + static const String translucenStyleAsset = 'assets/translucent_style.json'; + static const String localStyleAsset = 'assets/style.json'; + + // ============================================================================ + // Colors + // ============================================================================ + + /// Primary accent color for UI elements + static const Color primaryColor = Color(0xFF1976D2); + + /// Secondary accent color + static const Color secondaryColor = Color(0xFF388E3C); + + /// Error/destructive action color + static const Color errorColor = Color(0xFFD32F2F); + + /// Warning color + static const Color warningColor = Color(0xFFFFA726); + + // ============================================================================ + // Animation Durations + // ============================================================================ + + /// Standard animation duration + static const Duration animationDuration = Duration(milliseconds: 300); + + /// Camera animation duration + static const Duration cameraAnimationDuration = Duration(milliseconds: 1000); + + /// Long animation duration + static const Duration longAnimationDuration = Duration(milliseconds: 2000); + + // ============================================================================ + // Camera Positions + // ============================================================================ + + /// Default camera position + static CameraPosition defaultCameraPosition = toCameraPosition( + const LatLng(0.0, 0.0), + ); + + /// Camera position for Sydney + static CameraPosition sydneyCameraPosition = toCameraPosition(sydneyCenter); + + /// Camera position for San Francisco + static CameraPosition sanFranciscoCameraPosition = + toCameraPosition(sanFrancisco); + + /// Camera position for London + static CameraPosition londonCameraPosition = toCameraPosition(london); + + /// Camera position for Tokyo + static CameraPosition tokyoCameraPosition = toCameraPosition(tokyo); + + /// Helper to create a CameraPosition with default zoom. + static CameraPosition toCameraPosition( + LatLng target, [ + double zoom = defaultZoom, + ]) { + return CameraPosition(target: target, zoom: zoom); + } + + // ============================================================================ + // Map Settings + // ============================================================================ + + /// Default tilt angle + static const double defaultTilt = 0.0; + + /// Default bearing (rotation) + static const double defaultBearing = 0.0; + + /// Minimum zoom level + static const double minZoom = 0.0; + + /// Maximum zoom level + static const double maxZoom = 22.0; + + // =========================================================================== + // Pattern images paths + // =========================================================================== + + /// Pattern image for fill layer example + static const String catPatternPath = + 'assets/pattern/cat_silhouette_pattern.png'; + + /// Pattern image for line layer example + static const String markerPatternPath = 'assets/pattern/marker_pattern.png'; +} diff --git a/maplibre_gl_example/lib/shared/extensions.dart b/maplibre_gl_example/lib/shared/extensions.dart new file mode 100644 index 000000000..45c3a4882 --- /dev/null +++ b/maplibre_gl_example/lib/shared/extensions.dart @@ -0,0 +1,8 @@ +/// String extensions for common operations +extension StringExtensions on String { + /// Capitalizes the first letter of the string + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} diff --git a/maplibre_gl_example/lib/shared/shared.dart b/maplibre_gl_example/lib/shared/shared.dart new file mode 100644 index 000000000..74c2d166c --- /dev/null +++ b/maplibre_gl_example/lib/shared/shared.dart @@ -0,0 +1,10 @@ +// Shared widgets +export 'widgets/map_example_scaffold.dart'; +export 'widgets/example_button.dart'; +export 'widgets/color_picker.dart'; + +// Constants +export 'constants.dart'; + +// Extensions +export 'extensions.dart'; diff --git a/maplibre_gl_example/lib/shared/widgets/color_picker.dart b/maplibre_gl_example/lib/shared/widgets/color_picker.dart new file mode 100644 index 000000000..e9593afc2 --- /dev/null +++ b/maplibre_gl_example/lib/shared/widgets/color_picker.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +/// A reusable color picker widget that displays a modal bottom sheet +/// with a grid of color options +class ColorPickerModal { + /// Shows a color picker modal and returns the selected color + /// + /// [context] - The build context + /// [title] - Optional title for the picker (defaults to "Select Color") + /// [currentColor] - The currently selected color (for highlighting) + /// [colorFormat] - Whether to return hex string or Color object + static Future show({ + required BuildContext context, + String title = 'Select Color', + Color? currentColor, + ColorFormat colorFormat = ColorFormat.color, + }) async { + final colorOptions = _getDefaultColors(); + + return showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: colorOptions.entries.map((entry) { + final color = entry.value; + final isSelected = currentColor == color; + + return InkWell( + onTap: () { + final result = colorFormat == ColorFormat.hex + ? _colorToHex(color) + : color; + Navigator.pop(context, result); + }, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: color, + border: Border.all( + color: isSelected ? Colors.blue : Colors.grey, + width: isSelected ? 3 : 2, + ), + borderRadius: BorderRadius.circular(8), + ), + child: entry.key.isNotEmpty + ? Center( + child: Text( + entry.key, + textAlign: TextAlign.center, + style: TextStyle( + color: _getContrastColor(color), + fontSize: 10, + fontWeight: FontWeight.bold, + shadows: const [ + Shadow( + color: Colors.black45, + blurRadius: 2, + ), + ], + ), + ), + ) + : null, + ), + ); + }).toList(), + ), + ], + ), + ), + ); + } + + /// Shows a color picker and returns a hex color string + static Future showForHex({ + required BuildContext context, + String title = 'Select Color', + String? currentHexColor, + }) async { + final currentColor = + currentHexColor != null ? _hexToColor(currentHexColor) : null; + + return await show( + context: context, + title: title, + currentColor: currentColor, + colorFormat: ColorFormat.hex, + ); + } + + /// Default color palette + static Map _getDefaultColors() { + return { + 'Red': const Color(0xFFE74C3C), + 'Blue': const Color(0xFF3498DB), + 'Green': const Color(0xFF2ECC71), + 'Yellow': const Color(0xFFF1C40F), + 'Purple': const Color(0xFF9B59B6), + 'Orange': const Color(0xFFE67E22), + 'Pink': const Color(0xFFEC7063), + 'Teal': const Color(0xFF1ABC9C), + 'Cyan': const Color(0xFF00BCD4), + 'Indigo': const Color(0xFF5C6BC0), + 'White': Colors.white, + 'Black': Colors.black, + }; + } + + /// Converts a Color to hex string format (#RRGGBB) + static String _colorToHex(Color color) { + return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; + } + + /// Converts a hex string to Color + static Color _hexToColor(String hex) { + final hexCode = hex.replaceAll('#', ''); + return Color(int.parse(hexCode, radix: 16) + 0xFF000000); + } + + /// Get contrasting color for text (white or black) + static Color _getContrastColor(Color color) { + final luminance = + (0.299 * color.r + 0.587 * color.g + 0.114 * color.b) / 255; + return luminance > 0.5 ? Colors.black : Colors.white; + } +} + +/// Format for the color picker return value +enum ColorFormat { + /// Returns a Color object + color, + + /// Returns a hex string (#RRGGBB) + hex, +} + +/// A simple widget that displays a color swatch with an optional border +class ColorSwatch extends StatelessWidget { + final Color color; + final double size; + final bool isSelected; + + const ColorSwatch({ + super.key, + required this.color, + this.size = 48, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + border: Border.all( + color: isSelected ? Colors.blue : Colors.grey, + width: isSelected ? 3 : 1, + ), + borderRadius: BorderRadius.circular(4), + ), + ); + } +} diff --git a/maplibre_gl_example/lib/shared/widgets/example_button.dart b/maplibre_gl_example/lib/shared/widgets/example_button.dart new file mode 100644 index 000000000..eea54c8e3 --- /dev/null +++ b/maplibre_gl_example/lib/shared/widgets/example_button.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.dart'; + +/// A styled button component for map examples with Material 3 design +class ExampleButton extends StatelessWidget { + /// Button label text + final String label; + + /// Callback when button is pressed + final VoidCallback? onPressed; + + /// Button style variant + final ExampleButtonStyle style; + + /// Optional icon + final IconData? icon; + + const ExampleButton({ + super.key, + required this.label, + this.onPressed, + this.style = ExampleButtonStyle.filled, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + switch (style) { + case ExampleButtonStyle.filled: + return icon != null + ? FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ) + : FilledButton( + onPressed: onPressed, + child: Text(label), + ); + + case ExampleButtonStyle.tonal: + return icon != null + ? FilledButton.tonalIcon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ) + : FilledButton.tonal( + onPressed: onPressed, + child: Text(label), + ); + + case ExampleButtonStyle.outlined: + return icon != null + ? OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ) + : OutlinedButton( + onPressed: onPressed, + child: Text(label), + ); + + case ExampleButtonStyle.text: + return icon != null + ? TextButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ) + : TextButton( + onPressed: onPressed, + child: Text(label), + ); + + case ExampleButtonStyle.destructive: + return icon != null + ? FilledButton.tonalIcon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + ) + : FilledButton.tonal( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + child: Text(label), + ); + } + } +} + +/// Button style variants +enum ExampleButtonStyle { + /// Filled button (primary action) + filled, + + /// Tonal button (secondary action) + tonal, + + /// Outlined button (tertiary action) + outlined, + + /// Text button (low emphasis) + text, + + /// Destructive action (remove, delete, clear) + destructive, +} + +/// A group of buttons with a label +class ControlGroup extends StatelessWidget { + /// Group title/label + final String title; + + /// Buttons in this group + final List children; + + /// Whether to show as a vertical list. Defaults to false (horizontal wrap). + final bool vertical; + + /// Whether to wrap the group in a Card. Defaults to true. + final bool wrapInCard; + + const ControlGroup({ + super.key, + required this.title, + required this.children, + this.vertical = false, + this.wrapInCard = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (wrapInCard) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: SizedBox(width: double.infinity, child: _buildContent(theme)), + ), + ); + } + return _buildContent(theme); + } + + Widget _buildContent(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + ), + if (vertical) + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ) + else + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: children, + ), + ], + ); + } +} + +/// A segmented button group for mutually exclusive options +class ExampleSegmentedButton extends StatelessWidget { + /// Available options + final List> segments; + + /// Currently selected value + final T selected; + + /// Callback when selection changes + final ValueChanged onSelectionChanged; + + const ExampleSegmentedButton({ + super.key, + required this.segments, + required this.selected, + required this.onSelectionChanged, + }); + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: segments + .map((segment) => ButtonSegment( + value: segment.value, + label: Text(segment.label), + icon: segment.icon != null ? Icon(segment.icon) : null, + )) + .toList(), + selected: {selected}, + onSelectionChanged: (Set newSelection) { + if (newSelection.isNotEmpty) { + onSelectionChanged(newSelection.first); + } + }, + ); + } +} + +/// A segment for ExampleSegmentedButton +class ExampleSegment { + final T value; + final String label; + final IconData? icon; + + const ExampleSegment({ + required this.value, + required this.label, + this.icon, + }); +} + +/// A toggle switch with label +class ExampleSwitch extends StatelessWidget { + final String label; + final bool value; + final ValueChanged onChanged; + + const ExampleSwitch({ + super.key, + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return SwitchListTile( + title: Text(label), + value: value, + onChanged: onChanged, + contentPadding: EdgeInsets.zero, + ); + } +} + +/// An info card to display status or information +class InfoCard extends StatelessWidget { + final String title; + final String? subtitle; + final IconData? icon; + final Color? color; + + const InfoCard({ + super.key, + required this.title, + this.subtitle, + this.icon, + this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cardColor = color ?? theme.colorScheme.secondaryContainer; + final textColor = color != null + ? ThemeData.estimateBrightnessForColor(color!) == Brightness.light + ? Colors.black87 + : Colors.white + : theme.colorScheme.onSecondaryContainer; + + return Card( + color: cardColor, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: textColor), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/maplibre_gl_example/lib/shared/widgets/map_example_scaffold.dart b/maplibre_gl_example/lib/shared/widgets/map_example_scaffold.dart new file mode 100644 index 000000000..9cb51729c --- /dev/null +++ b/maplibre_gl_example/lib/shared/widgets/map_example_scaffold.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../constants.dart'; + +/// A reusable scaffold for map examples that provides a consistent layout: +/// - Map on top (50% of screen by default) +/// - Scrollable control panel below +/// - Automatic handling of responsive sizing +class MapExampleScaffold extends StatelessWidget { + /// The MapLibre map widget + final MapLibreMap map; + + /// Control widgets to display below the map + final List controls; + + /// Optional title for the example (shown in app bar if provided) + final String? title; + + /// Map height ratio (0.0 to 1.0). Defaults to 0.5 (50% of screen) + final double mapHeightRatio; + + /// Whether to show the app bar. Defaults to false. + final bool showAppBar; + + /// Optional floating action button + final Widget? floatingActionButton; + + /// Alignment of control buttons. Defaults to center. + final WrapAlignment controlsAlignment; + + /// Padding around controls. Defaults to standard padding. + final EdgeInsets? controlsPadding; + + /// Whether to wrap controls in a Card. Defaults to false. + final bool wrapInCard; + + const MapExampleScaffold({ + super.key, + required this.map, + required this.controls, + this.title, + this.mapHeightRatio = ExampleConstants.mapHeightRatio, + this.showAppBar = false, + this.floatingActionButton, + this.controlsAlignment = WrapAlignment.start, + this.controlsPadding, + this.wrapInCard = false, + }) : assert(mapHeightRatio > 0.0 && mapHeightRatio <= 1.0, + 'mapHeightRatio must be between 0.0 and 1.0'); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final screenHeight = MediaQuery.of(context).size.height; + final mapHeight = screenHeight * mapHeightRatio; + + final controlsWidget = _buildControls(theme); + + final body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: mapHeight, + child: map, + ), + Expanded( + child: SingleChildScrollView( + child: controlsWidget, + ), + ), + ], + ); + + return Scaffold( + appBar: showAppBar && title != null + ? AppBar( + title: Text(title!), + elevation: 0, + ) + : null, + body: body, + floatingActionButton: floatingActionButton, + ); + } + + Widget _buildControls(ThemeData theme) { + final padding = controlsPadding ?? + const EdgeInsets.all(ExampleConstants.paddingStandard); + + final wrappedControls = Wrap( + runSpacing: ExampleConstants.buttonRunSpacing, + alignment: controlsAlignment, + children: controls, + ); + + if (wrapInCard) { + return Padding( + padding: padding, + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ExampleConstants.borderRadius), + ), + child: Padding( + padding: const EdgeInsets.all(ExampleConstants.paddingStandard), + child: wrappedControls, + ), + ), + ); + } + + return Padding( + padding: padding, + child: wrappedControls, + ); + } +} + +/// A builder variant that accepts a child builder for more complex layouts +class MapExampleScaffoldBuilder extends StatelessWidget { + /// The MapLibre map widget + final MapLibreMap map; + + /// Builder for the control panel content + final Widget Function(BuildContext context) controlsBuilder; + + /// Optional title for the example + final String? title; + + /// Map height ratio (0.0 to 1.0). Defaults to 0.5 (50% of screen) + final double mapHeightRatio; + + /// Whether to show the app bar. Defaults to false. + final bool showAppBar; + + /// Optional floating action button + final Widget? floatingActionButton; + + const MapExampleScaffoldBuilder({ + super.key, + required this.map, + required this.controlsBuilder, + this.title, + this.mapHeightRatio = ExampleConstants.mapHeightRatio, + this.showAppBar = false, + this.floatingActionButton, + }); + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + final mapHeight = screenHeight * mapHeightRatio; + + final body = Column( + children: [ + SizedBox( + height: mapHeight, + child: map, + ), + Expanded( + child: SingleChildScrollView( + child: controlsBuilder(context), + ), + ), + ], + ); + + return Scaffold( + appBar: showAppBar && title != null + ? AppBar( + title: Text(title!), + elevation: 0, + ) + : null, + body: body, + floatingActionButton: floatingActionButton, + ); + } +} diff --git a/maplibre_gl_example/lib/translucent_full_map.dart b/maplibre_gl_example/lib/translucent_full_map.dart index 9aa61b1b5..b6f33d6d5 100644 --- a/maplibre_gl_example/lib/translucent_full_map.dart +++ b/maplibre_gl_example/lib/translucent_full_map.dart @@ -1,45 +1,61 @@ import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; - -import 'page.dart'; +import '../../page.dart'; +import '../../shared/shared.dart'; const _nullIsland = CameraPosition(target: LatLng(0, 0), zoom: 4.0); +/// Example demonstrating a translucent map with content underneath class TranslucentFullMapPage extends ExamplePage { const TranslucentFullMapPage({super.key}) - : super(const Icon(Icons.map), 'Translucent full screen map'); + : super( + const Icon(Icons.layers), + 'Translucent map', + category: ExampleCategory.advanced, + ); @override - Widget build(BuildContext context) { - return const TranslucentFullMap(); - } + Widget build(BuildContext context) => const _TranslucentMapBody(); } -class TranslucentFullMap extends StatefulWidget { - const TranslucentFullMap({super.key}); +class _TranslucentMapBody extends StatefulWidget { + const _TranslucentMapBody(); @override - State createState() => TranslucentFullMapState(); + State<_TranslucentMapBody> createState() => _TranslucentMapBodyState(); } -class TranslucentFullMapState extends State { - final Completer mapController = Completer(); - bool canInteractWithMap = false; +class _TranslucentMapBodyState extends State<_TranslucentMapBody> { + MapLibreMapController? _mapController; + bool _canInteractWithMap = false; + bool _canReset = false; + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + } + + void _onStyleLoaded() { + setState(() => _canInteractWithMap = true); + } + + Future _moveCameraToNullIsland() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition(_nullIsland), + ); + setState(() => _canReset = true); + } + + Future _resetCamera() async { + await _mapController?.animateCamera( + CameraUpdate.newCameraPosition(ExampleConstants.defaultCameraPosition), + ); + setState(() => _canReset = false); + } @override Widget build(BuildContext context) { return Scaffold( - floatingActionButtonLocation: - FloatingActionButtonLocation.miniCenterFloat, - floatingActionButton: canInteractWithMap - ? FloatingActionButton( - onPressed: _moveCameraToNullIsland, - mini: true, - child: const Icon(Icons.restore), - ) - : null, body: Stack( children: [ const ColoredBox( @@ -47,17 +63,22 @@ class TranslucentFullMapState extends State { child: Center( child: Text( 'Any widget can be here', - style: TextStyle(fontSize: 20), + style: TextStyle( + fontSize: 20, + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), ), MapLibreMap( - styleString: _styleString, - onMapCreated: (controller) => mapController.complete(controller), - initialCameraPosition: _nullIsland, - onStyleLoadedCallback: () => setState( - () => canInteractWithMap = true, - ), + styleString: 'assets/translucent_style.json', + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + initialCameraPosition: ExampleConstants.defaultCameraPosition, + logoEnabled: true, + trackCameraPosition: true, + compassEnabled: true, // This is a random color, for example purposes. foregroundLoadColor: Colors.purple, // This sets the map to be translucent. @@ -65,11 +86,17 @@ class TranslucentFullMapState extends State { ), ], ), + floatingActionButton: ExampleButton( + label: _canReset ? 'Reset camera' : 'Go to Null Island', + icon: _canReset ? Icons.refresh : Icons.flight_takeoff, + onPressed: _canInteractWithMap + ? _canReset + ? _resetCamera + : _moveCameraToNullIsland + : null, + style: ExampleButtonStyle.tonal, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, ); } - - String get _styleString => 'assets/translucent_style.json'; - - void _moveCameraToNullIsland() => mapController.future.then( - (c) => c.animateCamera(CameraUpdate.newCameraPosition(_nullIsland))); } diff --git a/maplibre_gl_example/pubspec.yaml b/maplibre_gl_example/pubspec.yaml index 1787d2288..5cdb6a3de 100644 --- a/maplibre_gl_example/pubspec.yaml +++ b/maplibre_gl_example/pubspec.yaml @@ -1,7 +1,7 @@ name: maplibre_gl_example description: Demonstrates how to use the maplibre_gl plugin. publish_to: 'none' -version: 0.24.1 +version: 0.25.0 repository: https://github.com/maplibre/flutter-maplibre-gl issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues resolution: workspace @@ -30,7 +30,8 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/fill/cat_silhouette_pattern.png + - assets/pattern/cat_silhouette_pattern.png + - assets/pattern/marker_pattern.png - assets/symbols/custom-icon.png - assets/symbols/2.0x/custom-icon.png - assets/symbols/3.0x/custom-icon.png diff --git a/maplibre_gl_platform_interface/CHANGELOG.md b/maplibre_gl_platform_interface/CHANGELOG.md index f04ca6408..bebedb1a1 100644 --- a/maplibre_gl_platform_interface/CHANGELOG.md +++ b/maplibre_gl_platform_interface/CHANGELOG.md @@ -1,3 +1,15 @@ +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +See top-level [CHANGELOG.md](../CHANGELOG.md) for full details. + +### Changed +* Updated to align with main package v0.25.0. +* No breaking changes to the platform interface in this release. + +## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) + +See top-level CHANGELOG.md + ## 0.23.0 > Note: This release has breaking changes. diff --git a/maplibre_gl_platform_interface/lib/src/method_channel_maplibre_gl.dart b/maplibre_gl_platform_interface/lib/src/method_channel_maplibre_gl.dart index 2c124038b..78ca3d6ed 100644 --- a/maplibre_gl_platform_interface/lib/src/method_channel_maplibre_gl.dart +++ b/maplibre_gl_platform_interface/lib/src/method_channel_maplibre_gl.dart @@ -819,8 +819,8 @@ class MapLibreMethodChannel extends MapLibrePlatform { @override void dispose() { - super.dispose(); _channel.setMethodCallHandler(null); + super.dispose(); } @override diff --git a/maplibre_gl_platform_interface/lib/src/ui.dart b/maplibre_gl_platform_interface/lib/src/ui.dart index abeebfc98..a4064f237 100644 --- a/maplibre_gl_platform_interface/lib/src/ui.dart +++ b/maplibre_gl_platform_interface/lib/src/ui.dart @@ -40,6 +40,14 @@ enum AttributionButtonPosition { bottomRight, } +/// Logo View Position +enum LogoViewPosition { + topLeft, + topRight, + bottomLeft, + bottomRight, +} + /// Bounds for the map camera target. /// Used with [_MapLibreMapOptions] to wrap a [LatLngBounds] value. This allows /// distinguishing between specifying an unbounded target (null `LatLngBounds`) diff --git a/maplibre_gl_platform_interface/pubspec.yaml b/maplibre_gl_platform_interface/pubspec.yaml index cdf710952..b1cbe2850 100644 --- a/maplibre_gl_platform_interface/pubspec.yaml +++ b/maplibre_gl_platform_interface/pubspec.yaml @@ -1,6 +1,6 @@ name: maplibre_gl_platform_interface description: A common platform interface for the maplibre_gl plugin. This package is only intended to be used by the maplibre_gl package. -version: 0.24.1 +version: 0.25.0 repository: https://github.com/maplibre/flutter-maplibre-gl issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues resolution: workspace diff --git a/maplibre_gl_web/CHANGELOG.md b/maplibre_gl_web/CHANGELOG.md index eccac2bf1..aa36bf3cb 100644 --- a/maplibre_gl_web/CHANGELOG.md +++ b/maplibre_gl_web/CHANGELOG.md @@ -1,5 +1,47 @@ See top-level [CHANGELOG.md](../CHANGELOG.md) for full details. +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +### Major Changes + +#### **BREAKING**: Migration to Modern JS Interop (#687) +* **WASM Compatible**: Migrated from deprecated `dart:js_util` to modern `dart:js_interop` API +* Required for Flutter 3.38.4+ compatibility +* Now fully compatible with Flutter's WASM compilation target +* **No public API changes** - this is an internal implementation update + +#### Technical Details of JS Interop Migration: +* Replaced `dart:js_util` with `dart:js_interop` and `dart:js_interop_unsafe` +* Updated all JS interop classes to use `@staticInterop` + extension methods pattern +* Migrated from `@JS()` factory constructors to new interop model +* Converted `allowInterop()` callbacks to `.toJS` +* Updated property access from `getProperty()`/`setProperty()` to native JS property access +* Replaced `jsify()`/`dartify()` utilities to work with `JSAny`/`JSObject` types +* Fixed primitive type conversions: `JSString.toDart`, `JSNumber.toDartDouble`, `JSArray.toDart` +* Converted static methods to top-level functions (e.g., `LngLat.convert()` → `lngLatConvert()`) + +### Added +* Implemented `getStyle()` - returns map style as JSON string (previously threw `UnimplementedError`) +* Implemented `getSourceIds()` - returns list of source IDs from current style +* Improved `getLayers()` - safely handles null styles and returns empty list instead of crashing + +### Fixed +* Fixed `setPaintProperty` and `setLayoutProperty` to handle nullable `JSAny` values correctly (#12dfad2) +* Improved `jsify` function to create JS arrays correctly +* Enhanced error handling in `getLayer()`, `getFilter()`, and `isStyleLoaded()` with null-safety checks +* Fixed pattern images loading - all images now correctly converted to RGBA format (#9ce52a6) + - Resolves mismatched image size errors when loading pattern images + - Ensures consistent image format across all image uploads + +### Refactor +* Improved null safety across the web platform +* Enhanced type safety for JS ↔ Dart conversions +* More descriptive error messages in the web implementation +* Example app improvements: + - Maps now use responsive sizing (50-60% of screen height) + - Removed fixed width constraints for full-screen responsiveness + - Better button and control layouts + ## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) * Rollback maplibre-gl to `4.7.1` version. (#660) diff --git a/maplibre_gl_web/lib/maplibre_gl_web.dart b/maplibre_gl_web/lib/maplibre_gl_web.dart index dff296381..3766c9a7d 100644 --- a/maplibre_gl_web/lib/maplibre_gl_web.dart +++ b/maplibre_gl_web/lib/maplibre_gl_web.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'dart:developer' as dev; import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; import 'dart:math'; import 'dart:ui' as ui; diff --git a/maplibre_gl_web/lib/src/convert.dart b/maplibre_gl_web/lib/src/convert.dart index b890aa9b4..9a93d4b36 100644 --- a/maplibre_gl_web/lib/src/convert.dart +++ b/maplibre_gl_web/lib/src/convert.dart @@ -63,6 +63,10 @@ class Convert { if (options.containsKey('myLocationRenderMode')) { sink.setMyLocationRenderMode(options['myLocationRenderMode']); } + if (options.containsKey('logoViewPosition')) { + final position = LogoViewPosition.values[options['logoViewPosition']]; + sink.setLogoViewAlignment(position); + } if (options.containsKey('logoViewMargins')) { sink.setLogoViewMargins( options['logoViewMargins'][0], options['logoViewMargins'][1]); diff --git a/maplibre_gl_web/lib/src/interop/style/style_interop.dart b/maplibre_gl_web/lib/src/interop/style/style_interop.dart index 15f7fe79d..52056da73 100644 --- a/maplibre_gl_web/lib/src/interop/style/style_interop.dart +++ b/maplibre_gl_web/lib/src/interop/style/style_interop.dart @@ -149,6 +149,9 @@ extension StyleJsImplExtension on StyleJsImpl { String mapId, RequestParametersJsImpl params, JSFunction callback); external JSArray layers; + + /// Map of source IDs to source objects + external JSAny? get sources; } @JS() diff --git a/maplibre_gl_web/lib/src/interop/ui/map_interop.dart b/maplibre_gl_web/lib/src/interop/ui/map_interop.dart index 33e5b28f8..cc7c7bd2d 100644 --- a/maplibre_gl_web/lib/src/interop/ui/map_interop.dart +++ b/maplibre_gl_web/lib/src/interop/ui/map_interop.dart @@ -760,7 +760,7 @@ extension MapLibreMapJsImplExtension on MapLibreMapJsImpl { /// @see [Change a layer's color with buttons](https://maplibre.org/maplibre-gl-js/docs/examples/color-switcher/) /// @see [Adjust a layer's opacity](https://maplibre.org/maplibre-gl-js/docs/examples/adjust-layer-opacity/) /// @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) - external void setPaintProperty(String layerId, String name, JSAny value, + external void setPaintProperty(String layerId, String name, JSAny? value, [StyleSetterOptionsJsImpl? options]); /// Returns the value of a paint property in the specified style layer. @@ -781,7 +781,7 @@ extension MapLibreMapJsImplExtension on MapLibreMapJsImpl { /// @example /// map.setLayoutProperty('my-layer', 'visibility', 'none'); external MapLibreMapJsImpl setLayoutProperty( - String layerId, String name, JSAny value, + String layerId, String name, JSAny? value, [StyleSetterOptionsJsImpl? options]); /// Returns the value of a layout property in the specified style layer. diff --git a/maplibre_gl_web/lib/src/maplibre_web_gl_platform.dart b/maplibre_gl_web/lib/src/maplibre_web_gl_platform.dart index c067a15b6..c48dea840 100644 --- a/maplibre_gl_web/lib/src/maplibre_web_gl_platform.dart +++ b/maplibre_gl_web/lib/src/maplibre_web_gl_platform.dart @@ -37,8 +37,8 @@ class MapLibreMapController extends MapLibrePlatform @override void dispose() { - super.dispose(); _map.remove(); + super.dispose(); } void _registerViewFactory(Function(int) callback, int identifier) { @@ -193,19 +193,19 @@ class MapLibreMapController extends MapLibrePlatform @override Future animateCamera(CameraUpdate cameraUpdate, {Duration? duration}) async { - final cameraOptions = Convert.toCameraOptions(cameraUpdate, _map).jsObject; - final jsObj = cameraOptions as JSObject; + final cameraOptions = Convert.toCameraOptions(cameraUpdate, _map); - final around = jsObj.getProperty('around'.toJS); - final bearing = jsObj.getProperty('bearing'.toJS); - final center = jsObj.getProperty('center'.toJS); - final pitch = jsObj.getProperty('pitch'.toJS); - final zoom = jsObj.getProperty('zoom'.toJS); + // Use the existing CameraOptions wrapper which has proper WASM-compatible accessors + final around = cameraOptions.around; + final bearing = cameraOptions.bearing; + final center = cameraOptions.center; + final pitch = cameraOptions.pitch; + final zoom = cameraOptions.zoom; _map.flyTo({ - if (around != null) 'around': around, + if (around != null) 'around': around.jsObject, if (bearing != null) 'bearing': bearing, - if (center != null) 'center': center, + if (center != null) 'center': center.jsObject, if (pitch != null) 'pitch': pitch, if (zoom != null) 'zoom': zoom, if (duration != null) 'duration': duration.inMilliseconds, @@ -385,10 +385,10 @@ class MapLibreMapController extends MapLibrePlatform final pointAsList = [point.x, point.y]; return _map .queryRenderedFeatures([pointAsList, pointAsList], options) - .map((feature) => { + .map((feature) => { 'type': 'Feature', 'id': feature.id, - 'geometry': { + 'geometry': { 'type': feature.geometry.type, 'coordinates': feature.geometry.coordinates, }, @@ -421,10 +421,10 @@ class MapLibreMapController extends MapLibrePlatform [rect.left, rect.bottom], [rect.right, rect.top], ], options) - .map((feature) => { + .map((feature) => { 'type': 'Feature', 'id': feature.id, - 'geometry': { + 'geometry': { 'type': feature.geometry.type, 'coordinates': feature.geometry.coordinates, }, @@ -457,10 +457,10 @@ class MapLibreMapController extends MapLibrePlatform return _map .querySourceFeatures(sourceId, parameters) - .map((feature) => { + .map((feature) => { 'type': 'Feature', 'id': feature.id, - 'geometry': { + 'geometry': { 'type': feature.geometry.type, 'coordinates': feature.geometry.coordinates, }, @@ -505,14 +505,18 @@ class MapLibreMapController extends MapLibrePlatform [bool sdf = false]) async { final photo = decodeImage(bytes)!; if (!_map.hasImage(name)) { + // Convert image to RGBA format with proper byte ordering + final rgbaBytes = photo.convert(numChannels: 4).getBytes(); + final data = Uint8List.fromList(rgbaBytes); + await _map.addImage( name, { 'width': photo.width, 'height': photo.height, - 'data': photo.getBytes(), + 'data': data, }, - {'sdf': sdf}, + {'sdf': sdf, 'pixelRatio': 1}, ); } else { dev.log('Image already exists on map: $name', @@ -803,6 +807,11 @@ class MapLibreMapController extends MapLibrePlatform print('setCompassViewMargins not available in web'); } + @override + void setLogoViewAlignment(LogoViewPosition position) { + print('setLogoViewAlignment not available in web'); + } + @override void setLogoViewMargins(int x, int y) { print('setLogoViewMargins not available in web'); @@ -943,7 +952,7 @@ class MapLibreMapController extends MapLibrePlatform {String? promoteId}) async { final data = _makeFeatureCollection(geojson); _addedFeaturesByLayer[sourceId] = data; - _map.addSource(sourceId, { + _map.addSource(sourceId, { "type": 'geojson', "data": geojson, // pass the raw string here to avoid errors if (promoteId != null) "promoteId": promoteId @@ -951,12 +960,19 @@ class MapLibreMapController extends MapLibrePlatform } Feature _makeFeature(Map geojsonFeature) { + final geometry = + Map.from(geojsonFeature["geometry"] as Map); + final propertiesRaw = geojsonFeature["properties"]; + final properties = propertiesRaw != null + ? Map.from(propertiesRaw as Map) + : null; + return Feature( - geometry: Geometry( - type: geojsonFeature["geometry"]["type"], - coordinates: geojsonFeature["geometry"]["coordinates"]), - properties: geojsonFeature["properties"], - id: geojsonFeature["properties"]?["id"] ?? geojsonFeature["id"]); + geometry: Geometry( + type: geometry["type"], coordinates: geometry["coordinates"]), + properties: properties, + id: properties?["id"] ?? geojsonFeature["id"], + ); } FeatureCollection _makeFeatureCollection(Map geojson) { @@ -1061,19 +1077,18 @@ class MapLibreMapController extends MapLibrePlatform Future setLayerProperties( String layerId, Map properties) async { for (final entry in properties.entries) { - // Very hacky: because we don't know if the property is a layout - // or paint property, we try to set it as both. - try { - _map.setLayoutProperty(layerId, entry.key, entry.value); - } catch (e) { - print( - 'Caught exception (usually safe to ignore): $e.\nLayerId: $layerId, Property: ${entry.key}, Value: ${entry.value}'); - } + // Try paint property first (most common), then layout property try { _map.setPaintProperty(layerId, entry.key, entry.value); } catch (e) { - print( - 'Caught exception (usually safe to ignore): $e.\nLayerId: $layerId, Property: ${entry.key}, Value: ${entry.value}'); + // If setPaintProperty fails, try setLayoutProperty + try { + _map.setLayoutProperty(layerId, entry.key, entry.value); + } catch (e) { + // If both fail, the property doesn't exist on this layer type + print( + 'Warning: Could not set property "${entry.key}" on layer "$layerId" for value "${entry.value}": $e'); + } } } } @@ -1149,12 +1164,12 @@ class MapLibreMapController extends MapLibrePlatform double? maxzoom, dynamic filter, required bool enableInteraction}) async { - final layout = Map.fromEntries( + final layout = Map.fromEntries( properties.entries.where((entry) => isLayoutProperty(entry.key))); - final paint = Map.fromEntries( + final paint = Map.fromEntries( properties.entries.where((entry) => !isLayoutProperty(entry.key))); - _map.addLayer({ + _map.addLayer({ 'id': layerId, 'type': layerType, 'source': sourceId, @@ -1362,9 +1377,8 @@ class MapLibreMapController extends MapLibrePlatform final style = _map.getStyle(); if (style == null) return []; - // The style is a JavaScript object with a 'sources' property - final jsStyle = style as JSObject; - final jsSources = jsStyle.getProperty('sources'.toJS); + // Get the sources from the style object + final jsSources = style.sources; if (jsSources == null) return []; diff --git a/maplibre_gl_web/lib/src/options_sink.dart b/maplibre_gl_web/lib/src/options_sink.dart index d3afcee80..527848b91 100644 --- a/maplibre_gl_web/lib/src/options_sink.dart +++ b/maplibre_gl_web/lib/src/options_sink.dart @@ -27,6 +27,8 @@ abstract class MapLibreMapOptionsSink { void setMyLocationRenderMode(int myLocationRenderMode); + void setLogoViewAlignment(LogoViewPosition position); + void setLogoViewMargins(int x, int y); void setCompassAlignment(CompassViewPosition position); diff --git a/maplibre_gl_web/lib/src/ui/map.dart b/maplibre_gl_web/lib/src/ui/map.dart index 629162fee..a4518d505 100644 --- a/maplibre_gl_web/lib/src/ui/map.dart +++ b/maplibre_gl_web/lib/src/ui/map.dart @@ -865,8 +865,10 @@ class MapLibreMap extends Camera { /// @see [Adjust a layer's opacity](https://maplibre.org/maplibre-gl-js/docs/examples/adjust-layer-opacity/) /// @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) setPaintProperty(String layerId, String name, dynamic value, - [StyleSetterOptions? options]) => - jsObject.setPaintProperty(layerId, name, utils.jsify(value)!); + [StyleSetterOptions? options]) { + final jsValue = utils.jsify(value); + jsObject.setPaintProperty(layerId, name, jsValue); + } /// Returns the value of a paint property in the specified style layer. /// @@ -887,9 +889,12 @@ class MapLibreMap extends Camera { /// @example /// map.setLayoutProperty('my-layer', 'visibility', 'none'); MapLibreMap setLayoutProperty(String layerId, String name, dynamic value, - [StyleSetterOptions? options]) => - MapLibreMap.fromJsObject( - jsObject.setLayoutProperty(layerId, name, utils.jsify(value)!)); + [StyleSetterOptions? options]) { + final jsValue = utils.jsify(value); + return MapLibreMap.fromJsObject( + jsObject.setLayoutProperty(layerId, name, jsValue), + ); + } /// Returns the value of a layout property in the specified style layer. /// diff --git a/maplibre_gl_web/lib/src/utils.dart b/maplibre_gl_web/lib/src/utils.dart index b1be72593..ac43dfe04 100644 --- a/maplibre_gl_web/lib/src/utils.dart +++ b/maplibre_gl_web/lib/src/utils.dart @@ -45,7 +45,8 @@ JSAny? jsify(Object? dartObject) { if (dartObject is num) return dartObject.toJS; if (dartObject is bool) return dartObject.toJS; if (dartObject is List) { - return dartObject.map((e) => jsify(e)).toList().toJS as JSAny; + final jsArray = dartObject.map((e) => jsify(e)).toList(); + return jsArray.toJS; } if (dartObject is Map) { return jsifyMap(dartObject); diff --git a/maplibre_gl_web/pubspec.yaml b/maplibre_gl_web/pubspec.yaml index 04bc1b675..1c12f91b6 100644 --- a/maplibre_gl_web/pubspec.yaml +++ b/maplibre_gl_web/pubspec.yaml @@ -1,6 +1,6 @@ name: maplibre_gl_web description: Web platform implementation of maplibre_gl. This package is only intended to be used by the maplibre_gl package. -version: 0.24.1 +version: 0.25.0 repository: https://github.com/maplibre/flutter-maplibre-gl issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues resolution: workspace @@ -22,7 +22,7 @@ dependencies: flutter_web_plugins: sdk: flutter image: ^4.0.17 - maplibre_gl_platform_interface: ^0.24.1 + maplibre_gl_platform_interface: ^0.25.0 meta: ^1.3.0 web: ^1.1.1 diff --git a/scripts/lib/generate.dart b/scripts/lib/generate.dart index d0df94f13..f74e87e17 100644 --- a/scripts/lib/generate.dart +++ b/scripts/lib/generate.dart @@ -171,6 +171,7 @@ Map buildStyleProperty( 'value': key, 'isFloatArrayProperty': typeDart == "List" && nestedTypeDart == "double", 'isVisibilityProperty': key == "visibility", + 'isPatternProperty': key.endsWith("-pattern"), 'requiresLiteral': key == "icon-image", 'isIosAsCamelCase': renamedIosProperties.containsKey(camelCase), 'iosAsCamelCase': renamedIosProperties[camelCase], diff --git a/scripts/pubspec.yaml b/scripts/pubspec.yaml index 55d66fa00..afdf778a7 100644 --- a/scripts/pubspec.yaml +++ b/scripts/pubspec.yaml @@ -3,7 +3,7 @@ description: code generation for flutter-maplibre-gl publish_to: 'none' resolution: workspace -version: 0.24.1 +version: 0.25.0 environment: sdk: ">=3.5.0 <4.0.0" diff --git a/scripts/templates/LayerPropertyConverter.java.template b/scripts/templates/LayerPropertyConverter.java.template index 8b0c1236d..94479b32d 100644 --- a/scripts/templates/LayerPropertyConverter.java.template +++ b/scripts/templates/LayerPropertyConverter.java.template @@ -31,8 +31,18 @@ class LayerPropertyConverter { switch (entry.getKey()) { {{#paint_properties}} {{^isFloatArrayProperty}} + {{^isPatternProperty}} case "{{value}}": properties.add(PropertyFactory.{{valueAsCamelCase}}(expression)); + {{/isPatternProperty}} + {{#isPatternProperty}} + case "{{value}}": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.{{valueAsCamelCase}}(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.{{valueAsCamelCase}}(expression)); + } + {{/isPatternProperty}} {{/isFloatArrayProperty}} {{#isFloatArrayProperty}} case "{{value}}": diff --git a/scripts/templates/LayerPropertyConverter.swift.template b/scripts/templates/LayerPropertyConverter.swift.template index b452b0e47..65d9e886d 100644 --- a/scripts/templates/LayerPropertyConverter.swift.template +++ b/scripts/templates/LayerPropertyConverter.swift.template @@ -7,7 +7,20 @@ class LayerPropertyConverter { {{#layerTypes}} class func add{{typePascal}}Properties({{typeCamel}}Layer: MLN{{typePascal}}StyleLayer, properties: [String: String]) { for (propertyName, propertyValue) in properties { - let expression = interpretExpression(propertyName: propertyName, expression: propertyValue) + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + switch propertyName { {{#paint_properties}} case "{{{value}}}": @@ -29,8 +42,10 @@ class LayerPropertyConverter { {{/isIosAsCamelCase}} {{/isVisibilityProperty}} {{#isVisibilityProperty}} - let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) - {{typeCamel}}Layer.{{iosAsCamelCase}} = trimmedPropertyValue == "visible" + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + {{typeCamel}}Layer.{{iosAsCamelCase}} = trimmedPropertyValue == "visible" + } {{/isVisibilityProperty}} {{/layout_properties}} @@ -48,6 +63,12 @@ class LayerPropertyConverter { do { let json = try JSONSerialization.jsonObject(with: expression.data(using: .utf8)!, options: .fragmentsAllowed) + + // Check if JSON contains NSNull - this would create an invalid NSExpression + if json is NSNull { + return nil + } + // this is required because NSExpression.init(mglJSONObject: json) fails to create // a proper Expression if the data of is a hexString if isColor { @@ -81,6 +102,13 @@ class LayerPropertyConverter { // this is required because NSExpression.init(mglJSONObject: json) fails to create // a proper Expression if the data is an array of double return NSExpression(forConstantValue: [NSNumber(value: x), NSNumber(value: y)]) + } else { + // Handle arrays with any number of elements (e.g., dash arrays with 3+ elements) + // Convert to array of NSNumbers for proper expression creation + let numbers = offset.compactMap { $0 as? Double }.map { NSNumber(value: $0) } + if numbers.count == offset.count { + return NSExpression(forConstantValue: numbers) + } } } diff --git a/scripts/templates/layer_properties.dart.template b/scripts/templates/layer_properties.dart.template index bef01c502..d909e5fd3 100644 --- a/scripts/templates/layer_properties.dart.template +++ b/scripts/templates/layer_properties.dart.template @@ -4,7 +4,7 @@ part of '../maplibre_gl.dart'; abstract class LayerProperties { - Map toJson(); + Map toJson({bool skipNulls = true}); } {{#layerTypes}} @@ -46,13 +46,12 @@ class {{typePascal}}LayerProperties implements LayerProperties { } @override - Map toJson() { + Map toJson({bool skipNulls = true}) { final json = {}; void addIfPresent(String fieldName, dynamic value) { - if (value != null) { - json[fieldName] = value; - } + if (value == null && skipNulls) return; + json[fieldName] = value; } {{#paint_properties}}