From e411afe3fd269001295b19e71856075e624827f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 15:51:08 +0000 Subject: [PATCH 01/12] [image_picker]: Bump androidx.annotation:annotation from 1.8.0 to 1.8.1 in /packages/image_picker/image_picker_android/android (#7302) Bumps androidx.annotation:annotation from 1.8.0 to 1.8.1. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=androidx.annotation:annotation&package-manager=gradle&previous-version=1.8.0&new-version=1.8.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- packages/image_picker/image_picker_android/CHANGELOG.md | 4 ++++ .../image_picker/image_picker_android/android/build.gradle | 2 +- packages/image_picker/image_picker_android/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/image_picker/image_picker_android/CHANGELOG.md b/packages/image_picker/image_picker_android/CHANGELOG.md index e493acf3e5a4..578949881f52 100644 --- a/packages/image_picker/image_picker_android/CHANGELOG.md +++ b/packages/image_picker/image_picker_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.12+11 + +* Bumps androidx.annotation:annotation from 1.8.0 to 1.8.1. + ## 0.8.12+10 * Bumps androidx.activity:activity from 1.9.0 to 1.9.1. diff --git a/packages/image_picker/image_picker_android/android/build.gradle b/packages/image_picker/image_picker_android/android/build.gradle index 117dd8ab5ce1..33c566465ed8 100644 --- a/packages/image_picker/image_picker_android/android/build.gradle +++ b/packages/image_picker/image_picker_android/android/build.gradle @@ -39,7 +39,7 @@ android { } dependencies { implementation 'androidx.core:core:1.13.1' - implementation 'androidx.annotation:annotation:1.8.0' + implementation 'androidx.annotation:annotation:1.8.1' implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation 'androidx.activity:activity:1.9.1' // org.jetbrains.kotlin:kotlin-bom artifact purpose is to align kotlin stdlib and related code versions. diff --git a/packages/image_picker/image_picker_android/pubspec.yaml b/packages/image_picker/image_picker_android/pubspec.yaml index a8f0c1a3ea5f..3e66b1438f31 100755 --- a/packages/image_picker/image_picker_android/pubspec.yaml +++ b/packages/image_picker/image_picker_android/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_android description: Android implementation of the image_picker plugin. repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.12+10 +version: 0.8.12+11 environment: sdk: ^3.4.0 From 4df8f65efc330d506306b6ab48432f3dfba5de1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 17:23:22 +0000 Subject: [PATCH 02/12] Bump actions/upload-artifact from 4.3.4 to 4.3.5 (#7308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.4 to 4.3.5.
Release notes

Sourced from actions/upload-artifact's releases.

v4.3.5

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4.3.4...v4.3.5

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4.3.4&new-version=4.3.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- .github/workflows/scorecards-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml index f84130a6a8df..0d079d97729d 100644 --- a/.github/workflows/scorecards-analysis.yml +++ b/.github/workflows/scorecards-analysis.yml @@ -41,7 +41,7 @@ jobs: # Upload the results as artifacts (optional). - name: "Upload artifact" - uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v2.3.1 + uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v2.3.1 with: name: SARIF file path: results.sarif From 064cdb4a80ad5a7b5704f8b973b0b9630f057a2a Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 5 Aug 2024 14:22:15 -0400 Subject: [PATCH 03/12] Roll Flutter from cbfb22244697 to 383282300c1e (4 revisions) (#7316) https://github.com/flutter/flutter/compare/cbfb22244697...383282300c1e 2024-08-05 engine-flutter-autoroll@skia.org Roll Packages from cc9ff470587b to 82e8d1eecb45 (7 revisions) (flutter/flutter#152866) 2024-08-05 mohellebiabdessalem@gmail.com reduce warnings inside flutter.groovy file (flutter/flutter#152073) 2024-08-05 engine-flutter-autoroll@skia.org Roll Flutter Engine from 540c22589911 to 0aac60342005 (2 revisions) (flutter/flutter#152859) 2024-08-05 engine-flutter-autoroll@skia.org Roll Flutter Engine from 16012e2f8ccd to 540c22589911 (4 revisions) (flutter/flutter#152854) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages Please CC rmistry@google.com,stuartmorgan@google.com,tarrinneal@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- .ci/flutter_master.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/flutter_master.version b/.ci/flutter_master.version index 28f2b2ee9ddb..3eae0b0f48ed 100644 --- a/.ci/flutter_master.version +++ b/.ci/flutter_master.version @@ -1 +1 @@ -cbfb222446979e31669b173d0165f261dbcd10d9 +383282300c1e395105fd8de73b38031f04402f45 From c451af75a4a4b5d96a00acd169990a7b4dd8a965 Mon Sep 17 00:00:00 2001 From: hellohuanlin <41930132+hellohuanlin@users.noreply.github.com> Date: Mon, 5 Aug 2024 11:59:21 -0700 Subject: [PATCH 04/12] [pointer_interceptor] Add performance warning on using pointer interceptor on iOS (#7288) Add performance warning to package README on using pointer interceptor on iOS. ## Issues Fixes https://github.com/flutter/flutter/issues/151535 --- packages/pointer_interceptor/pointer_interceptor/CHANGELOG.md | 3 ++- packages/pointer_interceptor/pointer_interceptor/README.md | 2 ++ packages/pointer_interceptor/pointer_interceptor/pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/pointer_interceptor/pointer_interceptor/CHANGELOG.md b/packages/pointer_interceptor/pointer_interceptor/CHANGELOG.md index 0a47b7de02e5..46a6f15db1bc 100644 --- a/packages/pointer_interceptor/pointer_interceptor/CHANGELOG.md +++ b/packages/pointer_interceptor/pointer_interceptor/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.10.1+2 +* Adds performance warning about using multiple pointer interceptors on iOS. * Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. ## 0.10.1+1 diff --git a/packages/pointer_interceptor/pointer_interceptor/README.md b/packages/pointer_interceptor/pointer_interceptor/README.md index a2033491ae27..ebeb90cc02d5 100644 --- a/packages/pointer_interceptor/pointer_interceptor/README.md +++ b/packages/pointer_interceptor/pointer_interceptor/README.md @@ -6,6 +6,8 @@ `PointerInterceptor` is a widget that prevents mouse events from being captured by an underlying [`HtmlElementView`](https://api.flutter.dev/flutter/widgets/HtmlElementView-class.html) in web, or an underlying [`PlatformView`](https://api.flutter.dev/flutter/widgets/PlatformViewLink-class.html) on iOS. +Using multiple `PointerInterceptor` instances on iOS can be slow and increases memory usage due to the performance overhead of the underlying platform view. + ## What is the problem? When overlaying Flutter widgets on top of `HtmlElementView`/`PlatformView` widgets that respond to mouse gestures (handle clicks, for example), the clicks will be consumed by the `HtmlElementView`/`PlatformView`, and not relayed to Flutter. diff --git a/packages/pointer_interceptor/pointer_interceptor/pubspec.yaml b/packages/pointer_interceptor/pointer_interceptor/pubspec.yaml index 3e10acce31bb..af3f843cd0c8 100644 --- a/packages/pointer_interceptor/pointer_interceptor/pubspec.yaml +++ b/packages/pointer_interceptor/pointer_interceptor/pubspec.yaml @@ -2,7 +2,7 @@ name: pointer_interceptor description: A widget to prevent clicks from being swallowed by underlying HtmlElementViews on the web. repository: https://github.com/flutter/packages/tree/main/packages/pointer_interceptor/pointer_interceptor issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+pointer_interceptor%22 -version: 0.10.1+1 +version: 0.10.1+2 environment: sdk: ^3.2.0 From 9308857a0a72f2556fa4c4ce87f2fcf7a13a6bf0 Mon Sep 17 00:00:00 2001 From: Rexios Date: Mon, 5 Aug 2024 16:40:11 -0400 Subject: [PATCH 05/12] [google_maps_flutter_platform_interface] Platform interface changes to support heatmaps (#7312) Prequel to: - https://github.com/flutter/packages/pull/7313 - https://github.com/flutter/packages/pull/7314 - https://github.com/flutter/packages/pull/7315 - https://github.com/flutter/packages/pull/3257 --- .../CHANGELOG.md | 4 + .../method_channel_google_maps_flutter.dart | 13 + .../lib/src/method_channel/serialization.dart | 159 +++++++ .../google_maps_flutter_platform.dart | 25 +- .../google_maps_inspector_platform.dart | 16 + .../lib/src/types/heatmap.dart | 372 ++++++++++++++++ .../lib/src/types/heatmap_updates.dart | 26 ++ .../lib/src/types/map_objects.dart | 2 + .../lib/src/types/types.dart | 3 + .../lib/src/types/utils/heatmap.dart | 19 + .../pubspec.yaml | 2 +- .../test/types/heatmap_test.dart | 402 ++++++++++++++++++ 12 files changed, 1036 insertions(+), 7 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/serialization.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/heatmap_test.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md index a137b29112c7..62ba5277b898 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.9.0 + +* Adds support for heatmap layers. + ## 2.8.0 * Deprecates `BitmapDescriptor.fromAssetImage` in favor of `BitmapDescriptor.asset` and `AssetMapBitmap.create`. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart index 2f162bf50462..254a5444dc76 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart @@ -14,6 +14,7 @@ import 'package:stream_transform/stream_transform.dart'; import '../../google_maps_flutter_platform_interface.dart'; import '../types/tile_overlay_updates.dart'; import '../types/utils/map_configuration_serialization.dart'; +import 'serialization.dart'; /// Error thrown when an unknown map ID is provided to a method channel API. class UnknownMapIDError extends Error { @@ -363,6 +364,17 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + @override + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + required int mapId, + }) { + return channel(mapId).invokeMethod( + 'heatmaps#update', + serializeMapsObjectUpdates(heatmapUpdates, serializeHeatmap), + ); + } + @override Future updateTileOverlays({ required Set newTileOverlays, @@ -542,6 +554,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { 'polygonsToAdd': serializePolygonSet(mapObjects.polygons), 'polylinesToAdd': serializePolylineSet(mapObjects.polylines), 'circlesToAdd': serializeCircleSet(mapObjects.circles), + 'heatmapsToAdd': serializeHeatmapSet(mapObjects.heatmaps), 'tileOverlaysToAdd': serializeTileOverlaySet(mapObjects.tileOverlays), }; diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/serialization.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/serialization.dart new file mode 100644 index 000000000000..2a82a05565f9 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/serialization.dart @@ -0,0 +1,159 @@ +// Copyright 2013 The Flutter 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 '../../google_maps_flutter_platform_interface.dart'; + +String _objectsToAddKey(String name) => '${name}sToAdd'; +String _objectsToChangeKey(String name) => '${name}sToChange'; +String _objectIdsToRemoveKey(String name) => '${name}IdsToRemove'; +const String _heatmapIdKey = 'heatmapId'; +const String _heatmapDataKey = 'data'; +const String _heatmapDissipatingKey = 'dissipating'; +const String _heatmapGradientKey = 'gradient'; +const String _heatmapMaxIntensityKey = 'maxIntensity'; +const String _heatmapOpacityKey = 'opacity'; +const String _heatmapRadiusKey = 'radius'; +const String _heatmapMinimumZoomIntensityKey = 'minimumZoomIntensity'; +const String _heatmapMaximumZoomIntensityKey = 'maximumZoomIntensity'; +const String _heatmapGradientColorsKey = 'colors'; +const String _heatmapGradientStartPointsKey = 'startPoints'; +const String _heatmapGradientColorMapSizeKey = 'colorMapSize'; + +void _addIfNonNull(Map map, String fieldName, Object? value) { + if (value != null) { + map[fieldName] = value; + } +} + +/// Serialize [MapsObjectUpdates] +Object serializeMapsObjectUpdates>( + MapsObjectUpdates updates, + Object Function(T) serialize, +) { + final Map json = {}; + + _addIfNonNull( + json, + _objectsToAddKey(updates.objectName), + updates.objectsToAdd.map(serialize).toList(), + ); + _addIfNonNull( + json, + _objectsToChangeKey(updates.objectName), + updates.objectsToChange.map(serialize).toList(), + ); + _addIfNonNull( + json, + _objectIdsToRemoveKey(updates.objectName), + updates.objectIdsToRemove + .map((MapsObjectId m) => m.value) + .toList(), + ); + + return json; +} + +/// Serialize [Heatmap] +Object serializeHeatmap(Heatmap heatmap) { + final Map json = {}; + + _addIfNonNull(json, _heatmapIdKey, heatmap.heatmapId.value); + _addIfNonNull( + json, + _heatmapDataKey, + heatmap.data.map(serializeWeightedLatLng).toList(), + ); + _addIfNonNull(json, _heatmapDissipatingKey, heatmap.dissipating); + + final HeatmapGradient? gradient = heatmap.gradient; + if (gradient != null) { + _addIfNonNull( + json, _heatmapGradientKey, serializeHeatmapGradient(gradient)); + } + _addIfNonNull(json, _heatmapMaxIntensityKey, heatmap.maxIntensity); + _addIfNonNull(json, _heatmapOpacityKey, heatmap.opacity); + _addIfNonNull(json, _heatmapRadiusKey, heatmap.radius.radius); + _addIfNonNull( + json, _heatmapMinimumZoomIntensityKey, heatmap.minimumZoomIntensity); + _addIfNonNull( + json, _heatmapMaximumZoomIntensityKey, heatmap.maximumZoomIntensity); + + return json; +} + +/// Serialize [WeightedLatLng] +Object serializeWeightedLatLng(WeightedLatLng wll) { + return [serializeLatLng(wll.point), wll.weight]; +} + +/// Deserialize [WeightedLatLng] +WeightedLatLng? deserializeWeightedLatLng(Object? json) { + if (json == null) { + return null; + } + assert(json is List && json.length == 2); + final List list = json as List; + final LatLng latLng = deserializeLatLng(list[0])!; + return WeightedLatLng(latLng, weight: list[1] as double); +} + +/// Serialize [LatLng] +Object serializeLatLng(LatLng latLng) { + return [latLng.latitude, latLng.longitude]; +} + +/// Deserialize [LatLng] +LatLng? deserializeLatLng(Object? json) { + if (json == null) { + return null; + } + assert(json is List && json.length == 2); + final List list = json as List; + return LatLng(list[0]! as double, list[1]! as double); +} + +/// Serialize [HeatmapGradient] +Object serializeHeatmapGradient(HeatmapGradient gradient) { + final Map json = {}; + + _addIfNonNull( + json, + _heatmapGradientColorsKey, + gradient.colors.map((HeatmapGradientColor e) => e.color.value).toList(), + ); + _addIfNonNull( + json, + _heatmapGradientStartPointsKey, + gradient.colors.map((HeatmapGradientColor e) => e.startPoint).toList(), + ); + _addIfNonNull(json, _heatmapGradientColorMapSizeKey, gradient.colorMapSize); + + return json; +} + +/// Deserialize [HeatmapGradient] +HeatmapGradient? deserializeHeatmapGradient(Object? json) { + if (json == null) { + return null; + } + assert(json is Map); + final Map map = (json as Map).cast(); + final List colors = (map[_heatmapGradientColorsKey]! as List) + .whereType() + .map((int e) => Color(e)) + .toList(); + final List startPoints = + (map[_heatmapGradientStartPointsKey]! as List) + .whereType() + .toList(); + final List gradientColors = []; + for (int i = 0; i < colors.length; i++) { + gradientColors.add(HeatmapGradientColor(colors[i], startPoints[i])); + } + return HeatmapGradient( + gradientColors, + colorMapSize: map[_heatmapGradientColorMapSizeKey] as int? ?? 256, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart index 648c6a162d68..a8f8e6d8b329 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart @@ -49,13 +49,13 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('init() has not been implemented.'); } - /// Updates configuration options of the map user interface - deprecated, use - /// updateMapConfiguration instead. + /// Updates configuration options of the map user interface. /// /// Change listeners are notified once the update has been made on the /// platform side. /// /// The returned [Future] completes after listeners have been notified. + @Deprecated('Use updateMapConfiguration instead.') Future updateMapOptions( Map optionsUpdate, { required int mapId, @@ -129,6 +129,19 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('updateCircles() has not been implemented.'); } + /// Updates heatmap configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + required int mapId, + }) { + throw UnimplementedError('updateHeatmaps() has not been implemented.'); + } + /// Updates tile overlay configuration. /// /// Change listeners are notified once the update has been made on the @@ -387,8 +400,8 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { return null; } - /// Returns a widget displaying the map view - deprecated, use - /// [buildViewWithConfiguration] instead. + /// Returns a widget displaying the map view. + @Deprecated('Use buildViewWithConfiguration instead.') Widget buildView( int creationId, PlatformViewCreatedCallback onPlatformViewCreated, { @@ -407,8 +420,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('buildView() has not been implemented.'); } - /// Returns a widget displaying the map view - deprecated, use - /// [buildViewWithConfiguration] instead. + /// Returns a widget displaying the map view. /// /// This method is similar to [buildView], but contains a parameter for /// platforms that require a text direction. @@ -417,6 +429,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// [buildView]. This is for backward compatibility with existing /// implementations. Platforms that use the text direction should override /// this as the primary implementation, and delegate to it from buildView. + @Deprecated('Use buildViewWithConfiguration instead.') Widget buildViewWithTextDirection( int creationId, PlatformViewCreatedCallback onPlatformViewCreated, { diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart index 461d38a61616..8bf6f6f89baf 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart @@ -116,6 +116,22 @@ abstract class GoogleMapsInspectorPlatform extends PlatformInterface { throw UnimplementedError('getTileOverlayInfo() has not been implemented.'); } + /// If the platform supports getting information about heatmaps. + bool supportsGettingHeatmapInfo() { + throw UnimplementedError( + 'supportsGettingHeatmapInfo() has not been implemented.', + ); + } + + /// Returns information about the heatmap with the given ID. + /// + /// The returned object will be synthesized from platform data, so will not + /// be the same Dart object as the original [Heatmap] provided to the + /// platform interface with that ID, and not all fields will be populated. + Future getHeatmapInfo(HeatmapId heatmapId, {required int mapId}) { + throw UnimplementedError('getHeatmapInfo() has not been implemented.'); + } + /// Returns current clusters from [ClusterManager]. Future> getClusters( {required int mapId, required ClusterManagerId clusterManagerId}) { diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart new file mode 100644 index 000000000000..41b4dcb2abc3 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart @@ -0,0 +1,372 @@ +// Copyright 2013 The Flutter 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' + show immutable, listEquals, objectRuntimeType; +import 'package:flutter/material.dart' show Color; + +import 'types.dart'; + +/// Uniquely identifies a [Heatmap] among [GoogleMap] heatmaps. +/// +/// This does not have to be globally unique, only unique among the list. +@immutable +class HeatmapId extends MapsObjectId { + /// Creates an immutable identifier for a [Heatmap]. + const HeatmapId(super.value); +} + +/// A wrapper around platform specific behavior for the radius of a [Heatmap]. +/// +/// Currently this class only handles platform-specific values, but in the +/// future it may provide alternate constructors that abstract platform +/// differences in handling of heatmap radius values. +/// +/// See https://github.com/flutter/flutter/issues/145411 +/// +// TODO(stuartmorgan): Add constructor and enum field that informs the platform how to perform the conversion. +@immutable +class HeatmapRadius { + /// Create a [HeatmapRadius] with a radius in pixels. + /// + /// This value will be used verbatim on all platforms. It is up to the + /// developer to ensure that the value is appropriate for the platform. + const HeatmapRadius.fromPixels(this.radius); + + /// The platform-independent value of the radius. + final int radius; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is HeatmapRadius && other.radius == radius; + } + + @override + int get hashCode => radius.hashCode; +} + +/// Draws a heatmap on the map. +@immutable +class Heatmap implements MapsObject { + /// Creates an immutable representation of a [Heatmap] to draw on + /// [GoogleMap]. + const Heatmap({ + required this.heatmapId, + required this.data, + this.dissipating = true, + this.gradient, + this.maxIntensity, + this.opacity = _defaultOpacity, + required this.radius, + this.minimumZoomIntensity = _defaultMinimumZoomIntensity, + this.maximumZoomIntensity = _defaultMaximumZoomIntensity, + }) : assert(opacity >= 0 && opacity <= 1); + + /// The default heatmap opacity as seen in the Google Maps SDKS: + /// - https://github.com/googlemaps/google-maps-ios-utils/blob/0e7ed81f1bbd9d29e4529c40ae39b0791b0a0eb8/src/Heatmap/GMUHeatmapTileLayer.m#L66 + /// - https://github.com/googlemaps/android-maps-utils/blob/2883d5d471bc04fa0e74f286b7c5beeac634df84/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java#L49 + /// - https://developers.google.com/maps/documentation/javascript/reference/visualization#HeatmapLayerOptions.opacity + /// + /// The default on web is actually 0.6, but to maintain consistency with + /// iOS and Android, we use 0.7. + static const double _defaultOpacity = 0.7; + + /// The minimum and maximum zoom intensity values required to get iOS + /// heatmap rendering to match the other platforms. + /// + /// See: + /// - https://github.com/googlemaps/google-maps-ios-utils/issues/419 + /// - https://github.com/googlemaps/google-maps-ios-utils/issues/371 + /// + /// The values used are respectively the minimum and maximum zoom levels + /// supported by Google Maps. + static const int _defaultMinimumZoomIntensity = 0; + static const int _defaultMaximumZoomIntensity = 21; + + /// Uniquely identifies a [Heatmap]. + final HeatmapId heatmapId; + + @override + HeatmapId get mapsId => heatmapId; + + /// The data points to display. + /// + /// This list must not be empty. + final List data; + + /// Specifies whether the heatmap dissipate on zoom. + /// + /// By default, the radius of influence of a data point is specified by the + /// radius option only. When dissipating is disabled, the radius option is + /// interpreted as a radius at zoom level 0. + final bool dissipating; + + /// The color gradient of the heatmap + final HeatmapGradient? gradient; + + /// The maximum intensity of the heatmap. + /// + /// By default, heatmap colors are dynamically scaled according to the + /// greatest concentration of points at any particular pixel on the map. + /// This property allows you to specify a fixed maximum. + final double? maxIntensity; + + /// The opacity of the heatmap, expressed as a number between 0 and 1. + final double opacity; + + /// The radius of influence for each data point, in pixels. + final HeatmapRadius radius; + + /// The minimum zoom intensity used for normalizing intensities. + final int minimumZoomIntensity; + + /// The maximum zoom intensity used for normalizing intensities. + final int maximumZoomIntensity; + + /// Creates a new [Heatmap] object whose values are the same as this + /// instance, unless overwritten by the specified parameters. + Heatmap copyWith({ + List? dataParam, + bool? dissipatingParam, + HeatmapGradient? gradientParam, + double? maxIntensityParam, + double? opacityParam, + HeatmapRadius? radiusParam, + int? minimumZoomIntensityParam, + int? maximumZoomIntensityParam, + }) { + return Heatmap( + heatmapId: heatmapId, + data: dataParam ?? data, + dissipating: dissipatingParam ?? dissipating, + gradient: gradientParam ?? gradient, + maxIntensity: maxIntensityParam ?? maxIntensity, + opacity: opacityParam ?? opacity, + radius: radiusParam ?? radius, + minimumZoomIntensity: minimumZoomIntensityParam ?? minimumZoomIntensity, + maximumZoomIntensity: maximumZoomIntensityParam ?? maximumZoomIntensity, + ); + } + + /// Creates a new [Heatmap] object whose values are the same as this + /// instance. + @override + Heatmap clone() => copyWith( + dataParam: List.of(data), + gradientParam: gradient?.clone(), + ); + + /// Converts this object to something serializable in JSON. + @override + Object toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, Object? value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('heatmapId', heatmapId.value); + addIfPresent('data', data.map((WeightedLatLng e) => e.toJson()).toList()); + addIfPresent('dissipating', dissipating); + addIfPresent('gradient', gradient?.toJson()); + addIfPresent('maxIntensity', maxIntensity); + addIfPresent('opacity', opacity); + addIfPresent('radius', radius.radius); + addIfPresent('minimumZoomIntensity', minimumZoomIntensity); + addIfPresent('maximumZoomIntensity', maximumZoomIntensity); + + return json; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is Heatmap && + heatmapId == other.heatmapId && + listEquals(data, other.data) && + dissipating == other.dissipating && + gradient == other.gradient && + maxIntensity == other.maxIntensity && + opacity == other.opacity && + radius == other.radius && + minimumZoomIntensity == other.minimumZoomIntensity && + maximumZoomIntensity == other.maximumZoomIntensity; + } + + @override + int get hashCode => heatmapId.hashCode; +} + +/// A data point entry for a heatmap. +/// +/// This is a geographical data point with a weight attribute. +@immutable +class WeightedLatLng { + /// Creates a [WeightedLatLng] with the specified [weight] + const WeightedLatLng(this.point, {this.weight = 1.0}); + + /// The geographical data point. + final LatLng point; + + /// The weighting value of the data point. + final double weight; + + /// Converts this object to something serializable in JSON. + Object toJson() { + return [point.toJson(), weight]; + } + + @override + String toString() { + return '${objectRuntimeType(this, 'WeightedLatLng')}($point, $weight)'; + } + + @override + bool operator ==(Object other) { + return other is WeightedLatLng && + other.point == point && + other.weight == weight; + } + + @override + int get hashCode => Object.hash(point, weight); +} + +/// Represents a mapping of intensity to color. +/// +/// Interpolates between given set of intensity and color values to produce a +/// full mapping for the range [0, 1]. +@immutable +class HeatmapGradient { + /// Creates a new [HeatmapGradient] object. + const HeatmapGradient( + this.colors, { + this.colorMapSize = 256, + }); + + /// The gradient colors. + /// + /// Distributed along [startPoint]s or uniformly depending on the platform. + /// Must contain at least one color. + final List colors; + + /// Number of entries in the generated color map. + final int colorMapSize; + + /// Creates a new [HeatmapGradient] object whose values are the same as this + /// instance, unless overwritten by the specified parameters. + HeatmapGradient copyWith({ + List? colorsParam, + int? colorMapSizeParam, + }) { + return HeatmapGradient( + colorsParam ?? colors, + colorMapSize: colorMapSizeParam ?? colorMapSize, + ); + } + + /// Creates a new [HeatmapGradient] object whose values are the same as this + /// instance. + HeatmapGradient clone() => copyWith( + colorsParam: List.of(colors), + ); + + /// Converts this object to something serializable in JSON. + Object toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, Object? value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('colors', + colors.map((HeatmapGradientColor e) => e.color.value).toList()); + addIfPresent('startPoints', + colors.map((HeatmapGradientColor e) => e.startPoint).toList()); + addIfPresent('colorMapSize', colorMapSize); + + return json; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is HeatmapGradient && + listEquals(colors, other.colors) && + colorMapSize == other.colorMapSize; + } + + @override + int get hashCode => Object.hash(colors, colorMapSize); +} + +/// A [Color] with a [startPoint] for use in a [HeatmapGradient]. +@immutable +class HeatmapGradientColor { + /// Creates a new [HeatmapGradientColor] object. + const HeatmapGradientColor(this.color, this.startPoint); + + /// The color for this portion of the gradient. + final Color color; + + /// The start point of this color. + final double startPoint; + + /// Creates a new [HeatmapGradientColor] object whose values are the same as + /// this instance, unless overwritten by the specified parameters. + HeatmapGradientColor copyWith({ + Color? colorParam, + double? startPointParam, + }) { + return HeatmapGradientColor( + colorParam ?? color, + startPointParam ?? startPoint, + ); + } + + /// Creates a new [HeatmapGradientColor] object whose values are the same as + /// this instance. + HeatmapGradientColor clone() => copyWith(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is HeatmapGradientColor && + color == other.color && + startPoint == other.startPoint; + } + + @override + int get hashCode => Object.hash(color, startPoint); + + @override + String toString() { + return '${objectRuntimeType(this, 'HeatmapGradientColor')}($color, $startPoint)'; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart new file mode 100644 index 000000000000..bd74c6301fc3 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart @@ -0,0 +1,26 @@ +// Copyright 2013 The Flutter 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 'types.dart'; + +/// [Heatmap] update events to be applied to the [GoogleMap]. +/// +/// Used in [GoogleMapController] when the map is updated. +// (Do not re-export) +class HeatmapUpdates extends MapsObjectUpdates { + /// Computes [HeatmapUpdates] given previous and current [Heatmap]s. + HeatmapUpdates.from( + super.previous, + super.current, + ) : super.from(objectName: 'heatmap'); + + /// Set of Heatmaps to be added in this update. + Set get heatmapsToAdd => objectsToAdd; + + /// Set of Heatmaps to be removed in this update. + Set get heatmapIdsToRemove => objectIdsToRemove.cast(); + + /// Set of Heatmaps to be changed in this update. + Set get heatmapsToChange => objectsToChange; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart index 009a6a078268..23d605c43eff 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart @@ -20,6 +20,7 @@ class MapObjects { this.polygons = const {}, this.polylines = const {}, this.circles = const {}, + this.heatmaps = const {}, this.tileOverlays = const {}, this.clusterManagers = const {}, }); @@ -28,6 +29,7 @@ class MapObjects { final Set polygons; final Set polylines; final Set circles; + final Set heatmaps; final Set tileOverlays; final Set clusterManagers; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart index 3ef0e4ab18b5..745e300ff05c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart @@ -12,6 +12,8 @@ export 'circle_updates.dart'; export 'cluster.dart'; export 'cluster_manager.dart'; export 'cluster_manager_updates.dart'; +export 'heatmap.dart'; +export 'heatmap_updates.dart'; export 'joint_type.dart'; export 'location.dart'; export 'map_configuration.dart'; @@ -34,6 +36,7 @@ export 'ui.dart'; // Export the utils used by the Widget export 'utils/circle.dart'; export 'utils/cluster_manager.dart'; +export 'utils/heatmap.dart'; export 'utils/marker.dart'; export 'utils/polygon.dart'; export 'utils/polyline.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart new file mode 100644 index 000000000000..ff6e7944601f --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter 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 '../types.dart'; +import 'maps_object.dart'; + +/// Converts an [Iterable] of Heatmaps in a Map of +/// HeatmapId -> Heatmap. +Map keyByHeatmapId( + Iterable heatmaps, +) { + return keyByMapsObjectId(heatmaps).cast(); +} + +/// Converts a Set of Heatmaps into something serializable in JSON. +Object serializeHeatmapSet(Set heatmaps) { + return serializeMapsObjectSet(heatmaps); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml index e9d672efb1c2..a01bed82ff40 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/google_maps_f issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.8.0 +version: 2.9.0 environment: sdk: ^3.2.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/heatmap_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/heatmap_test.dart new file mode 100644 index 000000000000..bc7d779d00ad --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/heatmap_test.dart @@ -0,0 +1,402 @@ +// Copyright 2013 The Flutter 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:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$HeatmapRadius', () { + test('fromPixels', () { + const HeatmapRadius radius = HeatmapRadius.fromPixels(10); + expect(radius.radius, 10); + }); + + test('==', () { + const HeatmapRadius radius1 = HeatmapRadius.fromPixels(10); + const HeatmapRadius radius2 = HeatmapRadius.fromPixels(10); + const HeatmapRadius radius3 = HeatmapRadius.fromPixels(20); + expect(radius1, radius2); + expect(radius1, isNot(radius3)); + }); + + test('hashCode', () { + const int radius = 10; + const HeatmapRadius heatmapRadius = HeatmapRadius.fromPixels(radius); + expect(heatmapRadius.hashCode, radius.hashCode); + }); + }); + + group('$Heatmap', () { + test('constructor defaults', () { + const HeatmapId id = HeatmapId('heatmap'); + const List data = [ + WeightedLatLng(LatLng(1, 1)), + ]; + const HeatmapRadius radius = HeatmapRadius.fromPixels(10); + const Heatmap heatmap = Heatmap( + heatmapId: id, + data: data, + radius: radius, + ); + + expect(heatmap.heatmapId, id); + expect(heatmap.data, data); + expect(heatmap.dissipating, true); + expect(heatmap.gradient, null); + expect(heatmap.maxIntensity, null); + expect(heatmap.opacity, 0.7); + expect(heatmap.radius, radius); + expect(heatmap.minimumZoomIntensity, 0); + expect(heatmap.maximumZoomIntensity, 21); + + expect(heatmap.heatmapId, heatmap.mapsId); + }); + + test('construct with values', () { + const HeatmapId id = HeatmapId('heatmap'); + const List data = [ + WeightedLatLng(LatLng(1, 1)), + ]; + const HeatmapGradient gradient = HeatmapGradient([ + HeatmapGradientColor(Colors.red, 0.0), + ]); + const double maxIntensity = 1.0; + const double opacity = 0.5; + const HeatmapRadius radius = HeatmapRadius.fromPixels(10); + const int minimumZoomIntensity = 1; + const int maximumZoomIntensity = 20; + const Heatmap heatmap = Heatmap( + heatmapId: id, + data: data, + dissipating: false, + gradient: gradient, + maxIntensity: maxIntensity, + opacity: opacity, + radius: radius, + minimumZoomIntensity: minimumZoomIntensity, + maximumZoomIntensity: maximumZoomIntensity, + ); + + expect(heatmap.heatmapId, id); + expect(heatmap.data, data); + expect(heatmap.dissipating, false); + expect(heatmap.gradient, gradient); + expect(heatmap.maxIntensity, maxIntensity); + expect(heatmap.opacity, opacity); + expect(heatmap.radius, radius); + expect(heatmap.minimumZoomIntensity, minimumZoomIntensity); + expect(heatmap.maximumZoomIntensity, maximumZoomIntensity); + }); + + test('copyWith', () { + const Heatmap heatmap1 = Heatmap( + heatmapId: HeatmapId('heatmap'), + data: [], + radius: HeatmapRadius.fromPixels(10), + ); + + const List data = [ + WeightedLatLng(LatLng(1, 1)), + ]; + const HeatmapGradient gradient = HeatmapGradient([ + HeatmapGradientColor(Colors.red, 0.0), + ]); + const double maxIntensity = 1.0; + const double opacity = 0.5; + const HeatmapRadius radius = HeatmapRadius.fromPixels(20); + const int minimumZoomIntensity = 1; + const int maximumZoomIntensity = 20; + + final Heatmap heatmap2 = heatmap1.copyWith( + dataParam: data, + dissipatingParam: false, + gradientParam: gradient, + maxIntensityParam: maxIntensity, + opacityParam: opacity, + radiusParam: radius, + minimumZoomIntensityParam: minimumZoomIntensity, + maximumZoomIntensityParam: maximumZoomIntensity, + ); + + expect(heatmap2.heatmapId, heatmap1.heatmapId); + expect(heatmap2.data, data); + expect(heatmap2.dissipating, false); + expect(heatmap2.gradient, gradient); + expect(heatmap2.maxIntensity, maxIntensity); + expect(heatmap2.opacity, opacity); + expect(heatmap2.radius, radius); + expect(heatmap2.minimumZoomIntensity, minimumZoomIntensity); + }); + + test('clone', () { + const Heatmap heatmap1 = Heatmap( + heatmapId: HeatmapId('heatmap'), + data: [], + radius: HeatmapRadius.fromPixels(10), + ); + + final Heatmap heatmap2 = heatmap1.clone(); + + expect(heatmap2, heatmap1); + }); + + test('==', () { + const HeatmapId id = HeatmapId('heatmap'); + const List data = [ + WeightedLatLng(LatLng(1, 1)), + ]; + const HeatmapRadius radius = HeatmapRadius.fromPixels(10); + const Heatmap heatmap1 = Heatmap( + heatmapId: id, + data: data, + radius: radius, + ); + const Heatmap heatmap2 = Heatmap( + heatmapId: id, + data: data, + radius: radius, + ); + const Heatmap heatmap3 = Heatmap( + heatmapId: id, + data: data, + radius: HeatmapRadius.fromPixels(20), + ); + + expect(heatmap1, heatmap2); + expect(heatmap1, isNot(heatmap3)); + }); + + test('hashCode', () { + const HeatmapId id = HeatmapId('heatmap'); + const Heatmap heatmap = Heatmap( + heatmapId: id, + data: [], + radius: HeatmapRadius.fromPixels(10), + ); + + expect(heatmap.hashCode, id.hashCode); + }); + }); + + group('$WeightedLatLng', () { + test('constructor defaults', () { + const LatLng point = LatLng(1, 1); + const WeightedLatLng wll = WeightedLatLng(point); + + expect(wll.point, point); + expect(wll.weight, 1.0); + }); + + test('construct with values', () { + const LatLng point = LatLng(1, 1); + const double weight = 2.0; + const WeightedLatLng wll = WeightedLatLng(point, weight: weight); + + expect(wll.point, point); + expect(wll.weight, weight); + }); + + test('toJson', () { + const LatLng point = LatLng(1, 1); + const double weight = 2.0; + const WeightedLatLng wll = WeightedLatLng(point, weight: weight); + + expect(wll.toJson(), [ + [point.latitude, point.longitude], + weight, + ]); + }); + + test('toString', () { + const LatLng point = LatLng(1, 1); + const double weight = 2.0; + const WeightedLatLng wll = WeightedLatLng(point, weight: weight); + + expect(wll.toString(), 'WeightedLatLng($point, $weight)'); + }); + + test('==', () { + const LatLng point = LatLng(1, 1); + const double weight = 2.0; + const WeightedLatLng wll1 = WeightedLatLng(point, weight: weight); + const WeightedLatLng wll2 = WeightedLatLng(point, weight: weight); + const WeightedLatLng wll3 = WeightedLatLng(point, weight: 3.0); + + expect(wll1, wll2); + expect(wll1, isNot(wll3)); + }); + + test('hashCode', () { + const LatLng point = LatLng(1, 1); + const double weight = 2.0; + const WeightedLatLng wll = WeightedLatLng(point, weight: weight); + + expect(wll.hashCode, Object.hash(point, weight)); + }); + }); + + group('$HeatmapGradient', () { + test('constructor defaults', () { + const List colors = [ + HeatmapGradientColor(Colors.red, 0.0), + ]; + const HeatmapGradient gradient = HeatmapGradient(colors); + + expect(gradient.colors, colors); + expect(gradient.colorMapSize, 256); + }); + + test('construct with values', () { + const List colors = [ + HeatmapGradientColor(Colors.red, 0.0), + ]; + const int colorMapSize = 512; + const HeatmapGradient gradient = + HeatmapGradient(colors, colorMapSize: colorMapSize); + + expect(gradient.colors, colors); + expect(gradient.colorMapSize, colorMapSize); + }); + + test('copyWith', () { + const HeatmapGradient gradient1 = HeatmapGradient([ + HeatmapGradientColor(Colors.red, 0.0), + ]); + + const List colors = [ + HeatmapGradientColor(Colors.blue, 0.0), + ]; + const int colorMapSize = 512; + final HeatmapGradient gradient2 = gradient1.copyWith( + colorsParam: colors, + colorMapSizeParam: colorMapSize, + ); + + expect(gradient2.colors, colors); + expect(gradient2.colorMapSize, colorMapSize); + }); + + test('clone', () { + const HeatmapGradient gradient1 = HeatmapGradient( + [HeatmapGradientColor(Colors.red, 0.0)], + colorMapSize: 512, + ); + + final HeatmapGradient gradient2 = gradient1.clone(); + expect(gradient2, gradient1); + }); + + test('toJson', () { + const List colors = [ + HeatmapGradientColor(Colors.red, 0.0), + ]; + const int colorMapSize = 512; + const HeatmapGradient gradient = + HeatmapGradient(colors, colorMapSize: colorMapSize); + + expect(gradient.toJson(), { + 'colors': + colors.map((HeatmapGradientColor e) => e.color.value).toList(), + 'startPoints': + colors.map((HeatmapGradientColor e) => e.startPoint).toList(), + 'colorMapSize': colorMapSize, + }); + }); + + test('==', () { + const List colors = [ + HeatmapGradientColor(Colors.red, 0.0), + ]; + const HeatmapGradient gradient1 = HeatmapGradient(colors); + const HeatmapGradient gradient2 = HeatmapGradient(colors); + const HeatmapGradient gradient3 = HeatmapGradient( + [HeatmapGradientColor(Colors.blue, 0.0)], + colorMapSize: 512); + + expect(gradient1, gradient2); + expect(gradient1, isNot(gradient3)); + }); + + test('hashCode', () { + const List colors = [ + HeatmapGradientColor(Colors.red, 0.0), + ]; + const int colorMapSize = 512; + const HeatmapGradient gradient = + HeatmapGradient(colors, colorMapSize: colorMapSize); + + expect(gradient.hashCode, Object.hash(colors, colorMapSize)); + }); + }); + + group('$HeatmapGradientColor', () { + test('construct with values', () { + const Color color = Colors.red; + const double startPoint = 0.0; + const HeatmapGradientColor gradientColor = + HeatmapGradientColor(color, startPoint); + + expect(gradientColor.color, color); + expect(gradientColor.startPoint, startPoint); + }); + + test('copyWith', () { + const HeatmapGradientColor gradientColor1 = + HeatmapGradientColor(Colors.red, 0.0); + + const Color color = Colors.blue; + const double startPoint = 0.5; + final HeatmapGradientColor gradientColor2 = gradientColor1.copyWith( + colorParam: color, + startPointParam: startPoint, + ); + + expect(gradientColor2.color, color); + expect(gradientColor2.startPoint, startPoint); + }); + + test('clone', () { + const HeatmapGradientColor gradientColor1 = + HeatmapGradientColor(Colors.red, 0.0); + + final HeatmapGradientColor gradientColor2 = gradientColor1.clone(); + expect(gradientColor2, gradientColor1); + }); + + test('==', () { + const HeatmapGradientColor gradientColor1 = + HeatmapGradientColor(Colors.red, 0.0); + const HeatmapGradientColor gradientColor2 = + HeatmapGradientColor(Colors.red, 0.0); + const HeatmapGradientColor gradientColor3 = + HeatmapGradientColor(Colors.blue, 0.0); + + expect(gradientColor1, gradientColor2); + expect(gradientColor1, isNot(gradientColor3)); + }); + + test('hashCode', () { + const HeatmapGradientColor gradientColor = + HeatmapGradientColor(Colors.red, 0.0); + + expect( + gradientColor.hashCode, + Object.hash(gradientColor.color, gradientColor.startPoint), + ); + }); + + test('toString', () { + const HeatmapGradientColor gradientColor = + HeatmapGradientColor(Colors.red, 0.0); + + expect( + gradientColor.toString(), + 'HeatmapGradientColor(${gradientColor.color}, ${gradientColor.startPoint})', + ); + }); + }); +} From 59aa310fd4dd780b34120dbf5caf2cb7dfb27561 Mon Sep 17 00:00:00 2001 From: Tirth Date: Tue, 6 Aug 2024 05:07:18 +0530 Subject: [PATCH 06/12] [go_router] redirect example signature fix (#7278) Fixes https://github.com/flutter/flutter/issues/112754 --- packages/go_router/CHANGELOG.md | 4 ++++ packages/go_router/lib/src/route.dart | 8 ++++---- packages/go_router/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 21fa68a8f217..256b030129e9 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.2.3 + +- Fixes redirect example's signature in `route.dart`. + ## 14.2.2 - Adds section for "Stateful nested navigation" to configuration.md. diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 21f2a039562f..0183f91ea543 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -132,7 +132,7 @@ typedef ExitCallback = FutureOr Function( /// routes: [ /// GoRoute( /// path: '/', -/// redirect: (_) => '/family/${Families.data[0].id}', +/// redirect: (_, __) => '/family/${Families.data[0].id}', /// ), /// GoRoute( /// path: '/family', @@ -169,7 +169,7 @@ abstract class RouteBase with Diagnosticable { /// routes: [ /// GoRoute( /// path: '/', - /// redirect: (_) => '/family/${Families.data[0].id}', + /// redirect: (_, __) => '/family/${Families.data[0].id}', /// ), /// GoRoute( /// path: '/family/:fid', @@ -188,11 +188,11 @@ abstract class RouteBase with Diagnosticable { /// routes: [ /// GoRoute( /// path: '/', - /// redirect: (_) => '/page1', // this takes priority over the sub-route. + /// redirect: (_, __) => '/page1', // this takes priority over the sub-route. /// routes: [ /// GoRoute( /// path: 'child', - /// redirect: (_) => '/page2', + /// redirect: (_, __) => '/page2', /// ), /// ], /// ), diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 60d5443e09e2..c45ec140d391 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 14.2.2 +version: 14.2.3 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 From 1520ffb564b335210db44b36d20d0059516a1fcd Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Mon, 5 Aug 2024 18:54:52 -0700 Subject: [PATCH 07/12] [go_router_builder] Fixes readme typos and uses code excerpts. (#7280) as title should clear go_router_builder from the list, since only readme has code example https://github.com/flutter/flutter/issues/102679 --- packages/go_router_builder/CHANGELOG.md | 4 + packages/go_router_builder/README.md | 188 +++++--- .../example/lib/readme_excerpts.dart | 417 ++++++++++++++++++ .../example/lib/readme_excerpts.g.dart | 190 ++++++++ packages/go_router_builder/pubspec.yaml | 2 +- script/configs/temp_exclude_excerpt.yaml | 1 - 6 files changed, 738 insertions(+), 64 deletions(-) create mode 100644 packages/go_router_builder/example/lib/readme_excerpts.dart create mode 100644 packages/go_router_builder/example/lib/readme_excerpts.g.dart diff --git a/packages/go_router_builder/CHANGELOG.md b/packages/go_router_builder/CHANGELOG.md index 3332a70e1b8d..72be3473bf7a 100644 --- a/packages/go_router_builder/CHANGELOG.md +++ b/packages/go_router_builder/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.7.1 + +- Fixes readme typos and uses code excerpts. + ## 2.7.0 - Adds an example and a test with `onExit`. diff --git a/packages/go_router_builder/README.md b/packages/go_router_builder/README.md index 73faf973937c..5d90b22f2851 100644 --- a/packages/go_router_builder/README.md +++ b/packages/go_router_builder/README.md @@ -23,10 +23,11 @@ Along with importing the `go_router.dart` library, it's essential to also include a `part` directive that references the generated Dart file. The generated file will always have the name `[source_file].g.dart`. + ```dart import 'package:go_router/go_router.dart'; -part 'this_file.g.dart'; +part 'readme_excerpts.g.dart'; ``` ### Running `build_runner` @@ -34,7 +35,7 @@ part 'this_file.g.dart'; To do a one-time build: ```console -flutter pub run build_runner build +dart run build_runner build ``` Read more about using @@ -50,23 +51,26 @@ via the `pathParameters` and `queryParameters` properties of the `GoRouterState` often the page builder must first parse the parameters into types that aren't `String`s, e.g. + ```dart GoRoute( - path: ':authorId', - builder: (context, state) { - // require the authorId to be present and be an integer - final authorId = int.parse(state.pathParameters['authorId']!); - return AuthorDetailsScreen(authorId: authorId); + path: ':familyId', + builder: (BuildContext context, GoRouterState state) { + // Require the familyId to be present and be an integer. + final int familyId = int.parse(state.pathParameters['familyId']!); + return FamilyScreen(familyId); }, -), +); ``` -In this example, the `authorId` parameter is a) required and b) must be an +In this example, the `familyId` parameter is a) required and b) must be an `int`. However, neither of these requirements are checked until run-time, making it easy to write code that is not type-safe, e.g. + ```dart -void _tap() => context.go('/author/a42'); // error: `a42` is not an `int` +void tap() => + context.go('/familyId/a42'); // This is an error: `a42` is not an `int`. ``` Dart's type system allows mistakes to be caught at compile-time instead of @@ -80,6 +84,7 @@ boilerplate code implementations ourselves. Define each route as a class extending `GoRouteData` and overriding the `build` method. + ```dart class HomeRoute extends GoRouteData { const HomeRoute(); @@ -93,24 +98,41 @@ class HomeRoute extends GoRouteData { The tree of routes is defined as an attribute on each of the top-level routes: + ```dart @TypedGoRoute( path: '/', routes: >[ TypedGoRoute( - path: 'family/:familyId', - ) + path: 'family/:fid', + ), ], ) class HomeRoute extends GoRouteData { const HomeRoute(); @override - Widget build(BuildContext context, GoRouterState state) => HomeScreen(families: familyData); + Widget build(BuildContext context, GoRouterState state) => const HomeScreen(); +} + +class RedirectRoute extends GoRouteData { + // There is no need to implement [build] when this [redirect] is unconditional. + @override + String? redirect(BuildContext context, GoRouterState state) { + return const HomeRoute().location; + } } @TypedGoRoute(path: '/login') -class LoginRoute extends GoRouteData {...} +class LoginRoute extends GoRouteData { + LoginRoute({this.from}); + final String? from; + + @override + Widget build(BuildContext context, GoRouterState state) { + return LoginScreen(from: from); + } +} ``` ## `GoRouter` initialization @@ -118,30 +140,37 @@ class LoginRoute extends GoRouteData {...} The code generator aggregates all top-level routes into a single list called `$appRoutes` for use in initializing the `GoRouter` instance: + ```dart -final _router = GoRouter(routes: $appRoutes); +final GoRouter router = GoRouter(routes: $appRoutes); ``` ## Error builder One can use typed routes to provide an error builder as well: + ```dart class ErrorRoute extends GoRouteData { ErrorRoute({required this.error}); final Exception error; @override - Widget build(BuildContext context, GoRouterState state) => ErrorScreen(error: error); + Widget build(BuildContext context, GoRouterState state) { + return ErrorScreen(error: error); + } } ``` With this in place, you can provide the `errorBuilder` parameter like so: + ```dart -final _router = GoRouter( +final GoRouter routerWithErrorBuilder = GoRouter( routes: $appRoutes, - errorBuilder: (c, s) => ErrorRoute(s.error!).build(c), + errorBuilder: (BuildContext context, GoRouterState state) { + return ErrorRoute(error: state.error!).build(context, state); + }, ); ``` @@ -149,15 +178,17 @@ final _router = GoRouter( Navigate using the `go` or `push` methods provided by the code generator: + ```dart -void _tap() => PersonRoute(fid: 'f2', pid: 'p1').go(context); +void onTap() => const FamilyRoute(fid: 'f2').go(context); ``` If you get this wrong, the compiler will complain: + ```dart -// error: missing required parameter 'fid' -void _tap() => PersonRoute(pid: 'p1').go(context); +// This is an error: missing required parameter 'fid'. +void errorTap() => const FamilyRoute().go(context); ``` This is the point of typed routing: the error is found statically. @@ -167,24 +198,27 @@ This is the point of typed routing: the error is found statically. Starting from `go_router` 6.5.0, pushing a route and subsequently popping it, can produce a return value. The generated routes also follow this functionality. + ```dart -void _tap() async { - final result = await PersonRoute(pid: 'p1').go(context); -} +final bool? result = + await const FamilyRoute(fid: 'John').push(context); ``` ## Query parameters Parameters (named or positional) not listed in the path of `TypedGoRoute` indicate query parameters: + ```dart -@TypedGoRoute(path: '/login') +@TypedGoRoute(path: '/login') class LoginRoute extends GoRouteData { LoginRoute({this.from}); final String? from; @override - Widget build(BuildContext context, GoRouterState state) => LoginScreen(from: from); + Widget build(BuildContext context, GoRouterState state) { + return LoginScreen(from: from); + } } ``` @@ -192,14 +226,17 @@ class LoginRoute extends GoRouteData { For query parameters with a **non-nullable** type, you can define a default value: + ```dart -@TypedGoRoute(path: '/my-route') +@TypedGoRoute(path: '/my-route') class MyRoute extends GoRouteData { MyRoute({this.queryParameter = 'defaultValue'}); final String queryParameter; @override - Widget build(BuildContext context, GoRouterState state) => MyScreen(queryParameter: queryParameter); + Widget build(BuildContext context, GoRouterState state) { + return MyScreen(queryParameter: queryParameter); + } } ``` @@ -211,20 +248,26 @@ A query parameter that equals to its default value is not included in the locati A route can consume an extra parameter by taking it as a typed constructor parameter with the special name `$extra`: + ```dart class PersonRouteWithExtra extends GoRouteData { - PersonRouteWithExtra({this.$extra}); - final int? $extra; + PersonRouteWithExtra(this.$extra); + final Person? $extra; @override - Widget build(BuildContext context, GoRouterState state) => PersonScreen(personId: $extra); + Widget build(BuildContext context, GoRouterState state) { + return PersonScreen($extra); + } } ``` Pass the extra param as a typed object: + ```dart -void _tap() => PersonRouteWithExtra(Person(name: 'Marvin', age: 42)).go(context); +void tapWithExtra() { + PersonRouteWithExtra(Person(id: 1, name: 'Marvin', age: 42)).go(context); +} ``` The `$extra` parameter is still passed outside the location, still defeats @@ -235,16 +278,19 @@ recommended when targeting Flutter web. You can, of course, combine the use of path, query and $extra parameters: + ```dart @TypedGoRoute(path: '/:ketchup') class HotdogRouteWithEverything extends GoRouteData { HotdogRouteWithEverything(this.ketchup, this.mustard, this.$extra); - final bool ketchup; // required path parameter - final String? mustard; // optional query parameter - final Sauce $extra; // special $extra parameter + final bool ketchup; // A required path parameter. + final String? mustard; // An optional query parameter. + final Sauce $extra; // A special $extra parameter. @override - Widget build(BuildContext context, GoRouterState state) => HotdogScreen(ketchup, mustard, $extra); + Widget build(BuildContext context, GoRouterState state) { + return HotdogScreen(ketchup, mustard, $extra); + } } ``` @@ -255,25 +301,33 @@ This seems kinda silly, but it works. Redirect using the `location` property on a route provided by the code generator: + ```dart -redirect: (state) { - final loggedIn = loginInfo.loggedIn; - final loggingIn = state.matchedLocation == LoginRoute().location; - if( !loggedIn && !loggingIn ) return LoginRoute(from: state.matchedLocation).location; - if( loggedIn && loggingIn ) return HomeRoute().location; +redirect: (BuildContext context, GoRouterState state) { + final bool loggedIn = loginInfo.loggedIn; + final bool loggingIn = state.matchedLocation == LoginRoute().location; + if (!loggedIn && !loggingIn) { + return LoginRoute(from: state.matchedLocation).location; + } + if (loggedIn && loggingIn) { + return const HomeRoute().location; + } return null; -} +}, ``` ## Route-level redirection Handle route-level redirects by implementing the `redirect` method on the route: + ```dart -class HomeRoute extends GoRouteData { - // no need to implement [build] when this [redirect] is unconditional +class RedirectRoute extends GoRouteData { + // There is no need to implement [build] when this [redirect] is unconditional. @override - String? redirect(BuildContext context, GoRouterState state) => BooksRoute().location; + String? redirect(BuildContext context, GoRouterState state) { + return const HomeRoute().location; + } } ``` @@ -282,6 +336,7 @@ class HomeRoute extends GoRouteData { The code generator can convert simple types like `int` and `enum` to/from the `String` type of the underlying pathParameters: + ```dart enum BookKind { all, popular, recent } @@ -290,7 +345,9 @@ class BooksRoute extends GoRouteData { final BookKind kind; @override - Widget build(BuildContext context, GoRouterState state) => BooksScreen(kind: kind); + Widget build(BuildContext context, GoRouterState state) { + return BooksScreen(kind: kind); + } } ``` @@ -310,15 +367,17 @@ type, pass non-default parameters when creating the page (like a custom key) or access the `GoRouteState` object, you can override the `buildPage` method of the base class instead of the `build` method: + ```dart class MyMaterialRouteWithKey extends GoRouteData { - static final _key = LocalKey('my-route-with-key'); + static const LocalKey _key = ValueKey('my-route-with-key'); @override - MaterialPage buildPage(BuildContext context, GoRouterState state) => - MaterialPage( + MaterialPage buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( key: _key, child: MyPage(), ); + } } ``` @@ -326,16 +385,22 @@ class MyMaterialRouteWithKey extends GoRouteData { Overriding the `buildPage` method is also useful for custom transitions: + ```dart class FancyRoute extends GoRouteData { @override - MaterialPage buildPage(BuildContext context, GoRouterState state) => - CustomTransitionPage( - key: state.pageKey, - child: FancyPage(), - transitionsBuilder: (context, animation, animation2, child) => - RotationTransition(turns: animation, child: child), - ), + CustomTransitionPage buildPage( + BuildContext context, + GoRouterState state, + ) { + return CustomTransitionPage( + key: state.pageKey, + child: const MyPage(), + transitionsBuilder: (BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) { + return RotationTransition(turns: animation, child: child); + }); + } } ``` @@ -350,9 +415,10 @@ different navigator. This kind of scenarios can be achieved by declaring a Example: + ```dart -// For ShellRoutes: final GlobalKey shellNavigatorKey = GlobalKey(); +final GlobalKey rootNavigatorKey = GlobalKey(); class MyShellRouteData extends ShellRouteData { const MyShellRouteData(); @@ -361,7 +427,7 @@ class MyShellRouteData extends ShellRouteData { @override Widget builder(BuildContext context, GoRouterState state, Widget navigator) { - // ... + return MyShellRoutePage(navigator); } } @@ -372,9 +438,7 @@ class MyGoRouteData extends GoRouteData { static final GlobalKey $parentNavigatorKey = rootNavigatorKey; @override - Widget build(BuildContext context, GoRouterState state) { - // ... - } + Widget build(BuildContext context, GoRouterState state) => const MyPage(); } ``` @@ -384,4 +448,4 @@ An example is available [here](https://github.com/flutter/packages/blob/main/pac To run unit tests, run command `dart tool/run_tests.dart` from `packages/go_router_builder/`. -To run tests in examples, run `flutter test` from `packages/go_router_builder/example`. \ No newline at end of file +To run tests in examples, run `flutter test` from `packages/go_router_builder/example`. diff --git a/packages/go_router_builder/example/lib/readme_excerpts.dart b/packages/go_router_builder/example/lib/readme_excerpts.dart new file mode 100644 index 000000000000..28eabdf1fa52 --- /dev/null +++ b/packages/go_router_builder/example/lib/readme_excerpts.dart @@ -0,0 +1,417 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs, unreachable_from_main, avoid_print, unused_element, unused_local_variable, directives_ordering + +import 'package:flutter/material.dart'; +import 'shared/data.dart'; +// #docregion import +import 'package:go_router/go_router.dart'; + +part 'readme_excerpts.g.dart'; +// #enddocregion import + +void otherDoc(BuildContext context) { + // #docregion GoRoute + GoRoute( + path: ':familyId', + builder: (BuildContext context, GoRouterState state) { + // Require the familyId to be present and be an integer. + final int familyId = int.parse(state.pathParameters['familyId']!); + return FamilyScreen(familyId); + }, + ); + // #enddocregion GoRoute + + // #docregion GoWrong + void tap() => + context.go('/familyId/a42'); // This is an error: `a42` is not an `int`. + // #enddocregion GoWrong + + // #docregion GoRouter + final GoRouter router = GoRouter(routes: $appRoutes); + // #enddocregion GoRouter + + // #docregion routerWithErrorBuilder + final GoRouter routerWithErrorBuilder = GoRouter( + routes: $appRoutes, + errorBuilder: (BuildContext context, GoRouterState state) { + return ErrorRoute(error: state.error!).build(context, state); + }, + ); + // #enddocregion routerWithErrorBuilder + + // #docregion go + void onTap() => const FamilyRoute(fid: 'f2').go(context); + // #enddocregion go + + // #docregion goError + // This is an error: missing required parameter 'fid'. + void errorTap() => const FamilyRoute().go(context); + // #enddocregion goError + + // #docregion tapWithExtra + void tapWithExtra() { + PersonRouteWithExtra(Person(id: 1, name: 'Marvin', age: 42)).go(context); + } + // #enddocregion tapWithExtra + + final LoginInfo loginInfo = LoginInfo(); + + final GoRouter routerWithRedirect = GoRouter( + routes: $appRoutes, + // #docregion redirect + redirect: (BuildContext context, GoRouterState state) { + final bool loggedIn = loginInfo.loggedIn; + final bool loggingIn = state.matchedLocation == LoginRoute().location; + if (!loggedIn && !loggingIn) { + return LoginRoute(from: state.matchedLocation).location; + } + if (loggedIn && loggingIn) { + return const HomeRoute().location; + } + return null; + }, + // #enddocregion redirect + ); +} + +// #docregion TypedGoRouteHomeRoute +@TypedGoRoute( + path: '/', + routes: >[ + TypedGoRoute( + path: 'family/:fid', + ), + ], +) +// #docregion HomeRoute +class HomeRoute extends GoRouteData { + const HomeRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const HomeScreen(); +} +// #enddocregion HomeRoute + +// #docregion RedirectRoute +class RedirectRoute extends GoRouteData { + // There is no need to implement [build] when this [redirect] is unconditional. + @override + String? redirect(BuildContext context, GoRouterState state) { + return const HomeRoute().location; + } +} +// #enddocregion RedirectRoute + +// #docregion login +@TypedGoRoute(path: '/login') +class LoginRoute extends GoRouteData { + LoginRoute({this.from}); + final String? from; + + @override + Widget build(BuildContext context, GoRouterState state) { + return LoginScreen(from: from); + } +} +// #enddocregion login +// #enddocregion TypedGoRouteHomeRoute + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('home'), + ), + body: TextButton( + onPressed: () async { + // #docregion awaitPush + final bool? result = + await const FamilyRoute(fid: 'John').push(context); + // #enddocregion awaitPush + print('result is $result'); + }, + child: const Text('push'), + ), + ); + } +} + +class FamilyRoute extends GoRouteData { + const FamilyRoute({this.fid}); + + final String? fid; + + @override + Widget build(BuildContext context, GoRouterState state) { + return FamilyScreen(int.parse(fid!)); + } +} + +class FamilyScreen extends StatelessWidget { + const FamilyScreen(this.fid, {super.key}); + + final int fid; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('family'), + ), + body: TextButton( + onPressed: () { + context.pop(true); + }, + child: const Text('pop with true'), + ), + ); + } +} + +// #docregion ErrorRoute +class ErrorRoute extends GoRouteData { + ErrorRoute({required this.error}); + final Exception error; + + @override + Widget build(BuildContext context, GoRouterState state) { + return ErrorScreen(error: error); + } +} +// #enddocregion ErrorRoute + +class ErrorScreen extends StatelessWidget { + const ErrorScreen({required this.error, super.key}); + + final Exception error; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Error'), + ), + body: Text(error.toString()), + ); + } +} + +class LoginScreen extends StatelessWidget { + const LoginScreen({required this.from, super.key}); + final String? from; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Login'), + ), + ); + } +} + +// #docregion MyRoute +@TypedGoRoute(path: '/my-route') +class MyRoute extends GoRouteData { + MyRoute({this.queryParameter = 'defaultValue'}); + final String queryParameter; + + @override + Widget build(BuildContext context, GoRouterState state) { + return MyScreen(queryParameter: queryParameter); + } +} +// #enddocregion MyRoute + +class MyScreen extends StatelessWidget { + const MyScreen({required this.queryParameter, super.key}); + final String queryParameter; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('MyScreen'), + ), + ); + } +} + +@TypedGoRoute(path: '/person') +// #docregion PersonRouteWithExtra +class PersonRouteWithExtra extends GoRouteData { + PersonRouteWithExtra(this.$extra); + final Person? $extra; + + @override + Widget build(BuildContext context, GoRouterState state) { + return PersonScreen($extra); + } +} +// #enddocregion PersonRouteWithExtra + +class PersonScreen extends StatelessWidget { + const PersonScreen(this.person, {super.key}); + final Person? person; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('PersonScreen'), + ), + ); + } +} + +// #docregion HotdogRouteWithEverything +@TypedGoRoute(path: '/:ketchup') +class HotdogRouteWithEverything extends GoRouteData { + HotdogRouteWithEverything(this.ketchup, this.mustard, this.$extra); + final bool ketchup; // A required path parameter. + final String? mustard; // An optional query parameter. + final Sauce $extra; // A special $extra parameter. + + @override + Widget build(BuildContext context, GoRouterState state) { + return HotdogScreen(ketchup, mustard, $extra); + } +} +// #enddocregion HotdogRouteWithEverything + +class Sauce {} + +class HotdogScreen extends StatelessWidget { + const HotdogScreen(this.ketchup, this.mustard, this.extra, {super.key}); + final bool ketchup; + final String? mustard; + final Sauce extra; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Hotdog'), + ), + ); + } +} + +// #docregion BookKind +enum BookKind { all, popular, recent } + +class BooksRoute extends GoRouteData { + BooksRoute({this.kind = BookKind.popular}); + final BookKind kind; + + @override + Widget build(BuildContext context, GoRouterState state) { + return BooksScreen(kind: kind); + } +} +// #enddocregion BookKind + +class BooksScreen extends StatelessWidget { + const BooksScreen({required this.kind, super.key}); + final BookKind kind; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('BooksScreen'), + ), + ); + } +} + +// #docregion MyMaterialRouteWithKey +class MyMaterialRouteWithKey extends GoRouteData { + static const LocalKey _key = ValueKey('my-route-with-key'); + @override + MaterialPage buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + key: _key, + child: MyPage(), + ); + } +} +// #enddocregion MyMaterialRouteWithKey + +class MyPage extends StatelessWidget { + const MyPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('MyPage'), + ), + ); + } +} + +class MyShellRoutePage extends StatelessWidget { + const MyShellRoutePage(this.child, {super.key}); + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('MyShellRoutePage'), + ), + body: child, + ); + } +} + +// #docregion FancyRoute +class FancyRoute extends GoRouteData { + @override + CustomTransitionPage buildPage( + BuildContext context, + GoRouterState state, + ) { + return CustomTransitionPage( + key: state.pageKey, + child: const MyPage(), + transitionsBuilder: (BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) { + return RotationTransition(turns: animation, child: child); + }); + } +} +// #enddocregion FancyRoute + +// #docregion MyShellRouteData +final GlobalKey shellNavigatorKey = GlobalKey(); +final GlobalKey rootNavigatorKey = GlobalKey(); + +class MyShellRouteData extends ShellRouteData { + const MyShellRouteData(); + + static final GlobalKey $navigatorKey = shellNavigatorKey; + + @override + Widget builder(BuildContext context, GoRouterState state, Widget navigator) { + return MyShellRoutePage(navigator); + } +} + +// For GoRoutes: +class MyGoRouteData extends GoRouteData { + const MyGoRouteData(); + + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Widget build(BuildContext context, GoRouterState state) => const MyPage(); +} +// #enddocregion MyShellRouteData diff --git a/packages/go_router_builder/example/lib/readme_excerpts.g.dart b/packages/go_router_builder/example/lib/readme_excerpts.g.dart new file mode 100644 index 000000000000..f6ec99f9d2c2 --- /dev/null +++ b/packages/go_router_builder/example/lib/readme_excerpts.g.dart @@ -0,0 +1,190 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: always_specify_types, public_member_api_docs + +part of 'readme_excerpts.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $homeRoute, + $loginRoute, + $myRoute, + $personRouteWithExtra, + $hotdogRouteWithEverything, + ]; + +RouteBase get $homeRoute => GoRouteData.$route( + path: '/', + factory: $HomeRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'family/:fid', + factory: $FamilyRouteExtension._fromState, + ), + ], + ); + +extension $HomeRouteExtension on HomeRoute { + static HomeRoute _fromState(GoRouterState state) => const HomeRoute(); + + String get location => GoRouteData.$location( + '/', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $FamilyRouteExtension on FamilyRoute { + static FamilyRoute _fromState(GoRouterState state) => FamilyRoute( + fid: state.pathParameters['fid']!, + ); + + String get location => GoRouteData.$location( + '/family/${Uri.encodeComponent(fid)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $loginRoute => GoRouteData.$route( + path: '/login', + factory: $LoginRouteExtension._fromState, + ); + +extension $LoginRouteExtension on LoginRoute { + static LoginRoute _fromState(GoRouterState state) => LoginRoute( + from: state.uri.queryParameters['from'], + ); + + String get location => GoRouteData.$location( + '/login', + queryParams: { + if (from != null) 'from': from, + }, + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $myRoute => GoRouteData.$route( + path: '/my-route', + factory: $MyRouteExtension._fromState, + ); + +extension $MyRouteExtension on MyRoute { + static MyRoute _fromState(GoRouterState state) => MyRoute( + queryParameter: + state.uri.queryParameters['query-parameter'] ?? 'defaultValue', + ); + + String get location => GoRouteData.$location( + '/my-route', + queryParams: { + if (queryParameter != 'defaultValue') + 'query-parameter': queryParameter, + }, + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $personRouteWithExtra => GoRouteData.$route( + path: '/person', + factory: $PersonRouteWithExtraExtension._fromState, + ); + +extension $PersonRouteWithExtraExtension on PersonRouteWithExtra { + static PersonRouteWithExtra _fromState(GoRouterState state) => + PersonRouteWithExtra( + state.extra as Person?, + ); + + String get location => GoRouteData.$location( + '/person', + ); + + void go(BuildContext context) => context.go(location, extra: $extra); + + Future push(BuildContext context) => + context.push(location, extra: $extra); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: $extra); + + void replace(BuildContext context) => + context.replace(location, extra: $extra); +} + +RouteBase get $hotdogRouteWithEverything => GoRouteData.$route( + path: '/:ketchup', + factory: $HotdogRouteWithEverythingExtension._fromState, + ); + +extension $HotdogRouteWithEverythingExtension on HotdogRouteWithEverything { + static HotdogRouteWithEverything _fromState(GoRouterState state) => + HotdogRouteWithEverything( + _$boolConverter(state.pathParameters['ketchup']!), + state.uri.queryParameters['mustard'], + state.extra as Sauce, + ); + + String get location => GoRouteData.$location( + '/${Uri.encodeComponent(ketchup.toString())}', + queryParams: { + if (mustard != null) 'mustard': mustard, + }, + ); + + void go(BuildContext context) => context.go(location, extra: $extra); + + Future push(BuildContext context) => + context.push(location, extra: $extra); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: $extra); + + void replace(BuildContext context) => + context.replace(location, extra: $extra); +} + +bool _$boolConverter(String value) { + switch (value) { + case 'true': + return true; + case 'false': + return false; + default: + throw UnsupportedError('Cannot convert "$value" into a bool.'); + } +} diff --git a/packages/go_router_builder/pubspec.yaml b/packages/go_router_builder/pubspec.yaml index 0e6e10b6b3d6..6fba2f8dc9f5 100644 --- a/packages/go_router_builder/pubspec.yaml +++ b/packages/go_router_builder/pubspec.yaml @@ -2,7 +2,7 @@ name: go_router_builder description: >- A builder that supports generated strongly-typed route helpers for package:go_router -version: 2.7.0 +version: 2.7.1 repository: https://github.com/flutter/packages/tree/main/packages/go_router_builder issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router_builder%22 diff --git a/script/configs/temp_exclude_excerpt.yaml b/script/configs/temp_exclude_excerpt.yaml index dbaa5d71535d..25580a780f97 100644 --- a/script/configs/temp_exclude_excerpt.yaml +++ b/script/configs/temp_exclude_excerpt.yaml @@ -6,7 +6,6 @@ # TODO(stuartmorgan): Remove everything from this list. See # https://github.com/flutter/flutter/issues/102679 - espresso -- go_router_builder - in_app_purchase/in_app_purchase - palette_generator - pointer_interceptor From cfbcc94f3c4490002adaab772d190ada462942c7 Mon Sep 17 00:00:00 2001 From: Rexios Date: Tue, 6 Aug 2024 00:43:50 -0400 Subject: [PATCH 08/12] [camera_web] Migrate to `package:web` (#7012) Fixes https://github.com/flutter/flutter/issues/139748 Notes: - Some fields are no longer nullable in `package:web`. We need to decide if we want to make them nullable again. --- packages/camera/camera/CHANGELOG.md | 4 + packages/camera/camera/example/pubspec.yaml | 4 + packages/camera/camera/example/web/index.html | 7 +- packages/camera/camera/pubspec.yaml | 6 +- packages/camera/camera_web/CHANGELOG.md | 5 + .../integration_test/camera_bitrate_test.dart | 37 +- .../camera_error_code_test.dart | 20 +- .../integration_test/camera_options_test.dart | 14 +- .../integration_test/camera_service_test.dart | 377 +++++----- .../example/integration_test/camera_test.dart | 629 +++++++++------- .../integration_test/camera_web_test.dart | 703 ++++++++++-------- .../integration_test/helpers/mocks.dart | 219 +++--- .../zoom_level_capability_test.dart | 9 +- .../camera/camera_web/example/pubspec.yaml | 6 +- .../camera/camera_web/lib/src/camera.dart | 237 +++--- .../camera_web/lib/src/camera_service.dart | 106 +-- .../camera/camera_web/lib/src/camera_web.dart | 191 ++--- .../camera_web/lib/src/pkg_web_tweaks.dart | 74 ++ .../lib/src/shims/dart_js_util.dart | 10 +- .../lib/src/types/camera_error_code.dart | 25 +- .../lib/src/types/camera_options.dart | 50 +- .../lib/src/types/zoom_level_capability.dart | 5 +- packages/camera/camera_web/pubspec.yaml | 7 +- .../exclude_all_packages_app_wasm.yaml | 5 +- 24 files changed, 1507 insertions(+), 1243 deletions(-) create mode 100644 packages/camera/camera_web/lib/src/pkg_web_tweaks.dart diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 53a6c4ca1ad8..d018f4941993 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.11.0+2 + +* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. + ## 0.11.0+1 * Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. diff --git a/packages/camera/camera/example/pubspec.yaml b/packages/camera/camera/example/pubspec.yaml index f3b240b56c24..54f2cebd4fef 100644 --- a/packages/camera/camera/example/pubspec.yaml +++ b/packages/camera/camera/example/pubspec.yaml @@ -28,5 +28,9 @@ dev_dependencies: integration_test: sdk: flutter +dependency_overrides: + camera_web: + path: ../../camera_web + flutter: uses-material-design: true diff --git a/packages/camera/camera/example/web/index.html b/packages/camera/camera/example/web/index.html index 91502587edaf..0dd4a04db410 100644 --- a/packages/camera/camera/example/web/index.html +++ b/packages/camera/camera/example/web/index.html @@ -3,8 +3,9 @@ Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. --> - + + @@ -16,14 +17,12 @@ - + Camera Web Example - - diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index f00a7e798f01..931f091bbdcc 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,11 +4,11 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.11.0+1 +version: 0.11.0+2 environment: - sdk: ^3.2.3 - flutter: ">=3.16.6" + sdk: ^3.3.0 + flutter: ">=3.19.0" flutter: plugin: diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index c9e8e661da42..d508877041c4 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.3.5 + +* Migrates to package:web to support WASM +* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. + ## 0.3.4 * Removes `maxVideoDuration`/`maxDuration`, as the feature was never exposed at diff --git a/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart b/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart index 3bf946029c27..3e2c9bd40c99 100644 --- a/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:html'; +import 'dart:js_interop'; import 'dart:math'; import 'dart:ui'; @@ -13,6 +13,7 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:web/web.dart'; import 'helpers/helpers.dart'; @@ -22,7 +23,7 @@ void main() { const Size videoSize = Size(320, 240); /// Draw some seconds of random video frames on canvas in realtime. - Future simulateCamera(CanvasElement canvasElement) async { + Future simulateCamera(HTMLCanvasElement canvasElement) async { const int fps = 15; const int seconds = 3; const int frameDuration = 1000 ~/ fps; @@ -34,8 +35,10 @@ void main() { final int h = videoSize.height ~/ 20; for (int y = 0; y < videoSize.height; y += h) { for (int x = 0; x < videoSize.width; x += w) { - canvasElement.context2D.setFillColorRgb( - random.nextInt(255), random.nextInt(255), random.nextInt(255)); + final int r = random.nextInt(255); + final int g = random.nextInt(255); + final int b = random.nextInt(255); + canvasElement.context2D.fillStyle = 'rgba($r, $g, $b, 1)'.toJS; canvasElement.context2D.fillRect(x, y, w, h); } } @@ -53,19 +56,25 @@ void main() { bool isVideoTypeSupported(String type) => type == supportedVideoType; Future recordVideo(int videoBitrate) async { - final Window window = MockWindow(); - final Navigator navigator = MockNavigator(); - final MediaDevices mediaDevices = MockMediaDevices(); + final MockWindow mockWindow = MockWindow(); + final MockNavigator mockNavigator = MockNavigator(); + final MockMediaDevices mockMediaDevices = MockMediaDevices(); - when(() => window.navigator).thenReturn(navigator); - when(() => navigator.mediaDevices).thenReturn(mediaDevices); + final Window window = createJSInteropWrapper(mockWindow) as Window; + final Navigator navigator = + createJSInteropWrapper(mockNavigator) as Navigator; + final MediaDevices mediaDevices = + createJSInteropWrapper(mockMediaDevices) as MediaDevices; - final CanvasElement canvasElement = CanvasElement( - width: videoSize.width.toInt(), - height: videoSize.height.toInt(), - )..context2D.clearRect(0, 0, videoSize.width, videoSize.height); + mockWindow.navigator = navigator; + mockNavigator.mediaDevices = mediaDevices; - final VideoElement videoElement = VideoElement(); + final HTMLCanvasElement canvasElement = HTMLCanvasElement() + ..width = videoSize.width.toInt() + ..height = videoSize.height.toInt() + ..context2D.clearRect(0, 0, videoSize.width, videoSize.height); + + final HTMLVideoElement videoElement = HTMLVideoElement(); final MockCameraService cameraService = MockCameraService(); diff --git a/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart index 6bd86b0fe8e5..4adb5cc40468 100644 --- a/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart @@ -2,12 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:html'; - // ignore: implementation_imports +import 'dart:js_interop'; + import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:web/web.dart'; import 'helpers/helpers.dart'; @@ -132,7 +133,8 @@ void main() { testWidgets('with aborted error code', (WidgetTester tester) async { expect( CameraErrorCode.fromMediaError( - FakeMediaError(MediaError.MEDIA_ERR_ABORTED), + createJSInteropWrapper( + FakeMediaError(MediaError.MEDIA_ERR_ABORTED)) as MediaError, ).toString(), equals('mediaErrorAborted'), ); @@ -141,7 +143,8 @@ void main() { testWidgets('with network error code', (WidgetTester tester) async { expect( CameraErrorCode.fromMediaError( - FakeMediaError(MediaError.MEDIA_ERR_NETWORK), + createJSInteropWrapper( + FakeMediaError(MediaError.MEDIA_ERR_NETWORK)) as MediaError, ).toString(), equals('mediaErrorNetwork'), ); @@ -150,7 +153,8 @@ void main() { testWidgets('with decode error code', (WidgetTester tester) async { expect( CameraErrorCode.fromMediaError( - FakeMediaError(MediaError.MEDIA_ERR_DECODE), + createJSInteropWrapper( + FakeMediaError(MediaError.MEDIA_ERR_DECODE)) as MediaError, ).toString(), equals('mediaErrorDecode'), ); @@ -160,7 +164,9 @@ void main() { (WidgetTester tester) async { expect( CameraErrorCode.fromMediaError( - FakeMediaError(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED), + createJSInteropWrapper( + FakeMediaError(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)) + as MediaError, ).toString(), equals('mediaErrorSourceNotSupported'), ); @@ -169,7 +175,7 @@ void main() { testWidgets('with unknown error code', (WidgetTester tester) async { expect( CameraErrorCode.fromMediaError( - FakeMediaError(5), + createJSInteropWrapper(FakeMediaError(5)) as MediaError, ).toString(), equals('mediaErrorUnknown'), ); diff --git a/packages/camera/camera_web/example/integration_test/camera_options_test.dart b/packages/camera/camera_web/example/integration_test/camera_options_test.dart index 7dd25e375561..66f4a6bd1eb8 100644 --- a/packages/camera/camera_web/example/integration_test/camera_options_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_options_test.dart @@ -3,6 +3,8 @@ // found in the LICENSE file. // ignore: implementation_imports +import 'dart:js_interop'; + import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -20,10 +22,10 @@ void main() { ); expect( - cameraOptions.toJson(), + cameraOptions.toMediaStreamConstraints().dartify(), equals({ - 'audio': cameraOptions.audio.toJson(), - 'video': cameraOptions.video.toJson(), + 'audio': cameraOptions.audio.toMediaStreamConstraints().dartify()!, + 'video': cameraOptions.video.toMediaStreamConstraints().dartify()!, }), ); }); @@ -61,8 +63,8 @@ void main() { group('AudioConstraints', () { testWidgets('serializes correctly', (WidgetTester tester) async { expect( - const AudioConstraints(enabled: true).toJson(), - equals(true), + const AudioConstraints(enabled: true).toMediaStreamConstraints(), + true.toJS, ); }); @@ -84,7 +86,7 @@ void main() { ); expect( - videoConstraints.toJson(), + videoConstraints.toMediaStreamConstraints().dartify(), equals({ 'facingMode': videoConstraints.facingMode!.toJson(), 'width': videoConstraints.width!.toJson(), diff --git a/packages/camera/camera_web/example/integration_test/camera_service_test.dart b/packages/camera/camera_web/example/integration_test/camera_service_test.dart index fb2279a09421..2ed0c54e6339 100644 --- a/packages/camera/camera_web/example/integration_test/camera_service_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_service_test.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:html'; -import 'dart:js_util' as js_util; +// ignore_for_file: only_throw_errors + +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:camera_platform_interface/camera_platform_interface.dart'; // ignore_for_file: implementation_imports @@ -15,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:web/web.dart' as web; import 'helpers/helpers.dart'; @@ -24,27 +27,40 @@ void main() { group('CameraService', () { const int cameraId = 1; - late Window window; - late Navigator navigator; - late MediaDevices mediaDevices; - late CameraService cameraService; + late MockWindow mockWindow; + late MockNavigator mockNavigator; + late MockMediaDevices mockMediaDevices; + + late web.Window window; + late web.Navigator navigator; + late web.MediaDevices mediaDevices; + late JsUtil jsUtil; + late CameraService cameraService; + setUp(() async { - window = MockWindow(); - navigator = MockNavigator(); - mediaDevices = MockMediaDevices(); + mockWindow = MockWindow(); + mockNavigator = MockNavigator(); + mockMediaDevices = MockMediaDevices(); + + window = createJSInteropWrapper(mockWindow) as web.Window; + navigator = createJSInteropWrapper(mockNavigator) as web.Navigator; + mediaDevices = + createJSInteropWrapper(mockMediaDevices) as web.MediaDevices; + + mockWindow.navigator = navigator; + mockNavigator.mediaDevices = mediaDevices; + jsUtil = MockJsUtil(); - when(() => window.navigator).thenReturn(navigator); - when(() => navigator.mediaDevices).thenReturn(mediaDevices); + registerFallbackValue(createJSInteropWrapper(MockWindow())); // Mock JsUtil to return the real getProperty from dart:js_util. when(() => jsUtil.getProperty(any(), any())).thenAnswer( - (Invocation invocation) => js_util.getProperty( - invocation.positionalArguments[0] as Object, - invocation.positionalArguments[1] as Object, - ), + (Invocation invocation) => + (invocation.positionalArguments[0] as JSObject) + .getProperty(invocation.positionalArguments[1] as JSAny), ); cameraService = CameraService()..window = window; @@ -54,8 +70,15 @@ void main() { testWidgets( 'calls MediaDevices.getUserMedia ' 'with provided options', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenAnswer((_) async => FakeMediaStream([])); + late final web.MediaStreamConstraints? capturedConstraints; + mockMediaDevices.getUserMedia = + ([web.MediaStreamConstraints? constraints]) { + capturedConstraints = constraints; + final web.MediaStream stream = + createJSInteropWrapper(FakeMediaStream([])) + as web.MediaStream; + return Future.value(stream).toJS; + }.toJS; final CameraOptions options = CameraOptions( video: VideoConstraints( @@ -66,26 +89,13 @@ void main() { await cameraService.getMediaStreamForOptions(options); - verify( - () => mediaDevices.getUserMedia(options.toJson()), - ).called(1); - }); - - testWidgets( - 'throws PlatformException ' - 'with notSupported error ' - 'when there are no media devices', (WidgetTester tester) async { - when(() => navigator.mediaDevices).thenReturn(null); - expect( - () => cameraService.getMediaStreamForOptions(const CameraOptions()), - throwsA( - isA().having( - (PlatformException e) => e.code, - 'code', - CameraErrorCode.notSupported.toString(), - ), - ), + capturedConstraints?.video.dartify(), + equals(options.video.toMediaStreamConstraints().dartify()), + ); + expect( + capturedConstraints?.audio.dartify(), + equals(options.audio.toMediaStreamConstraints().dartify()), ); }); @@ -94,8 +104,11 @@ void main() { 'with notFound error ' 'when MediaDevices.getUserMedia throws DomException ' 'with NotFoundError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('NotFoundError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'NotFoundError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -116,8 +129,11 @@ void main() { 'with notFound error ' 'when MediaDevices.getUserMedia throws DomException ' 'with DevicesNotFoundError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('DevicesNotFoundError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'DevicesNotFoundError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -138,9 +154,11 @@ void main() { 'with notReadable error ' 'when MediaDevices.getUserMedia throws DomException ' 'with NotReadableError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('NotReadableError')); - + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'NotReadableError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( const CameraOptions(), @@ -160,8 +178,11 @@ void main() { 'with notReadable error ' 'when MediaDevices.getUserMedia throws DomException ' 'with TrackStartError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('TrackStartError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'TrackStartError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -182,8 +203,11 @@ void main() { 'with overconstrained error ' 'when MediaDevices.getUserMedia throws DomException ' 'with OverconstrainedError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('OverconstrainedError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'OverconstrainedError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -204,8 +228,11 @@ void main() { 'with overconstrained error ' 'when MediaDevices.getUserMedia throws DomException ' 'with ConstraintNotSatisfiedError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('ConstraintNotSatisfiedError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'ConstraintNotSatisfiedError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -226,8 +253,11 @@ void main() { 'with permissionDenied error ' 'when MediaDevices.getUserMedia throws DomException ' 'with NotAllowedError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('NotAllowedError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'NotAllowedError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -248,8 +278,11 @@ void main() { 'with permissionDenied error ' 'when MediaDevices.getUserMedia throws DomException ' 'with PermissionDeniedError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('PermissionDeniedError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'PermissionDeniedError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -270,8 +303,11 @@ void main() { 'with type error ' 'when MediaDevices.getUserMedia throws DomException ' 'with TypeError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('TypeError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'TypeError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -292,8 +328,11 @@ void main() { 'with abort error ' 'when MediaDevices.getUserMedia throws DomException ' 'with AbortError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('AbortError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'AbortError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -314,8 +353,11 @@ void main() { 'with security error ' 'when MediaDevices.getUserMedia throws DomException ' 'with SecurityError', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('SecurityError')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'SecurityError'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -336,8 +378,11 @@ void main() { 'with unknown error ' 'when MediaDevices.getUserMedia throws DomException ' 'with an unknown error', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('Unknown')); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw web.DOMException('', 'Unknown'); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -358,7 +403,11 @@ void main() { 'with unknown error ' 'when MediaDevices.getUserMedia throws an unknown exception', (WidgetTester tester) async { - when(() => mediaDevices.getUserMedia(any())).thenThrow(Exception()); + mockMediaDevices.getUserMedia = ([web.MediaStreamConstraints? _]) { + throw Exception(); + // ignore: dead_code + return Future.value(web.MediaStream()).toJS; + }.toJS; expect( () => cameraService.getMediaStreamForOptions( @@ -379,17 +428,23 @@ void main() { group('getZoomLevelCapabilityForCamera', () { late Camera camera; - late List videoTracks; + late MockMediaStreamTrack mockVideoTrack; + late List videoTracks; setUp(() { camera = MockCamera(); - videoTracks = [ - MockMediaStreamTrack(), - MockMediaStreamTrack() + mockVideoTrack = MockMediaStreamTrack(); + videoTracks = [ + createJSInteropWrapper(mockVideoTrack) as web.MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) + as web.MediaStreamTrack, ]; when(() => camera.textureId).thenReturn(0); - when(() => camera.stream).thenReturn(FakeMediaStream(videoTracks)); + when(() => camera.stream).thenReturn( + createJSInteropWrapper(FakeMediaStream(videoTracks)) + as web.MediaStream, + ); cameraService.jsUtil = jsUtil; }); @@ -397,18 +452,15 @@ void main() { testWidgets( 'returns the zoom level capability ' 'based on the first video track', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'zoom': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return web.MediaTrackSupportedConstraints(zoom: true); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'zoom': js_util.jsify({ - 'min': 100, - 'max': 400, - 'step': 2, - }), - }); + mockVideoTrack.getCapabilities = () { + return web.MediaTrackCapabilities( + zoom: web.MediaSettingsRange(min: 100, max: 400, step: 2), + ); + }.toJS; final ZoomLevelCapability zoomLevelCapability = cameraService.getZoomLevelCapabilityForCamera(camera); @@ -419,75 +471,19 @@ void main() { }); group('throws CameraWebException', () { - testWidgets( - 'with zoomLevelNotSupported error ' - 'when there are no media devices', (WidgetTester tester) async { - when(() => navigator.mediaDevices).thenReturn(null); - - expect( - () => cameraService.getZoomLevelCapabilityForCamera(camera), - throwsA( - isA() - .having( - (CameraWebException e) => e.cameraId, - 'cameraId', - camera.textureId, - ) - .having( - (CameraWebException e) => e.code, - 'code', - CameraErrorCode.zoomLevelNotSupported, - ), - ), - ); - }); - testWidgets( 'with zoomLevelNotSupported error ' 'when the zoom level is not supported ' 'in the browser', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'zoom': false, - }); - - when(videoTracks.first.getCapabilities).thenReturn({ - 'zoom': { - 'min': 100, - 'max': 400, - 'step': 2, - }, - }); - - expect( - () => cameraService.getZoomLevelCapabilityForCamera(camera), - throwsA( - isA() - .having( - (CameraWebException e) => e.cameraId, - 'cameraId', - camera.textureId, - ) - .having( - (CameraWebException e) => e.code, - 'code', - CameraErrorCode.zoomLevelNotSupported, - ), - ), - ); - }); - - testWidgets( - 'with zoomLevelNotSupported error ' - 'when the zoom level is not supported ' - 'by the camera', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'zoom': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return web.MediaTrackSupportedConstraints(zoom: false); + }.toJS; - when(videoTracks.first.getCapabilities) - .thenReturn({}); + mockVideoTrack.getCapabilities = () { + return web.MediaTrackCapabilities( + zoom: web.MediaSettingsRange(min: 100, max: 400, step: 2), + ); + }.toJS; expect( () => cameraService.getZoomLevelCapabilityForCamera(camera), @@ -511,14 +507,15 @@ void main() { 'with notStarted error ' 'when the camera stream has not been initialized', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'zoom': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return web.MediaTrackSupportedConstraints(zoom: true); + }.toJS; // Create a camera stream with no video tracks. - when(() => camera.stream) - .thenReturn(FakeMediaStream([])); + when(() => camera.stream).thenReturn( + createJSInteropWrapper(FakeMediaStream([])) + as web.MediaStream, + ); expect( () => cameraService.getZoomLevelCapabilityForCamera(camera), @@ -545,59 +542,44 @@ void main() { cameraService.jsUtil = jsUtil; }); - testWidgets( - 'throws PlatformException ' - 'with notSupported error ' - 'when there are no media devices', (WidgetTester tester) async { - when(() => navigator.mediaDevices).thenReturn(null); - - expect( - () => - cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()), - throwsA( - isA().having( - (PlatformException e) => e.code, - 'code', - CameraErrorCode.notSupported.toString(), - ), - ), - ); - }); - testWidgets( 'returns null ' 'when the facing mode is not supported', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'facingMode': false, - }); + mockMediaDevices.getSupportedConstraints = () { + return web.MediaTrackSupportedConstraints(facingMode: false); + }.toJS; - final String? facingMode = - cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()); + final String? facingMode = cameraService.getFacingModeForVideoTrack( + createJSInteropWrapper(MockMediaStreamTrack()) + as web.MediaStreamTrack, + ); expect(facingMode, isNull); }); group('when the facing mode is supported', () { - late MediaStreamTrack videoTrack; + late MockMediaStreamTrack mockVideoTrack; + late web.MediaStreamTrack videoTrack; setUp(() { - videoTrack = MockMediaStreamTrack(); + mockVideoTrack = MockMediaStreamTrack(); + videoTrack = + createJSInteropWrapper(mockVideoTrack) as web.MediaStreamTrack; - when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities'.toJS)) .thenReturn(true); - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'facingMode': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return web.MediaTrackSupportedConstraints(facingMode: true); + }.toJS; }); testWidgets( 'returns an appropriate facing mode ' 'based on the video track settings', (WidgetTester tester) async { - when(videoTrack.getSettings) - .thenReturn({'facingMode': 'user'}); + mockVideoTrack.getSettings = () { + return web.MediaTrackSettings(facingMode: 'user'); + }.toJS; final String? facingMode = cameraService.getFacingModeForVideoTrack(videoTrack); @@ -610,12 +592,16 @@ void main() { 'based on the video track capabilities ' 'when the facing mode setting is empty', (WidgetTester tester) async { - when(videoTrack.getSettings).thenReturn({}); - when(videoTrack.getCapabilities).thenReturn({ - 'facingMode': ['environment', 'left'] - }); - - when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + mockVideoTrack.getSettings = () { + return web.MediaTrackSettings(facingMode: ''); + }.toJS; + mockVideoTrack.getCapabilities = () { + return web.MediaTrackCapabilities( + facingMode: ['environment'.toJS, 'left'.toJS].toJS, + ); + }.toJS; + + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities'.toJS)) .thenReturn(true); final String? facingMode = @@ -628,9 +614,12 @@ void main() { 'returns null ' 'when the facing mode setting ' 'and capabilities are empty', (WidgetTester tester) async { - when(videoTrack.getSettings).thenReturn({}); - when(videoTrack.getCapabilities) - .thenReturn({'facingMode': []}); + mockVideoTrack.getSettings = () { + return web.MediaTrackSettings(facingMode: ''); + }.toJS; + mockVideoTrack.getCapabilities = () { + return web.MediaTrackCapabilities(facingMode: [].toJS); + }.toJS; final String? facingMode = cameraService.getFacingModeForVideoTrack(videoTrack); @@ -643,9 +632,11 @@ void main() { 'when the facing mode setting is empty and ' 'the video track capabilities are not supported', (WidgetTester tester) async { - when(videoTrack.getSettings).thenReturn({}); + mockVideoTrack.getSettings = () { + return web.MediaTrackSettings(facingMode: ''); + }.toJS; - when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities'.toJS)) .thenReturn(false); final String? facingMode = diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 7611e288b84d..e953a06b0e8a 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -3,7 +3,8 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:html'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'dart:ui'; import 'package:async/async.dart'; @@ -15,6 +16,7 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:web/web.dart'; import 'helpers/helpers.dart'; @@ -24,6 +26,10 @@ void main() { group('Camera', () { const int textureId = 1; + late MockWindow mockWindow; + late MockNavigator mockNavigator; + late MockMediaDevices mockMediaDevices; + late Window window; late Navigator navigator; late MediaDevices mediaDevices; @@ -32,16 +38,20 @@ void main() { late CameraService cameraService; setUp(() { - window = MockWindow(); - navigator = MockNavigator(); - mediaDevices = MockMediaDevices(); + mockWindow = MockWindow(); + mockNavigator = MockNavigator(); + mockMediaDevices = MockMediaDevices(); + + window = createJSInteropWrapper(mockWindow) as Window; + navigator = createJSInteropWrapper(mockNavigator) as Navigator; + mediaDevices = createJSInteropWrapper(mockMediaDevices) as MediaDevices; - when(() => window.navigator).thenReturn(navigator); - when(() => navigator.mediaDevices).thenReturn(mediaDevices); + mockWindow.navigator = navigator; + mockNavigator.mediaDevices = mediaDevices; cameraService = MockCameraService(); - final VideoElement videoElement = + final HTMLVideoElement videoElement = getVideoElementWithBlankStream(const Size(10, 10)); mediaStream = videoElement.captureStream(); @@ -110,7 +120,8 @@ void main() { expect(camera.videoElement.autoplay, isFalse); expect(camera.videoElement.muted, isTrue); expect(camera.videoElement.srcObject, mediaStream); - expect(camera.videoElement.attributes.keys, contains('playsinline')); + expect(camera.videoElement.attributes.getNamedItem('playsinline'), + isNotNull); expect( camera.videoElement.style.transformOrigin, equals('center center')); @@ -154,7 +165,10 @@ void main() { expect(camera.divElement, isNotNull); expect(camera.divElement.style.objectFit, equals('cover')); - expect(camera.divElement.children, contains(camera.videoElement)); + final JSArray? array = (globalContext['Array']! as JSObject) + .callMethod('from'.toJS, camera.divElement.children) + as JSArray?; + expect(array?.toDart, contains(camera.videoElement)); }); testWidgets('initializes the camera stream', (WidgetTester tester) async { @@ -303,26 +317,26 @@ void main() { group( 'enables the torch mode ' 'when taking a picture', () { + late MockMediaStreamTrack mockVideoTrack; late List videoTracks; late MediaStream videoStream; - late VideoElement videoElement; + late HTMLVideoElement videoElement; setUp(() { + mockVideoTrack = MockMediaStreamTrack(); videoTracks = [ - MockMediaStreamTrack(), - MockMediaStreamTrack() + createJSInteropWrapper(mockVideoTrack) as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) as MediaStreamTrack, ]; - videoStream = FakeMediaStream(videoTracks); + videoStream = createJSInteropWrapper(FakeMediaStream(videoTracks)) + as MediaStream; videoElement = getVideoElementWithBlankStream(const Size(100, 100)) ..muted = true; - when(() => videoTracks.first.applyConstraints(any())) - .thenAnswer((_) async => {}); - - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': true, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [true.toJS].toJS); + }.toJS; }); testWidgets('if the flash mode is auto', (WidgetTester tester) async { @@ -337,27 +351,22 @@ void main() { await camera.play(); + final List capturedConstraints = + []; + mockVideoTrack.applyConstraints = ([ + MediaTrackConstraints? constraints, + ]) { + if (constraints != null) { + capturedConstraints.add(constraints); + } + return Future.value().toJS; + }.toJS; + final XFile _ = await camera.takePicture(); - verify( - () => videoTracks.first.applyConstraints({ - 'advanced': [ - { - 'torch': true, - } - ] - }), - ).called(1); - - verify( - () => videoTracks.first.applyConstraints({ - 'advanced': [ - { - 'torch': false, - } - ] - }), - ).called(1); + expect(capturedConstraints.length, 2); + expect(capturedConstraints[0].torch.dartify(), true); + expect(capturedConstraints[1].torch.dartify(), false); }); testWidgets('if the flash mode is always', (WidgetTester tester) async { @@ -372,27 +381,22 @@ void main() { await camera.play(); + final List capturedConstraints = + []; + mockVideoTrack.applyConstraints = ([ + MediaTrackConstraints? constraints, + ]) { + if (constraints != null) { + capturedConstraints.add(constraints); + } + return Future.value().toJS; + }.toJS; + final XFile _ = await camera.takePicture(); - verify( - () => videoTracks.first.applyConstraints({ - 'advanced': [ - { - 'torch': true, - } - ] - }), - ).called(1); - - verify( - () => videoTracks.first.applyConstraints({ - 'advanced': [ - { - 'torch': false, - } - ] - }), - ).called(1); + expect(capturedConstraints.length, 2); + expect(capturedConstraints[0].torch.dartify(), true); + expect(capturedConstraints[1].torch.dartify(), false); }); }); }); @@ -404,7 +408,7 @@ void main() { (WidgetTester tester) async { const Size videoSize = Size(1280, 720); - final VideoElement videoElement = + final HTMLVideoElement videoElement = getVideoElementWithBlankStream(videoSize); mediaStream = videoElement.captureStream(); @@ -425,7 +429,7 @@ void main() { 'returns Size.zero ' 'if the camera is missing video tracks', (WidgetTester tester) async { // Create a video stream with no video tracks. - final VideoElement videoElement = VideoElement(); + final HTMLVideoElement videoElement = HTMLVideoElement(); mediaStream = videoElement.captureStream(); final Camera camera = Camera( @@ -443,32 +447,38 @@ void main() { }); group('setFlashMode', () { + late MockMediaStreamTrack mockVideoTrack; late List videoTracks; late MediaStream videoStream; setUp(() { + mockVideoTrack = MockMediaStreamTrack(); videoTracks = [ - MockMediaStreamTrack(), - MockMediaStreamTrack() + createJSInteropWrapper(mockVideoTrack) as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) as MediaStreamTrack, ]; - videoStream = FakeMediaStream(videoTracks); - - when(() => videoTracks.first.applyConstraints(any())) - .thenAnswer((_) async => {}); - - when(videoTracks.first.getCapabilities) - .thenReturn({}); + videoStream = + createJSInteropWrapper(FakeMediaStream(videoTracks)) as MediaStream; + + mockVideoTrack.applyConstraints = ([ + MediaTrackConstraints? constraints, + ]) { + return Future.value().toJS; + }.toJS; + + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(); + }.toJS; }); testWidgets('sets the camera flash mode', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'torch': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': true, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [true.toJS].toJS); + }.toJS; final Camera camera = Camera( textureId: textureId, @@ -490,14 +500,13 @@ void main() { testWidgets( 'enables the torch mode ' 'if the flash mode is torch', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'torch': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': true, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [true.toJS].toJS); + }.toJS; final Camera camera = Camera( textureId: textureId, @@ -506,30 +515,33 @@ void main() { ..window = window ..stream = videoStream; + final List capturedConstraints = + []; + mockVideoTrack.applyConstraints = ([ + MediaTrackConstraints? constraints, + ]) { + if (constraints != null) { + capturedConstraints.add(constraints); + } + return Future.value().toJS; + }.toJS; + camera.setFlashMode(FlashMode.torch); - verify( - () => videoTracks.first.applyConstraints({ - 'advanced': [ - { - 'torch': true, - } - ] - }), - ).called(1); + expect(capturedConstraints.length, 1); + expect(capturedConstraints[0].torch.dartify(), true); }); testWidgets( 'disables the torch mode ' 'if the flash mode is not torch', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'torch': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': true, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [true.toJS].toJS); + }.toJS; final Camera camera = Camera( textureId: textureId, @@ -538,62 +550,35 @@ void main() { ..window = window ..stream = videoStream; + final List capturedConstraints = + []; + mockVideoTrack.applyConstraints = ([ + MediaTrackConstraints? constraints, + ]) { + if (constraints != null) { + capturedConstraints.add(constraints); + } + return Future.value().toJS; + }.toJS; + camera.setFlashMode(FlashMode.auto); - verify( - () => videoTracks.first.applyConstraints({ - 'advanced': [ - { - 'torch': false, - } - ] - }), - ).called(1); + expect(capturedConstraints.length, 1); + expect(capturedConstraints[0].torch.dartify(), false); }); group('throws a CameraWebException', () { - testWidgets( - 'with torchModeNotSupported error ' - 'when there are no media devices', (WidgetTester tester) async { - when(() => navigator.mediaDevices).thenReturn(null); - - final Camera camera = Camera( - textureId: textureId, - cameraService: cameraService, - ) - ..window = window - ..stream = videoStream; - - expect( - () => camera.setFlashMode(FlashMode.always), - throwsA( - isA() - .having( - (CameraWebException e) => e.cameraId, - 'cameraId', - textureId, - ) - .having( - (CameraWebException e) => e.code, - 'code', - CameraErrorCode.torchModeNotSupported, - ), - ), - ); - }); - testWidgets( 'with torchModeNotSupported error ' 'when the torch mode is not supported ' 'in the browser', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'torch': false, - }); + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: false); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': true, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [true.toJS].toJS); + }.toJS; final Camera camera = Camera( textureId: textureId, @@ -624,14 +609,13 @@ void main() { 'with torchModeNotSupported error ' 'when the torch mode is not supported ' 'by the camera', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'torch': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': false, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [false.toJS].toJS); + }.toJS; final Camera camera = Camera( textureId: textureId, @@ -662,14 +646,13 @@ void main() { 'with notStarted error ' 'when the camera stream has not been initialized', (WidgetTester tester) async { - when(mediaDevices.getSupportedConstraints) - .thenReturn({ - 'torch': true, - }); + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; - when(videoTracks.first.getCapabilities).thenReturn({ - 'torch': true, - }); + mockVideoTrack.getCapabilities = () { + return MediaTrackCapabilities(torch: [true.toJS].toJS); + }.toJS; final Camera camera = Camera( textureId: textureId, @@ -710,7 +693,8 @@ void main() { final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( minimum: 50.0, maximum: 100.0, - videoTrack: MockMediaStreamTrack(), + videoTrack: createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, ); when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) @@ -741,7 +725,8 @@ void main() { final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( minimum: 50.0, maximum: 100.0, - videoTrack: MockMediaStreamTrack(), + videoTrack: createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, ); when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) @@ -769,7 +754,9 @@ void main() { cameraService: cameraService, ); - final MockMediaStreamTrack videoTrack = MockMediaStreamTrack(); + final MockMediaStreamTrack mockVideoTrack = MockMediaStreamTrack(); + final MediaStreamTrack videoTrack = + createJSInteropWrapper(mockVideoTrack) as MediaStreamTrack; final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( minimum: 50.0, @@ -777,8 +764,16 @@ void main() { videoTrack: videoTrack, ); - when(() => videoTrack.applyConstraints(any())) - .thenAnswer((_) async {}); + final List capturedConstraints = + []; + mockVideoTrack.applyConstraints = ([ + MediaTrackConstraints? constraints, + ]) { + if (constraints != null) { + capturedConstraints.add(constraints); + } + return Future.value().toJS; + }.toJS; when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .thenReturn(zoomLevelCapability); @@ -787,15 +782,8 @@ void main() { camera.setZoomLevel(zoom); - verify( - () => videoTrack.applyConstraints({ - 'advanced': [ - { - ZoomLevelCapability.constraintName: zoom, - } - ] - }), - ).called(1); + expect(capturedConstraints.length, 1); + expect(capturedConstraints[0].zoom.dartify(), zoom); }); group('throws a CameraWebException', () { @@ -811,7 +799,8 @@ void main() { final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( minimum: 50.0, maximum: 100.0, - videoTrack: MockMediaStreamTrack(), + videoTrack: createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, ); when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) @@ -846,7 +835,8 @@ void main() { final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( minimum: 50.0, maximum: 100.0, - videoTrack: MockMediaStreamTrack(), + videoTrack: createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, ); when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) @@ -878,7 +868,9 @@ void main() { 'returns a lens direction ' 'based on the first video track settings', (WidgetTester tester) async { - final MockVideoElement videoElement = MockVideoElement(); + final MockVideoElement mockVideoElement = MockVideoElement(); + final HTMLVideoElement videoElement = + createJSInteropWrapper(mockVideoElement) as HTMLVideoElement; final Camera camera = Camera( textureId: textureId, @@ -887,15 +879,19 @@ void main() { final MockMediaStreamTrack firstVideoTrack = MockMediaStreamTrack(); - when(() => videoElement.srcObject).thenReturn( - FakeMediaStream([ - firstVideoTrack, - MockMediaStreamTrack(), - ]), - ); + mockVideoElement.srcObject = createJSInteropWrapper( + FakeMediaStream( + [ + createJSInteropWrapper(firstVideoTrack) as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + ], + ), + ) as MediaStream; - when(firstVideoTrack.getSettings) - .thenReturn({'facingMode': 'environment'}); + firstVideoTrack.getSettings = () { + return MediaTrackSettings(facingMode: 'environment'); + }.toJS; when(() => cameraService.mapFacingModeToLensDirection('environment')) .thenReturn(CameraLensDirection.external); @@ -910,7 +906,9 @@ void main() { 'returns null ' 'if the first video track is missing the facing mode', (WidgetTester tester) async { - final MockVideoElement videoElement = MockVideoElement(); + final MockVideoElement mockVideoElement = MockVideoElement(); + final HTMLVideoElement videoElement = + createJSInteropWrapper(mockVideoElement) as HTMLVideoElement; final Camera camera = Camera( textureId: textureId, @@ -919,14 +917,19 @@ void main() { final MockMediaStreamTrack firstVideoTrack = MockMediaStreamTrack(); - when(() => videoElement.srcObject).thenReturn( - FakeMediaStream([ - firstVideoTrack, - MockMediaStreamTrack(), - ]), - ); + videoElement.srcObject = createJSInteropWrapper( + FakeMediaStream( + [ + createJSInteropWrapper(firstVideoTrack) as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + ], + ), + ) as MediaStream; - when(firstVideoTrack.getSettings).thenReturn({}); + firstVideoTrack.getSettings = () { + return MediaTrackSettings(); + }.toJS; expect( camera.getLensDirection(), @@ -938,7 +941,7 @@ void main() { 'returns null ' 'if the camera is missing video tracks', (WidgetTester tester) async { // Create a video stream with no video tracks. - final VideoElement videoElement = VideoElement(); + final HTMLVideoElement videoElement = HTMLVideoElement(); mediaStream = videoElement.captureStream(); final Camera camera = Camera( @@ -974,15 +977,15 @@ void main() { group('video recording', () { const String supportedVideoType = 'video/webm'; + late MockMediaRecorder mockMediaRecorder; late MediaRecorder mediaRecorder; bool isVideoTypeSupported(String type) => type == supportedVideoType; setUp(() { - mediaRecorder = MockMediaRecorder(); - - when(() => mediaRecorder.onError) - .thenAnswer((_) => const Stream.empty()); + mockMediaRecorder = MockMediaRecorder(); + mediaRecorder = + createJSInteropWrapper(mockMediaRecorder) as MediaRecorder; }); group('startVideoRecording', () { @@ -1027,11 +1030,21 @@ void main() { await camera.initialize(); await camera.play(); + final List capturedEvents = []; + mockMediaRecorder.addEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + capturedEvents.add(type); + }.toJS; + await camera.startVideoRecording(); - verify( - () => mediaRecorder.addEventListener('dataavailable', any()), - ).called(1); + expect( + capturedEvents.where((String e) => e == 'dataavailable').length, + 1, + ); }); testWidgets('listens to the media recorder stop events', @@ -1046,11 +1059,21 @@ void main() { await camera.initialize(); await camera.play(); + final List capturedEvents = []; + mockMediaRecorder.addEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + capturedEvents.add(type); + }.toJS; + await camera.startVideoRecording(); - verify( - () => mediaRecorder.addEventListener('stop', any()), - ).called(1); + expect( + capturedEvents.where((String e) => e == 'stop').length, + 1, + ); }); testWidgets('starts a video recording', (WidgetTester tester) async { @@ -1064,9 +1087,14 @@ void main() { await camera.initialize(); await camera.play(); + final List capturedStarts = []; + mockMediaRecorder.start = ([int? timeslice]) { + capturedStarts.add(timeslice); + }.toJS; + await camera.startVideoRecording(); - verify(mediaRecorder.start).called(1); + expect(capturedStarts.length, 1); }); group('throws a CameraWebException', () { @@ -1108,9 +1136,14 @@ void main() { cameraService: cameraService, )..mediaRecorder = mediaRecorder; + int pauses = 0; + mockMediaRecorder.pause = () { + pauses++; + }.toJS; + await camera.pauseVideoRecording(); - verify(mediaRecorder.pause).called(1); + expect(pauses, 1); }); testWidgets( @@ -1149,9 +1182,14 @@ void main() { cameraService: cameraService, )..mediaRecorder = mediaRecorder; + int resumes = 0; + mockMediaRecorder.resume = () { + resumes++; + }.toJS; + await camera.resumeVideoRecording(); - verify(mediaRecorder.resume).called(1); + expect(resumes, 1); }); testWidgets( @@ -1198,50 +1236,62 @@ void main() { await camera.initialize(); await camera.play(); - late void Function(Event) videoDataAvailableListener; - late void Function(Event) videoRecordingStoppedListener; - - when( - () => mediaRecorder.addEventListener('dataavailable', any()), - ).thenAnswer((Invocation invocation) { - videoDataAvailableListener = - invocation.positionalArguments[1] as void Function(Event); - }); - - when( - () => mediaRecorder.addEventListener('stop', any()), - ).thenAnswer((Invocation invocation) { - videoRecordingStoppedListener = - invocation.positionalArguments[1] as void Function(Event); - }); + late EventListener videoDataAvailableListener; + late EventListener videoRecordingStoppedListener; + + mockMediaRecorder.addEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + if (type == 'dataavailable') { + videoDataAvailableListener = callback!; + } else if (type == 'stop') { + videoRecordingStoppedListener = callback!; + } + }.toJS; Blob? finalVideo; List? videoParts; camera.blobBuilder = (List blobs, String videoType) { videoParts = [...blobs]; - finalVideo = Blob(blobs, videoType); + finalVideo = Blob(blobs.toJS, BlobPropertyBag(type: videoType)); return finalVideo!; }; await camera.startVideoRecording(); + + int stops = 0; + mockMediaRecorder.stop = () { + stops++; + }.toJS; + final Future videoFileFuture = camera.stopVideoRecording(); - final Blob capturedVideoPartOne = Blob([]); - final Blob capturedVideoPartTwo = Blob([]); + final Blob capturedVideoPartOne = Blob([].toJS); + final Blob capturedVideoPartTwo = Blob([].toJS); final List capturedVideoParts = [ capturedVideoPartOne, capturedVideoPartTwo, ]; - videoDataAvailableListener(FakeBlobEvent(capturedVideoPartOne)); - videoDataAvailableListener(FakeBlobEvent(capturedVideoPartTwo)); + videoDataAvailableListener.callAsFunction( + null, + createJSInteropWrapper(FakeBlobEvent(capturedVideoPartOne)) + as BlobEvent, + ); + videoDataAvailableListener.callAsFunction( + null, + createJSInteropWrapper(FakeBlobEvent(capturedVideoPartTwo)) + as BlobEvent, + ); - videoRecordingStoppedListener(Event('stop')); + videoRecordingStoppedListener.callAsFunction(null, Event('stop')); final XFile videoFile = await videoFileFuture; - verify(mediaRecorder.stop).called(1); + expect(stops, 1); expect( videoFile, @@ -1294,15 +1344,18 @@ void main() { }); group('on video recording stopped', () { - late void Function(Event) videoRecordingStoppedListener; + late EventListener videoRecordingStoppedListener; setUp(() { - when( - () => mediaRecorder.addEventListener('stop', any()), - ).thenAnswer((Invocation invocation) { - videoRecordingStoppedListener = - invocation.positionalArguments[1] as void Function(Event); - }); + mockMediaRecorder.addEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + if (type == 'stop') { + videoRecordingStoppedListener = callback!; + } + }.toJS; }); testWidgets('stops listening to the media recorder data events', @@ -1319,13 +1372,23 @@ void main() { await camera.startVideoRecording(); - videoRecordingStoppedListener(Event('stop')); + final List capturedEvents = []; + mockMediaRecorder.removeEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + capturedEvents.add(type); + }.toJS; + + videoRecordingStoppedListener.callAsFunction(null, Event('stop')); await Future.microtask(() {}); - verify( - () => mediaRecorder.removeEventListener('dataavailable', any()), - ).called(1); + expect( + capturedEvents.where((String e) => e == 'dataavailable').length, + 1, + ); }); testWidgets('stops listening to the media recorder stop events', @@ -1342,28 +1405,41 @@ void main() { await camera.startVideoRecording(); - videoRecordingStoppedListener(Event('stop')); + final List capturedEvents = []; + mockMediaRecorder.removeEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + capturedEvents.add(type); + }.toJS; + + videoRecordingStoppedListener.callAsFunction(null, Event('stop')); await Future.microtask(() {}); - verify( - () => mediaRecorder.removeEventListener('stop', any()), - ).called(1); + expect( + capturedEvents.where((String e) => e == 'stop').length, + 1, + ); }); testWidgets('stops listening to the media recorder errors', (WidgetTester tester) async { final StreamController onErrorStreamController = StreamController(); + final MockEventStreamProvider provider = + MockEventStreamProvider(); final Camera camera = Camera( textureId: 1, cameraService: cameraService, ) ..mediaRecorder = mediaRecorder - ..isVideoTypeSupported = isVideoTypeSupported; + ..isVideoTypeSupported = isVideoTypeSupported + ..mediaRecorderOnErrorProvider = provider; - when(() => mediaRecorder.onError) + when(() => provider.forTarget(mediaRecorder)) .thenAnswer((_) => onErrorStreamController.stream); await camera.initialize(); @@ -1371,7 +1447,7 @@ void main() { await camera.startVideoRecording(); - videoRecordingStoppedListener(Event('stop')); + videoRecordingStoppedListener.callAsFunction(null, Event('stop')); await Future.microtask(() {}); @@ -1452,9 +1528,9 @@ void main() { 'when a video recording is created', (WidgetTester tester) async { const String supportedVideoType = 'video/webm'; - final MockMediaRecorder mediaRecorder = MockMediaRecorder(); - when(() => mediaRecorder.onError) - .thenAnswer((_) => const Stream.empty()); + final MockMediaRecorder mockMediaRecorder = MockMediaRecorder(); + final MediaRecorder mediaRecorder = + createJSInteropWrapper(mockMediaRecorder) as MediaRecorder; final Camera camera = Camera( textureId: 1, @@ -1466,22 +1542,20 @@ void main() { await camera.initialize(); await camera.play(); - late void Function(Event) videoDataAvailableListener; - late void Function(Event) videoRecordingStoppedListener; - - when( - () => mediaRecorder.addEventListener('dataavailable', any()), - ).thenAnswer((Invocation invocation) { - videoDataAvailableListener = - invocation.positionalArguments[1] as void Function(Event); - }); - - when( - () => mediaRecorder.addEventListener('stop', any()), - ).thenAnswer((Invocation invocation) { - videoRecordingStoppedListener = - invocation.positionalArguments[1] as void Function(Event); - }); + late EventListener videoDataAvailableListener; + late EventListener videoRecordingStoppedListener; + + mockMediaRecorder.addEventListener = ( + String type, + EventListener? callback, [ + JSAny? options, + ]) { + if (type == 'dataavailable') { + videoDataAvailableListener = callback!; + } else if (type == 'stop') { + videoRecordingStoppedListener = callback!; + } + }.toJS; final StreamQueue streamQueue = StreamQueue(camera.onVideoRecordedEvent); @@ -1490,12 +1564,15 @@ void main() { Blob? finalVideo; camera.blobBuilder = (List blobs, String videoType) { - finalVideo = Blob(blobs, videoType); + finalVideo = Blob(blobs.toJS, BlobPropertyBag(type: videoType)); return finalVideo!; }; - videoDataAvailableListener(FakeBlobEvent(Blob([]))); - videoRecordingStoppedListener(Event('stop')); + videoDataAvailableListener.callAsFunction( + null, + createJSInteropWrapper(FakeBlobEvent(Blob([].toJS))), + ); + videoRecordingStoppedListener.callAsFunction(null, Event('stop')); expect( await streamQueue.next, @@ -1543,7 +1620,7 @@ void main() { await camera.initialize(); final List videoTracks = - camera.stream!.getVideoTracks(); + camera.stream!.getVideoTracks().toDart; final MediaStreamTrack defaultVideoTrack = videoTracks.first; defaultVideoTrack.dispatchEvent(Event('ended')); @@ -1570,7 +1647,7 @@ void main() { await camera.initialize(); final List videoTracks = - camera.stream!.getVideoTracks(); + camera.stream!.getVideoTracks().toDart; final MediaStreamTrack defaultVideoTrack = videoTracks.first; camera.stop(); @@ -1589,16 +1666,22 @@ void main() { 'emits an ErrorEvent ' 'when the media recorder fails ' 'when recording a video', (WidgetTester tester) async { - final MockMediaRecorder mediaRecorder = MockMediaRecorder(); + final MockMediaRecorder mockMediaRecorder = MockMediaRecorder(); + final MediaRecorder mediaRecorder = + createJSInteropWrapper(mockMediaRecorder) as MediaRecorder; final StreamController errorController = StreamController(); + final MockEventStreamProvider provider = + MockEventStreamProvider(); final Camera camera = Camera( textureId: textureId, cameraService: cameraService, - )..mediaRecorder = mediaRecorder; + ) + ..mediaRecorder = mediaRecorder + ..mediaRecorderOnErrorProvider = provider; - when(() => mediaRecorder.onError) + when(() => provider.forTarget(mediaRecorder)) .thenAnswer((_) => errorController.stream); final StreamQueue streamQueue = diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index d4c7d5825337..ac31979eb176 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -2,8 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: only_throw_errors + import 'dart:async'; -import 'dart:html'; +import 'dart:js_interop'; +import 'dart:math'; import 'package:async/async.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; @@ -17,6 +20,7 @@ import 'package:flutter/widgets.dart' as widgets; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:web/web.dart' hide MediaDeviceKind, OrientationType; import 'helpers/helpers.dart'; @@ -26,41 +30,67 @@ void main() { group('CameraPlugin', () { const int cameraId = 1; + late MockWindow mockWindow; + late MockNavigator mockNavigator; + late MockMediaDevices mockMediaDevices; + late Window window; late Navigator navigator; late MediaDevices mediaDevices; - late VideoElement videoElement; + + late HTMLVideoElement videoElement; + + late MockScreen mockScreen; + late MockScreenOrientation mockScreenOrientation; + late Screen screen; late ScreenOrientation screenOrientation; + + late MockDocument mockDocument; + late MockElement mockDocumentElement; + late Document document; late Element documentElement; late CameraService cameraService; setUp(() async { - window = MockWindow(); - navigator = MockNavigator(); - mediaDevices = MockMediaDevices(); + mockWindow = MockWindow(); + mockNavigator = MockNavigator(); + mockMediaDevices = MockMediaDevices(); + + window = createJSInteropWrapper(mockWindow) as Window; + navigator = createJSInteropWrapper(mockNavigator) as Navigator; + mediaDevices = createJSInteropWrapper(mockMediaDevices) as MediaDevices; + + mockWindow.navigator = navigator; + mockNavigator.mediaDevices = mediaDevices; videoElement = getVideoElementWithBlankStream(const Size(10, 10)); - when(() => window.navigator).thenReturn(navigator); - when(() => navigator.mediaDevices).thenReturn(mediaDevices); + mockScreen = MockScreen(); + mockScreenOrientation = MockScreenOrientation(); - screen = MockScreen(); - screenOrientation = MockScreenOrientation(); + screen = createJSInteropWrapper(mockScreen) as Screen; + screenOrientation = + createJSInteropWrapper(mockScreenOrientation) as ScreenOrientation; - when(() => screen.orientation).thenReturn(screenOrientation); - when(() => window.screen).thenReturn(screen); + mockScreen.orientation = screenOrientation; + mockWindow.screen = screen; - document = MockDocument(); - documentElement = MockElement(); + mockDocument = MockDocument(); + mockDocumentElement = MockElement(); - when(() => document.documentElement).thenReturn(documentElement); - when(() => window.document).thenReturn(document); + document = createJSInteropWrapper(mockDocument) as Document; + documentElement = createJSInteropWrapper(mockDocumentElement) as Element; + + mockDocument.documentElement = documentElement; + mockWindow.document = document; cameraService = MockCameraService(); + registerFallbackValue(createJSInteropWrapper(MockWindow())); + when( () => cameraService.getMediaStreamForOptions( any(), @@ -94,9 +124,11 @@ void main() { ), ).thenReturn(null); - when(mediaDevices.enumerateDevices).thenAnswer( - (_) async => [], - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [].toJS, + ).toJS; + }.toJS; }); testWidgets('requests video permissions', (WidgetTester tester) async { @@ -111,18 +143,22 @@ void main() { testWidgets( 'releases the camera stream ' 'used to request video permissions', (WidgetTester tester) async { - final MockMediaStreamTrack videoTrack = MockMediaStreamTrack(); + final MockMediaStreamTrack mockVideoTrack = MockMediaStreamTrack(); + final MediaStreamTrack videoTrack = + createJSInteropWrapper(mockVideoTrack) as MediaStreamTrack; bool videoTrackStopped = false; - when(videoTrack.stop).thenAnswer((Invocation _) { + mockVideoTrack.stop = () { videoTrackStopped = true; - }); + }.toJS; when( () => cameraService.getMediaStreamForOptions(const CameraOptions()), ).thenAnswer( (_) => Future.value( - FakeMediaStream([videoTrack]), + createJSInteropWrapper( + FakeMediaStream([videoTrack]), + ) as MediaStream, ), ); @@ -135,15 +171,19 @@ void main() { testWidgets( 'gets a video stream ' 'for a video input device', (WidgetTester tester) async { - final FakeMediaDeviceInfo videoDevice = FakeMediaDeviceInfo( - '1', - 'Camera 1', - MediaDeviceKind.videoInput, - ); + final MediaDeviceInfo videoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; - when(mediaDevices.enumerateDevices).thenAnswer( - (_) => Future>.value([videoDevice]), - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [videoDevice].toJS) + .toJS; + }.toJS; final List _ = await CameraPlatform.instance.availableCameras(); @@ -163,15 +203,19 @@ void main() { 'does not get a video stream ' 'for the video input device ' 'with an empty device id', (WidgetTester tester) async { - final FakeMediaDeviceInfo videoDevice = FakeMediaDeviceInfo( - '', - 'Camera 1', - MediaDeviceKind.videoInput, - ); + final MediaDeviceInfo videoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '', + 'Camera 1', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; - when(mediaDevices.enumerateDevices).thenAnswer( - (_) => Future>.value([videoDevice]), - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [videoDevice].toJS) + .toJS; + }.toJS; final List _ = await CameraPlatform.instance.availableCameras(); @@ -191,14 +235,24 @@ void main() { 'gets the facing mode ' 'from the first available video track ' 'of the video input device', (WidgetTester tester) async { - final FakeMediaDeviceInfo videoDevice = FakeMediaDeviceInfo( - '1', - 'Camera 1', - MediaDeviceKind.videoInput, - ); - - final FakeMediaStream videoStream = FakeMediaStream( - [MockMediaStreamTrack(), MockMediaStreamTrack()]); + final MediaDeviceInfo videoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; + + final MediaStream videoStream = createJSInteropWrapper( + FakeMediaStream( + [ + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + ], + ), + ) as MediaStream; when( () => cameraService.getMediaStreamForOptions( @@ -208,16 +262,18 @@ void main() { ), ).thenAnswer((Invocation _) => Future.value(videoStream)); - when(mediaDevices.enumerateDevices).thenAnswer( - (_) => Future>.value([videoDevice]), - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [videoDevice].toJS) + .toJS; + }.toJS; final List _ = await CameraPlatform.instance.availableCameras(); verify( () => cameraService.getFacingModeForVideoTrack( - videoStream.getVideoTracks().first, + videoStream.getVideoTracks().toDart.first, ), ).called(1); }); @@ -226,44 +282,68 @@ void main() { 'returns appropriate camera descriptions ' 'for multiple video devices ' 'based on video streams', (WidgetTester tester) async { - final FakeMediaDeviceInfo firstVideoDevice = FakeMediaDeviceInfo( - '1', - 'Camera 1', - MediaDeviceKind.videoInput, - ); + final MediaDeviceInfo firstVideoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; - final FakeMediaDeviceInfo secondVideoDevice = FakeMediaDeviceInfo( - '4', - 'Camera 4', - MediaDeviceKind.videoInput, - ); + final MediaDeviceInfo secondVideoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '4', + 'Camera 4', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; // Create a video stream for the first video device. - final FakeMediaStream firstVideoStream = FakeMediaStream( - [MockMediaStreamTrack(), MockMediaStreamTrack()]); + final MediaStream firstVideoStream = createJSInteropWrapper( + FakeMediaStream( + [ + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + ], + ), + ) as MediaStream; // Create a video stream for the second video device. - final FakeMediaStream secondVideoStream = - FakeMediaStream([MockMediaStreamTrack()]); + final MediaStream secondVideoStream = createJSInteropWrapper( + FakeMediaStream( + [ + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + ], + ), + ) as MediaStream; // Mock media devices to return two video input devices // and two audio devices. - when(mediaDevices.enumerateDevices).thenAnswer( - (_) => Future>.value([ - firstVideoDevice, - FakeMediaDeviceInfo( - '2', - 'Audio Input 2', - MediaDeviceKind.audioInput, - ), - FakeMediaDeviceInfo( - '3', - 'Audio Output 3', - MediaDeviceKind.audioOutput, - ), - secondVideoDevice, - ]), - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [ + firstVideoDevice, + createJSInteropWrapper( + FakeMediaDeviceInfo( + '2', + 'Audio Input 2', + MediaDeviceKind.audioInput, + ), + ) as MediaDeviceInfo, + createJSInteropWrapper( + FakeMediaDeviceInfo( + '3', + 'Audio Output 3', + MediaDeviceKind.audioOutput, + ), + ) as MediaDeviceInfo, + secondVideoDevice, + ].toJS, + ).toJS; + }.toJS; // Mock camera service to return the first video stream // for the first video device. @@ -291,7 +371,7 @@ void main() { // for the first video stream. when( () => cameraService.getFacingModeForVideoTrack( - firstVideoStream.getVideoTracks().first, + firstVideoStream.getVideoTracks().toDart.first, ), ).thenReturn('user'); @@ -302,7 +382,7 @@ void main() { // for the second video stream. when( () => cameraService.getFacingModeForVideoTrack( - secondVideoStream.getVideoTracks().first, + secondVideoStream.getVideoTracks().toDart.first, ), ).thenReturn('environment'); @@ -317,12 +397,12 @@ void main() { cameras, equals([ CameraDescription( - name: firstVideoDevice.label!, + name: firstVideoDevice.label, lensDirection: CameraLensDirection.front, sensorOrientation: 0, ), CameraDescription( - name: secondVideoDevice.label!, + name: secondVideoDevice.label, lensDirection: CameraLensDirection.back, sensorOrientation: 0, ) @@ -333,18 +413,30 @@ void main() { testWidgets( 'sets camera metadata ' 'for the camera description', (WidgetTester tester) async { - final FakeMediaDeviceInfo videoDevice = FakeMediaDeviceInfo( - '1', - 'Camera 1', - MediaDeviceKind.videoInput, - ); - - final FakeMediaStream videoStream = FakeMediaStream( - [MockMediaStreamTrack(), MockMediaStreamTrack()]); + final MediaDeviceInfo videoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; + + final MediaStream videoStream = createJSInteropWrapper( + FakeMediaStream( + [ + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + createJSInteropWrapper(MockMediaStreamTrack()) + as MediaStreamTrack, + ], + ), + ) as MediaStream; - when(mediaDevices.enumerateDevices).thenAnswer( - (_) => Future>.value([videoDevice]), - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [videoDevice].toJS) + .toJS; + }.toJS; when( () => cameraService.getMediaStreamForOptions( @@ -356,7 +448,7 @@ void main() { when( () => cameraService.getFacingModeForVideoTrack( - videoStream.getVideoTracks().first, + videoStream.getVideoTracks().toDart.first, ), ).thenReturn('left'); @@ -370,7 +462,7 @@ void main() { (CameraPlatform.instance as CameraPlugin).camerasMetadata, equals({ camera: CameraMetadata( - deviceId: videoDevice.deviceId!, + deviceId: videoDevice.deviceId, facingMode: 'left', ) }), @@ -380,18 +472,32 @@ void main() { testWidgets( 'releases the video stream ' 'of a video input device', (WidgetTester tester) async { - final FakeMediaDeviceInfo videoDevice = FakeMediaDeviceInfo( - '1', - 'Camera 1', - MediaDeviceKind.videoInput, - ); + final MediaDeviceInfo videoDevice = createJSInteropWrapper( + FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ), + ) as MediaDeviceInfo; + + final List tracks = []; + final List stops = List.generate(2, (_) => false); + for (int i = 0; i < stops.length; i++) { + final MockMediaStreamTrack track = MockMediaStreamTrack(); + track.stop = () { + stops[i] = true; + }.toJS; + tracks.add(createJSInteropWrapper(track) as MediaStreamTrack); + } - final FakeMediaStream videoStream = FakeMediaStream( - [MockMediaStreamTrack(), MockMediaStreamTrack()]); + final MediaStream videoStream = + createJSInteropWrapper(FakeMediaStream(tracks)) as MediaStream; - when(mediaDevices.enumerateDevices).thenAnswer( - (_) => Future>.value([videoDevice]), - ); + mockMediaDevices.enumerateDevices = () { + return Future>.value( + [videoDevice].toJS) + .toJS; + }.toJS; when( () => cameraService.getMediaStreamForOptions( @@ -404,36 +510,21 @@ void main() { final List _ = await CameraPlatform.instance.availableCameras(); - for (final MediaStreamTrack videoTrack - in videoStream.getVideoTracks()) { - verify(videoTrack.stop).called(1); - } + expect(stops.every((bool e) => e), isTrue); }); group('throws CameraException', () { - testWidgets( - 'with notSupported error ' - 'when there are no media devices', (WidgetTester tester) async { - when(() => navigator.mediaDevices).thenReturn(null); - - expect( - () => CameraPlatform.instance.availableCameras(), - throwsA( - isA().having( - (CameraException e) => e.code, - 'code', - CameraErrorCode.notSupported.toString(), - ), - ), - ); - }); - testWidgets('when MediaDevices.enumerateDevices throws DomException', (WidgetTester tester) async { - final FakeDomException exception = - FakeDomException(DomException.UNKNOWN); + final DOMException exception = DOMException('UnknownError'); - when(mediaDevices.enumerateDevices).thenThrow(exception); + mockMediaDevices.enumerateDevices = () { + throw exception; + // ignore: dead_code + return Future>.value( + [].toJS, + ).toJS; + }.toJS; expect( () => CameraPlatform.instance.availableCameras(), @@ -700,14 +791,17 @@ void main() { group('initializeCamera', () { late Camera camera; - late VideoElement videoElement; + late MockVideoElement mockVideoElement; + late HTMLVideoElement videoElement; late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; setUp(() { camera = MockCamera(); - videoElement = MockVideoElement(); + mockVideoElement = MockVideoElement(); + videoElement = + createJSInteropWrapper(mockVideoElement) as HTMLVideoElement; errorStreamController = StreamController(); abortStreamController = StreamController(); @@ -719,10 +813,23 @@ void main() { when(camera.play).thenAnswer((Invocation _) => Future.value()); when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError).thenAnswer((Invocation _) => - FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort).thenAnswer((Invocation _) => - FakeElementStream(abortStreamController.stream)); + + final MockEventStreamProvider errorProvider = + MockEventStreamProvider(); + final MockEventStreamProvider abortProvider = + MockEventStreamProvider(); + + (CameraPlatform.instance as CameraPlugin).videoElementOnErrorProvider = + errorProvider; + (CameraPlatform.instance as CameraPlugin).videoElementOnAbortProvider = + abortProvider; + + when(() => errorProvider.forElement(videoElement)).thenAnswer( + (_) => FakeElementStream(errorStreamController.stream), + ); + when(() => abortProvider.forElement(videoElement)).thenAnswer( + (_) => FakeElementStream(abortStreamController.stream), + ); when(() => camera.onEnded) .thenAnswer((Invocation _) => endedStreamController.stream); @@ -808,8 +915,7 @@ void main() { testWidgets('when camera throws DomException', (WidgetTester tester) async { - final FakeDomException exception = - FakeDomException(DomException.NOT_ALLOWED); + final DOMException exception = DOMException('NotAllowedError'); when(camera.initialize) .thenAnswer((Invocation _) => Future.value()); @@ -834,6 +940,7 @@ void main() { group('lockCaptureOrientation', () { setUp(() { + registerFallbackValue(DeviceOrientation.portraitUp); when( () => cameraService.mapDeviceOrientationToOrientationType(any()), ).thenReturn(OrientationType.portraitPrimary); @@ -842,12 +949,18 @@ void main() { testWidgets( 'requests full-screen mode ' 'on documentElement', (WidgetTester tester) async { + int fullscreenCalls = 0; + mockDocumentElement.requestFullscreen = ([FullscreenOptions? options]) { + fullscreenCalls++; + return Future.value().toJS; + }.toJS; + await CameraPlatform.instance.lockCaptureOrientation( cameraId, DeviceOrientation.portraitUp, ); - verify(documentElement.requestFullscreen).called(1); + expect(fullscreenCalls, 1); }); testWidgets( @@ -859,6 +972,12 @@ void main() { ), ).thenReturn(OrientationType.landscapeSecondary); + final List capturedTypes = []; + mockScreenOrientation.lock = (OrientationLockType orientation) { + capturedTypes.add(orientation); + return Future.value().toJS; + }.toJS; + await CameraPlatform.instance.lockCaptureOrientation( cameraId, DeviceOrientation.landscapeRight, @@ -870,60 +989,16 @@ void main() { ), ).called(1); - verify( - () => screenOrientation.lock( - OrientationType.landscapeSecondary, - ), - ).called(1); + expect(capturedTypes.length, 1); + expect(capturedTypes[0], OrientationType.landscapeSecondary); }); group('throws PlatformException', () { - testWidgets( - 'with orientationNotSupported error ' - 'when screen is not supported', (WidgetTester tester) async { - when(() => window.screen).thenReturn(null); - - expect( - () => CameraPlatform.instance.lockCaptureOrientation( - cameraId, - DeviceOrientation.portraitUp, - ), - throwsA( - isA().having( - (PlatformException e) => e.code, - 'code', - CameraErrorCode.orientationNotSupported.toString(), - ), - ), - ); - }); - - testWidgets( - 'with orientationNotSupported error ' - 'when screen orientation is not supported', - (WidgetTester tester) async { - when(() => screen.orientation).thenReturn(null); - - expect( - () => CameraPlatform.instance.lockCaptureOrientation( - cameraId, - DeviceOrientation.portraitUp, - ), - throwsA( - isA().having( - (PlatformException e) => e.code, - 'code', - CameraErrorCode.orientationNotSupported.toString(), - ), - ), - ); - }); - testWidgets( 'with orientationNotSupported error ' 'when documentElement is not available', (WidgetTester tester) async { - when(() => document.documentElement).thenReturn(null); + mockDocument.documentElement = null; expect( () => CameraPlatform.instance.lockCaptureOrientation( @@ -938,14 +1013,19 @@ void main() { ), ), ); + + mockDocument.documentElement = documentElement; }); testWidgets('when lock throws DomException', (WidgetTester tester) async { - final FakeDomException exception = - FakeDomException(DomException.NOT_ALLOWED); + final DOMException exception = DOMException('NotAllowedError'); - when(() => screenOrientation.lock(any())).thenThrow(exception); + mockScreenOrientation.lock = (OrientationLockType orientation) { + throw exception; + // ignore: dead_code + return Future.value().toJS; + }.toJS; expect( () => CameraPlatform.instance.lockCaptureOrientation( @@ -973,58 +1053,24 @@ void main() { testWidgets('unlocks the capture orientation', (WidgetTester tester) async { + int unlocks = 0; + mockScreenOrientation.unlock = () { + unlocks++; + }.toJS; + await CameraPlatform.instance.unlockCaptureOrientation( cameraId, ); - verify(screenOrientation.unlock).called(1); + expect(unlocks, 1); }); group('throws PlatformException', () { - testWidgets( - 'with orientationNotSupported error ' - 'when screen is not supported', (WidgetTester tester) async { - when(() => window.screen).thenReturn(null); - - expect( - () => CameraPlatform.instance.unlockCaptureOrientation( - cameraId, - ), - throwsA( - isA().having( - (PlatformException e) => e.code, - 'code', - CameraErrorCode.orientationNotSupported.toString(), - ), - ), - ); - }); - - testWidgets( - 'with orientationNotSupported error ' - 'when screen orientation is not supported', - (WidgetTester tester) async { - when(() => screen.orientation).thenReturn(null); - - expect( - () => CameraPlatform.instance.unlockCaptureOrientation( - cameraId, - ), - throwsA( - isA().having( - (PlatformException e) => e.code, - 'code', - CameraErrorCode.orientationNotSupported.toString(), - ), - ), - ); - }); - testWidgets( 'with orientationNotSupported error ' 'when documentElement is not available', (WidgetTester tester) async { - when(() => document.documentElement).thenReturn(null); + mockDocument.documentElement = null; expect( () => CameraPlatform.instance.unlockCaptureOrientation( @@ -1038,14 +1084,19 @@ void main() { ), ), ); + + mockDocument.documentElement = documentElement; }); testWidgets('when unlock throws DomException', (WidgetTester tester) async { - final FakeDomException exception = - FakeDomException(DomException.NOT_ALLOWED); + final DOMException exception = DOMException('NotAllowedError'); - when(screenOrientation.unlock).thenThrow(exception); + mockScreenOrientation.unlock = () { + throw exception; + // ignore: dead_code + return Future.value().toJS; + }.toJS; expect( () => CameraPlatform.instance.unlockCaptureOrientation( @@ -1066,10 +1117,10 @@ void main() { group('takePicture', () { testWidgets('captures a picture', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final MockXFile capturedPicture = MockXFile(); + final XFile capturedPicture = XFile('/bogus/test'); when(camera.takePicture) - .thenAnswer((Invocation _) => Future.value(capturedPicture)); + .thenAnswer((Invocation _) async => capturedPicture); // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; @@ -1101,8 +1152,7 @@ void main() { testWidgets('when takePicture throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(camera.takePicture).thenThrow(exception); @@ -1207,8 +1257,7 @@ void main() { testWidgets('when startVideoRecording throws DomException', (WidgetTester tester) async { - final FakeDomException exception = - FakeDomException(DomException.INVALID_STATE); + final DOMException exception = DOMException('InvalidStateError'); when(camera.startVideoRecording).thenThrow(exception); @@ -1284,10 +1333,10 @@ void main() { group('stopVideoRecording', () { testWidgets('stops a video recording', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final MockXFile capturedVideo = MockXFile(); + final XFile capturedVideo = XFile('/bogus/test'); when(camera.stopVideoRecording) - .thenAnswer((Invocation _) => Future.value(capturedVideo)); + .thenAnswer((Invocation _) async => capturedVideo); // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; @@ -1305,11 +1354,12 @@ void main() { final MockCamera camera = MockCamera(); final StreamController videoRecordingErrorController = StreamController(); + final XFile capturedVideo = XFile('/bogus/test'); when(camera.startVideoRecording).thenAnswer((Invocation _) async {}); when(camera.stopVideoRecording) - .thenAnswer((Invocation _) => Future.value(MockXFile())); + .thenAnswer((Invocation _) async => capturedVideo); when(() => camera.onVideoRecordingError) .thenAnswer((Invocation _) => videoRecordingErrorController.stream); @@ -1346,8 +1396,7 @@ void main() { testWidgets('when stopVideoRecording throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.INVALID_STATE); + final DOMException exception = DOMException('InvalidStateError'); when(camera.stopVideoRecording).thenThrow(exception); @@ -1427,8 +1476,7 @@ void main() { testWidgets('when pauseVideoRecording throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.INVALID_STATE); + final DOMException exception = DOMException('InvalidStateError'); when(camera.pauseVideoRecording).thenThrow(exception); @@ -1508,8 +1556,7 @@ void main() { testWidgets('when resumeVideoRecording throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.INVALID_STATE); + final DOMException exception = DOMException('InvalidStateError'); when(camera.resumeVideoRecording).thenThrow(exception); @@ -1595,8 +1642,7 @@ void main() { testWidgets('when setFlashMode throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(() => camera.setFlashMode(any())).thenThrow(exception); @@ -1770,8 +1816,7 @@ void main() { testWidgets('when getMaxZoomLevel throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(camera.getMaxZoomLevel).thenThrow(exception); @@ -1864,8 +1909,7 @@ void main() { testWidgets('when getMinZoomLevel throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(camera.getMinZoomLevel).thenThrow(exception); @@ -1953,8 +1997,7 @@ void main() { testWidgets('when setZoomLevel throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(() => camera.setZoomLevel(any())).thenThrow(exception); @@ -2066,8 +2109,7 @@ void main() { testWidgets('when pause throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(camera.pause).thenThrow(exception); @@ -2121,8 +2163,7 @@ void main() { testWidgets('when play throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.NOT_SUPPORTED); + final DOMException exception = DOMException('NotSupportedError'); when(camera.play).thenThrow(exception); @@ -2192,7 +2233,8 @@ void main() { group('dispose', () { late Camera camera; - late VideoElement videoElement; + late MockVideoElement mockVideoElement; + late HTMLVideoElement videoElement; late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; @@ -2200,7 +2242,9 @@ void main() { setUp(() { camera = MockCamera(); - videoElement = MockVideoElement(); + mockVideoElement = MockVideoElement(); + videoElement = + createJSInteropWrapper(mockVideoElement) as HTMLVideoElement; errorStreamController = StreamController(); abortStreamController = StreamController(); @@ -2214,10 +2258,23 @@ void main() { when(camera.dispose).thenAnswer((Invocation _) => Future.value()); when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError).thenAnswer((Invocation _) => - FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort).thenAnswer((Invocation _) => - FakeElementStream(abortStreamController.stream)); + + final MockEventStreamProvider errorProvider = + MockEventStreamProvider(); + final MockEventStreamProvider abortProvider = + MockEventStreamProvider(); + + (CameraPlatform.instance as CameraPlugin).videoElementOnErrorProvider = + errorProvider; + (CameraPlatform.instance as CameraPlugin).videoElementOnAbortProvider = + abortProvider; + + when(() => errorProvider.forElement(videoElement)).thenAnswer( + (_) => FakeElementStream(errorStreamController.stream), + ); + when(() => abortProvider.forElement(videoElement)).thenAnswer( + (_) => FakeElementStream(abortStreamController.stream), + ); when(() => camera.onEnded) .thenAnswer((Invocation _) => endedStreamController.stream); @@ -2316,8 +2373,7 @@ void main() { testWidgets('when dispose throws DomException', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final FakeDomException exception = - FakeDomException(DomException.INVALID_ACCESS); + final DOMException exception = DOMException('InvalidAccessError'); when(camera.dispose).thenThrow(exception); @@ -2373,7 +2429,8 @@ void main() { group('events', () { late Camera camera; - late VideoElement videoElement; + late MockVideoElement mockVideoElement; + late HTMLVideoElement videoElement; late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; @@ -2381,7 +2438,9 @@ void main() { setUp(() { camera = MockCamera(); - videoElement = MockVideoElement(); + mockVideoElement = MockVideoElement(); + videoElement = + createJSInteropWrapper(mockVideoElement) as HTMLVideoElement; errorStreamController = StreamController(); abortStreamController = StreamController(); @@ -2394,10 +2453,23 @@ void main() { when(camera.play).thenAnswer((Invocation _) => Future.value()); when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError).thenAnswer((Invocation _) => - FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort).thenAnswer((Invocation _) => - FakeElementStream(abortStreamController.stream)); + + final MockEventStreamProvider errorProvider = + MockEventStreamProvider(); + final MockEventStreamProvider abortProvider = + MockEventStreamProvider(); + + (CameraPlatform.instance as CameraPlugin).videoElementOnErrorProvider = + errorProvider; + (CameraPlatform.instance as CameraPlugin).videoElementOnAbortProvider = + abortProvider; + + when(() => errorProvider.forElement(any())).thenAnswer( + (_) => FakeElementStream(errorStreamController.stream), + ); + when(() => abortProvider.forElement(any())).thenAnswer( + (_) => FakeElementStream(abortStreamController.stream), + ); when(() => camera.onEnded) .thenAnswer((Invocation _) => endedStreamController.stream); @@ -2405,8 +2477,7 @@ void main() { when(() => camera.onVideoRecordingError) .thenAnswer((Invocation _) => videoRecordingErrorController.stream); - when(() => camera.startVideoRecording()) - .thenAnswer((Invocation _) async {}); + when(camera.startVideoRecording).thenAnswer((Invocation _) async {}); }); testWidgets( @@ -2479,7 +2550,9 @@ void main() { await CameraPlatform.instance.initializeCamera(cameraId); - endedStreamController.add(MockMediaStreamTrack()); + endedStreamController.add( + createJSInteropWrapper(MockMediaStreamTrack()) as MediaStreamTrack, + ); expect( await streamQueue.next, @@ -2509,16 +2582,17 @@ void main() { await CameraPlatform.instance.initializeCamera(cameraId); - final FakeMediaError error = FakeMediaError( - MediaError.MEDIA_ERR_NETWORK, - 'A network error occurred.', - ); + final MediaError error = createJSInteropWrapper( + FakeMediaError( + MediaError.MEDIA_ERR_NETWORK, + 'A network error occurred.', + ), + ) as MediaError; final CameraErrorCode errorCode = CameraErrorCode.fromMediaError(error); - when(() => videoElement.error).thenReturn(error); - + mockVideoElement.error = error; errorStreamController.add(Event('error')); expect( @@ -2546,13 +2620,13 @@ void main() { await CameraPlatform.instance.initializeCamera(cameraId); - final FakeMediaError error = - FakeMediaError(MediaError.MEDIA_ERR_NETWORK); + final MediaError error = createJSInteropWrapper( + FakeMediaError(MediaError.MEDIA_ERR_NETWORK), + ) as MediaError; final CameraErrorCode errorCode = CameraErrorCode.fromMediaError(error); - when(() => videoElement.error).thenReturn(error); - + mockVideoElement.error = error; errorStreamController.add(Event('error')); expect( @@ -2881,7 +2955,9 @@ void main() { await CameraPlatform.instance.initializeCamera(cameraId); await CameraPlatform.instance.startVideoRecording(cameraId); - final FakeErrorEvent errorEvent = FakeErrorEvent('type', 'message'); + final ErrorEvent errorEvent = + createJSInteropWrapper(FakeErrorEvent('type', 'message')) + as ErrorEvent; videoRecordingErrorController.add(errorEvent); @@ -3013,7 +3089,7 @@ void main() { testWidgets('onVideoRecordedEvent emits a VideoRecordedEvent', (WidgetTester tester) async { final MockCamera camera = MockCamera(); - final MockXFile capturedVideo = MockXFile(); + final XFile capturedVideo = XFile('/bogus/test'); final Stream stream = Stream.value( VideoRecordedEvent(cameraId, capturedVideo, Duration.zero)); @@ -3036,24 +3112,16 @@ void main() { }); group('onDeviceOrientationChanged', () { - group('emits an empty stream', () { - testWidgets('when screen is not supported', - (WidgetTester tester) async { - when(() => window.screen).thenReturn(null); - - final Stream stream = - CameraPlatform.instance.onDeviceOrientationChanged(); - expect(await stream.isEmpty, isTrue); - }); + final StreamController eventStreamController = + StreamController(); - testWidgets('when screen orientation is not supported', - (WidgetTester tester) async { - when(() => screen.orientation).thenReturn(null); - - final Stream stream = - CameraPlatform.instance.onDeviceOrientationChanged(); - expect(await stream.isEmpty, isTrue); - }); + setUp(() { + final MockEventStreamProvider provider = + MockEventStreamProvider(); + (CameraPlatform.instance as CameraPlugin) + .orientationOnChangeProvider = provider; + when(() => provider.forTarget(any())) + .thenAnswer((_) => eventStreamController.stream); }); testWidgets('emits the initial DeviceOrientationChangedEvent', @@ -3065,14 +3133,7 @@ void main() { ).thenReturn(DeviceOrientation.portraitUp); // Set the initial screen orientation to portraitPrimary. - when(() => screenOrientation.type) - .thenReturn(OrientationType.portraitPrimary); - - final StreamController eventStreamController = - StreamController(); - - when(() => screenOrientation.onChange) - .thenAnswer((Invocation _) => eventStreamController.stream); + mockScreenOrientation.type = OrientationType.portraitPrimary; final Stream eventStream = CameraPlatform.instance.onDeviceOrientationChanged(); @@ -3108,12 +3169,6 @@ void main() { ), ).thenReturn(DeviceOrientation.portraitDown); - final StreamController eventStreamController = - StreamController(); - - when(() => screenOrientation.onChange) - .thenAnswer((Invocation _) => eventStreamController.stream); - final Stream eventStream = CameraPlatform.instance.onDeviceOrientationChanged(); @@ -3122,8 +3177,7 @@ void main() { // Change the screen orientation to landscapePrimary and // emit an event on the screenOrientation.onChange stream. - when(() => screenOrientation.type) - .thenReturn(OrientationType.landscapePrimary); + mockScreenOrientation.type = OrientationType.landscapePrimary; eventStreamController.add(Event('change')); @@ -3138,8 +3192,7 @@ void main() { // Change the screen orientation to portraitSecondary and // emit an event on the screenOrientation.onChange stream. - when(() => screenOrientation.type) - .thenReturn(OrientationType.portraitSecondary); + mockScreenOrientation.type = OrientationType.portraitSecondary; eventStreamController.add(Event('change')); diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index d1fbdd574ba4..e4852843d1ff 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: avoid_implementing_value_types import 'dart:async'; -import 'dart:html'; +import 'dart:js_interop'; import 'dart:ui'; // ignore_for_file: implementation_imports @@ -13,103 +13,155 @@ import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/shims/dart_js_util.dart'; import 'package:camera_web/src/types/types.dart'; -import 'package:cross_file/cross_file.dart'; import 'package:mocktail/mocktail.dart'; +// TODO(srujzs): This is exported in `package:web` 0.6.0. Remove this when it is available. +import 'package:web/src/helpers/events/streams.dart'; +import 'package:web/web.dart' as web; + +@JSExport() +class MockWindow { + late web.Navigator navigator; + late web.Screen screen; + late web.Document document; +} + +@JSExport() +class MockScreen { + late web.ScreenOrientation orientation; +} -class MockWindow extends Mock implements Window {} +@JSExport() +class MockScreenOrientation { + /// JSPromise Function(web.OrientationLockType orientation) + JSFunction lock = (web.OrientationLockType orientation) { + return Future.value().toJS; + }.toJS; -class MockScreen extends Mock implements Screen {} + /// void Function() + late JSFunction unlock; + late web.OrientationType type; +} + +@JSExport() +class MockDocument { + web.Element? documentElement; +} -class MockScreenOrientation extends Mock implements ScreenOrientation {} +@JSExport() +class MockElement { + /// JSPromise Function([FullscreenOptions options]) + JSFunction requestFullscreen = ([web.FullscreenOptions? options]) { + return Future.value().toJS; + }.toJS; +} -class MockDocument extends Mock implements Document {} +@JSExport() +class MockNavigator { + late web.MediaDevices mediaDevices; +} -class MockElement extends Mock implements Element {} +@JSExport() +class MockMediaDevices { + /// JSPromise Function([web.MediaStreamConstraints? constraints]) + late JSFunction getUserMedia; -class MockNavigator extends Mock implements Navigator {} + /// web.MediaTrackSupportedConstraints Function() + late JSFunction getSupportedConstraints; -class MockMediaDevices extends Mock implements MediaDevices {} + /// JSPromise> Function() + late JSFunction enumerateDevices; +} class MockCameraService extends Mock implements CameraService {} -class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} +@JSExport() +class MockMediaStreamTrack { + /// web.MediaTrackCapabilities Function(); + late JSFunction getCapabilities; -class MockCamera extends Mock implements Camera {} + /// web.MediaTrackSettings Function() + JSFunction getSettings = () { + return web.MediaTrackSettings(); + }.toJS; -class MockCameraOptions extends Mock implements CameraOptions {} + /// JSPromise Function([web.MediaTrackConstraints? constraints]) + late JSFunction applyConstraints; -class MockVideoElement extends Mock implements VideoElement {} + /// void Function() + JSFunction stop = () {}.toJS; +} -class MockXFile extends Mock implements XFile {} +class MockCamera extends Mock implements Camera {} -class MockJsUtil extends Mock implements JsUtil {} +class MockCameraOptions extends Mock implements CameraOptions {} -class MockMediaRecorder extends Mock implements MediaRecorder {} +@JSExport() +class MockVideoElement { + web.MediaProvider? srcObject; + web.MediaError? error; +} -/// A fake [MediaStream] that returns the provided [_videoTracks]. -class FakeMediaStream extends Fake implements MediaStream { - FakeMediaStream(this._videoTracks); +class MockJsUtil extends Mock implements JsUtil {} - final List _videoTracks; +@JSExport() +class MockMediaRecorder { + /// void Function(String type, web.EventListener? callback, [JSAny options]) + JSFunction addEventListener = + (String type, web.EventListener? callback, [JSAny? options]) {}.toJS; - @override - List getVideoTracks() => _videoTracks; -} + /// void Function(String type, web.EventListener? callback, [JSAny options]) + JSFunction removeEventListener = + (String type, web.EventListener? callback, [JSAny? options]) {}.toJS; -/// A fake [MediaDeviceInfo] that returns the provided [_deviceId], [_label] and [_kind]. -class FakeMediaDeviceInfo extends Fake implements MediaDeviceInfo { - FakeMediaDeviceInfo(this._deviceId, this._label, this._kind); + /// void Function([int timeslice]) + JSFunction start = ([int? timeslice]) {}.toJS; - final String _deviceId; - final String _label; - final String _kind; + /// void Function() + JSFunction pause = () {}.toJS; - @override - String? get deviceId => _deviceId; + /// void Function() + JSFunction resume = () {}.toJS; - @override - String? get label => _label; + /// void Function() + JSFunction stop = () {}.toJS; - @override - String? get kind => _kind; + web.RecordingState state = 'inactive'; } -/// A fake [MediaError] that returns the provided error [_code] and [_message]. -class FakeMediaError extends Fake implements MediaError { - FakeMediaError( - this._code, [ - String message = '', - ]) : _message = message; - - final int _code; - final String _message; +/// A fake [MediaStream] that returns the provided [_videoTracks]. +@JSExport() +class FakeMediaStream { + FakeMediaStream(this._videoTracks); - @override - int get code => _code; + final List _videoTracks; - @override - String? get message => _message; + List getVideoTracks() => _videoTracks; } -/// A fake [DomException] that returns the provided error [_name] and [_message]. -class FakeDomException extends Fake implements DomException { - FakeDomException( - this._name, [ - String? message, - ]) : _message = message; +/// A fake [MediaDeviceInfo] that returns the provided [_deviceId], [_label] and [_kind]. +@JSExport() +class FakeMediaDeviceInfo { + FakeMediaDeviceInfo(this.deviceId, this.label, this.kind); - final String _name; - final String? _message; + final String deviceId; + final String label; + final String kind; +} - @override - String get name => _name; +/// A fake [MediaError] that returns the provided error [_code] and [_message]. +@JSExport() +class FakeMediaError { + FakeMediaError( + this.code, [ + this.message = '', + ]); - @override - String? get message => _message; + final int code; + final String message; } /// A fake [ElementStream] that listens to the provided [_stream] on [listen]. -class FakeElementStream extends Fake +class FakeElementStream extends Fake implements ElementStream { FakeElementStream(this._stream); @@ -128,31 +180,23 @@ class FakeElementStream extends Fake } /// A fake [BlobEvent] that returns the provided blob [data]. -class FakeBlobEvent extends Fake implements BlobEvent { - FakeBlobEvent(this._blob); - - final Blob? _blob; +@JSExport() +class FakeBlobEvent { + FakeBlobEvent(this.data); - @override - Blob? get data => _blob; + final web.Blob? data; } /// A fake [DomException] that returns the provided error [_name] and [_message]. -class FakeErrorEvent extends Fake implements ErrorEvent { +@JSExport() +class FakeErrorEvent { FakeErrorEvent( - String type, [ - String? message, - ]) : _type = type, - _message = message; + this.type, [ + this.message = '', + ]); - final String _type; - final String? _message; - - @override - String get type => _type; - - @override - String? get message => _message; + final String type; + final String message; } /// Returns a video element with a blank stream of size [videoSize]. @@ -162,14 +206,17 @@ class FakeErrorEvent extends Fake implements ErrorEvent { /// final videoElement = getVideoElementWithBlankStream(Size(100, 100)); /// final videoStream = videoElement.captureStream(); /// ``` -VideoElement getVideoElementWithBlankStream(Size videoSize) { - final CanvasElement canvasElement = CanvasElement( - width: videoSize.width.toInt(), - height: videoSize.height.toInt(), - )..context2D.fillRect(0, 0, videoSize.width, videoSize.height); +web.HTMLVideoElement getVideoElementWithBlankStream(Size videoSize) { + final web.HTMLCanvasElement canvasElement = web.HTMLCanvasElement() + ..width = videoSize.width.toInt() + ..height = videoSize.height.toInt() + ..context2D.fillRect(0, 0, videoSize.width, videoSize.height); - final VideoElement videoElement = VideoElement() + final web.HTMLVideoElement videoElement = web.HTMLVideoElement() ..srcObject = canvasElement.captureStream(); return videoElement; } + +class MockEventStreamProvider extends Mock + implements web.EventStreamProvider {} diff --git a/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart b/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart index d93b42690e5e..3a2145e39ec6 100644 --- a/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart +++ b/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart @@ -3,9 +3,12 @@ // found in the LICENSE file. // ignore: implementation_imports +import 'dart:js_interop'; + import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:web/web.dart'; import 'helpers/helpers.dart'; @@ -16,7 +19,8 @@ void main() { testWidgets('sets all properties', (WidgetTester tester) async { const double minimum = 100.0; const double maximum = 400.0; - final MockMediaStreamTrack videoTrack = MockMediaStreamTrack(); + final MediaStreamTrack videoTrack = + createJSInteropWrapper(MockMediaStreamTrack()) as MediaStreamTrack; final ZoomLevelCapability capability = ZoomLevelCapability( minimum: minimum, @@ -30,7 +34,8 @@ void main() { }); testWidgets('supports value equality', (WidgetTester tester) async { - final MockMediaStreamTrack videoTrack = MockMediaStreamTrack(); + final MediaStreamTrack videoTrack = + createJSInteropWrapper(MockMediaStreamTrack()) as MediaStreamTrack; expect( ZoomLevelCapability( diff --git a/packages/camera/camera_web/example/pubspec.yaml b/packages/camera/camera_web/example/pubspec.yaml index 5cf64e1c5ced..d4f398ec197f 100644 --- a/packages/camera/camera_web/example/pubspec.yaml +++ b/packages/camera/camera_web/example/pubspec.yaml @@ -2,8 +2,8 @@ name: camera_web_integration_tests publish_to: none environment: - sdk: ^3.2.0 - flutter: ">=3.16.0" + sdk: ^3.3.0 + flutter: ">=3.19.0" dependencies: camera_platform_interface: ^2.6.0 @@ -16,6 +16,7 @@ dependencies: path: ../ flutter: sdk: flutter + web: ^1.0.0 dev_dependencies: async: ^2.5.0 @@ -25,4 +26,3 @@ dev_dependencies: integration_test: sdk: flutter mocktail: 0.3.0 - diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 7512fc627712..124f595fecff 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -3,14 +3,17 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:html' as html; +import 'dart:js_interop'; import 'dart:ui'; import 'dart:ui_web' as ui_web; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/foundation.dart'; +import 'package:web/web.dart' as web; +import 'package:web/web.dart'; import 'camera_service.dart'; +import 'pkg_web_tweaks.dart'; import 'types/types.dart'; String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; @@ -48,10 +51,6 @@ class Camera { this.recorderOptions = const (audioBitrate: null, videoBitrate: null), }) : _cameraService = cameraService; - // A torch mode constraint name. - // See: https://w3c.github.io/mediacapture-image/#dom-mediatracksupportedconstraints-torch - static const String _torchModeKey = 'torch'; - /// The texture id used to register the camera view. final int textureId; @@ -63,16 +62,16 @@ class Camera { /// The video element that displays the camera stream. /// Initialized in [initialize]. - late final html.VideoElement videoElement; + late final web.HTMLVideoElement videoElement; /// The wrapping element for the [videoElement] to avoid overriding /// the custom styles applied in [_applyDefaultVideoStyles]. /// Initialized in [initialize]. - late final html.DivElement divElement; + late final web.HTMLDivElement divElement; /// The camera stream displayed in the [videoElement]. /// Initialized in [initialize] and [play], reset in [stop]. - html.MediaStream? stream; + web.MediaStream? stream; /// The stream of the camera video tracks that have ended playing. /// @@ -82,14 +81,14 @@ class Camera { /// /// MediaStreamTrack.onended: /// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/onended - Stream get onEnded => onEndedController.stream; + Stream get onEnded => onEndedController.stream; /// The stream controller for the [onEnded] stream. @visibleForTesting - final StreamController onEndedController = - StreamController.broadcast(); + final StreamController onEndedController = + StreamController.broadcast(); - StreamSubscription? _onEndedSubscription; + StreamSubscription? _onEndedSubscription; /// The stream of the camera video recording errors. /// @@ -98,15 +97,20 @@ class Camera { /// /// MediaRecorder.error: /// https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/error_event - Stream get onVideoRecordingError => + Stream get onVideoRecordingError => videoRecordingErrorController.stream; + /// The stream provider for [MediaRecorder] error events. + @visibleForTesting + EventStreamProvider mediaRecorderOnErrorProvider = + EventStreamProviders.errorMediaRecorderEvent; + /// The stream controller for the [onVideoRecordingError] stream. @visibleForTesting - final StreamController videoRecordingErrorController = - StreamController.broadcast(); + final StreamController videoRecordingErrorController = + StreamController.broadcast(); - StreamSubscription? _onVideoRecordingErrorSubscription; + StreamSubscription? _onVideoRecordingErrorSubscription; /// The camera flash mode. @visibleForTesting @@ -117,36 +121,34 @@ class Camera { /// The current browser window used to access media devices. @visibleForTesting - html.Window? window = html.window; + web.Window window = web.window; /// The recorder used to record a video from the camera. @visibleForTesting - html.MediaRecorder? mediaRecorder; + web.MediaRecorder? mediaRecorder; /// Whether the video of the given type is supported. @visibleForTesting bool Function(String) isVideoTypeSupported = - html.MediaRecorder.isTypeSupported; + (String type) => web.MediaRecorder.isTypeSupported(type); /// The list of consecutive video data files recorded with [mediaRecorder]. - final List _videoData = []; + final List _videoData = []; /// Completes when the video recording is stopped/finished. Completer? _videoAvailableCompleter; /// A data listener fired when a new part of video data is available. - void Function(html.Event)? _videoDataAvailableListener; + void Function(web.BlobEvent)? _videoDataAvailableListener; /// A listener fired when a video recording is stopped. - void Function(html.Event)? _videoRecordingStoppedListener; + void Function(web.Event)? _videoRecordingStoppedListener; /// A builder to merge a list of blobs into a single blob. @visibleForTesting - // TODO(stuartmorgan): Remove this 'ignore' once we don't analyze using 2.10 - // any more. It's a false positive that is fixed in later versions. - // ignore: prefer_function_declarations_over_variables - html.Blob Function(List blobs, String type) blobBuilder = - (List blobs, String type) => html.Blob(blobs, type); + web.Blob Function(List blobs, String type) blobBuilder = + (List blobs, String type) => + web.Blob(blobs.toJS, web.BlobPropertyBag(type: type)); /// The stream that emits a [VideoRecordedEvent] when a video recording is created. Stream get onVideoRecordedEvent => @@ -166,10 +168,12 @@ class Camera { cameraId: textureId, ); - videoElement = html.VideoElement(); + videoElement = web.HTMLVideoElement(); - divElement = html.DivElement() + divElement = web.HTMLDivElement() ..style.setProperty('object-fit', 'cover') + ..style.setProperty('height', '100%') + ..style.setProperty('width', '100%') ..append(videoElement); ui_web.platformViewRegistry.registerViewFactory( @@ -185,12 +189,14 @@ class Camera { _applyDefaultVideoStyles(videoElement); - final List videoTracks = stream!.getVideoTracks(); + final List videoTracks = + stream!.getVideoTracks().toDart; if (videoTracks.isNotEmpty) { - final html.MediaStreamTrack defaultVideoTrack = videoTracks.first; - - _onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) { + final web.MediaStreamTrack defaultVideoTrack = videoTracks.first; + _onEndedSubscription = EventStreamProviders.endedEvent + .forTarget(defaultVideoTrack) + .listen((web.Event _) { onEndedController.add(defaultVideoTrack); }); } @@ -207,7 +213,7 @@ class Camera { ); videoElement.srcObject = stream; } - await videoElement.play(); + await videoElement.play().toDart; } /// Pauses the camera stream on the current frame. @@ -217,14 +223,15 @@ class Camera { /// Stops the camera stream and resets the camera source. void stop() { - final List videoTracks = stream!.getVideoTracks(); + final List videoTracks = + stream!.getVideoTracks().toDart; if (videoTracks.isNotEmpty) { onEndedController.add(videoTracks.first); } - final List? tracks = stream?.getTracks(); + final List? tracks = stream?.getTracks().toDart; if (tracks != null) { - for (final html.MediaStreamTrack track in tracks) { + for (final web.MediaStreamTrack track in tracks) { track.stop(); } } @@ -246,8 +253,9 @@ class Camera { final int videoWidth = videoElement.videoWidth; final int videoHeight = videoElement.videoHeight; - final html.CanvasElement canvas = - html.CanvasElement(width: videoWidth, height: videoHeight); + final web.HTMLCanvasElement canvas = web.HTMLCanvasElement() + ..width = videoWidth + ..height = videoHeight; final bool isBackCamera = getLensDirection() == CameraLensDirection.back; // Flip the picture horizontally if it is not taken from a back camera. @@ -257,16 +265,28 @@ class Camera { ..scale(-1, 1); } - canvas.context2D - .drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); + canvas.context2D.drawImageScaled( + videoElement, + 0, + 0, + videoWidth.toDouble(), + videoHeight.toDouble(), + ); - final html.Blob blob = await canvas.toBlob('image/jpeg'); + final Completer blobCompleter = Completer(); + canvas.toBlob( + (web.Blob blob) { + blobCompleter.complete(blob); + }.toJS, + 'image/jpeg', + ); + final web.Blob blob = await blobCompleter.future; if (shouldEnableTorchMode) { _setTorchMode(enabled: false); } - return XFile(html.Url.createObjectUrl(blob)); + return XFile(web.URL.createObjectURL(blob)); } /// Returns a size of the camera video based on its first video track size. @@ -274,25 +294,23 @@ class Camera { /// Returns [Size.zero] if the camera is missing a video track or /// the video track does not include the width or height setting. Size getVideoSize() { - final List videoTracks = - videoElement.srcObject?.getVideoTracks() ?? []; + final List videoTracks = + (videoElement.srcObject as web.MediaStream?)?.getVideoTracks().toDart ?? + []; if (videoTracks.isEmpty) { return Size.zero; } - final html.MediaStreamTrack defaultVideoTrack = videoTracks.first; - final Map defaultVideoTrackSettings = + final web.MediaStreamTrack defaultVideoTrack = videoTracks.first; + + final web.MediaTrackSettings defaultVideoTrackSettings = defaultVideoTrack.getSettings(); - final double? width = defaultVideoTrackSettings['width'] as double?; - final double? height = defaultVideoTrackSettings['height'] as double?; + final int width = defaultVideoTrackSettings.width; + final int height = defaultVideoTrackSettings.height; - if (width != null && height != null) { - return Size(width, height); - } else { - return Size.zero; - } + return Size(width.toDouble(), height.toDouble()); } /// Sets the camera flash mode to [mode] by modifying the camera @@ -307,11 +325,10 @@ class Camera { /// Throws a [CameraWebException] if the torch mode is not supported /// or the camera has not been initialized or started. void setFlashMode(FlashMode mode) { - final html.MediaDevices? mediaDevices = window?.navigator.mediaDevices; - final Map? supportedConstraints = - mediaDevices?.getSupportedConstraints(); - final bool torchModeSupported = - supportedConstraints?[_torchModeKey] as bool? ?? false; + final web.MediaDevices mediaDevices = window.navigator.mediaDevices; + final web.MediaTrackSupportedConstraints supportedConstraints = + mediaDevices.getSupportedConstraints(); + final bool torchModeSupported = supportedConstraints.torchNullable ?? false; if (!torchModeSupported) { throw CameraWebException( @@ -333,23 +350,22 @@ class Camera { /// Throws a [CameraWebException] if the torch mode is not supported /// or the camera has not been initialized or started. void _setTorchMode({required bool enabled}) { - final List videoTracks = - stream?.getVideoTracks() ?? []; + final List videoTracks = + stream?.getVideoTracks().toDart ?? []; if (videoTracks.isNotEmpty) { - final html.MediaStreamTrack defaultVideoTrack = videoTracks.first; - - final bool canEnableTorchMode = - defaultVideoTrack.getCapabilities()[_torchModeKey] as bool? ?? false; + final web.MediaStreamTrack defaultVideoTrack = videoTracks.first; + final bool canEnableTorchMode = defaultVideoTrack + .getCapabilities() + .torchNullable + ?.toDart + .first + .toDart ?? + false; if (canEnableTorchMode) { - defaultVideoTrack.applyConstraints({ - 'advanced': [ - { - _torchModeKey: enabled, - } - ] - }); + defaultVideoTrack.applyWebTweakConstraints( + WebTweakMediaTrackConstraints(torch: enabled.toJS)); } else { throw CameraWebException( textureId, @@ -397,13 +413,8 @@ class Camera { ); } - zoomLevelCapability.videoTrack.applyConstraints({ - 'advanced': [ - { - ZoomLevelCapability.constraintName: zoom, - } - ] - }); + zoomLevelCapability.videoTrack.applyWebTweakConstraints( + WebTweakMediaTrackConstraints(zoom: zoom.toJS)); } /// Returns a lens direction of this camera. @@ -411,21 +422,21 @@ class Camera { /// Returns null if the camera is missing a video track or /// the video track does not include the facing mode setting. CameraLensDirection? getLensDirection() { - final List videoTracks = - videoElement.srcObject?.getVideoTracks() ?? []; + final List videoTracks = + (videoElement.srcObject as web.MediaStream?)?.getVideoTracks().toDart ?? + []; if (videoTracks.isEmpty) { return null; } - final html.MediaStreamTrack defaultVideoTrack = videoTracks.first; - final Map defaultVideoTrackSettings = + final web.MediaStreamTrack defaultVideoTrack = videoTracks.first; + final web.MediaTrackSettings defaultVideoTrackSettings = defaultVideoTrack.getSettings(); - final String? facingMode = - defaultVideoTrackSettings['facingMode'] as String?; + final String? facingMode = defaultVideoTrackSettings.facingModeNullable; - if (facingMode != null) { + if (facingMode != null && facingMode.isNotEmpty) { return _cameraService.mapFacingModeToLensDirection(facingMode); } else { return null; @@ -435,65 +446,65 @@ class Camera { /// Returns the registered view type of the camera. String getViewType() => _getViewType(textureId); - /// Starts a new video recording using [html.MediaRecorder]. + /// Starts a new video recording using [web.MediaRecorder]. /// /// Throws a [CameraWebException] if the browser does not support any of the /// available video mime types from [_videoMimeType]. Future startVideoRecording() async { + final web.MediaRecorderOptions options = + web.MediaRecorderOptions(mimeType: _videoMimeType); + if (recorderOptions.audioBitrate != null) { + options.audioBitsPerSecond = recorderOptions.audioBitrate!; + } + if (recorderOptions.videoBitrate != null) { + options.videoBitsPerSecond = recorderOptions.videoBitrate!; + } + mediaRecorder ??= - html.MediaRecorder(videoElement.srcObject!, { - 'mimeType': _videoMimeType, - if (recorderOptions.audioBitrate != null) - 'audioBitsPerSecond': recorderOptions.audioBitrate!, - if (recorderOptions.videoBitrate != null) - 'videoBitsPerSecond': recorderOptions.videoBitrate!, - }); + web.MediaRecorder(videoElement.srcObject! as web.MediaStream, options); _videoAvailableCompleter = Completer(); _videoDataAvailableListener = - (html.Event event) => _onVideoDataAvailable(event); + (web.BlobEvent event) => _onVideoDataAvailable(event); _videoRecordingStoppedListener = - (html.Event event) => _onVideoRecordingStopped(event); + (web.Event event) => _onVideoRecordingStopped(event); mediaRecorder!.addEventListener( 'dataavailable', - _videoDataAvailableListener, + _videoDataAvailableListener?.toJS, ); mediaRecorder!.addEventListener( 'stop', - _videoRecordingStoppedListener, + _videoRecordingStoppedListener?.toJS, ); - _onVideoRecordingErrorSubscription = - mediaRecorder!.onError.listen((html.Event event) { - final html.ErrorEvent error = event as html.ErrorEvent; + _onVideoRecordingErrorSubscription = mediaRecorderOnErrorProvider + .forTarget(mediaRecorder) + .listen((web.Event event) { + final web.ErrorEvent error = event as web.ErrorEvent; videoRecordingErrorController.add(error); }); mediaRecorder!.start(); } - void _onVideoDataAvailable(html.Event event) { - final html.Blob? blob = (event as html.BlobEvent).data; - + void _onVideoDataAvailable(web.BlobEvent event) { // Append the recorded part of the video to the list of all video data files. - if (blob != null) { - _videoData.add(blob); - } + _videoData.add(event.data); } - Future _onVideoRecordingStopped(html.Event event) async { + Future _onVideoRecordingStopped(web.Event event) async { if (_videoData.isNotEmpty) { // Concatenate all video data files into a single blob. final String videoType = _videoData.first.type; - final html.Blob videoBlob = blobBuilder(_videoData, videoType); + final web.Blob videoBlob = blobBuilder(_videoData, videoType); // Create a file containing the video blob. final XFile file = XFile( - html.Url.createObjectUrl(videoBlob), + web.URL.createObjectURL(videoBlob), mimeType: _videoMimeType, name: videoBlob.hashCode.toString(), ); @@ -509,12 +520,12 @@ class Camera { // Clean up the media recorder with its event listeners and video data. mediaRecorder!.removeEventListener( 'dataavailable', - _videoDataAvailableListener, + _videoDataAvailableListener?.toJS, ); mediaRecorder!.removeEventListener( 'stop', - _videoDataAvailableListener, + _videoDataAvailableListener?.toJS, ); await _onVideoRecordingErrorSubscription?.cancel(); @@ -612,7 +623,7 @@ class Camera { ); /// Applies default styles to the video [element]. - void _applyDefaultVideoStyles(html.VideoElement element) { + void _applyDefaultVideoStyles(web.HTMLVideoElement element) { final bool isBackCamera = getLensDirection() == CameraLensDirection.back; // Flip the video horizontally if it is not taken from a back camera. diff --git a/packages/camera/camera_web/lib/src/camera_service.dart b/packages/camera/camera_web/lib/src/camera_service.dart index 8ac40ff33ee6..072fe06859f0 100644 --- a/packages/camera/camera_web/lib/src/camera_service.dart +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -2,25 +2,24 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:html' as html; +import 'dart:js_interop'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:web/web.dart' as web; import 'camera.dart'; +import 'pkg_web_tweaks.dart'; import 'shims/dart_js_util.dart'; import 'types/types.dart'; /// A service to fetch, map camera settings and /// obtain the camera stream. class CameraService { - // A facing mode constraint name. - static const String _facingModeKey = 'facingMode'; - /// The current browser window used to access media devices. @visibleForTesting - html.Window? window = html.window; + web.Window window = web.window; /// The utility to manipulate JavaScript interop objects. @visibleForTesting @@ -28,25 +27,19 @@ class CameraService { /// Returns a media stream associated with the camera device /// with [cameraId] and constrained by [options]. - Future getMediaStreamForOptions( + Future getMediaStreamForOptions( CameraOptions options, { int cameraId = 0, }) async { - final html.MediaDevices? mediaDevices = window?.navigator.mediaDevices; - - // Throw a not supported exception if the current browser window - // does not support any media devices. - if (mediaDevices == null) { - throw PlatformException( - code: CameraErrorCode.notSupported.toString(), - message: 'The camera is not supported on this device.', - ); - } + final web.MediaDevices mediaDevices = window.navigator.mediaDevices; try { - final Map constraints = options.toJson(); - return await mediaDevices.getUserMedia(constraints); - } on html.DomException catch (e) { + return await mediaDevices + .getUserMedia( + options.toMediaStreamConstraints(), + ) + .toDart; + } on web.DOMException catch (e) { switch (e.name) { case 'NotFoundError': case 'DevicesNotFoundError': @@ -120,12 +113,10 @@ class CameraService { ZoomLevelCapability getZoomLevelCapabilityForCamera( Camera camera, ) { - final html.MediaDevices? mediaDevices = window?.navigator.mediaDevices; - final Map? supportedConstraints = - mediaDevices?.getSupportedConstraints(); - final bool zoomLevelSupported = - supportedConstraints?[ZoomLevelCapability.constraintName] as bool? ?? - false; + final web.MediaDevices mediaDevices = window.navigator.mediaDevices; + final web.MediaTrackSupportedConstraints supportedConstraints = + mediaDevices.getSupportedConstraints(); + final bool zoomLevelSupported = supportedConstraints.zoomNullable ?? false; if (!zoomLevelSupported) { throw CameraWebException( @@ -135,31 +126,21 @@ class CameraService { ); } - final List videoTracks = - camera.stream?.getVideoTracks() ?? []; + final List videoTracks = + camera.stream?.getVideoTracks().toDart ?? []; if (videoTracks.isNotEmpty) { - final html.MediaStreamTrack defaultVideoTrack = videoTracks.first; + final web.MediaStreamTrack defaultVideoTrack = videoTracks.first; /// The zoom level capability is represented by MediaSettingsRange. /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaSettingsRange - final Object zoomLevelCapability = defaultVideoTrack - .getCapabilities()[ZoomLevelCapability.constraintName] - as Object? ?? - {}; - - // The zoom level capability is a nested JS object, therefore - // we need to access its properties with the js_util library. - // See: https://api.dart.dev/stable/2.13.4/dart-js_util/getProperty.html - final num? minimumZoomLevel = - jsUtil.getProperty(zoomLevelCapability, 'min') as num?; - final num? maximumZoomLevel = - jsUtil.getProperty(zoomLevelCapability, 'max') as num?; - - if (minimumZoomLevel != null && maximumZoomLevel != null) { + final WebTweakMediaSettingsRange? zoomLevelCapability = + defaultVideoTrack.getCapabilities().zoomNullable; + + if (zoomLevelCapability != null) { return ZoomLevelCapability( - minimum: minimumZoomLevel.toDouble(), - maximum: maximumZoomLevel.toDouble(), + minimum: zoomLevelCapability.min, + maximum: zoomLevelCapability.max, videoTrack: defaultVideoTrack, ); } else { @@ -180,26 +161,15 @@ class CameraService { /// Returns a facing mode of the [videoTrack] /// (null if the facing mode is not available). - String? getFacingModeForVideoTrack(html.MediaStreamTrack videoTrack) { - final html.MediaDevices? mediaDevices = window?.navigator.mediaDevices; - - // Throw a not supported exception if the current browser window - // does not support any media devices. - if (mediaDevices == null) { - throw PlatformException( - code: CameraErrorCode.notSupported.toString(), - message: 'The camera is not supported on this device.', - ); - } + String? getFacingModeForVideoTrack(web.MediaStreamTrack videoTrack) { + final web.MediaDevices mediaDevices = window.navigator.mediaDevices; // Check if the camera facing mode is supported by the current browser. - final Map supportedConstraints = + final web.MediaTrackSupportedConstraints supportedConstraints = mediaDevices.getSupportedConstraints(); - final bool facingModeSupported = - supportedConstraints[_facingModeKey] as bool? ?? false; // Return null if the facing mode is not supported. - if (!facingModeSupported) { + if (!supportedConstraints.facingMode) { return null; } @@ -209,10 +179,10 @@ class CameraService { // // MediaTrackSettings: // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings - final Map videoTrackSettings = videoTrack.getSettings(); - final String? facingMode = videoTrackSettings[_facingModeKey] as String?; + final web.MediaTrackSettings videoTrackSettings = videoTrack.getSettings(); + final String? facingMode = videoTrackSettings.facingModeNullable; - if (facingMode == null) { + if (facingMode == null || facingMode.isEmpty) { // If the facing mode does not exist in the video track settings, // check for the facing mode in the video track capabilities. // @@ -223,20 +193,20 @@ class CameraService { // // The method may not be supported on Firefox. // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getCapabilities#browser_compatibility - if (!jsUtil.hasProperty(videoTrack, 'getCapabilities')) { + if (!jsUtil.hasProperty(videoTrack, 'getCapabilities'.toJS)) { // Return null if the video track capabilities are not supported. return null; } - final Map videoTrackCapabilities = + final web.MediaTrackCapabilities videoTrackCapabilities = videoTrack.getCapabilities(); // A list of facing mode capabilities as // the camera may support multiple facing modes. - final List facingModeCapabilities = List.from( - (videoTrackCapabilities[_facingModeKey] as List?) - ?.cast() ?? - []); + final List facingModeCapabilities = videoTrackCapabilities + .facingMode.toDart + .map((JSString e) => e.toDart) + .toList(); if (facingModeCapabilities.isNotEmpty) { final String facingModeCapability = facingModeCapabilities.first; diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 799d742533af..11d14316971f 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:html' as html; +import 'dart:js_interop'; import 'dart:math'; import 'package:camera_platform_interface/camera_platform_interface.dart'; @@ -11,9 +11,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:stream_transform/stream_transform.dart'; +import 'package:web/web.dart' as web; import 'camera.dart'; import 'camera_service.dart'; +import 'pkg_web_tweaks.dart'; import 'types/types.dart'; // The default error message, when the error is an empty string. @@ -58,81 +60,86 @@ class CameraPlugin extends CameraPlatform { final StreamController cameraEventStreamController = StreamController.broadcast(); - final Map> - _cameraVideoErrorSubscriptions = >{}; + /// The stream provider for [web.HTMLVideoElement] error events. + @visibleForTesting + web.EventStreamProvider videoElementOnErrorProvider = + web.EventStreamProviders.errorElementEvent; + + final Map> _cameraVideoErrorSubscriptions = + >{}; - final Map> - _cameraVideoAbortSubscriptions = >{}; + /// The stream provider for [web.HTMLVideoElement] abort events. + @visibleForTesting + web.EventStreamProvider videoElementOnAbortProvider = + web.EventStreamProviders.errorElementEvent; - final Map> + final Map> _cameraVideoAbortSubscriptions = + >{}; + + final Map> _cameraEndedSubscriptions = - >{}; + >{}; - final Map> + final Map> _cameraVideoRecordingErrorSubscriptions = - >{}; + >{}; /// Returns a stream of camera events for the given [cameraId]. Stream _cameraEvents(int cameraId) => cameraEventStreamController.stream .where((CameraEvent event) => event.cameraId == cameraId); + /// The stream provider for [web.ScreenOrientation] change events. + @visibleForTesting + web.EventStreamProvider orientationOnChangeProvider = + web.EventStreamProviders.changeEvent; + /// The current browser window used to access media devices. @visibleForTesting - html.Window? window = html.window; + web.Window window = web.window; @override Future> availableCameras() async { try { - final html.MediaDevices? mediaDevices = window?.navigator.mediaDevices; + final web.MediaDevices mediaDevices = window.navigator.mediaDevices; final List cameras = []; - // Throw a not supported exception if the current browser window - // does not support any media devices. - if (mediaDevices == null) { - throw PlatformException( - code: CameraErrorCode.notSupported.toString(), - message: 'The camera is not supported on this device.', - ); - } - // Request video permissions only. - final html.MediaStream cameraStream = + final web.MediaStream cameraStream = await _cameraService.getMediaStreamForOptions(const CameraOptions()); // Release the camera stream used to request video permissions. cameraStream .getVideoTracks() - .forEach((html.MediaStreamTrack videoTrack) => videoTrack.stop()); + .toDart + .forEach((web.MediaStreamTrack videoTrack) => videoTrack.stop()); // Request available media devices. - final List devices = await mediaDevices.enumerateDevices(); + final List devices = + (await mediaDevices.enumerateDevices().toDart).toDart; // Filter video input devices. - final Iterable videoInputDevices = devices - .whereType() - .where((html.MediaDeviceInfo device) => - device.kind == MediaDeviceKind.videoInput) + final Iterable videoInputDevices = devices + .where( + (web.MediaDeviceInfo device) => + device.kind == MediaDeviceKind.videoInput, + ) /// The device id property is currently not supported on Internet Explorer: /// https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/deviceId#browser_compatibility - .where( - (html.MediaDeviceInfo device) => - device.deviceId != null && device.deviceId!.isNotEmpty, - ); + .where((web.MediaDeviceInfo device) => device.deviceId.isNotEmpty); // Map video input devices to camera descriptions. - for (final html.MediaDeviceInfo videoInputDevice in videoInputDevices) { + for (final web.MediaDeviceInfo videoInputDevice in videoInputDevices) { // Get the video stream for the current video input device // to later use for the available video tracks. - final html.MediaStream videoStream = await _getVideoStreamForDevice( - videoInputDevice.deviceId!, - ); + final web.MediaStream videoStream = + await _getVideoStreamForDevice(videoInputDevice.deviceId); // Get all video tracks in the video stream // to later extract the lens direction from the first track. - final List videoTracks = - videoStream.getVideoTracks(); + final List videoTracks = + videoStream.getVideoTracks().toDart; if (videoTracks.isNotEmpty) { // Get the facing mode from the first available video track. @@ -155,15 +162,14 @@ class CameraPlugin extends CameraPlatform { // https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/label // // Sensor orientation is currently not supported. - final String cameraLabel = videoInputDevice.label ?? ''; final CameraDescription camera = CameraDescription( - name: cameraLabel, + name: videoInputDevice.label, lensDirection: lensDirection, sensorOrientation: 0, ); final CameraMetadata cameraMetadata = CameraMetadata( - deviceId: videoInputDevice.deviceId!, + deviceId: videoInputDevice.deviceId, facingMode: facingMode, ); @@ -172,7 +178,7 @@ class CameraPlugin extends CameraPlatform { camerasMetadata[camera] = cameraMetadata; // Release the camera stream of the current video input device. - for (final html.MediaStreamTrack videoTrack in videoTracks) { + for (final web.MediaStreamTrack videoTrack in videoTracks) { videoTrack.stop(); } } else { @@ -182,7 +188,7 @@ class CameraPlugin extends CameraPlatform { } return cameras; - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw CameraException(e.name, e.message); } on PlatformException catch (e) { throw CameraException(e.code, e.message); @@ -278,14 +284,15 @@ class CameraPlugin extends CameraPlatform { // Add camera's video error events to the camera events stream. // The error event fires when the video element's source has failed to load, or can't be used. - _cameraVideoErrorSubscriptions[cameraId] = - camera.videoElement.onError.listen((html.Event _) { + _cameraVideoErrorSubscriptions[cameraId] = videoElementOnErrorProvider + .forElement(camera.videoElement) + .listen((web.Event _) { // The Event itself (_) doesn't contain information about the actual error. // We need to look at the HTMLMediaElement.error. // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error - final html.MediaError error = camera.videoElement.error!; + final web.MediaError error = camera.videoElement.error!; final CameraErrorCode errorCode = CameraErrorCode.fromMediaError(error); - final String? errorMessage = + final String errorMessage = error.message != '' ? error.message : _kDefaultErrorMessage; cameraEventStreamController.add( @@ -298,8 +305,9 @@ class CameraPlugin extends CameraPlatform { // Add camera's video abort events to the camera events stream. // The abort event fires when the video element's source has not fully loaded. - _cameraVideoAbortSubscriptions[cameraId] = - camera.videoElement.onAbort.listen((html.Event _) { + _cameraVideoAbortSubscriptions[cameraId] = videoElementOnAbortProvider + .forElement(camera.videoElement) + .listen((web.Event _) { cameraEventStreamController.add( CameraErrorEvent( cameraId, @@ -313,7 +321,7 @@ class CameraPlugin extends CameraPlatform { // Add camera's closing events to the camera events stream. // The onEnded stream fires when there is no more camera stream data. _cameraEndedSubscriptions[cameraId] = - camera.onEnded.listen((html.MediaStreamTrack _) { + camera.onEnded.listen((web.MediaStreamTrack _) { cameraEventStreamController.add( CameraClosingEvent(cameraId), ); @@ -334,7 +342,7 @@ class CameraPlugin extends CameraPlatform { false, ), ); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -374,23 +382,22 @@ class CameraPlugin extends CameraPlatform { @override Stream onDeviceOrientationChanged() { - final html.ScreenOrientation? orientation = window?.screen?.orientation; - - if (orientation != null) { - // Create an initial orientation event that emits the device orientation - // as soon as subscribed to this stream. - final html.Event initialOrientationEvent = html.Event('change'); - - return orientation.onChange.startWith(initialOrientationEvent).map( - (html.Event _) { - final DeviceOrientation deviceOrientation = _cameraService - .mapOrientationTypeToDeviceOrientation(orientation.type!); - return DeviceOrientationChangedEvent(deviceOrientation); - }, - ); - } else { - return const Stream.empty(); - } + final web.ScreenOrientation orientation = window.screen.orientation; + + // Create an initial orientation event that emits the device orientation + // as soon as subscribed to this stream. + final web.Event initialOrientationEvent = web.Event('change'); + + return orientationOnChangeProvider + .forTarget(orientation) + .startWith(initialOrientationEvent) + .map( + (web.Event _) { + final DeviceOrientation deviceOrientation = _cameraService + .mapOrientationTypeToDeviceOrientation(orientation.type); + return DeviceOrientationChangedEvent(deviceOrientation); + }, + ); } @override @@ -399,11 +406,10 @@ class CameraPlugin extends CameraPlatform { DeviceOrientation orientation, ) async { try { - final html.ScreenOrientation? screenOrientation = - window?.screen?.orientation; - final html.Element? documentElement = window?.document.documentElement; + final web.ScreenOrientation screenOrientation = window.screen.orientation; + final web.Element? documentElement = window.document.documentElement; - if (screenOrientation != null && documentElement != null) { + if (documentElement != null) { final String orientationType = _cameraService.mapDeviceOrientationToOrientationType(orientation); @@ -411,16 +417,16 @@ class CameraPlugin extends CameraPlatform { // See: https://w3c.github.io/screen-orientation/#interaction-with-fullscreen-api // Recent versions of Dart changed requestFullscreen to return a Future instead of void. // This wrapper allows use of both the old and new APIs. - dynamic fullScreen() => documentElement.requestFullscreen(); + dynamic fullScreen() => documentElement.requestFullScreenTweak(); await fullScreen(); - await screenOrientation.lock(orientationType); + await screenOrientation.lock(orientationType).toDart; } else { throw PlatformException( code: CameraErrorCode.orientationNotSupported.toString(), message: 'Orientation is not supported in the current browser.', ); } - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } } @@ -428,10 +434,10 @@ class CameraPlugin extends CameraPlatform { @override Future unlockCaptureOrientation(int cameraId) async { try { - final html.ScreenOrientation? orientation = window?.screen?.orientation; - final html.Element? documentElement = window?.document.documentElement; + final web.ScreenOrientation orientation = window.screen.orientation; + final web.Element? documentElement = window.document.documentElement; - if (orientation != null && documentElement != null) { + if (documentElement != null) { orientation.unlock(); } else { throw PlatformException( @@ -439,7 +445,7 @@ class CameraPlugin extends CameraPlatform { message: 'Orientation is not supported in the current browser.', ); } - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } } @@ -448,7 +454,7 @@ class CameraPlugin extends CameraPlatform { Future takePicture(int cameraId) { try { return getCamera(cameraId).takePicture(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -480,7 +486,7 @@ class CameraPlugin extends CameraPlatform { // The error event fires when the video recording is not allowed or an unsupported // codec is used. _cameraVideoRecordingErrorSubscriptions[options.cameraId] = - camera.onVideoRecordingError.listen((html.ErrorEvent errorEvent) { + camera.onVideoRecordingError.listen((web.ErrorEvent errorEvent) { cameraEventStreamController.add( CameraErrorEvent( options.cameraId, @@ -490,7 +496,7 @@ class CameraPlugin extends CameraPlatform { }); return camera.startVideoRecording(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -505,7 +511,7 @@ class CameraPlugin extends CameraPlatform { await getCamera(cameraId).stopVideoRecording(); await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); return videoRecording; - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -517,7 +523,7 @@ class CameraPlugin extends CameraPlatform { Future pauseVideoRecording(int cameraId) { try { return getCamera(cameraId).pauseVideoRecording(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -529,7 +535,7 @@ class CameraPlugin extends CameraPlatform { Future resumeVideoRecording(int cameraId) { try { return getCamera(cameraId).resumeVideoRecording(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -541,7 +547,7 @@ class CameraPlugin extends CameraPlatform { Future setFlashMode(int cameraId, FlashMode mode) async { try { getCamera(cameraId).setFlashMode(mode); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -593,7 +599,7 @@ class CameraPlugin extends CameraPlatform { Future getMaxZoomLevel(int cameraId) async { try { return getCamera(cameraId).getMaxZoomLevel(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -605,7 +611,7 @@ class CameraPlugin extends CameraPlatform { Future getMinZoomLevel(int cameraId) async { try { return getCamera(cameraId).getMinZoomLevel(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -617,7 +623,7 @@ class CameraPlugin extends CameraPlatform { Future setZoomLevel(int cameraId, double zoom) async { try { getCamera(cameraId).setZoomLevel(zoom); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw CameraException(e.name, e.message); } on PlatformException catch (e) { throw CameraException(e.code, e.message); @@ -631,7 +637,7 @@ class CameraPlugin extends CameraPlatform { Future pausePreview(int cameraId) async { try { getCamera(cameraId).pause(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } } @@ -640,7 +646,7 @@ class CameraPlugin extends CameraPlatform { Future resumePreview(int cameraId) async { try { await getCamera(cameraId).play(); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { _addCameraErrorEvent(e); @@ -661,6 +667,7 @@ class CameraPlugin extends CameraPlatform { await getCamera(cameraId).dispose(); await _cameraVideoErrorSubscriptions[cameraId]?.cancel(); await _cameraVideoAbortSubscriptions[cameraId]?.cancel(); + await _cameraEndedSubscriptions[cameraId]?.cancel(); await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); @@ -668,13 +675,13 @@ class CameraPlugin extends CameraPlatform { _cameraVideoErrorSubscriptions.remove(cameraId); _cameraVideoAbortSubscriptions.remove(cameraId); _cameraEndedSubscriptions.remove(cameraId); - } on html.DomException catch (e) { + } on web.DOMException catch (e) { throw PlatformException(code: e.name, message: e.message); } } /// Returns a media video stream for the device with the given [deviceId]. - Future _getVideoStreamForDevice( + Future _getVideoStreamForDevice( String deviceId, ) { // Create camera options with the desired device id. diff --git a/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart b/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart new file mode 100644 index 000000000000..fb0e84ef3774 --- /dev/null +++ b/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart @@ -0,0 +1,74 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:js_interop'; + +import 'package:web/web.dart'; + +/// Adds missing fields to [Element]. +extension FullScreenSupportMethods on Element { + @JS('requestFullscreen') + external JSPromise requestFullScreenTweak([JSAny options]); +} + +/// Adds missing fields to [MediaTrackSupportedConstraints]. +extension NonStandardFieldsOnMediaTrackSupportedConstraints + on MediaTrackSupportedConstraints { + @JS('zoom') + external bool? get zoomNullable; + + @JS('torch') + external bool? get torchNullable; +} + +/// Adds missing fields to [MediaTrackCapabilities]. +extension NonStandardFieldsOnMediaTrackCapabilities on MediaTrackCapabilities { + @JS('zoom') + external WebTweakMediaSettingsRange? get zoomNullable; + + @JS('torch') + external JSArray? get torchNullable; +} + +/// Adds missing fields to [MediaTrackSettings] +extension NonStandardFieldsOnMediaTrackSettings on MediaTrackSettings { + @JS('facingMode') + external String? get facingModeNullable; +} + +/// Brought over from package:web 1.0.0 +extension type WebTweakMediaSettingsRange._(JSObject _) implements JSObject { + @JS('MediaSettingsRange') + external factory WebTweakMediaSettingsRange({ + num max, + num min, + num step, + }); + + external double get max; + external set max(num value); + external double get min; + external set min(num value); + external double get step; + external set step(num value); +} + +/// Adds an applyConstraints method that accepts the WebTweakMediaTrackConstraints. +extension WebTweakMethodVersions on MediaStreamTrack { + @JS('applyConstraints') + external JSPromise applyWebTweakConstraints( + [WebTweakMediaTrackConstraints constraints]); +} + +/// Allows creating the MediaTrackConstraints that are needed. +/// Brought over from package:web 1.0.0 +extension type WebTweakMediaTrackConstraints._(JSObject _) implements JSObject { + @JS('MediaTrackConstraints') + external factory WebTweakMediaTrackConstraints({ + JSAny zoom, + ConstrainBoolean torch, + }); +} diff --git a/packages/camera/camera_web/lib/src/shims/dart_js_util.dart b/packages/camera/camera_web/lib/src/shims/dart_js_util.dart index 7d766e8c269e..50cda211725f 100644 --- a/packages/camera/camera_web/lib/src/shims/dart_js_util.dart +++ b/packages/camera/camera_web/lib/src/shims/dart_js_util.dart @@ -2,14 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:js_util' as js_util; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; -/// A utility that shims dart:js_util to manipulate JavaScript interop objects. +/// A utility that shims dart:js_interop to manipulate JavaScript interop objects. class JsUtil { /// Returns true if the object [o] has the property [name]. - bool hasProperty(Object o, Object name) => js_util.hasProperty(o, name); + bool hasProperty(JSObject o, JSAny name) => o.hasProperty(name).toDart; /// Returns the value of the property [name] in the object [o]. - dynamic getProperty(Object o, Object name) => - js_util.getProperty(o, name); + JSAny? getProperty(JSObject o, JSAny name) => o.getProperty(name); } diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart index 8f1831f79cf5..6619e5fb388b 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_code.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:html' as html; +import 'package:web/web.dart' as web; /// Error codes that may occur during the camera initialization, /// configuration or video streaming. @@ -78,18 +78,17 @@ class CameraErrorCode { /// Returns a camera error code based on the media error. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code - static CameraErrorCode fromMediaError(html.MediaError error) { - switch (error.code) { - case html.MediaError.MEDIA_ERR_ABORTED: - return const CameraErrorCode._('mediaErrorAborted'); - case html.MediaError.MEDIA_ERR_NETWORK: - return const CameraErrorCode._('mediaErrorNetwork'); - case html.MediaError.MEDIA_ERR_DECODE: - return const CameraErrorCode._('mediaErrorDecode'); - case html.MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: - return const CameraErrorCode._('mediaErrorSourceNotSupported'); - default: - return const CameraErrorCode._('mediaErrorUnknown'); + static CameraErrorCode fromMediaError(web.MediaError error) { + if (error.code == web.MediaError.MEDIA_ERR_ABORTED) { + return const CameraErrorCode._('mediaErrorAborted'); + } else if (error.code == web.MediaError.MEDIA_ERR_NETWORK) { + return const CameraErrorCode._('mediaErrorNetwork'); + } else if (error.code == web.MediaError.MEDIA_ERR_DECODE) { + return const CameraErrorCode._('mediaErrorDecode'); + } else if (error.code == web.MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) { + return const CameraErrorCode._('mediaErrorSourceNotSupported'); + } else { + return const CameraErrorCode._('mediaErrorUnknown'); } } } diff --git a/packages/camera/camera_web/lib/src/types/camera_options.dart b/packages/camera/camera_web/lib/src/types/camera_options.dart index ecb729d74544..45b446215a89 100644 --- a/packages/camera/camera_web/lib/src/types/camera_options.dart +++ b/packages/camera/camera_web/lib/src/types/camera_options.dart @@ -2,7 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:js_interop'; + import 'package:flutter/foundation.dart'; +import 'package:web/web.dart' as web; /// Options used to create a camera with the given /// [audio] and [video] media constraints. @@ -28,12 +31,12 @@ class CameraOptions { /// The video constraints for the camera. final VideoConstraints video; - /// Converts the current instance to a Map. - Map toJson() { - return { - 'audio': audio.toJson(), - 'video': video.toJson(), - }; + /// Converts `this` to something that can be used by the browser. + web.MediaStreamConstraints toMediaStreamConstraints() { + return web.MediaStreamConstraints( + audio: audio.toMediaStreamConstraints(), + video: video.toMediaStreamConstraints(), + ); } @override @@ -63,8 +66,8 @@ class AudioConstraints { /// Whether the audio track should be enabled. final bool enabled; - /// Converts the current instance to a Map. - Object toJson() => enabled; + /// Convert `this` to something that can be used on the browser. + JSAny toMediaStreamConstraints() => enabled.toJS; @override bool operator ==(Object other) { @@ -104,24 +107,15 @@ class VideoConstraints { /// The device id of the video track. final String? deviceId; - /// Converts the current instance to a Map. - Object toJson() { - final Map json = {}; - - if (width != null) { - json['width'] = width!.toJson(); - } - if (height != null) { - json['height'] = height!.toJson(); - } - if (facingMode != null) { - json['facingMode'] = facingMode!.toJson(); - } - if (deviceId != null) { - json['deviceId'] = {'exact': deviceId!}; - } - - return json; + // TODO(dit): package:web has a class for this. Use it instead of jsify and toJson. + /// Convert `this` to something that can be used on the browser. + JSAny toMediaStreamConstraints() { + return { + if (width != null) 'width': width!.toJson(), + if (height != null) 'height': height!.toJson(), + if (facingMode != null) 'facingMode': facingMode!.toJson(), + if (deviceId != null) 'deviceId': {'exact': deviceId!}, + }.jsify()!; } @override @@ -162,6 +156,7 @@ enum CameraType { String toString() => _type; } +// TODO(dit): package:web has a class for this. Use it instead of toJson. /// Indicates the direction in which the desired camera should be pointing. @immutable class FacingModeConstraint { @@ -191,6 +186,7 @@ class FacingModeConstraint { /// the desired facing [type] to be considered acceptable. final CameraType? exact; + // TODO(dit): package:web has a class for this. Use it instead of toJson. /// Converts the current instance to a Map. Object toJson() { return { @@ -214,6 +210,7 @@ class FacingModeConstraint { int get hashCode => Object.hash(ideal, exact); } +// TODO(dit): package:web has a class for this. Use it instead of toJson. /// The size of the requested video track used in /// [VideoConstraints.width] and [VideoConstraints.height]. /// @@ -240,6 +237,7 @@ class VideoSizeConstraint { /// The maximum video size. final int? maximum; + // TODO(dit): package:web has a class for this. Use it instead of toJson. /// Converts the current instance to a Map. Object toJson() { final Map json = {}; diff --git a/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart b/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart index d20bd25108bb..71e250b3a8a8 100644 --- a/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart +++ b/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart @@ -2,9 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:html' as html; - import 'package:flutter/foundation.dart'; +import 'package:web/web.dart' as web; /// The possible range of values for the zoom level configurable /// on the camera video track. @@ -30,7 +29,7 @@ class ZoomLevelCapability { final double maximum; /// The video track capable of configuring the zoom level. - final html.MediaStreamTrack videoTrack; + final web.MediaStreamTrack videoTrack; @override bool operator ==(Object other) { diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index e33a2e0fd286..f439c6250362 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,11 +2,11 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.3.4 +version: 0.3.5 environment: - sdk: ^3.2.0 - flutter: ">=3.16.0" + sdk: ^3.3.0 + flutter: ">=3.19.0" flutter: plugin: @@ -23,6 +23,7 @@ dependencies: flutter_web_plugins: sdk: flutter stream_transform: ^2.0.0 + web: ">=0.5.1 <2.0.0" dev_dependencies: flutter_test: diff --git a/script/configs/exclude_all_packages_app_wasm.yaml b/script/configs/exclude_all_packages_app_wasm.yaml index cad06d3043a5..8a02d4e47996 100644 --- a/script/configs/exclude_all_packages_app_wasm.yaml +++ b/script/configs/exclude_all_packages_app_wasm.yaml @@ -5,7 +5,4 @@ # This is only used for wasm compilation. Once all packages in the repo have # been migrated, remove this file and use `exclude_all_packages_app.yaml` only. -# Packages that aren't migrated yet. -# https://github.com/flutter/flutter/issues/117022 -- camera - +[] # Needed so the contents of this file are an empty array, not `null`! From 89d28fdb2b0cf5337a12085fb8dfb803ee691b72 Mon Sep 17 00:00:00 2001 From: Rexios Date: Tue, 6 Aug 2024 06:59:20 -0400 Subject: [PATCH 09/12] [google_maps_flutter_android] Android changes to support heatmaps (#7313) Sequel to: - https://github.com/flutter/packages/pull/7312 Prequel to: - https://github.com/flutter/packages/pull/3257 --- .../google_maps_flutter_android/CHANGELOG.md | 4 + .../google_maps_flutter_android/README.md | 10 + .../flutter/plugins/googlemaps/Convert.java | 129 ++++++++++++ .../plugins/googlemaps/GoogleMapBuilder.java | 7 + .../googlemaps/GoogleMapController.java | 30 +++ .../plugins/googlemaps/GoogleMapFactory.java | 5 + .../googlemaps/GoogleMapOptionsSink.java | 2 + .../plugins/googlemaps/HeatmapBuilder.java | 51 +++++ .../plugins/googlemaps/HeatmapController.java | 59 ++++++ .../googlemaps/HeatmapOptionsSink.java | 28 +++ .../googlemaps/HeatmapsController.java | 114 +++++++++++ .../flutter/plugins/googlemaps/Messages.java | 184 +++++++++++++---- .../plugins/googlemaps/ConvertTest.java | 189 ++++++++++++++++++ .../googlemaps/GoogleMapControllerTest.java | 29 +++ .../googlemaps/HeatmapsControllerTest.java | 108 ++++++++++ .../example/pubspec.yaml | 2 +- .../lib/src/google_map_inspector_android.dart | 3 + .../lib/src/google_maps_flutter_android.dart | 23 +++ .../lib/src/messages.g.dart | 124 ++++++++---- .../lib/src/serialization.dart | 121 +++++++++++ .../pigeons/messages.dart | 33 ++- .../google_maps_flutter_android/pubspec.yaml | 4 +- ...oogle_maps_flutter_android_test.mocks.dart | 19 ++ 23 files changed, 1199 insertions(+), 79 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/HeatmapsControllerTest.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/lib/src/serialization.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md index fa0e4a06a869..e48f90c9073c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.13.0 + +* Adds support for heatmap layers. + ## 2.12.2 * Updates the example app to use TLHC mode, per current package guidance. diff --git a/packages/google_maps_flutter/google_maps_flutter_android/README.md b/packages/google_maps_flutter/google_maps_flutter_android/README.md index ccef3a0b1277..a4f0ff5a3486 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_android/README.md @@ -78,6 +78,16 @@ Google Play the latest renderer will not be available and the legacy renderer wi WARNING: `AndroidMapRenderer.legacy` is known to crash apps and is no longer supported by the Google Maps team and therefore cannot be supported by the Flutter team. +## Supported Heatmap Options + +| Field | Supported | +| ---------------------------- | :-------: | +| Heatmap.dissipating | x | +| Heatmap.maxIntensity | ✓ | +| Heatmap.minimumZoomIntensity | x | +| Heatmap.maximumZoomIntensity | x | +| HeatmapGradient.colorMapSize | ✓ | + [1]: https://pub.dev/packages/google_maps_flutter [2]: https://flutter.dev/to/endorsed-federated-plugin [3]: https://docs.flutter.dev/development/platform-integration/android/platform-views diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java index d9caf66d688c..825bb504abbf 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java @@ -30,6 +30,8 @@ import com.google.android.gms.maps.model.SquareCap; import com.google.android.gms.maps.model.Tile; import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.WeightedLatLng; import io.flutter.FlutterInjector; import java.io.IOException; import java.io.InputStream; @@ -41,6 +43,17 @@ /** Conversions between JSON-like values and GoogleMaps data types. */ class Convert { + // These constants must match the corresponding constants in serialization.dart + public static final String HEATMAPS_TO_ADD_KEY = "heatmapsToAdd"; + public static final String HEATMAP_ID_KEY = "heatmapId"; + public static final String HEATMAP_DATA_KEY = "data"; + public static final String HEATMAP_GRADIENT_KEY = "gradient"; + public static final String HEATMAP_MAX_INTENSITY_KEY = "maxIntensity"; + public static final String HEATMAP_OPACITY_KEY = "opacity"; + public static final String HEATMAP_RADIUS_KEY = "radius"; + public static final String HEATMAP_GRADIENT_COLORS_KEY = "colors"; + public static final String HEATMAP_GRADIENT_START_POINTS_KEY = "startPoints"; + public static final String HEATMAP_GRADIENT_COLOR_MAP_SIZE_KEY = "colorMapSize"; private static BitmapDescriptor toBitmapDescriptor( Object o, AssetManager assetManager, float density) { @@ -465,6 +478,17 @@ static LatLng toLatLng(Object o) { return new LatLng(toDouble(data.get(0)), toDouble(data.get(1))); } + /** + * Converts a list of serialized weighted lat/lng to a list of WeightedLatLng. + * + * @param o The serialized list of weighted lat/lng. + * @return The list of WeightedLatLng. + */ + static WeightedLatLng toWeightedLatLng(Object o) { + final List data = toList(o); + return new WeightedLatLng(toLatLng(data.get(0)), toDouble(data.get(1))); + } + static Point pointFromPigeon(Messages.PlatformPoint point) { return new Point(point.getX().intValue(), point.getY().intValue()); } @@ -842,6 +866,55 @@ static String interpretCircleOptions(Map data, CircleOptionsSink sink } } + /** + * Set the options in the given heatmap object to the given sink. + * + * @param o the object expected to be a Map containing the heatmap options. The options map is + * expected to have the following structure: + *
{@code
+   * {
+   *   "heatmapId": String,
+   *   "data": List, // List of serialized weighted lat/lng
+   *   "gradient": Map, // Serialized heatmap gradient
+   *   "maxIntensity": Double,
+   *   "opacity": Double,
+   *   "radius": Integer
+   * }
+   * }
+ * + * @param sink the HeatmapOptionsSink where the options will be set. + * @return the heatmapId. + * @throws IllegalArgumentException if heatmapId is null. + */ + static String interpretHeatmapOptions(Map data, HeatmapOptionsSink sink) { + final Object rawWeightedData = data.get(HEATMAP_DATA_KEY); + if (rawWeightedData != null) { + sink.setWeightedData(toWeightedData(rawWeightedData)); + } + final Object gradient = data.get(HEATMAP_GRADIENT_KEY); + if (gradient != null) { + sink.setGradient(toGradient(gradient)); + } + final Object maxIntensity = data.get(HEATMAP_MAX_INTENSITY_KEY); + if (maxIntensity != null) { + sink.setMaxIntensity(toDouble(maxIntensity)); + } + final Object opacity = data.get(HEATMAP_OPACITY_KEY); + if (opacity != null) { + sink.setOpacity(toDouble(opacity)); + } + final Object radius = data.get(HEATMAP_RADIUS_KEY); + if (radius != null) { + sink.setRadius(toInt(radius)); + } + final String heatmapId = (String) data.get(HEATMAP_ID_KEY); + if (heatmapId == null) { + throw new IllegalArgumentException("heatmapId was null"); + } else { + return heatmapId; + } + } + @VisibleForTesting static List toPoints(Object o) { final List data = toList(o); @@ -854,6 +927,62 @@ static List toPoints(Object o) { return points; } + /** + * Converts the given object to a list of WeightedLatLng objects. + * + * @param o the object to convert. The object is expected to be a List of serialized weighted + * lat/lng. + * @return a list of WeightedLatLng objects. + */ + @VisibleForTesting + static List toWeightedData(Object o) { + final List data = toList(o); + final List weightedData = new ArrayList<>(data.size()); + + for (Object rawWeightedPoint : data) { + weightedData.add(toWeightedLatLng(rawWeightedPoint)); + } + return weightedData; + } + + /** + * Converts the given object to a Gradient object. + * + * @param o the object to convert. The object is expected to be a Map containing the gradient + * options. The gradient map is expected to have the following structure: + *
{@code
+   * {
+   *   "colors": List,
+   *   "startPoints": List,
+   *   "colorMapSize": Integer
+   * }
+   * }
+ * + * @return a Gradient object. + */ + @VisibleForTesting + static Gradient toGradient(Object o) { + final Map data = toMap(o); + + final List colorData = toList(data.get(HEATMAP_GRADIENT_COLORS_KEY)); + assert colorData != null; + final int[] colors = new int[colorData.size()]; + for (int i = 0; i < colorData.size(); i++) { + colors[i] = toInt(colorData.get(i)); + } + + final List startPointData = toList(data.get(HEATMAP_GRADIENT_START_POINTS_KEY)); + assert startPointData != null; + final float[] startPoints = new float[startPointData.size()]; + for (int i = 0; i < startPointData.size(); i++) { + startPoints[i] = toFloat(startPointData.get(i)); + } + + final int colorMapSize = toInt(data.get(HEATMAP_GRADIENT_COLOR_MAP_SIZE_KEY)); + + return new Gradient(colors, startPoints, colorMapSize); + } + private static List> toHoles(Object o) { final List data = toList(o); final List> holes = new ArrayList<>(data.size()); diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java index 02477418d425..1273a21f6647 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java @@ -27,6 +27,7 @@ class GoogleMapBuilder implements GoogleMapOptionsSink { private Object initialPolygons; private Object initialPolylines; private Object initialCircles; + private Object initialHeatmaps; private List> initialTileOverlays; private Rect padding = new Rect(0, 0, 0, 0); private @Nullable String style; @@ -50,6 +51,7 @@ GoogleMapController build( controller.setInitialPolygons(initialPolygons); controller.setInitialPolylines(initialPolylines); controller.setInitialCircles(initialCircles); + controller.setInitialHeatmaps(initialHeatmaps); controller.setPadding(padding.top, padding.left, padding.bottom, padding.right); controller.setInitialTileOverlays(initialTileOverlays); controller.setMapStyle(style); @@ -184,6 +186,11 @@ public void setInitialCircles(Object initialCircles) { this.initialCircles = initialCircles; } + @Override + public void setInitialHeatmaps(Object initialHeatmaps) { + this.initialHeatmaps = initialHeatmaps; + } + @Override public void setInitialTileOverlays(List> initialTileOverlays) { this.initialTileOverlays = initialTileOverlays; diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java index 3e8aaffe97c0..4cb5e3b57af7 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java @@ -92,6 +92,7 @@ class GoogleMapController private final PolygonsController polygonsController; private final PolylinesController polylinesController; private final CirclesController circlesController; + private final HeatmapsController heatmapsController; private final TileOverlaysController tileOverlaysController; private MarkerManager markerManager; private MarkerManager.Collection markerCollection; @@ -100,6 +101,7 @@ class GoogleMapController private List initialPolygons; private List initialPolylines; private List initialCircles; + private List initialHeatmaps; private List> initialTileOverlays; // Null except between initialization and onMapReady. private @Nullable String initialMapStyle; @@ -129,6 +131,7 @@ class GoogleMapController this.polygonsController = new PolygonsController(flutterApi, density); this.polylinesController = new PolylinesController(flutterApi, assetManager, density); this.circlesController = new CirclesController(flutterApi, density); + this.heatmapsController = new HeatmapsController(); this.tileOverlaysController = new TileOverlaysController(flutterApi); } @@ -146,6 +149,7 @@ class GoogleMapController PolygonsController polygonsController, PolylinesController polylinesController, CirclesController circlesController, + HeatmapsController heatmapController, TileOverlaysController tileOverlaysController) { this.id = id; this.context = context; @@ -160,6 +164,7 @@ class GoogleMapController this.polygonsController = polygonsController; this.polylinesController = polylinesController; this.circlesController = circlesController; + this.heatmapsController = heatmapController; this.tileOverlaysController = tileOverlaysController; } @@ -198,6 +203,7 @@ public void onMapReady(@NonNull GoogleMap googleMap) { polygonsController.setGoogleMap(googleMap); polylinesController.setGoogleMap(googleMap); circlesController.setGoogleMap(googleMap); + heatmapsController.setGoogleMap(googleMap); tileOverlaysController.setGoogleMap(googleMap); setMarkerCollectionListener(this); setClusterItemClickListener(this); @@ -207,6 +213,7 @@ public void onMapReady(@NonNull GoogleMap googleMap) { updateInitialPolygons(); updateInitialPolylines(); updateInitialCircles(); + updateInitialHeatmaps(); updateInitialTileOverlays(); if (initialPadding != null && initialPadding.size() == 4) { setPadding( @@ -679,10 +686,23 @@ public void setInitialCircles(Object initialCircles) { } } + @Override + public void setInitialHeatmaps(Object initialHeatmaps) { + List heatmaps = (List) initialHeatmaps; + this.initialHeatmaps = heatmaps != null ? new ArrayList<>(heatmaps) : null; + if (googleMap != null) { + updateInitialHeatmaps(); + } + } + private void updateInitialCircles() { circlesController.addJsonCircles(initialCircles); } + private void updateInitialHeatmaps() { + heatmapsController.addJsonHeatmaps(initialHeatmaps); + } + @Override public void setInitialTileOverlays(List> initialTileOverlays) { this.initialTileOverlays = initialTileOverlays; @@ -802,6 +822,16 @@ public void updateCircles( circlesController.removeCircles(idsToRemove); } + @Override + public void updateHeatmaps( + @NonNull List toAdd, + @NonNull List toChange, + @NonNull List idsToRemove) { + heatmapsController.addHeatmaps(toAdd); + heatmapsController.changeHeatmaps(toChange); + heatmapsController.removeHeatmaps(idsToRemove); + } + @Override public void updateClusterManagers( @NonNull List toAdd, @NonNull List idsToRemove) { diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java index b8d6485d35eb..81ab3ec72079 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java @@ -4,6 +4,8 @@ package io.flutter.plugins.googlemaps; +import static io.flutter.plugins.googlemaps.Convert.HEATMAPS_TO_ADD_KEY; + import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -58,6 +60,9 @@ public PlatformView create(@NonNull Context context, int id, @Nullable Object ar if (params.containsKey("circlesToAdd")) { builder.setInitialCircles(params.get("circlesToAdd")); } + if (params.containsKey(HEATMAPS_TO_ADD_KEY)) { + builder.setInitialHeatmaps(params.get(HEATMAPS_TO_ADD_KEY)); + } if (params.containsKey("tileOverlaysToAdd")) { builder.setInitialTileOverlays((List>) params.get("tileOverlaysToAdd")); } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java index 9f744a653b3c..b353da6f65ff 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java @@ -57,6 +57,8 @@ interface GoogleMapOptionsSink { void setInitialCircles(Object initialCircles); + void setInitialHeatmaps(Object initialHeatmaps); + void setInitialTileOverlays(List> initialTileOverlays); void setMapStyle(@Nullable String style); diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java new file mode 100644 index 000000000000..8b335951958b --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java @@ -0,0 +1,51 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import androidx.annotation.NonNull; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.HeatmapTileProvider; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +/** Builder of a single Heatmap on the map. */ +public class HeatmapBuilder implements HeatmapOptionsSink { + private final HeatmapTileProvider.Builder heatmapOptions; + + /** Construct a HeatmapBuilder. */ + HeatmapBuilder() { + this.heatmapOptions = new HeatmapTileProvider.Builder(); + } + + /** Build the HeatmapTileProvider with the given options. */ + HeatmapTileProvider build() { + return heatmapOptions.build(); + } + + @Override + public void setWeightedData(@NonNull List weightedData) { + heatmapOptions.weightedData(weightedData); + } + + @Override + public void setGradient(@NonNull Gradient gradient) { + heatmapOptions.gradient(gradient); + } + + @Override + public void setMaxIntensity(double maxIntensity) { + heatmapOptions.maxIntensity(maxIntensity); + } + + @Override + public void setOpacity(double opacity) { + heatmapOptions.opacity(opacity); + } + + @Override + public void setRadius(int radius) { + heatmapOptions.radius(radius); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java new file mode 100644 index 000000000000..a2990e234293 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java @@ -0,0 +1,59 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import androidx.annotation.NonNull; +import com.google.android.gms.maps.model.TileOverlay; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.HeatmapTileProvider; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +/** Controller of a single Heatmap on the map. */ +public class HeatmapController implements HeatmapOptionsSink { + private final @NonNull HeatmapTileProvider heatmap; + private final @NonNull TileOverlay heatmapTileOverlay; + + /** Construct a HeatmapController with the given heatmap and heatmapTileOverlay. */ + HeatmapController(@NonNull HeatmapTileProvider heatmap, @NonNull TileOverlay heatmapTileOverlay) { + this.heatmap = heatmap; + this.heatmapTileOverlay = heatmapTileOverlay; + } + + /** Remove the heatmap from the map. */ + void remove() { + heatmapTileOverlay.remove(); + } + + /** Clear the tile cache of the heatmap in order to update the heatmap. */ + void clearTileCache() { + heatmapTileOverlay.clearTileCache(); + } + + @Override + public void setWeightedData(@NonNull List weightedData) { + heatmap.setWeightedData(weightedData); + } + + @Override + public void setGradient(@NonNull Gradient gradient) { + heatmap.setGradient(gradient); + } + + @Override + public void setMaxIntensity(double maxIntensity) { + heatmap.setMaxIntensity(maxIntensity); + } + + @Override + public void setOpacity(double opacity) { + heatmap.setOpacity(opacity); + } + + @Override + public void setRadius(int radius) { + heatmap.setRadius(radius); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java new file mode 100644 index 000000000000..fc571665c7d2 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java @@ -0,0 +1,28 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import androidx.annotation.NonNull; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +/** Receiver of Heatmap configuration options. */ +interface HeatmapOptionsSink { + /** Set the weighted data to be used to generate the heatmap. */ + void setWeightedData(@NonNull List weightedData); + + /** Set the gradient to be used to color the heatmap. */ + void setGradient(@NonNull Gradient gradient); + + /** Set the maximum intensity for the heatmap. */ + void setMaxIntensity(double maxIntensity); + + /** Set the opacity of the heatmap. */ + void setOpacity(double opacity); + + /** Set the radius of the heatmap. */ + void setRadius(int radius); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java new file mode 100644 index 000000000000..2bbc685365c2 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java @@ -0,0 +1,114 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_ID_KEY; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.TileOverlay; +import com.google.android.gms.maps.model.TileOverlayOptions; +import com.google.maps.android.heatmaps.HeatmapTileProvider; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Controller of multiple Heatmaps on the map. */ +public class HeatmapsController { + /** Mapping from Heatmap ID to HeatmapController. */ + private final Map heatmapIdToController; + /** The GoogleMap to which the heatmaps are added. */ + private GoogleMap googleMap; + + /** Constructs a HeatmapsController. */ + HeatmapsController() { + this.heatmapIdToController = new HashMap<>(); + } + + /** Sets the GoogleMap to which the heatmaps are added. */ + void setGoogleMap(GoogleMap googleMap) { + this.googleMap = googleMap; + } + + /** Adds heatmaps to the map from json data. */ + void addJsonHeatmaps(List heatmapsToAdd) { + if (heatmapsToAdd != null) { + for (Object heatmapToAdd : heatmapsToAdd) { + @SuppressWarnings("unchecked") + Map heatmapMap = (Map) heatmapToAdd; + addJsonHeatmap(heatmapMap); + } + } + } + + /** Adds heatmaps to the map. */ + void addHeatmaps(@NonNull List heatmapsToAdd) { + for (Messages.PlatformHeatmap heatmapToAdd : heatmapsToAdd) { + addJsonHeatmap(heatmapToAdd.getJson()); + } + } + + /** Updates the given heatmaps on the map. */ + void changeHeatmaps(@NonNull List heatmapsToChange) { + for (Messages.PlatformHeatmap heatmapToChange : heatmapsToChange) { + changeJsonHeatmap(heatmapToChange.getJson()); + } + } + + /** Removes heatmaps with the given ids from the map. */ + void removeHeatmaps(@NonNull List heatmapIdsToRemove) { + for (String heatmapId : heatmapIdsToRemove) { + HeatmapController heatmapController = heatmapIdToController.remove(heatmapId); + if (heatmapController != null) { + heatmapController.remove(); + heatmapIdToController.remove(heatmapId); + } + } + } + + /** Builds the heatmap. This method exists to allow mocking the HeatmapTileProvider in tests. */ + @VisibleForTesting + public @NonNull HeatmapTileProvider buildHeatmap(@NonNull HeatmapBuilder builder) { + return builder.build(); + } + + /** Adds a heatmap to the map from json data. */ + private void addJsonHeatmap(Map heatmap) { + if (heatmap == null) { + return; + } + HeatmapBuilder heatmapBuilder = new HeatmapBuilder(); + String heatmapId = Convert.interpretHeatmapOptions(heatmap, heatmapBuilder); + HeatmapTileProvider options = buildHeatmap(heatmapBuilder); + addHeatmap(heatmapId, options); + } + + /** Adds a heatmap to the map. */ + private void addHeatmap(String heatmapId, HeatmapTileProvider options) { + TileOverlay heatmapTileOverlay = + googleMap.addTileOverlay(new TileOverlayOptions().tileProvider(options)); + HeatmapController heatmapController = new HeatmapController(options, heatmapTileOverlay); + heatmapIdToController.put(heatmapId, heatmapController); + } + + /** Updates the given heatmap on the map. */ + private void changeJsonHeatmap(Map heatmap) { + if (heatmap == null) { + return; + } + String heatmapId = getHeatmapId(heatmap); + HeatmapController heatmapController = heatmapIdToController.get(heatmapId); + if (heatmapController != null) { + Convert.interpretHeatmapOptions(heatmap, heatmapController); + heatmapController.clearTileCache(); + } + } + + /** Returns the heatmap id from the given heatmap data. */ + private static String getHeatmapId(Map heatmap) { + return (String) heatmap.get(HEATMAP_ID_KEY); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Messages.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Messages.java index 2e5a61cb0755..32fc6eaf6cb4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Messages.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Messages.java @@ -242,7 +242,7 @@ ArrayList toList() { public static final class PlatformCameraUpdate { /** * The update data, as JSON. This should only be set from CameraUpdate.toJson, and the native - * code must intepret it according to the internal implementation details of the CameraUpdate + * code must interpret it according to the internal implementation details of the CameraUpdate * class. */ private @NonNull Object json; @@ -318,7 +318,7 @@ ArrayList toList() { public static final class PlatformCircle { /** * The circle data, as JSON. This should only be set from Circle.toJson, and the native code - * must intepret it according to the internal implementation details of that method. + * must interpret it according to the internal implementation details of that method. */ private @NonNull Map json; @@ -385,6 +385,81 @@ ArrayList toList() { } } + /** + * Pigeon equivalent of the Heatmap class. + * + *

Generated class from Pigeon that represents data sent in messages. + */ + public static final class PlatformHeatmap { + /** + * The heatmap data, as JSON. This should only be set from Heatmap.toJson, and the native code + * must interpret it according to the internal implementation details of that method. + */ + private @NonNull Map json; + + public @NonNull Map getJson() { + return json; + } + + public void setJson(@NonNull Map setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"json\" is null."); + } + this.json = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + PlatformHeatmap() {} + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PlatformHeatmap that = (PlatformHeatmap) o; + return json.equals(that.json); + } + + @Override + public int hashCode() { + return Objects.hash(json); + } + + public static final class Builder { + + private @Nullable Map json; + + @CanIgnoreReturnValue + public @NonNull Builder setJson(@NonNull Map setterArg) { + this.json = setterArg; + return this; + } + + public @NonNull PlatformHeatmap build() { + PlatformHeatmap pigeonReturn = new PlatformHeatmap(); + pigeonReturn.setJson(json); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList(1); + toListResult.add(json); + return toListResult; + } + + static @NonNull PlatformHeatmap fromList(@NonNull ArrayList __pigeon_list) { + PlatformHeatmap pigeonResult = new PlatformHeatmap(); + Object json = __pigeon_list.get(0); + pigeonResult.setJson((Map) json); + return pigeonResult; + } + } + /** * Pigeon equivalent of the ClusterManager class. * @@ -464,7 +539,7 @@ ArrayList toList() { public static final class PlatformMarker { /** * The marker data, as JSON. This should only be set from Marker.toJson, and the native code - * must intepret it according to the internal implementation details of that method. + * must interpret it according to the internal implementation details of that method. */ private @NonNull Map json; @@ -539,7 +614,7 @@ ArrayList toList() { public static final class PlatformPolygon { /** * The polygon data, as JSON. This should only be set from Polygon.toJson, and the native code - * must intepret it according to the internal implementation details of that method. + * must interpret it according to the internal implementation details of that method. */ private @NonNull Map json; @@ -614,7 +689,7 @@ ArrayList toList() { public static final class PlatformPolyline { /** * The polyline data, as JSON. This should only be set from Polyline.toJson, and the native code - * must intepret it according to the internal implementation details of that method. + * must interpret it according to the internal implementation details of that method. */ private @NonNull Map json; @@ -815,7 +890,8 @@ ArrayList toList() { public static final class PlatformTileOverlay { /** * The tile overlay data, as JSON. This should only be set from TileOverlay.toJson, and the - * native code must intepret it according to the internal implementation details of that method. + * native code must interpret it according to the internal implementation details of that + * method. */ private @NonNull Map json; @@ -1231,8 +1307,8 @@ ArrayList toList() { public static final class PlatformMapConfiguration { /** * The configuration options, as JSON. This should only be set from _jsonForMapConfiguration, - * and the native code must intepret it according to the internal implementation details of that - * method. + * and the native code must interpret it according to the internal implementation details of + * that method. */ private @NonNull Map json; @@ -1655,32 +1731,34 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { case (byte) 131: return PlatformCircle.fromList((ArrayList) readValue(buffer)); case (byte) 132: - return PlatformClusterManager.fromList((ArrayList) readValue(buffer)); + return PlatformHeatmap.fromList((ArrayList) readValue(buffer)); case (byte) 133: - return PlatformMarker.fromList((ArrayList) readValue(buffer)); + return PlatformClusterManager.fromList((ArrayList) readValue(buffer)); case (byte) 134: - return PlatformPolygon.fromList((ArrayList) readValue(buffer)); + return PlatformMarker.fromList((ArrayList) readValue(buffer)); case (byte) 135: - return PlatformPolyline.fromList((ArrayList) readValue(buffer)); + return PlatformPolygon.fromList((ArrayList) readValue(buffer)); case (byte) 136: - return PlatformTile.fromList((ArrayList) readValue(buffer)); + return PlatformPolyline.fromList((ArrayList) readValue(buffer)); case (byte) 137: - return PlatformTileOverlay.fromList((ArrayList) readValue(buffer)); + return PlatformTile.fromList((ArrayList) readValue(buffer)); case (byte) 138: - return PlatformLatLng.fromList((ArrayList) readValue(buffer)); + return PlatformTileOverlay.fromList((ArrayList) readValue(buffer)); case (byte) 139: - return PlatformLatLngBounds.fromList((ArrayList) readValue(buffer)); + return PlatformLatLng.fromList((ArrayList) readValue(buffer)); case (byte) 140: - return PlatformCluster.fromList((ArrayList) readValue(buffer)); + return PlatformLatLngBounds.fromList((ArrayList) readValue(buffer)); case (byte) 141: - return PlatformMapConfiguration.fromList((ArrayList) readValue(buffer)); + return PlatformCluster.fromList((ArrayList) readValue(buffer)); case (byte) 142: - return PlatformPoint.fromList((ArrayList) readValue(buffer)); + return PlatformMapConfiguration.fromList((ArrayList) readValue(buffer)); case (byte) 143: - return PlatformTileLayer.fromList((ArrayList) readValue(buffer)); + return PlatformPoint.fromList((ArrayList) readValue(buffer)); case (byte) 144: - return PlatformZoomRange.fromList((ArrayList) readValue(buffer)); + return PlatformTileLayer.fromList((ArrayList) readValue(buffer)); case (byte) 145: + return PlatformZoomRange.fromList((ArrayList) readValue(buffer)); + case (byte) 146: Object value = readValue(buffer); return value == null ? null : PlatformRendererType.values()[(int) value]; default: @@ -1699,47 +1777,50 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof PlatformCircle) { stream.write(131); writeValue(stream, ((PlatformCircle) value).toList()); - } else if (value instanceof PlatformClusterManager) { + } else if (value instanceof PlatformHeatmap) { stream.write(132); + writeValue(stream, ((PlatformHeatmap) value).toList()); + } else if (value instanceof PlatformClusterManager) { + stream.write(133); writeValue(stream, ((PlatformClusterManager) value).toList()); } else if (value instanceof PlatformMarker) { - stream.write(133); + stream.write(134); writeValue(stream, ((PlatformMarker) value).toList()); } else if (value instanceof PlatformPolygon) { - stream.write(134); + stream.write(135); writeValue(stream, ((PlatformPolygon) value).toList()); } else if (value instanceof PlatformPolyline) { - stream.write(135); + stream.write(136); writeValue(stream, ((PlatformPolyline) value).toList()); } else if (value instanceof PlatformTile) { - stream.write(136); + stream.write(137); writeValue(stream, ((PlatformTile) value).toList()); } else if (value instanceof PlatformTileOverlay) { - stream.write(137); + stream.write(138); writeValue(stream, ((PlatformTileOverlay) value).toList()); } else if (value instanceof PlatformLatLng) { - stream.write(138); + stream.write(139); writeValue(stream, ((PlatformLatLng) value).toList()); } else if (value instanceof PlatformLatLngBounds) { - stream.write(139); + stream.write(140); writeValue(stream, ((PlatformLatLngBounds) value).toList()); } else if (value instanceof PlatformCluster) { - stream.write(140); + stream.write(141); writeValue(stream, ((PlatformCluster) value).toList()); } else if (value instanceof PlatformMapConfiguration) { - stream.write(141); + stream.write(142); writeValue(stream, ((PlatformMapConfiguration) value).toList()); } else if (value instanceof PlatformPoint) { - stream.write(142); + stream.write(143); writeValue(stream, ((PlatformPoint) value).toList()); } else if (value instanceof PlatformTileLayer) { - stream.write(143); + stream.write(144); writeValue(stream, ((PlatformTileLayer) value).toList()); } else if (value instanceof PlatformZoomRange) { - stream.write(144); + stream.write(145); writeValue(stream, ((PlatformZoomRange) value).toList()); } else if (value instanceof PlatformRendererType) { - stream.write(145); + stream.write(146); writeValue(stream, value == null ? null : ((PlatformRendererType) value).index); } else { super.writeValue(stream, value); @@ -1793,6 +1874,11 @@ void updateCircles( @NonNull List toAdd, @NonNull List toChange, @NonNull List idsToRemove); + /** Updates the set of heatmaps on the map. */ + void updateHeatmaps( + @NonNull List toAdd, + @NonNull List toChange, + @NonNull List idsToRemove); /** Updates the set of custer managers for clusters on the map. */ void updateClusterManagers( @NonNull List toAdd, @NonNull List idsToRemove); @@ -1959,6 +2045,34 @@ public void error(Throwable error) { channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.google_maps_flutter_android.MapsApi.updateHeatmaps" + + messageChannelSuffix, + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + ArrayList args = (ArrayList) message; + List toAddArg = (List) args.get(0); + List toChangeArg = (List) args.get(1); + List idsToRemoveArg = (List) args.get(2); + try { + api.updateHeatmaps(toAddArg, toChangeArg, idsToRemoveArg); + wrapped.add(0, null); + } catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } { BasicMessageChannel channel = new BasicMessageChannel<>( diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java index 8e00a67f23b0..156b8ce60e88 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java @@ -4,6 +4,15 @@ package io.flutter.plugins.googlemaps; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_DATA_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_GRADIENT_COLORS_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_GRADIENT_COLOR_MAP_SIZE_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_GRADIENT_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_GRADIENT_START_POINTS_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_ID_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_MAX_INTENSITY_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_OPACITY_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_RADIUS_KEY; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -17,9 +26,14 @@ import android.graphics.Paint; import android.os.Build; import android.util.Base64; +import androidx.annotation.NonNull; import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.clustering.algo.StaticCluster; +import com.google.maps.android.geometry.Point; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.WeightedLatLng; +import com.google.maps.android.projection.SphericalMercatorProjection; import io.flutter.plugins.googlemaps.Convert.BitmapDescriptorFactoryWrapper; import io.flutter.plugins.googlemaps.Convert.FlutterInjectorWrapper; import java.io.ByteArrayInputStream; @@ -324,6 +338,128 @@ public void GetBitmapFromBytesThrowsErrorIfInvalidImageData() throws Exception { fail("Expected an IllegalArgumentException to be thrown"); } + private static final SphericalMercatorProjection sProjection = new SphericalMercatorProjection(1); + + @Test() + public void ConvertToWeightedLatLngReturnsCorrectData() { + final double intensity = 3.3; + final Object data = List.of(List.of(1.1, 2.2), intensity); + final Point point = sProjection.toPoint(new LatLng(1.1, 2.2)); + + final WeightedLatLng result = Convert.toWeightedLatLng(data); + + Assert.assertEquals(point.x, result.getPoint().x, 0); + Assert.assertEquals(point.y, result.getPoint().y, 0); + Assert.assertEquals(intensity, result.getIntensity(), 0); + } + + @Test() + public void ConvertToWeightedDataReturnsCorrectData() { + final double intensity = 3.3; + final List data = List.of(List.of(List.of(1.1, 2.2), intensity)); + final Point point = sProjection.toPoint(new LatLng(1.1, 2.2)); + + final List result = Convert.toWeightedData(data); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals(point.x, result.get(0).getPoint().x, 0); + Assert.assertEquals(point.y, result.get(0).getPoint().y, 0); + Assert.assertEquals(intensity, result.get(0).getIntensity(), 0); + } + + @Test() + public void ConvertToGradientReturnsCorrectData() { + final int color1 = 0; + final int color2 = 1; + final int color3 = 2; + final List colorData = List.of(color1, color2, color3); + final double startPoint1 = 0.0; + final double startPoint2 = 1.0; + final double startPoint3 = 2.0; + List startPointData = List.of(startPoint1, startPoint2, startPoint3); + final int colorMapSize = 3; + final Map data = + Map.of( + HEATMAP_GRADIENT_COLORS_KEY, colorData, + HEATMAP_GRADIENT_START_POINTS_KEY, startPointData, + HEATMAP_GRADIENT_COLOR_MAP_SIZE_KEY, colorMapSize); + + final Gradient result = Convert.toGradient(data); + + Assert.assertEquals(3, result.mColors.length); + Assert.assertEquals(color1, result.mColors[0]); + Assert.assertEquals(color2, result.mColors[1]); + Assert.assertEquals(color3, result.mColors[2]); + Assert.assertEquals(3, result.mStartPoints.length); + Assert.assertEquals(startPoint1, result.mStartPoints[0], 0); + Assert.assertEquals(startPoint2, result.mStartPoints[1], 0); + Assert.assertEquals(startPoint3, result.mStartPoints[2], 0); + Assert.assertEquals(colorMapSize, result.mColorMapSize); + } + + @Test() + public void ConvertInterpretHeatmapOptionsReturnsCorrectData() { + final double intensity = 3.3; + final List dataData = List.of(List.of(List.of(1.1, 2.2), intensity)); + final Point point = sProjection.toPoint(new LatLng(1.1, 2.2)); + + final int color1 = 0; + final int color2 = 1; + final int color3 = 2; + final List colorData = List.of(color1, color2, color3); + final double startPoint1 = 0.0; + final double startPoint2 = 1.0; + final double startPoint3 = 2.0; + List startPointData = List.of(startPoint1, startPoint2, startPoint3); + final int colorMapSize = 3; + final Map gradientData = + Map.of( + HEATMAP_GRADIENT_COLORS_KEY, colorData, + HEATMAP_GRADIENT_START_POINTS_KEY, startPointData, + HEATMAP_GRADIENT_COLOR_MAP_SIZE_KEY, colorMapSize); + + final double maxIntensity = 4.4; + final double opacity = 5.5; + final int radius = 6; + final String idData = "heatmap_1"; + + final Map data = + Map.of( + HEATMAP_DATA_KEY, + dataData, + HEATMAP_GRADIENT_KEY, + gradientData, + HEATMAP_MAX_INTENSITY_KEY, + maxIntensity, + HEATMAP_OPACITY_KEY, + opacity, + HEATMAP_RADIUS_KEY, + radius, + HEATMAP_ID_KEY, + idData); + + final MockHeatmapBuilder builder = new MockHeatmapBuilder(); + final String id = Convert.interpretHeatmapOptions(data, builder); + + Assert.assertEquals(1, builder.getWeightedData().size()); + Assert.assertEquals(point.x, builder.getWeightedData().get(0).getPoint().x, 0); + Assert.assertEquals(point.y, builder.getWeightedData().get(0).getPoint().y, 0); + Assert.assertEquals(intensity, builder.getWeightedData().get(0).getIntensity(), 0); + Assert.assertEquals(3, builder.getGradient().mColors.length); + Assert.assertEquals(color1, builder.getGradient().mColors[0]); + Assert.assertEquals(color2, builder.getGradient().mColors[1]); + Assert.assertEquals(color3, builder.getGradient().mColors[2]); + Assert.assertEquals(3, builder.getGradient().mStartPoints.length); + Assert.assertEquals(startPoint1, builder.getGradient().mStartPoints[0], 0); + Assert.assertEquals(startPoint2, builder.getGradient().mStartPoints[1], 0); + Assert.assertEquals(startPoint3, builder.getGradient().mStartPoints[2], 0); + Assert.assertEquals(colorMapSize, builder.getGradient().mColorMapSize); + Assert.assertEquals(maxIntensity, builder.getMaxIntensity(), 0); + Assert.assertEquals(opacity, builder.getOpacity(), 0); + Assert.assertEquals(radius, builder.getRadius()); + Assert.assertEquals(idData, id); + } + private InputStream buildImageInputStream() { Bitmap fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); @@ -356,3 +492,56 @@ private String generateBase64Image() { return base64Image; } } + +class MockHeatmapBuilder implements HeatmapOptionsSink { + private List weightedData; + private Gradient gradient; + private double maxIntensity; + private double opacity; + private int radius; + + public List getWeightedData() { + return weightedData; + } + + public Gradient getGradient() { + return gradient; + } + + public double getMaxIntensity() { + return maxIntensity; + } + + public double getOpacity() { + return opacity; + } + + public int getRadius() { + return radius; + } + + @Override + public void setWeightedData(@NonNull List weightedData) { + this.weightedData = weightedData; + } + + @Override + public void setGradient(@NonNull Gradient gradient) { + this.gradient = gradient; + } + + @Override + public void setMaxIntensity(double maxIntensity) { + this.maxIntensity = maxIntensity; + } + + @Override + public void setOpacity(double opacity) { + this.opacity = opacity; + } + + @Override + public void setRadius(int radius) { + this.radius = radius; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java index 865920ee631d..2295ec1a757a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java @@ -52,6 +52,7 @@ public class GoogleMapControllerTest { @Mock PolygonsController mockPolygonsController; @Mock PolylinesController mockPolylinesController; @Mock CirclesController mockCirclesController; + @Mock HeatmapsController mockHeatmapsController; @Mock TileOverlaysController mockTileOverlaysController; @Before @@ -85,6 +86,7 @@ public GoogleMapController getGoogleMapControllerWithMockedDependencies() { mockPolygonsController, mockPolylinesController, mockCirclesController, + mockHeatmapsController, mockTileOverlaysController); googleMapController.init(); return googleMapController; @@ -222,4 +224,31 @@ public void OnClusterItemClickCallsMarkersController() { googleMapController.onClusterItemClick(markerBuilder); verify(mockMarkersController, times(1)).onMarkerTap(markerBuilder.markerId()); } + + @Test + public void SetInitialHeatmaps() { + GoogleMapController googleMapController = getGoogleMapControllerWithMockedDependencies(); + + List initialHeatmaps = List.of(Map.of("heatmapId", "hm_1")); + googleMapController.setInitialHeatmaps(initialHeatmaps); + googleMapController.onMapReady(mockGoogleMap); + + // Verify if the HeatmapsController.addHeatmaps method is called with initial heatmaps. + verify(mockHeatmapsController, times(1)).addJsonHeatmaps(initialHeatmaps); + } + + @Test + public void UpdateHeatmaps() { + GoogleMapController googleMapController = getGoogleMapControllerWithMockedDependencies(); + + final List toAdd = List.of(new Messages.PlatformHeatmap()); + final List toChange = List.of(new Messages.PlatformHeatmap()); + final List idsToRemove = List.of("hm_1"); + + googleMapController.updateHeatmaps(toAdd, toChange, idsToRemove); + + verify(mockHeatmapsController, times(1)).addHeatmaps(toAdd); + verify(mockHeatmapsController, times(1)).changeHeatmaps(toChange); + verify(mockHeatmapsController, times(1)).removeHeatmaps(idsToRemove); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/HeatmapsControllerTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/HeatmapsControllerTest.java new file mode 100644 index 000000000000..e087ba38683e --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/HeatmapsControllerTest.java @@ -0,0 +1,108 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_DATA_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_ID_KEY; +import static io.flutter.plugins.googlemaps.Convert.HEATMAP_OPACITY_KEY; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +import android.os.Build; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.TileOverlay; +import com.google.android.gms.maps.model.TileOverlayOptions; +import com.google.maps.android.heatmaps.HeatmapTileProvider; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.P) +public class HeatmapsControllerTest { + private HeatmapsController controller; + private GoogleMap googleMap; + + @Before + public void setUp() { + controller = spy(new HeatmapsController()); + googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + } + + @Test(expected = IllegalArgumentException.class) + public void controller_AddHeatmapThrowsErrorIfHeatmapIdIsNull() { + final Map heatmapOptions = new HashMap<>(); + + final List heatmaps = Collections.singletonList(heatmapOptions); + try { + controller.addJsonHeatmaps(heatmaps); + } catch (IllegalArgumentException e) { + assertEquals("heatmapId was null", e.getMessage()); + throw e; + } + } + + @Test + public void controller_AddChangeAndRemoveHeatmap() { + final TileOverlay tileOverlay = mock(TileOverlay.class); + final HeatmapTileProvider heatmap = mock(HeatmapTileProvider.class); + + final String googleHeatmapId = "abc123"; + final Object heatmapData = + Collections.singletonList(Arrays.asList(Arrays.asList(1.1, 2.2), 3.3)); + + when(googleMap.addTileOverlay(any(TileOverlayOptions.class))).thenReturn(tileOverlay); + doReturn(heatmap).when(controller).buildHeatmap(any(HeatmapBuilder.class)); + + final Map heatmapOptions1 = new HashMap<>(); + heatmapOptions1.put(HEATMAP_ID_KEY, googleHeatmapId); + heatmapOptions1.put(HEATMAP_DATA_KEY, heatmapData); + + final List heatmaps = Collections.singletonList(heatmapOptions1); + controller.addJsonHeatmaps(heatmaps); + + Mockito.verify(googleMap, times(1)) + .addTileOverlay( + Mockito.argThat(argument -> argument.getTileProvider() instanceof HeatmapTileProvider)); + + final float opacity = 0.1f; + final Map heatmapOptions2 = new HashMap<>(); + heatmapOptions2.put(HEATMAP_ID_KEY, googleHeatmapId); + heatmapOptions2.put(HEATMAP_DATA_KEY, heatmapData); + heatmapOptions2.put(HEATMAP_OPACITY_KEY, opacity); + + final List heatmapUpdates = + Collections.singletonList(heatmapOptions2) + .stream() + .map( + json -> { + final Messages.PlatformHeatmap platformHeatmap = new Messages.PlatformHeatmap(); + platformHeatmap.setJson(json); + return platformHeatmap; + }) + .toList(); + + controller.changeHeatmaps(heatmapUpdates); + Mockito.verify(heatmap, times(1)).setOpacity(opacity); + + controller.removeHeatmaps(Collections.singletonList(googleHeatmapId)); + + Mockito.verify(tileOverlay, times(1)).remove(); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml index ddac3d7b231c..9f3931140f67 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 dev_dependencies: build_runner: ^2.1.10 diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart index abe361566b67..76b8a8ce75d0 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart @@ -75,6 +75,9 @@ class GoogleMapsInspectorAndroid extends GoogleMapsInspectorPlatform { ); } + @override + bool supportsGettingHeatmapInfo() => false; + @override Future isCompassEnabled({required int mapId}) async { return _inspectorProvider(mapId)!.isCompassEnabled(); diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart index 30bc4b7c9cd6..214acc41ed24 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart @@ -14,6 +14,7 @@ import 'package:stream_transform/stream_transform.dart'; import 'google_map_inspector_android.dart'; import 'messages.g.dart'; +import 'serialization.dart'; import 'utils/cluster_manager_utils.dart'; // TODO(stuartmorgan): Remove the dependency on platform interface toJson @@ -293,6 +294,20 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { ); } + @override + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + required int mapId, + }) { + return _hostApi(mapId).updateHeatmaps( + heatmapUpdates.heatmapsToAdd.map(_platformHeatmapFromHeatmap).toList(), + heatmapUpdates.heatmapsToChange.map(_platformHeatmapFromHeatmap).toList(), + heatmapUpdates.heatmapIdsToRemove + .map((HeatmapId id) => id.value) + .toList(), + ); + } + @override Future updateTileOverlays({ required Set newTileOverlays, @@ -502,6 +517,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { 'polygonsToAdd': serializePolygonSet(mapObjects.polygons), 'polylinesToAdd': serializePolylineSet(mapObjects.polylines), 'circlesToAdd': serializeCircleSet(mapObjects.circles), + 'heatmapsToAdd': mapObjects.heatmaps.map(serializeHeatmap).toList(), 'tileOverlaysToAdd': serializeTileOverlaySet(mapObjects.tileOverlays), 'clusterManagersToAdd': serializeClusterManagerSet(mapObjects.clusterManagers), @@ -675,6 +691,13 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { return PlatformCircle(json: circle.toJson() as Map); } + static PlatformHeatmap _platformHeatmapFromHeatmap(Heatmap heatmap) { + // This cast is not ideal, but the Java code already assumes this format. + // See the TODOs at the top of this file and on the 'json' field in + // messages.dart. + return PlatformHeatmap(json: heatmap.toJson() as Map); + } + static PlatformClusterManager _platformClusterManagerFromClusterManager( ClusterManager clusterManager) { return PlatformClusterManager( diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/messages.g.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/messages.g.dart index c54b01c196ea..676b8f48b866 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/messages.g.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/messages.g.dart @@ -78,7 +78,7 @@ class PlatformCameraUpdate { }); /// The update data, as JSON. This should only be set from - /// CameraUpdate.toJson, and the native code must intepret it according to the + /// CameraUpdate.toJson, and the native code must interpret it according to the /// internal implementation details of the CameraUpdate class. Object json; @@ -103,7 +103,7 @@ class PlatformCircle { }); /// The circle data, as JSON. This should only be set from - /// Circle.toJson, and the native code must intepret it according to the + /// Circle.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Map json; @@ -121,6 +121,31 @@ class PlatformCircle { } } +/// Pigeon equivalent of the Heatmap class. +class PlatformHeatmap { + PlatformHeatmap({ + required this.json, + }); + + /// The heatmap data, as JSON. This should only be set from + /// Heatmap.toJson, and the native code must interpret it according to the + /// internal implementation details of that method. + Map json; + + Object encode() { + return [ + json, + ]; + } + + static PlatformHeatmap decode(Object result) { + result as List; + return PlatformHeatmap( + json: (result[0] as Map?)!.cast(), + ); + } +} + /// Pigeon equivalent of the ClusterManager class. class PlatformClusterManager { PlatformClusterManager({ @@ -150,7 +175,7 @@ class PlatformMarker { }); /// The marker data, as JSON. This should only be set from - /// Marker.toJson, and the native code must intepret it according to the + /// Marker.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Map json; @@ -175,7 +200,7 @@ class PlatformPolygon { }); /// The polygon data, as JSON. This should only be set from - /// Polygon.toJson, and the native code must intepret it according to the + /// Polygon.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Map json; @@ -200,7 +225,7 @@ class PlatformPolyline { }); /// The polyline data, as JSON. This should only be set from - /// Polyline.toJson, and the native code must intepret it according to the + /// Polyline.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Map json; @@ -257,7 +282,7 @@ class PlatformTileOverlay { }); /// The tile overlay data, as JSON. This should only be set from - /// TileOverlay.toJson, and the native code must intepret it according to the + /// TileOverlay.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Map json; @@ -373,7 +398,7 @@ class PlatformMapConfiguration { }); /// The configuration options, as JSON. This should only be set from - /// _jsonForMapConfiguration, and the native code must intepret it according + /// _jsonForMapConfiguration, and the native code must interpret it according /// to the internal implementation details of that method. Map json; @@ -495,47 +520,50 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is PlatformCircle) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is PlatformClusterManager) { + } else if (value is PlatformHeatmap) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is PlatformMarker) { + } else if (value is PlatformClusterManager) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is PlatformPolygon) { + } else if (value is PlatformMarker) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is PlatformPolyline) { + } else if (value is PlatformPolygon) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is PlatformTile) { + } else if (value is PlatformPolyline) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is PlatformTileOverlay) { + } else if (value is PlatformTile) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PlatformLatLng) { + } else if (value is PlatformTileOverlay) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is PlatformLatLngBounds) { + } else if (value is PlatformLatLng) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is PlatformCluster) { + } else if (value is PlatformLatLngBounds) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is PlatformMapConfiguration) { + } else if (value is PlatformCluster) { buffer.putUint8(141); writeValue(buffer, value.encode()); - } else if (value is PlatformPoint) { + } else if (value is PlatformMapConfiguration) { buffer.putUint8(142); writeValue(buffer, value.encode()); - } else if (value is PlatformTileLayer) { + } else if (value is PlatformPoint) { buffer.putUint8(143); writeValue(buffer, value.encode()); - } else if (value is PlatformZoomRange) { + } else if (value is PlatformTileLayer) { buffer.putUint8(144); writeValue(buffer, value.encode()); - } else if (value is PlatformRendererType) { + } else if (value is PlatformZoomRange) { buffer.putUint8(145); + writeValue(buffer, value.encode()); + } else if (value is PlatformRendererType) { + buffer.putUint8(146); writeValue(buffer, value.index); } else { super.writeValue(buffer, value); @@ -552,32 +580,34 @@ class _PigeonCodec extends StandardMessageCodec { case 131: return PlatformCircle.decode(readValue(buffer)!); case 132: - return PlatformClusterManager.decode(readValue(buffer)!); + return PlatformHeatmap.decode(readValue(buffer)!); case 133: - return PlatformMarker.decode(readValue(buffer)!); + return PlatformClusterManager.decode(readValue(buffer)!); case 134: - return PlatformPolygon.decode(readValue(buffer)!); + return PlatformMarker.decode(readValue(buffer)!); case 135: - return PlatformPolyline.decode(readValue(buffer)!); + return PlatformPolygon.decode(readValue(buffer)!); case 136: - return PlatformTile.decode(readValue(buffer)!); + return PlatformPolyline.decode(readValue(buffer)!); case 137: - return PlatformTileOverlay.decode(readValue(buffer)!); + return PlatformTile.decode(readValue(buffer)!); case 138: - return PlatformLatLng.decode(readValue(buffer)!); + return PlatformTileOverlay.decode(readValue(buffer)!); case 139: - return PlatformLatLngBounds.decode(readValue(buffer)!); + return PlatformLatLng.decode(readValue(buffer)!); case 140: - return PlatformCluster.decode(readValue(buffer)!); + return PlatformLatLngBounds.decode(readValue(buffer)!); case 141: - return PlatformMapConfiguration.decode(readValue(buffer)!); + return PlatformCluster.decode(readValue(buffer)!); case 142: - return PlatformPoint.decode(readValue(buffer)!); + return PlatformMapConfiguration.decode(readValue(buffer)!); case 143: - return PlatformTileLayer.decode(readValue(buffer)!); + return PlatformPoint.decode(readValue(buffer)!); case 144: - return PlatformZoomRange.decode(readValue(buffer)!); + return PlatformTileLayer.decode(readValue(buffer)!); case 145: + return PlatformZoomRange.decode(readValue(buffer)!); + case 146: final int? value = readValue(buffer) as int?; return value == null ? null : PlatformRendererType.values[value]; default: @@ -683,6 +713,32 @@ class MapsApi { } } + /// Updates the set of heatmaps on the map. + Future updateHeatmaps(List toAdd, + List toChange, List idsToRemove) async { + final String __pigeon_channelName = + 'dev.flutter.pigeon.google_maps_flutter_android.MapsApi.updateHeatmaps$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = + BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = await __pigeon_channel + .send([toAdd, toChange, idsToRemove]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else { + return; + } + } + /// Updates the set of custer managers for clusters on the map. Future updateClusterManagers( List toAdd, List idsToRemove) async { diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/serialization.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/serialization.dart new file mode 100644 index 000000000000..8858a6e481bb --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/serialization.dart @@ -0,0 +1,121 @@ +// Copyright 2013 The Flutter 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:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +// These constants must match the corresponding constants in Convert.java +const String _heatmapIdKey = 'heatmapId'; +const String _heatmapDataKey = 'data'; +const String _heatmapGradientKey = 'gradient'; +const String _heatmapMaxIntensityKey = 'maxIntensity'; +const String _heatmapOpacityKey = 'opacity'; +const String _heatmapRadiusKey = 'radius'; +const String _heatmapGradientColorsKey = 'colors'; +const String _heatmapGradientStartPointsKey = 'startPoints'; +const String _heatmapGradientColorMapSizeKey = 'colorMapSize'; + +void _addIfNonNull(Map map, String fieldName, Object? value) { + if (value != null) { + map[fieldName] = value; + } +} + +/// Serialize [Heatmap] +Object serializeHeatmap(Heatmap heatmap) { + final Map json = {}; + + _addIfNonNull(json, _heatmapIdKey, heatmap.heatmapId.value); + _addIfNonNull( + json, + _heatmapDataKey, + heatmap.data.map(serializeWeightedLatLng).toList(), + ); + + final HeatmapGradient? gradient = heatmap.gradient; + if (gradient != null) { + _addIfNonNull( + json, _heatmapGradientKey, serializeHeatmapGradient(gradient)); + } + _addIfNonNull(json, _heatmapMaxIntensityKey, heatmap.maxIntensity); + _addIfNonNull(json, _heatmapOpacityKey, heatmap.opacity); + _addIfNonNull(json, _heatmapRadiusKey, heatmap.radius.radius); + + return json; +} + +/// Serialize [WeightedLatLng] +Object serializeWeightedLatLng(WeightedLatLng wll) { + return [serializeLatLng(wll.point), wll.weight]; +} + +/// Deserialize [WeightedLatLng] +WeightedLatLng? deserializeWeightedLatLng(Object? json) { + if (json == null) { + return null; + } + assert(json is List && json.length == 2); + final List list = json as List; + final LatLng latLng = deserializeLatLng(list[0])!; + return WeightedLatLng(latLng, weight: list[1] as double); +} + +/// Serialize [LatLng] +Object serializeLatLng(LatLng latLng) { + return [latLng.latitude, latLng.longitude]; +} + +/// Deserialize [LatLng] +LatLng? deserializeLatLng(Object? json) { + if (json == null) { + return null; + } + assert(json is List && json.length == 2); + final List list = json as List; + return LatLng(list[0]! as double, list[1]! as double); +} + +/// Serialize [HeatmapGradient] +Object serializeHeatmapGradient(HeatmapGradient gradient) { + final Map json = {}; + + _addIfNonNull( + json, + _heatmapGradientColorsKey, + gradient.colors.map((HeatmapGradientColor e) => e.color.value).toList(), + ); + _addIfNonNull( + json, + _heatmapGradientStartPointsKey, + gradient.colors.map((HeatmapGradientColor e) => e.startPoint).toList(), + ); + _addIfNonNull(json, _heatmapGradientColorMapSizeKey, gradient.colorMapSize); + + return json; +} + +/// Deserialize [HeatmapGradient] +HeatmapGradient? deserializeHeatmapGradient(Object? json) { + if (json == null) { + return null; + } + assert(json is Map); + final Map map = (json as Map).cast(); + final List colors = (map[_heatmapGradientColorsKey]! as List) + .whereType() + .map((int e) => Color(e)) + .toList(); + final List startPoints = + (map[_heatmapGradientStartPointsKey]! as List) + .whereType() + .toList(); + final List gradientColors = []; + for (int i = 0; i < colors.length; i++) { + gradientColors.add(HeatmapGradientColor(colors[i], startPoints[i])); + } + return HeatmapGradient( + gradientColors, + colorMapSize: map[_heatmapGradientColorMapSizeKey] as int? ?? 256, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/pigeons/messages.dart b/packages/google_maps_flutter/google_maps_flutter_android/pigeons/messages.dart index 3bd8cbf36d36..7cf0afbea281 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/pigeons/messages.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/pigeons/messages.dart @@ -34,7 +34,7 @@ class PlatformCameraUpdate { PlatformCameraUpdate(this.json); /// The update data, as JSON. This should only be set from - /// CameraUpdate.toJson, and the native code must intepret it according to the + /// CameraUpdate.toJson, and the native code must interpret it according to the /// internal implementation details of the CameraUpdate class. // TODO(stuartmorgan): Update the google_maps_platform_interface CameraUpdate // class to provide a structured representation of an update. Currently it @@ -49,7 +49,19 @@ class PlatformCircle { PlatformCircle(this.json); /// The circle data, as JSON. This should only be set from - /// Circle.toJson, and the native code must intepret it according to the + /// Circle.toJson, and the native code must interpret it according to the + /// internal implementation details of that method. + // TODO(stuartmorgan): Replace this with structured data. This exists only to + // allow incremental migration to Pigeon. + final Map json; +} + +/// Pigeon equivalent of the Heatmap class. +class PlatformHeatmap { + PlatformHeatmap(this.json); + + /// The heatmap data, as JSON. This should only be set from + /// Heatmap.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -68,7 +80,7 @@ class PlatformMarker { PlatformMarker(this.json); /// The marker data, as JSON. This should only be set from - /// Marker.toJson, and the native code must intepret it according to the + /// Marker.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -80,7 +92,7 @@ class PlatformPolygon { PlatformPolygon(this.json); /// The polygon data, as JSON. This should only be set from - /// Polygon.toJson, and the native code must intepret it according to the + /// Polygon.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -92,7 +104,7 @@ class PlatformPolyline { PlatformPolyline(this.json); /// The polyline data, as JSON. This should only be set from - /// Polyline.toJson, and the native code must intepret it according to the + /// Polyline.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -113,7 +125,7 @@ class PlatformTileOverlay { PlatformTileOverlay(this.json); /// The tile overlay data, as JSON. This should only be set from - /// TileOverlay.toJson, and the native code must intepret it according to the + /// TileOverlay.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -159,7 +171,7 @@ class PlatformMapConfiguration { PlatformMapConfiguration({required this.json}); /// The configuration options, as JSON. This should only be set from - /// _jsonForMapConfiguration, and the native code must intepret it according + /// _jsonForMapConfiguration, and the native code must interpret it according /// to the internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -219,6 +231,13 @@ abstract class MapsApi { void updateCircles(List toAdd, List toChange, List idsToRemove); + /// Updates the set of heatmaps on the map. + // TODO(stuartmorgan): Make the generic type non-nullable once supported. + // https://github.com/flutter/flutter/issues/97848 + // The consuming code treats the entries as non-nullable. + void updateHeatmaps(List toAdd, + List toChange, List idsToRemove); + /// Updates the set of custer managers for clusters on the map. // TODO(stuartmorgan): Make the generic type non-nullable once supported. // https://github.com/flutter/flutter/issues/97848 diff --git a/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml index f4924aef5f48..82068018bc7f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_android description: Android implementation of the google_maps_flutter plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.12.2 +version: 2.13.0 environment: sdk: ^3.4.0 @@ -21,7 +21,7 @@ dependencies: flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 stream_transform: ^2.0.0 dev_dependencies: diff --git a/packages/google_maps_flutter/google_maps_flutter_android/test/google_maps_flutter_android_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_android/test/google_maps_flutter_android_test.mocks.dart index c49d62689264..e0fbdd574215 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/test/google_maps_flutter_android_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/test/google_maps_flutter_android_test.mocks.dart @@ -99,6 +99,25 @@ class MockMapsApi extends _i1.Mock implements _i2.MapsApi { returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + @override + _i3.Future updateHeatmaps( + List<_i2.PlatformHeatmap?>? toAdd, + List<_i2.PlatformHeatmap?>? toChange, + List? idsToRemove, + ) => + (super.noSuchMethod( + Invocation.method( + #updateHeatmaps, + [ + toAdd, + toChange, + idsToRemove, + ], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + @override _i3.Future updateClusterManagers( List<_i2.PlatformClusterManager?>? toAdd, From c6a30bc0347156efd9c00178d7746a5b1146312f Mon Sep 17 00:00:00 2001 From: Rexios Date: Tue, 6 Aug 2024 07:55:09 -0400 Subject: [PATCH 10/12] [google_maps_flutter_ios] iOS changes to support heatmaps (#7314) Sequel to: - https://github.com/flutter/packages/pull/7312 Prequel to: - https://github.com/flutter/packages/pull/3257 --- .../google_maps_flutter_ios/CHANGELOG.md | 4 + .../google_maps_flutter_ios/README.md | 10 + .../example/ios14/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 58 +++++- ...TGoogleMapJSONConversionsConversionTests.m | 50 +++++ .../example/ios14/pubspec.yaml | 2 +- .../example/ios15/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 54 +++++- .../example/ios15/pubspec.yaml | 2 +- .../shared/maps_example_dart/pubspec.yaml | 2 +- .../Classes/FLTGoogleMapHeatmapController.h | 77 ++++++++ .../Classes/FLTGoogleMapHeatmapController.m | 178 ++++++++++++++++++ .../ios/Classes/FLTGoogleMapJSONConversions.h | 21 +++ .../ios/Classes/FLTGoogleMapJSONConversions.m | 86 +++++++++ .../ios/Classes/GoogleMapController.m | 33 +++- .../google_maps_flutter_ios-umbrella.h | 1 + .../ios/Classes/messages.g.h | 39 +++- .../ios/Classes/messages.g.m | 136 ++++++++++--- .../ios/google_maps_flutter_ios.podspec | 11 +- .../lib/src/google_map_inspector_ios.dart | 29 +++ .../lib/src/google_maps_flutter_ios.dart | 20 ++ .../lib/src/messages.g.dart | 136 ++++++++++--- .../lib/src/serialization.dart | 125 ++++++++++++ .../pigeons/messages.dart | 38 +++- .../google_maps_flutter_ios/pubspec.yaml | 4 +- .../google_maps_flutter_ios_test.mocks.dart | 19 ++ 26 files changed, 1044 insertions(+), 97 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.h create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.m create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/lib/src/serialization.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md index 9ee2a86e4f1b..cb038750c136 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.11.0 + +* Adds support for heatmap layers. + ## 2.10.0 * Converts Obj-C->Dart calls to Pigeon. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/README.md b/packages/google_maps_flutter/google_maps_flutter_ios/README.md index 6b481626dbca..20fae87c8855 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_ios/README.md @@ -11,5 +11,15 @@ your app when you do, so you do not need to add it to your `pubspec.yaml`. However, if you `import` this package to use any of its APIs directly, you should add it to your `pubspec.yaml` as usual. +## Supported Heatmap Options + +| Field | Supported | +| ---------------------------- | :-------: | +| Heatmap.dissipating | x | +| Heatmap.maxIntensity | x | +| Heatmap.minimumZoomIntensity | ✓ | +| Heatmap.maximumZoomIntensity | ✓ | +| HeatmapGradient.colorMapSize | ✓ | + [1]: https://pub.dev/packages/google_maps_flutter [2]: https://flutter.dev/to/endorsed-federated-plugin diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Podfile b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Podfile index a5cfb3f7bfbb..b1a5e23c9ff5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Podfile +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Podfile @@ -28,6 +28,9 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + use_frameworks! + use_modular_headers! + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj index 10545d750ed3..cb8922d30b6e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj @@ -9,11 +9,11 @@ /* Begin PBXBuildFile section */ 0DD7B6C32B744EEF00E857FD /* FLTTileProviderControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0DD7B6C22B744EEF00E857FD /* FLTTileProviderControllerTests.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2BDE99378062AE3E60B40021 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 4510D964F3B1259FEDD3ABA6 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */; }; 478116522BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */; }; - 6851F3562835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */; }; 521AB0032B876A76005F460D /* ExtractIconFromDataTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */; }; + 6851F3562835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */; }; 68E4726A2836FF0C00BDDDAC /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68E472692836FF0C00BDDDAC /* MapKit.framework */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; @@ -21,10 +21,10 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 982F2A6C27BADE17003C81F4 /* PartiallyMockedMapView.m in Sources */ = {isa = PBXBuildFile; fileRef = 982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */; }; + B3A7FA04ABB7B84780729949 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61A9A8623F5CA9BBC813DC6B /* Pods_Runner.framework */; }; F269303B2BB389BF00BF17C4 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = F269303A2BB389BF00BF17C4 /* assets */; }; F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */; }; F7151F21265D7EE50028CB91 /* GoogleMapsUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F20265D7EE50028CB91 /* GoogleMapsUITests.m */; }; - FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,13 +61,14 @@ 0DD7B6C22B744EEF00E857FD /* FLTTileProviderControllerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTTileProviderControllerTests.m; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsPolylinesControllerTests.m; sourceTree = ""; }; - 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTGoogleMapJSONConversionsConversionTests.m; sourceTree = ""; }; 521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = ""; }; + 61A9A8623F5CA9BBC813DC6B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTGoogleMapJSONConversionsConversionTests.m; sourceTree = ""; }; 68E472692836FF0C00BDDDAC /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.0.sdk/System/iOSSupport/System/Library/Frameworks/MapKit.framework; sourceTree = DEVELOPER_DIR; }; 733AFAB37683A9DA7512F09C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -84,7 +85,6 @@ B7AFC65E3DD5AC60D834D83D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; E52C6A6210A56F027C582EF9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; EA0E91726245EDC22B97E8B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; F269303A2BB389BF00BF17C4 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = ""; }; F7151F10265D7ED70028CB91 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsTests.m; sourceTree = ""; }; @@ -99,7 +99,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4510D964F3B1259FEDD3ABA6 /* libPods-Runner.a in Frameworks */, + B3A7FA04ABB7B84780729949 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -108,7 +108,7 @@ buildActionMask = 2147483647; files = ( 68E4726A2836FF0C00BDDDAC /* MapKit.framework in Frameworks */, - FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */, + 2BDE99378062AE3E60B40021 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,8 +126,8 @@ isa = PBXGroup; children = ( 68E472692836FF0C00BDDDAC /* MapKit.framework */, - 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */, - F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */, + 61A9A8623F5CA9BBC813DC6B /* Pods_Runner.framework */, + 3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -241,6 +241,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, BB6BD9A1101E970BEF85B6D2 /* [CP] Copy Pods Resources */, + A7D3A643E249522B15BA2B1D /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -259,6 +260,7 @@ F7151F0C265D7ED70028CB91 /* Sources */, F7151F0D265D7ED70028CB91 /* Frameworks */, F7151F0E265D7ED70028CB91 /* Resources */, + DF182F6A1B9E41DA05BFCB87 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -411,6 +413,24 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A7D3A643E249522B15BA2B1D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; BB6BD9A1101E970BEF85B6D2 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -453,6 +473,24 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + DF182F6A1B9E41DA05BFCB87 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunnerTests/Pods-RunnerTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/OCMock/OCMock.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OCMock.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RunnerTests/Pods-RunnerTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FLTGoogleMapJSONConversionsConversionTests.m b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FLTGoogleMapJSONConversionsConversionTests.m index 79da52d8198c..42e3fde2552b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FLTGoogleMapJSONConversionsConversionTests.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FLTGoogleMapJSONConversionsConversionTests.m @@ -319,4 +319,54 @@ - (void)testLengthsFromPatterns { XCTAssertEqual(secondSpanLength.doubleValue, 6.4); } +- (void)testWeightedLatLngFromArray { + NSArray *weightedLatLng = @[ @[ @1, @2 ], @3 ]; + + GMUWeightedLatLng *weightedLocation = + [FLTGoogleMapJSONConversions weightedLatLngFromArray:weightedLatLng]; + + // The location gets projected to different values + XCTAssertEqual([weightedLocation intensity], 3); +} + +- (void)testWeightedLatLngFromArrayThrowsForInvalidInput { + NSArray *weightedLatLng = @[]; + + XCTAssertThrows([FLTGoogleMapJSONConversions weightedLatLngFromArray:weightedLatLng]); +} + +- (void)testWeightedDataFromArray { + NSNumber *intensity1 = @3; + NSNumber *intensity2 = @6; + NSArray *data = @[ @[ @[ @1, @2 ], intensity1 ], @[ @[ @4, @5 ], intensity2 ] ]; + + NSArray *weightedData = + [FLTGoogleMapJSONConversions weightedDataFromArray:data]; + XCTAssertEqual([weightedData[0] intensity], [intensity1 floatValue]); + XCTAssertEqual([weightedData[1] intensity], [intensity2 floatValue]); +} + +- (void)testGradientFromDictionary { + NSNumber *startPoint = @0.6; + NSNumber *colorMapSize = @200; + NSDictionary *gradientData = @{ + @"colors" : @[ + // Color.fromARGB(255, 0, 255, 255) + @4278255615, + ], + @"startPoints" : @[ startPoint ], + @"colorMapSize" : colorMapSize, + }; + + GMUGradient *gradient = [FLTGoogleMapJSONConversions gradientFromDictionary:gradientData]; + CGFloat red, green, blue, alpha; + [[gradient colors][0] getRed:&red green:&green blue:&blue alpha:&alpha]; + XCTAssertEqual(red, 0); + XCTAssertEqual(green, 1); + XCTAssertEqual(blue, 1); + XCTAssertEqual(alpha, 1); + XCTAssertEqualWithAccuracy([[gradient startPoints][0] doubleValue], [startPoint doubleValue], 0); + XCTAssertEqual([gradient mapSize], [colorMapSize intValue]); +} + @end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/pubspec.yaml index 0c50df87f12e..ad8a569e0eed 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../../ - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 maps_example_dart: path: ../shared/maps_example_dart/ diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Podfile b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Podfile index 833aacf78d38..a41a54965ba3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Podfile +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Podfile @@ -28,6 +28,9 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + use_frameworks! + use_modular_headers! + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Runner.xcodeproj/project.pbxproj index dc2ad2532b9e..defec130df00 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/ios/Runner.xcodeproj/project.pbxproj @@ -9,7 +9,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 4510D964F3B1259FEDD3ABA6 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */; }; + 5B5EF9A6C72A03092BDA553E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28D4666EF03E57DB7D03E916 /* Pods_RunnerTests.framework */; }; 68E4726A2836FF0C00BDDDAC /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68E472692836FF0C00BDDDAC /* MapKit.framework */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; @@ -17,9 +17,9 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 982F2A6C27BADE17003C81F4 /* PartiallyMockedMapView.m in Sources */ = {isa = PBXBuildFile; fileRef = 982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */; }; + AF2E6994ED025F4BCE652A48 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0D457E156D08F0B45EA2F6FF /* Pods_Runner.framework */; }; F269303B2BB389BF00BF17C4 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = F269303A2BB389BF00BF17C4 /* assets */; }; F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */; }; - FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,12 +46,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0D457E156D08F0B45EA2F6FF /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 28D4666EF03E57DB7D03E916 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 68E472692836FF0C00BDDDAC /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.0.sdk/System/iOSSupport/System/Library/Frameworks/MapKit.framework; sourceTree = DEVELOPER_DIR; }; 733AFAB37683A9DA7512F09C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -68,7 +69,6 @@ B7AFC65E3DD5AC60D834D83D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; E52C6A6210A56F027C582EF9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; EA0E91726245EDC22B97E8B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; F269303A2BB389BF00BF17C4 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = ""; }; F7151F10265D7ED70028CB91 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsTests.m; sourceTree = ""; }; @@ -80,7 +80,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4510D964F3B1259FEDD3ABA6 /* libPods-Runner.a in Frameworks */, + AF2E6994ED025F4BCE652A48 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,7 +89,7 @@ buildActionMask = 2147483647; files = ( 68E4726A2836FF0C00BDDDAC /* MapKit.framework in Frameworks */, - FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */, + 5B5EF9A6C72A03092BDA553E /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -100,8 +100,8 @@ isa = PBXGroup; children = ( 68E472692836FF0C00BDDDAC /* MapKit.framework */, - 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */, - F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */, + 0D457E156D08F0B45EA2F6FF /* Pods_Runner.framework */, + 28D4666EF03E57DB7D03E916 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -200,6 +200,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, BB6BD9A1101E970BEF85B6D2 /* [CP] Copy Pods Resources */, + 851CD952D11C6D91E216A90F /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -218,6 +219,7 @@ F7151F0C265D7ED70028CB91 /* Sources */, F7151F0D265D7ED70028CB91 /* Frameworks */, F7151F0E265D7ED70028CB91 /* Resources */, + 7862D82E36C37F9B7558DA5E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -324,6 +326,42 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 7862D82E36C37F9B7558DA5E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunnerTests/Pods-RunnerTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/OCMock/OCMock.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OCMock.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RunnerTests/Pods-RunnerTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 851CD952D11C6D91E216A90F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/pubspec.yaml index 0c50df87f12e..ad8a569e0eed 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../../ - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 maps_example_dart: path: ../shared/maps_example_dart/ diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/pubspec.yaml index 810adc8ee21f..ed831fa3dcf0 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../../../ - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 dev_dependencies: flutter_test: diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.h new file mode 100644 index 000000000000..0c3407504a75 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.h @@ -0,0 +1,77 @@ +// Copyright 2013 The Flutter 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 +#import +@import GoogleMapsUtils; + +#import "messages.g.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Controller of a single Heatmap on the map. +@interface FLTGoogleMapHeatmapController : NSObject + +/// Initializes an instance of this class with a heatmap tile layer, a map view, and additional +/// configuration options. +/// +/// @param heatmapTileLayer The heatmap tile layer (of type GMUHeatmapTileLayer) that will be used +/// to display heatmap data on the map. +/// @param mapView The map view (of type GMSMapView) where the heatmap layer will be overlaid. +/// @param options A dictionary (NSDictionary) containing any additional options or configuration +/// settings for customizing the heatmap layer. The options dictionary is expected to have the +/// following structure: +/// +/// @code +/// { +/// "heatmapId": NSString, +/// "data": NSArray, // Array of serialized weighted lat/lng +/// "gradient": NSDictionary?, // Serialized heatmap gradient +/// "opacity": NSNumber, +/// "radius": NSNumber, +/// "minimumZoomIntensity": NSNumber, +/// "maximumZoomIntensity": NSNumber +/// } +/// @endcode +/// +/// @return An initialized instance of this class, configured with the specified heatmap tile layer, +/// map view, and additional options. +- (instancetype)initWithHeatmapTileLayer:(GMUHeatmapTileLayer *)heatmapTileLayer + mapView:(GMSMapView *)mapView + options:(NSDictionary *)options; + +/// Removes this heatmap from the map. +- (void)removeHeatmap; + +/// Clears the tile cache in order to visually udpate this heatmap. +- (void)clearTileCache; +@end + +/// Controller of multiple Heatmaps on the map. +@interface FLTHeatmapsController : NSObject + +/// Initializes the controller with a GMSMapView. +- (instancetype)initWithMapView:(GMSMapView *)mapView; + +/// Adds heatmaps to the map from JSON data. +- (void)addJSONHeatmaps:(NSArray *> *)heatmapsToAdd; + +/// Adds heatmaps to the map. +- (void)addHeatmaps:(NSArray *)heatmapsToAdd; + +/// Updates heatmaps on the map. +- (void)changeHeatmaps:(NSArray *)heatmapsToChange; + +/// Removes heatmaps from the map. +- (void)removeHeatmapsWithIdentifiers:(NSArray *)identifiers; + +/// Returns true if a heatmap with the given identifier exists on the map. +- (BOOL)hasHeatmapWithIdentifier:(NSString *)identifier; + +/// Returns the JSON data of the heatmap with the given identifier. The JSON structure is equivalent +/// to the `options` parameter above. +- (nullable NSDictionary *)heatmapInfoWithIdentifier:(NSString *)identifier; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.m new file mode 100644 index 000000000000..38b6b35ce559 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapHeatmapController.m @@ -0,0 +1,178 @@ +// Copyright 2013 The Flutter 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 "FLTGoogleMapHeatmapController.h" +#import "FLTGoogleMapJSONConversions.h" +@import GoogleMapsUtils; + +@interface FLTGoogleMapHeatmapController () + +/// The heatmap tile layer this controller handles. +@property(nonatomic, strong) GMUHeatmapTileLayer *heatmapTileLayer; + +/// The GMSMapView to which the heatmaps are added. +@property(nonatomic, weak) GMSMapView *mapView; + +@end + +@implementation FLTGoogleMapHeatmapController +- (instancetype)initWithHeatmapTileLayer:(GMUHeatmapTileLayer *)heatmapTileLayer + mapView:(GMSMapView *)mapView + options:(NSDictionary *)options { + self = [super init]; + if (self) { + _heatmapTileLayer = heatmapTileLayer; + _mapView = mapView; + + [FLTGoogleMapHeatmapController interpretHeatmapOptions:_heatmapTileLayer + mapView:_mapView + options:options]; + } + return self; +} + +- (void)removeHeatmap { + _heatmapTileLayer.map = nil; +} + +- (void)clearTileCache { + [_heatmapTileLayer clearTileCache]; +} + +- (void)interpretHeatmapOptions:(NSDictionary *)data { + [FLTGoogleMapHeatmapController interpretHeatmapOptions:_heatmapTileLayer + mapView:_mapView + options:data]; +} + ++ (void)interpretHeatmapOptions:(GMUHeatmapTileLayer *)heatmapTileLayer + mapView:(GMSMapView *)mapView + options:(NSDictionary *)options { + id weightedData = options[kHeatmapDataKey]; + if ([weightedData isKindOfClass:[NSArray class]]) { + heatmapTileLayer.weightedData = + [FLTGoogleMapJSONConversions weightedDataFromArray:weightedData]; + } + + id gradient = options[kHeatmapGradientKey]; + if ([gradient isKindOfClass:[NSDictionary class]]) { + heatmapTileLayer.gradient = [FLTGoogleMapJSONConversions gradientFromDictionary:gradient]; + } + + id opacity = options[kHeatmapOpacityKey]; + if ([opacity isKindOfClass:[NSNumber class]]) { + heatmapTileLayer.opacity = [opacity doubleValue]; + } + + id radius = options[kHeatmapRadiusKey]; + if ([radius isKindOfClass:[NSNumber class]]) { + heatmapTileLayer.radius = [radius intValue]; + } + + id minimumZoomIntensity = options[kHeatmapMinimumZoomIntensityKey]; + if ([minimumZoomIntensity isKindOfClass:[NSNumber class]]) { + heatmapTileLayer.minimumZoomIntensity = [minimumZoomIntensity intValue]; + } + + id maximumZoomIntensity = options[kHeatmapMaximumZoomIntensityKey]; + if ([maximumZoomIntensity isKindOfClass:[NSNumber class]]) { + heatmapTileLayer.maximumZoomIntensity = [maximumZoomIntensity intValue]; + } + + // The map must be set each time for options to update. + heatmapTileLayer.map = mapView; +} +@end + +@interface FLTHeatmapsController () + +/// A map from heatmapId to the controller that manages it. +@property(nonatomic, strong) + NSMutableDictionary *heatmapIdToController; + +/// The map view owned by GoogmeMapController. +@property(nonatomic, weak) GMSMapView *mapView; + +@end + +@implementation FLTHeatmapsController +- (instancetype)initWithMapView:(GMSMapView *)mapView { + self = [super init]; + if (self) { + _mapView = mapView; + _heatmapIdToController = [NSMutableDictionary dictionary]; + } + return self; +} + +- (void)addJSONHeatmaps:(NSArray *> *)heatmapsToAdd { + for (NSDictionary *heatmap in heatmapsToAdd) { + NSString *heatmapId = [FLTHeatmapsController identifierForHeatmap:heatmap]; + GMUHeatmapTileLayer *heatmapTileLayer = [[GMUHeatmapTileLayer alloc] init]; + FLTGoogleMapHeatmapController *controller = + [[FLTGoogleMapHeatmapController alloc] initWithHeatmapTileLayer:heatmapTileLayer + mapView:_mapView + options:heatmap]; + _heatmapIdToController[heatmapId] = controller; + } +} + +- (void)addHeatmaps:(NSArray *)heatmapsToAdd { + for (FGMPlatformHeatmap *heatmap in heatmapsToAdd) { + NSString *heatmapId = [FLTHeatmapsController identifierForHeatmap:heatmap.json]; + GMUHeatmapTileLayer *heatmapTileLayer = [[GMUHeatmapTileLayer alloc] init]; + FLTGoogleMapHeatmapController *controller = + [[FLTGoogleMapHeatmapController alloc] initWithHeatmapTileLayer:heatmapTileLayer + mapView:_mapView + options:heatmap.json]; + _heatmapIdToController[heatmapId] = controller; + } +} + +- (void)changeHeatmaps:(NSArray *)heatmapsToChange { + for (FGMPlatformHeatmap *heatmap in heatmapsToChange) { + NSString *heatmapId = [FLTHeatmapsController identifierForHeatmap:heatmap.json]; + FLTGoogleMapHeatmapController *controller = _heatmapIdToController[heatmapId]; + + [controller interpretHeatmapOptions:heatmap.json]; + [controller clearTileCache]; + } +} + +- (void)removeHeatmapsWithIdentifiers:(NSArray *)identifiers { + for (NSString *heatmapId in identifiers) { + FLTGoogleMapHeatmapController *controller = _heatmapIdToController[heatmapId]; + if (!controller) { + continue; + } + [controller removeHeatmap]; + [_heatmapIdToController removeObjectForKey:heatmapId]; + } +} + +- (BOOL)hasHeatmapWithIdentifier:(NSString *)identifier { + return _heatmapIdToController[identifier] != nil; +} + +- (nullable NSDictionary *)heatmapInfoWithIdentifier:(NSString *)identifier { + FLTGoogleMapHeatmapController *heatmapController = self.heatmapIdToController[identifier]; + if (heatmapController) { + return @{ + kHeatmapDataKey : [FLTGoogleMapJSONConversions + arrayFromWeightedData:heatmapController.heatmapTileLayer.weightedData], + kHeatmapGradientKey : [FLTGoogleMapJSONConversions + dictionaryFromGradient:heatmapController.heatmapTileLayer.gradient], + kHeatmapOpacityKey : @(heatmapController.heatmapTileLayer.opacity), + kHeatmapRadiusKey : @(heatmapController.heatmapTileLayer.radius), + kHeatmapMinimumZoomIntensityKey : @(heatmapController.heatmapTileLayer.minimumZoomIntensity), + kHeatmapMaximumZoomIntensityKey : @(heatmapController.heatmapTileLayer.maximumZoomIntensity) + }; + } + return nil; +} + ++ (NSString *)identifierForHeatmap:(NSDictionary *)heatmap { + return heatmap[kHeatmapIdKey]; +} +@end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h index 5d45ddd7be7a..403e93af8596 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h @@ -3,7 +3,9 @@ // found in the LICENSE file. #import +#import #import +@import GoogleMapsUtils; #import "messages.g.h" @@ -34,16 +36,35 @@ extern FGMPlatformCameraPosition *FGMGetPigeonCameraPositionForPosition( @interface FLTGoogleMapJSONConversions : NSObject +extern NSString *const kHeatmapsToAddKey; +extern NSString *const kHeatmapIdKey; +extern NSString *const kHeatmapDataKey; +extern NSString *const kHeatmapGradientKey; +extern NSString *const kHeatmapOpacityKey; +extern NSString *const kHeatmapRadiusKey; +extern NSString *const kHeatmapMinimumZoomIntensityKey; +extern NSString *const kHeatmapMaximumZoomIntensityKey; +extern NSString *const kHeatmapGradientColorsKey; +extern NSString *const kHeatmapGradientStartPointsKey; +extern NSString *const kHeatmapGradientColorMapSizeKey; + + (CLLocationCoordinate2D)locationFromLatLong:(NSArray *)latlong; + (CGPoint)pointFromArray:(NSArray *)array; + (NSArray *)arrayFromLocation:(CLLocationCoordinate2D)location; + (UIColor *)colorFromRGBA:(NSNumber *)data; ++ (NSNumber *)RGBAFromColor:(UIColor *)color; + (NSArray *)pointsFromLatLongs:(NSArray *)data; + (NSArray *> *)holesFromPointsArray:(NSArray *)data; + (nullable GMSCameraPosition *)cameraPostionFromDictionary:(nullable NSDictionary *)channelValue; + (GMSCoordinateBounds *)coordinateBoundsFromLatLongs:(NSArray *)latlongs; + (GMSMapViewType)mapViewTypeFromTypeValue:(NSNumber *)value; + (nullable GMSCameraUpdate *)cameraUpdateFromArray:(NSArray *)channelValue; ++ (nullable GMUWeightedLatLng *)weightedLatLngFromArray:(NSArray *)data; ++ (NSArray *)arrayFromWeightedLatLng:(GMUWeightedLatLng *)weightedLatLng; ++ (NSArray *)weightedDataFromArray:(NSArray *> *)data; ++ (NSArray *> *)arrayFromWeightedData:(NSArray *)weightedData; ++ (GMUGradient *)gradientFromDictionary:(NSDictionary *)data; ++ (NSDictionary *)dictionaryFromGradient:(GMUGradient *)gradient; /// Return GMS strokestyle object array populated using the patterns and stroke colors passed in. /// diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m index 56db3fbee0c9..69ceb73620eb 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m @@ -41,6 +41,19 @@ CLLocationCoordinate2D FGMGetCoordinateForPigeonLatLng(FGMPlatformLatLng *latLng @implementation FLTGoogleMapJSONConversions +// These constants must match the corresponding constants in serialization.dart +NSString *const kHeatmapsToAddKey = @"heatmapsToAdd"; +NSString *const kHeatmapIdKey = @"heatmapId"; +NSString *const kHeatmapDataKey = @"data"; +NSString *const kHeatmapGradientKey = @"gradient"; +NSString *const kHeatmapOpacityKey = @"opacity"; +NSString *const kHeatmapRadiusKey = @"radius"; +NSString *const kHeatmapMinimumZoomIntensityKey = @"minimumZoomIntensity"; +NSString *const kHeatmapMaximumZoomIntensityKey = @"maximumZoomIntensity"; +NSString *const kHeatmapGradientColorsKey = @"colors"; +NSString *const kHeatmapGradientStartPointsKey = @"startPoints"; +NSString *const kHeatmapGradientColorMapSizeKey = @"colorMapSize"; + + (CLLocationCoordinate2D)locationFromLatLong:(NSArray *)latlong { return CLLocationCoordinate2DMake([latlong[0] doubleValue], [latlong[1] doubleValue]); } @@ -61,6 +74,14 @@ + (UIColor *)colorFromRGBA:(NSNumber *)numberColor { alpha:((float)((value & 0xFF000000) >> 24)) / 255.0]; } ++ (NSNumber *)RGBAFromColor:(UIColor *)color { + CGFloat red, green, blue, alpha; + [color getRed:&red green:&green blue:&blue alpha:&alpha]; + unsigned long value = ((unsigned long)(alpha * 255) << 24) | ((unsigned long)(red * 255) << 16) | + ((unsigned long)(green * 255) << 8) | ((unsigned long)(blue * 255)); + return @(value); +} + + (NSArray *)pointsFromLatLongs:(NSArray *)data { NSMutableArray *points = [[NSMutableArray alloc] init]; for (unsigned i = 0; i < [data count]; i++) { @@ -163,4 +184,69 @@ + (nullable GMSCameraUpdate *)cameraUpdateFromArray:(NSArray *)channelValue { return lengths; } + ++ (GMUWeightedLatLng *)weightedLatLngFromArray:(NSArray *)data { + NSAssert(data.count == 2, @"WeightedLatLng data must have length of 2"); + if (data.count != 2) { + return nil; + } + return [[GMUWeightedLatLng alloc] + initWithCoordinate:[FLTGoogleMapJSONConversions locationFromLatLong:data[0]] + intensity:[data[1] doubleValue]]; +} + ++ (NSArray *)arrayFromWeightedLatLng:(GMUWeightedLatLng *)weightedLatLng { + GMSMapPoint point = {weightedLatLng.point.x, weightedLatLng.point.y}; + return @[ + [FLTGoogleMapJSONConversions arrayFromLocation:GMSUnproject(point)], @(weightedLatLng.intensity) + ]; +} + ++ (NSArray *)weightedDataFromArray:(NSArray *> *)data { + NSMutableArray *weightedData = + [[NSMutableArray alloc] initWithCapacity:data.count]; + for (NSArray *item in data) { + GMUWeightedLatLng *weightedLatLng = [FLTGoogleMapJSONConversions weightedLatLngFromArray:item]; + if (weightedLatLng == nil) continue; + [weightedData addObject:weightedLatLng]; + } + + return weightedData; +} + ++ (NSArray *> *)arrayFromWeightedData:(NSArray *)weightedData { + NSMutableArray *data = [[NSMutableArray alloc] initWithCapacity:weightedData.count]; + for (GMUWeightedLatLng *weightedLatLng in weightedData) { + [data addObject:[FLTGoogleMapJSONConversions arrayFromWeightedLatLng:weightedLatLng]]; + } + + return data; +} + ++ (GMUGradient *)gradientFromDictionary:(NSDictionary *)data { + NSArray *colorData = data[kHeatmapGradientColorsKey]; + NSMutableArray *colors = [[NSMutableArray alloc] initWithCapacity:colorData.count]; + for (NSNumber *colorCode in colorData) { + [colors addObject:[FLTGoogleMapJSONConversions colorFromRGBA:colorCode]]; + } + + return [[GMUGradient alloc] initWithColors:colors + startPoints:data[kHeatmapGradientStartPointsKey] + colorMapSize:[data[kHeatmapGradientColorMapSizeKey] intValue]]; +} + ++ (NSDictionary *)dictionaryFromGradient:(GMUGradient *)gradient { + NSMutableArray *colorCodes = + [[NSMutableArray alloc] initWithCapacity:gradient.colors.count]; + for (UIColor *color in gradient.colors) { + [colorCodes addObject:[FLTGoogleMapJSONConversions RGBAFromColor:color]]; + } + + return @{ + kHeatmapGradientColorsKey : colorCodes, + kHeatmapGradientStartPointsKey : gradient.startPoints, + kHeatmapGradientColorMapSizeKey : @(gradient.mapSize) + }; +} + @end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m index 8a69f712ba33..0f1ca2e3ad99 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m @@ -3,6 +3,7 @@ // found in the LICENSE file. #import "GoogleMapController.h" +#import "FLTGoogleMapHeatmapController.h" #import "FLTGoogleMapJSONConversions.h" #import "FLTGoogleMapTileOverlayController.h" #import "messages.g.h" @@ -119,6 +120,9 @@ @interface FLTGoogleMapController () @property(nonatomic, strong) FLTPolygonsController *polygonsController; @property(nonatomic, strong) FLTPolylinesController *polylinesController; @property(nonatomic, strong) FLTCirclesController *circlesController; + +// The controller that handles heatmaps +@property(nonatomic, strong) FLTHeatmapsController *heatmapsController; @property(nonatomic, strong) FLTTileOverlaysController *tileOverlaysController; // The resulting error message, if any, from the last attempt to set the map style. // This is used to provide access to errors after the fact, since the map style is generally set at @@ -183,6 +187,7 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView _circlesController = [[FLTCirclesController alloc] initWithMapView:_mapView callbackHandler:_dartCallbackHandler registrar:registrar]; + _heatmapsController = [[FLTHeatmapsController alloc] initWithMapView:_mapView]; _tileOverlaysController = [[FLTTileOverlaysController alloc] initWithMapView:_mapView callbackHandler:_dartCallbackHandler @@ -203,6 +208,10 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView if ([circlesToAdd isKindOfClass:[NSArray class]]) { [_circlesController addJSONCircles:circlesToAdd]; } + id heatmapsToAdd = args[kHeatmapsToAddKey]; + if ([heatmapsToAdd isKindOfClass:[NSArray class]]) { + [_heatmapsController addJSONHeatmaps:heatmapsToAdd]; + } id tileOverlaysToAdd = args[@"tileOverlaysToAdd"]; if ([tileOverlaysToAdd isKindOfClass:[NSArray class]]) { [_tileOverlaysController addJSONTileOverlays:tileOverlaysToAdd]; @@ -532,6 +541,15 @@ - (void)updateCirclesByAdding:(nonnull NSArray *)toAdd [self.controller.circlesController removeCirclesWithIdentifiers:idsToRemove]; } +- (void)updateHeatmapsByAdding:(nonnull NSArray *)toAdd + changing:(nonnull NSArray *)toChange + removing:(nonnull NSArray *)idsToRemove + error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { + [self.controller.heatmapsController addHeatmaps:toAdd]; + [self.controller.heatmapsController changeHeatmaps:toChange]; + [self.controller.heatmapsController removeHeatmapsWithIdentifiers:idsToRemove]; +} + - (void)updateWithMapConfiguration:(nonnull FGMPlatformMapConfiguration *)configuration error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { [self.controller interpretMapOptions:configuration.json]; @@ -741,8 +759,8 @@ - (nullable NSNumber *)areZoomGesturesEnabledWithError: } - (nullable FGMPlatformTileLayer *) - getInfoForTileOverlayWithIdentifier:(nonnull NSString *)tileOverlayId - error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { + tileOverlayWithIdentifier:(nonnull NSString *)tileOverlayId + error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { GMSTileLayer *layer = [self.controller.tileOverlaysController tileOverlayWithIdentifier:tileOverlayId].layer; if (!layer) { @@ -754,6 +772,17 @@ - (nullable NSNumber *)areZoomGesturesEnabledWithError: zIndex:layer.zIndex]; } +- (nullable FGMPlatformHeatmap *) + heatmapWithIdentifier:(nonnull NSString *)heatmapId + error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { + NSDictionary *heatmapInfo = + [self.controller.heatmapsController heatmapInfoWithIdentifier:heatmapId]; + if (!heatmapInfo) { + return nil; + } + return [FGMPlatformHeatmap makeWithJson:heatmapInfo]; +} + - (nullable NSNumber *)isCompassEnabledWithError: (FlutterError *_Nullable __autoreleasing *_Nonnull)error { return @(self.controller.mapView.settings.compassButton); diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h index 4f331de069b4..de245e43bd3f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h @@ -3,6 +3,7 @@ // found in the LICENSE file. #import +#import #import #import #import diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h index 6dd112c6baaa..7752ff0f4ae6 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h @@ -16,6 +16,7 @@ NS_ASSUME_NONNULL_BEGIN @class FGMPlatformCameraPosition; @class FGMPlatformCameraUpdate; @class FGMPlatformCircle; +@class FGMPlatformHeatmap; @class FGMPlatformMarker; @class FGMPlatformPolygon; @class FGMPlatformPolyline; @@ -48,7 +49,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The update data, as JSON. This should only be set from -/// CameraUpdate.toJson, and the native code must intepret it according to the +/// CameraUpdate.toJson, and the native code must interpret it according to the /// internal implementation details of the CameraUpdate class. @property(nonatomic, strong) id json; @end @@ -59,7 +60,18 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The circle data, as JSON. This should only be set from -/// Circle.toJson, and the native code must intepret it according to the +/// Circle.toJson, and the native code must interpret it according to the +/// internal implementation details of that method. +@property(nonatomic, strong) id json; +@end + +/// Pigeon equivalent of the Heatmap class. +@interface FGMPlatformHeatmap : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithJson:(id)json; +/// The heatmap data, as JSON. This should only be set from +/// Heatmap.toJson, and the native code must interpret it according to the /// internal implementation details of that method. @property(nonatomic, strong) id json; @end @@ -70,7 +82,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The marker data, as JSON. This should only be set from -/// Marker.toJson, and the native code must intepret it according to the +/// Marker.toJson, and the native code must interpret it according to the /// internal implementation details of that method. @property(nonatomic, strong) id json; @end @@ -81,7 +93,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The polygon data, as JSON. This should only be set from -/// Polygon.toJson, and the native code must intepret it according to the +/// Polygon.toJson, and the native code must interpret it according to the /// internal implementation details of that method. @property(nonatomic, strong) id json; @end @@ -92,7 +104,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The polyline data, as JSON. This should only be set from -/// Polyline.toJson, and the native code must intepret it according to the +/// Polyline.toJson, and the native code must interpret it according to the /// internal implementation details of that method. @property(nonatomic, strong) id json; @end @@ -115,7 +127,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The tile overlay data, as JSON. This should only be set from -/// TileOverlay.toJson, and the native code must intepret it according to the +/// TileOverlay.toJson, and the native code must interpret it according to the /// internal implementation details of that method. @property(nonatomic, strong) id json; @end @@ -145,7 +157,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithJson:(id)json; /// The configuration options, as JSON. This should only be set from -/// _jsonForMapConfiguration, and the native code must intepret it according +/// _jsonForMapConfiguration, and the native code must interpret it according /// to the internal implementation details of that method. @property(nonatomic, strong) id json; @end @@ -202,6 +214,11 @@ NSObject *FGMGetMessagesCodec(void); changing:(NSArray *)toChange removing:(NSArray *)idsToRemove error:(FlutterError *_Nullable *_Nonnull)error; +/// Updates the set of heatmaps on the map. +- (void)updateHeatmapsByAdding:(NSArray *)toAdd + changing:(NSArray *)toChange + removing:(NSArray *)idsToRemove + error:(FlutterError *_Nullable *_Nonnull)error; /// Updates the set of markers on the map. - (void)updateMarkersByAdding:(NSArray *)toAdd changing:(NSArray *)toChange @@ -358,9 +375,11 @@ extern void SetUpFGMMapsApiWithSuffix(id binaryMessenger - (nullable NSNumber *)isMyLocationButtonEnabledWithError:(FlutterError *_Nullable *_Nonnull)error; /// @return `nil` only when `error != nil`. - (nullable NSNumber *)isTrafficEnabledWithError:(FlutterError *_Nullable *_Nonnull)error; -- (nullable FGMPlatformTileLayer *) - getInfoForTileOverlayWithIdentifier:(NSString *)tileOverlayId - error:(FlutterError *_Nullable *_Nonnull)error; +- (nullable FGMPlatformTileLayer *)tileOverlayWithIdentifier:(NSString *)tileOverlayId + error: + (FlutterError *_Nullable *_Nonnull)error; +- (nullable FGMPlatformHeatmap *)heatmapWithIdentifier:(NSString *)heatmapId + error:(FlutterError *_Nullable *_Nonnull)error; /// @return `nil` only when `error != nil`. - (nullable FGMPlatformZoomRange *)zoomRange:(FlutterError *_Nullable *_Nonnull)error; @end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m index 4836d517b896..d541e4c8ade6 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m @@ -57,6 +57,12 @@ + (nullable FGMPlatformCircle *)nullableFromList:(NSArray *)list; - (NSArray *)toList; @end +@interface FGMPlatformHeatmap () ++ (FGMPlatformHeatmap *)fromList:(NSArray *)list; ++ (nullable FGMPlatformHeatmap *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @interface FGMPlatformMarker () + (FGMPlatformMarker *)fromList:(NSArray *)list; + (nullable FGMPlatformMarker *)nullableFromList:(NSArray *)list; @@ -198,6 +204,27 @@ + (nullable FGMPlatformCircle *)nullableFromList:(NSArray *)list { } @end +@implementation FGMPlatformHeatmap ++ (instancetype)makeWithJson:(id)json { + FGMPlatformHeatmap *pigeonResult = [[FGMPlatformHeatmap alloc] init]; + pigeonResult.json = json; + return pigeonResult; +} ++ (FGMPlatformHeatmap *)fromList:(NSArray *)list { + FGMPlatformHeatmap *pigeonResult = [[FGMPlatformHeatmap alloc] init]; + pigeonResult.json = GetNullableObjectAtIndex(list, 0); + return pigeonResult; +} ++ (nullable FGMPlatformHeatmap *)nullableFromList:(NSArray *)list { + return (list) ? [FGMPlatformHeatmap fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.json ?: [NSNull null], + ]; +} +@end + @implementation FGMPlatformMarker + (instancetype)makeWithJson:(id)json { FGMPlatformMarker *pigeonResult = [[FGMPlatformMarker alloc] init]; @@ -474,26 +501,28 @@ - (nullable id)readValueOfType:(UInt8)type { case 131: return [FGMPlatformCircle fromList:[self readValue]]; case 132: - return [FGMPlatformMarker fromList:[self readValue]]; + return [FGMPlatformHeatmap fromList:[self readValue]]; case 133: - return [FGMPlatformPolygon fromList:[self readValue]]; + return [FGMPlatformMarker fromList:[self readValue]]; case 134: - return [FGMPlatformPolyline fromList:[self readValue]]; + return [FGMPlatformPolygon fromList:[self readValue]]; case 135: - return [FGMPlatformTile fromList:[self readValue]]; + return [FGMPlatformPolyline fromList:[self readValue]]; case 136: - return [FGMPlatformTileOverlay fromList:[self readValue]]; + return [FGMPlatformTile fromList:[self readValue]]; case 137: - return [FGMPlatformLatLng fromList:[self readValue]]; + return [FGMPlatformTileOverlay fromList:[self readValue]]; case 138: - return [FGMPlatformLatLngBounds fromList:[self readValue]]; + return [FGMPlatformLatLng fromList:[self readValue]]; case 139: - return [FGMPlatformMapConfiguration fromList:[self readValue]]; + return [FGMPlatformLatLngBounds fromList:[self readValue]]; case 140: - return [FGMPlatformPoint fromList:[self readValue]]; + return [FGMPlatformMapConfiguration fromList:[self readValue]]; case 141: - return [FGMPlatformTileLayer fromList:[self readValue]]; + return [FGMPlatformPoint fromList:[self readValue]]; case 142: + return [FGMPlatformTileLayer fromList:[self readValue]]; + case 143: return [FGMPlatformZoomRange fromList:[self readValue]]; default: return [super readValueOfType:type]; @@ -514,39 +543,42 @@ - (void)writeValue:(id)value { } else if ([value isKindOfClass:[FGMPlatformCircle class]]) { [self writeByte:131]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformMarker class]]) { + } else if ([value isKindOfClass:[FGMPlatformHeatmap class]]) { [self writeByte:132]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformPolygon class]]) { + } else if ([value isKindOfClass:[FGMPlatformMarker class]]) { [self writeByte:133]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformPolyline class]]) { + } else if ([value isKindOfClass:[FGMPlatformPolygon class]]) { [self writeByte:134]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformTile class]]) { + } else if ([value isKindOfClass:[FGMPlatformPolyline class]]) { [self writeByte:135]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformTileOverlay class]]) { + } else if ([value isKindOfClass:[FGMPlatformTile class]]) { [self writeByte:136]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformLatLng class]]) { + } else if ([value isKindOfClass:[FGMPlatformTileOverlay class]]) { [self writeByte:137]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformLatLngBounds class]]) { + } else if ([value isKindOfClass:[FGMPlatformLatLng class]]) { [self writeByte:138]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformMapConfiguration class]]) { + } else if ([value isKindOfClass:[FGMPlatformLatLngBounds class]]) { [self writeByte:139]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformPoint class]]) { + } else if ([value isKindOfClass:[FGMPlatformMapConfiguration class]]) { [self writeByte:140]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformTileLayer class]]) { + } else if ([value isKindOfClass:[FGMPlatformPoint class]]) { [self writeByte:141]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformZoomRange class]]) { + } else if ([value isKindOfClass:[FGMPlatformTileLayer class]]) { [self writeByte:142]; [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FGMPlatformZoomRange class]]) { + [self writeByte:143]; + [self writeValue:[value toList]]; } else { [super writeValue:value]; } @@ -664,6 +696,36 @@ void SetUpFGMMapsApiWithSuffix(id binaryMessenger, [channel setMessageHandler:nil]; } } + /// Updates the set of heatmaps on the map. + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_maps_flutter_ios." + @"MapsApi.updateHeatmaps", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FGMGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(updateHeatmapsByAdding:changing:removing:error:)], + @"FGMMapsApi api (%@) doesn't respond to " + @"@selector(updateHeatmapsByAdding:changing:removing:error:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSArray *arg_toAdd = GetNullableObjectAtIndex(args, 0); + NSArray *arg_toChange = GetNullableObjectAtIndex(args, 1); + NSArray *arg_idsToRemove = GetNullableObjectAtIndex(args, 2); + FlutterError *error; + [api updateHeatmapsByAdding:arg_toAdd + changing:arg_toChange + removing:arg_idsToRemove + error:&error]; + callback(wrapResult(nil, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } /// Updates the set of markers on the map. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] @@ -1688,16 +1750,40 @@ void SetUpFGMMapsInspectorApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FGMGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(getInfoForTileOverlayWithIdentifier:error:)], + NSCAssert([api respondsToSelector:@selector(tileOverlayWithIdentifier:error:)], @"FGMMapsInspectorApi api (%@) doesn't respond to " - @"@selector(getInfoForTileOverlayWithIdentifier:error:)", + @"@selector(tileOverlayWithIdentifier:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSString *arg_tileOverlayId = GetNullableObjectAtIndex(args, 0); FlutterError *error; - FGMPlatformTileLayer *output = [api getInfoForTileOverlayWithIdentifier:arg_tileOverlayId - error:&error]; + FGMPlatformTileLayer *output = [api tileOverlayWithIdentifier:arg_tileOverlayId + error:&error]; + callback(wrapResult(output, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_maps_flutter_ios." + @"MapsInspectorApi.getHeatmapInfo", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FGMGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(heatmapWithIdentifier:error:)], + @"FGMMapsInspectorApi api (%@) doesn't respond to " + @"@selector(heatmapWithIdentifier:error:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSString *arg_heatmapId = GetNullableObjectAtIndex(args, 0); + FlutterError *error; + FGMPlatformHeatmap *output = [api heatmapWithIdentifier:arg_heatmapId error:&error]; callback(wrapResult(output, error)); }]; } else { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec index ce652f3b41b8..2715a7dcb155 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec @@ -25,8 +25,17 @@ Downloaded by pub (not CocoaPods). # Versions earlier than 8.4 can't be supported because that's the first version # that supports privacy manifests. s.dependency 'GoogleMaps', '>= 8.4', '< 10.0' + # Google-Maps-iOS-Utils 5.x supports GoogleMaps 8.x and iOS 14.0+ + # Google-Maps-iOS-Utils 6.x supports GoogleMaps 9.x and iOS 15.0+ + s.dependency 'Google-Maps-iOS-Utils', '>= 5.0', '< 7.0' s.static_framework = true s.platform = :ios, '14.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + # "Google-Maps-iOS-Utils" is static and contains Swift classes. + # Find the Swift runtime when these plugins are built as libraries without `use_frameworks!` + s.swift_version = '5.0' + s.xcconfig = { + 'LIBRARY_SEARCH_PATHS' => '$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)/ $(SDKROOT)/usr/lib/swift', + 'LD_RUNPATH_SEARCH_PATHS' => '$(inherited) /usr/lib/swift', + } s.resource_bundles = {'google_maps_flutter_ios_privacy' => ['Resources/PrivacyInfo.xcprivacy']} end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart index 990505bcb136..35a5e73c30fd 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'messages.g.dart'; +import 'serialization.dart'; /// An Android of implementation of [GoogleMapsInspectorPlatform]. @visibleForTesting @@ -73,6 +74,34 @@ class GoogleMapsInspectorIOS extends GoogleMapsInspectorPlatform { ); } + @override + bool supportsGettingHeatmapInfo() => true; + + @override + Future getHeatmapInfo(HeatmapId heatmapId, + {required int mapId}) async { + final PlatformHeatmap? heatmapInfo = + await _inspectorProvider(mapId)!.getHeatmapInfo(heatmapId.value); + if (heatmapInfo == null) { + return null; + } + + final Map json = + (heatmapInfo.json as Map).cast(); + return Heatmap( + heatmapId: heatmapId, + data: (json['data']! as List) + .map(deserializeWeightedLatLng) + .whereType() + .toList(), + gradient: deserializeHeatmapGradient(json['gradient']), + opacity: json['opacity']! as double, + radius: HeatmapRadius.fromPixels(json['radius']! as int), + minimumZoomIntensity: json['minimumZoomIntensity']! as int, + maximumZoomIntensity: json['maximumZoomIntensity']! as int, + ); + } + @override Future isCompassEnabled({required int mapId}) async { return _inspectorProvider(mapId)!.isCompassEnabled(); diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart index 8e9a96d094b9..483b1e7b19d5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart @@ -13,6 +13,7 @@ import 'package:stream_transform/stream_transform.dart'; import 'google_map_inspector_ios.dart'; import 'messages.g.dart'; +import 'serialization.dart'; // TODO(stuartmorgan): Remove the dependency on platform interface toJson // methods. Channel serialization details should all be package-internal. @@ -273,6 +274,20 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { ); } + @override + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + required int mapId, + }) { + return _hostApi(mapId).updateHeatmaps( + heatmapUpdates.heatmapsToAdd.map(_platformHeatmapFromHeatmap).toList(), + heatmapUpdates.heatmapsToChange.map(_platformHeatmapFromHeatmap).toList(), + heatmapUpdates.heatmapIdsToRemove + .map((HeatmapId id) => id.value) + .toList(), + ); + } + @override Future updateTileOverlays({ required Set newTileOverlays, @@ -421,6 +436,7 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { 'polygonsToAdd': serializePolygonSet(mapObjects.polygons), 'polylinesToAdd': serializePolylineSet(mapObjects.polylines), 'circlesToAdd': serializeCircleSet(mapObjects.circles), + 'heatmapsToAdd': mapObjects.heatmaps.map(serializeHeatmap).toList(), 'tileOverlaysToAdd': serializeTileOverlaySet(mapObjects.tileOverlays), }; @@ -535,6 +551,10 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { return PlatformCircle(json: circle.toJson()); } + static PlatformHeatmap _platformHeatmapFromHeatmap(Heatmap heatmap) { + return PlatformHeatmap(json: heatmap.toJson()); + } + static PlatformMarker _platformMarkerFromMarker(Marker marker) { return PlatformMarker(json: marker.toJson()); } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart index be3095375889..e5041ef24e15 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart @@ -73,7 +73,7 @@ class PlatformCameraUpdate { }); /// The update data, as JSON. This should only be set from - /// CameraUpdate.toJson, and the native code must intepret it according to the + /// CameraUpdate.toJson, and the native code must interpret it according to the /// internal implementation details of the CameraUpdate class. Object json; @@ -98,7 +98,7 @@ class PlatformCircle { }); /// The circle data, as JSON. This should only be set from - /// Circle.toJson, and the native code must intepret it according to the + /// Circle.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Object json; @@ -116,6 +116,31 @@ class PlatformCircle { } } +/// Pigeon equivalent of the Heatmap class. +class PlatformHeatmap { + PlatformHeatmap({ + required this.json, + }); + + /// The heatmap data, as JSON. This should only be set from + /// Heatmap.toJson, and the native code must interpret it according to the + /// internal implementation details of that method. + Object json; + + Object encode() { + return [ + json, + ]; + } + + static PlatformHeatmap decode(Object result) { + result as List; + return PlatformHeatmap( + json: result[0]!, + ); + } +} + /// Pigeon equivalent of the Marker class. class PlatformMarker { PlatformMarker({ @@ -123,7 +148,7 @@ class PlatformMarker { }); /// The marker data, as JSON. This should only be set from - /// Marker.toJson, and the native code must intepret it according to the + /// Marker.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Object json; @@ -148,7 +173,7 @@ class PlatformPolygon { }); /// The polygon data, as JSON. This should only be set from - /// Polygon.toJson, and the native code must intepret it according to the + /// Polygon.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Object json; @@ -173,7 +198,7 @@ class PlatformPolyline { }); /// The polyline data, as JSON. This should only be set from - /// Polyline.toJson, and the native code must intepret it according to the + /// Polyline.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Object json; @@ -230,7 +255,7 @@ class PlatformTileOverlay { }); /// The tile overlay data, as JSON. This should only be set from - /// TileOverlay.toJson, and the native code must intepret it according to the + /// TileOverlay.toJson, and the native code must interpret it according to the /// internal implementation details of that method. Object json; @@ -309,7 +334,7 @@ class PlatformMapConfiguration { }); /// The configuration options, as JSON. This should only be set from - /// _jsonForMapConfiguration, and the native code must intepret it according + /// _jsonForMapConfiguration, and the native code must interpret it according /// to the internal implementation details of that method. Object json; @@ -431,39 +456,42 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is PlatformCircle) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is PlatformMarker) { + } else if (value is PlatformHeatmap) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is PlatformPolygon) { + } else if (value is PlatformMarker) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is PlatformPolyline) { + } else if (value is PlatformPolygon) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is PlatformTile) { + } else if (value is PlatformPolyline) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is PlatformTileOverlay) { + } else if (value is PlatformTile) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is PlatformLatLng) { + } else if (value is PlatformTileOverlay) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PlatformLatLngBounds) { + } else if (value is PlatformLatLng) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is PlatformMapConfiguration) { + } else if (value is PlatformLatLngBounds) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is PlatformPoint) { + } else if (value is PlatformMapConfiguration) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is PlatformTileLayer) { + } else if (value is PlatformPoint) { buffer.putUint8(141); writeValue(buffer, value.encode()); - } else if (value is PlatformZoomRange) { + } else if (value is PlatformTileLayer) { buffer.putUint8(142); writeValue(buffer, value.encode()); + } else if (value is PlatformZoomRange) { + buffer.putUint8(143); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -479,26 +507,28 @@ class _PigeonCodec extends StandardMessageCodec { case 131: return PlatformCircle.decode(readValue(buffer)!); case 132: - return PlatformMarker.decode(readValue(buffer)!); + return PlatformHeatmap.decode(readValue(buffer)!); case 133: - return PlatformPolygon.decode(readValue(buffer)!); + return PlatformMarker.decode(readValue(buffer)!); case 134: - return PlatformPolyline.decode(readValue(buffer)!); + return PlatformPolygon.decode(readValue(buffer)!); case 135: - return PlatformTile.decode(readValue(buffer)!); + return PlatformPolyline.decode(readValue(buffer)!); case 136: - return PlatformTileOverlay.decode(readValue(buffer)!); + return PlatformTile.decode(readValue(buffer)!); case 137: - return PlatformLatLng.decode(readValue(buffer)!); + return PlatformTileOverlay.decode(readValue(buffer)!); case 138: - return PlatformLatLngBounds.decode(readValue(buffer)!); + return PlatformLatLng.decode(readValue(buffer)!); case 139: - return PlatformMapConfiguration.decode(readValue(buffer)!); + return PlatformLatLngBounds.decode(readValue(buffer)!); case 140: - return PlatformPoint.decode(readValue(buffer)!); + return PlatformMapConfiguration.decode(readValue(buffer)!); case 141: - return PlatformTileLayer.decode(readValue(buffer)!); + return PlatformPoint.decode(readValue(buffer)!); case 142: + return PlatformTileLayer.decode(readValue(buffer)!); + case 143: return PlatformZoomRange.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -603,6 +633,32 @@ class MapsApi { } } + /// Updates the set of heatmaps on the map. + Future updateHeatmaps(List toAdd, + List toChange, List idsToRemove) async { + final String __pigeon_channelName = + 'dev.flutter.pigeon.google_maps_flutter_ios.MapsApi.updateHeatmaps$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = + BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = await __pigeon_channel + .send([toAdd, toChange, idsToRemove]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else { + return; + } + } + /// Updates the set of markers on the map. Future updateMarkers(List toAdd, List toChange, List idsToRemove) async { @@ -1794,6 +1850,30 @@ class MapsInspectorApi { } } + Future getHeatmapInfo(String heatmapId) async { + final String __pigeon_channelName = + 'dev.flutter.pigeon.google_maps_flutter_ios.MapsInspectorApi.getHeatmapInfo$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = + BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send([heatmapId]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else { + return (__pigeon_replyList[0] as PlatformHeatmap?); + } + } + Future getZoomRange() async { final String __pigeon_channelName = 'dev.flutter.pigeon.google_maps_flutter_ios.MapsInspectorApi.getZoomRange$__pigeon_messageChannelSuffix'; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/serialization.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/serialization.dart new file mode 100644 index 000000000000..8c80f19fb616 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/serialization.dart @@ -0,0 +1,125 @@ +// Copyright 2013 The Flutter 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:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +// These constants must match the corresponding constants in FLTGoogleMapJSONConversions.m +const String _heatmapIdKey = 'heatmapId'; +const String _heatmapDataKey = 'data'; +const String _heatmapGradientKey = 'gradient'; +const String _heatmapOpacityKey = 'opacity'; +const String _heatmapRadiusKey = 'radius'; +const String _heatmapMinimumZoomIntensityKey = 'minimumZoomIntensity'; +const String _heatmapMaximumZoomIntensityKey = 'maximumZoomIntensity'; +const String _heatmapGradientColorsKey = 'colors'; +const String _heatmapGradientStartPointsKey = 'startPoints'; +const String _heatmapGradientColorMapSizeKey = 'colorMapSize'; + +void _addIfNonNull(Map map, String fieldName, Object? value) { + if (value != null) { + map[fieldName] = value; + } +} + +/// Serialize [Heatmap] +Object serializeHeatmap(Heatmap heatmap) { + final Map json = {}; + + _addIfNonNull(json, _heatmapIdKey, heatmap.heatmapId.value); + _addIfNonNull( + json, + _heatmapDataKey, + heatmap.data.map(serializeWeightedLatLng).toList(), + ); + + final HeatmapGradient? gradient = heatmap.gradient; + if (gradient != null) { + _addIfNonNull( + json, _heatmapGradientKey, serializeHeatmapGradient(gradient)); + } + _addIfNonNull(json, _heatmapOpacityKey, heatmap.opacity); + _addIfNonNull(json, _heatmapRadiusKey, heatmap.radius.radius); + _addIfNonNull( + json, _heatmapMinimumZoomIntensityKey, heatmap.minimumZoomIntensity); + _addIfNonNull( + json, _heatmapMaximumZoomIntensityKey, heatmap.maximumZoomIntensity); + + return json; +} + +/// Serialize [WeightedLatLng] +Object serializeWeightedLatLng(WeightedLatLng wll) { + return [serializeLatLng(wll.point), wll.weight]; +} + +/// Deserialize [WeightedLatLng] +WeightedLatLng? deserializeWeightedLatLng(Object? json) { + if (json == null) { + return null; + } + assert(json is List && json.length == 2); + final List list = json as List; + final LatLng latLng = deserializeLatLng(list[0])!; + return WeightedLatLng(latLng, weight: list[1] as double); +} + +/// Serialize [LatLng] +Object serializeLatLng(LatLng latLng) { + return [latLng.latitude, latLng.longitude]; +} + +/// Deserialize [LatLng] +LatLng? deserializeLatLng(Object? json) { + if (json == null) { + return null; + } + assert(json is List && json.length == 2); + final List list = json as List; + return LatLng(list[0]! as double, list[1]! as double); +} + +/// Serialize [HeatmapGradient] +Object serializeHeatmapGradient(HeatmapGradient gradient) { + final Map json = {}; + + _addIfNonNull( + json, + _heatmapGradientColorsKey, + gradient.colors.map((HeatmapGradientColor e) => e.color.value).toList(), + ); + _addIfNonNull( + json, + _heatmapGradientStartPointsKey, + gradient.colors.map((HeatmapGradientColor e) => e.startPoint).toList(), + ); + _addIfNonNull(json, _heatmapGradientColorMapSizeKey, gradient.colorMapSize); + + return json; +} + +/// Deserialize [HeatmapGradient] +HeatmapGradient? deserializeHeatmapGradient(Object? json) { + if (json == null) { + return null; + } + assert(json is Map); + final Map map = (json as Map).cast(); + final List colors = (map[_heatmapGradientColorsKey]! as List) + .whereType() + .map((int e) => Color(e)) + .toList(); + final List startPoints = + (map[_heatmapGradientStartPointsKey]! as List) + .whereType() + .toList(); + final List gradientColors = []; + for (int i = 0; i < colors.length; i++) { + gradientColors.add(HeatmapGradientColor(colors[i], startPoints[i])); + } + return HeatmapGradient( + gradientColors, + colorMapSize: map[_heatmapGradientColorMapSizeKey] as int? ?? 256, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart b/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart index 19db3aa85024..cd6cab64c914 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart @@ -32,7 +32,7 @@ class PlatformCameraUpdate { PlatformCameraUpdate(this.json); /// The update data, as JSON. This should only be set from - /// CameraUpdate.toJson, and the native code must intepret it according to the + /// CameraUpdate.toJson, and the native code must interpret it according to the /// internal implementation details of the CameraUpdate class. // TODO(stuartmorgan): Update the google_maps_platform_interface CameraUpdate // class to provide a structured representation of an update. Currently it @@ -47,7 +47,19 @@ class PlatformCircle { PlatformCircle(this.json); /// The circle data, as JSON. This should only be set from - /// Circle.toJson, and the native code must intepret it according to the + /// Circle.toJson, and the native code must interpret it according to the + /// internal implementation details of that method. + // TODO(stuartmorgan): Replace this with structured data. This exists only to + // allow incremental migration to Pigeon. + final Object json; +} + +/// Pigeon equivalent of the Heatmap class. +class PlatformHeatmap { + PlatformHeatmap(this.json); + + /// The heatmap data, as JSON. This should only be set from + /// Heatmap.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -59,7 +71,7 @@ class PlatformMarker { PlatformMarker(this.json); /// The marker data, as JSON. This should only be set from - /// Marker.toJson, and the native code must intepret it according to the + /// Marker.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -71,7 +83,7 @@ class PlatformPolygon { PlatformPolygon(this.json); /// The polygon data, as JSON. This should only be set from - /// Polygon.toJson, and the native code must intepret it according to the + /// Polygon.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -83,7 +95,7 @@ class PlatformPolyline { PlatformPolyline(this.json); /// The polyline data, as JSON. This should only be set from - /// Polyline.toJson, and the native code must intepret it according to the + /// Polyline.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -104,7 +116,7 @@ class PlatformTileOverlay { PlatformTileOverlay(this.json); /// The tile overlay data, as JSON. This should only be set from - /// TileOverlay.toJson, and the native code must intepret it according to the + /// TileOverlay.toJson, and the native code must interpret it according to the /// internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -132,7 +144,7 @@ class PlatformMapConfiguration { PlatformMapConfiguration({required this.json}); /// The configuration options, as JSON. This should only be set from - /// _jsonForMapConfiguration, and the native code must intepret it according + /// _jsonForMapConfiguration, and the native code must interpret it according /// to the internal implementation details of that method. // TODO(stuartmorgan): Replace this with structured data. This exists only to // allow incremental migration to Pigeon. @@ -193,6 +205,14 @@ abstract class MapsApi { void updateCircles(List toAdd, List toChange, List idsToRemove); + /// Updates the set of heatmaps on the map. + // TODO(stuartmorgan): Make the generic type non-nullable once supported. + // https://github.com/flutter/flutter/issues/97848 + // The consuming code treats the entries as non-nullable. + @ObjCSelector('updateHeatmapsByAdding:changing:removing:') + void updateHeatmaps(List toAdd, + List toChange, List idsToRemove); + /// Updates the set of markers on the map. // TODO(stuartmorgan): Make the generic type non-nullable once supported. // https://github.com/flutter/flutter/issues/97848 @@ -360,8 +380,10 @@ abstract class MapsInspectorApi { bool isCompassEnabled(); bool isMyLocationButtonEnabled(); bool isTrafficEnabled(); - @ObjCSelector('getInfoForTileOverlayWithIdentifier:') + @ObjCSelector('tileOverlayWithIdentifier:') PlatformTileLayer? getTileOverlayInfo(String tileOverlayId); + @ObjCSelector('heatmapWithIdentifier:') + PlatformHeatmap? getHeatmapInfo(String heatmapId); @ObjCSelector('zoomRange') PlatformZoomRange getZoomRange(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml index d73df0ee6384..2ba981a262bc 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_ios description: iOS implementation of the google_maps_flutter plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.10.0 +version: 2.11.0 environment: sdk: ^3.2.3 @@ -19,7 +19,7 @@ flutter: dependencies: flutter: sdk: flutter - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 stream_transform: ^2.0.0 dev_dependencies: diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart index ca2dbb086af0..c8f9ca752fac 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart @@ -99,6 +99,25 @@ class MockMapsApi extends _i1.Mock implements _i2.MapsApi { returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + @override + _i3.Future updateHeatmaps( + List<_i2.PlatformHeatmap?>? toAdd, + List<_i2.PlatformHeatmap?>? toChange, + List? idsToRemove, + ) => + (super.noSuchMethod( + Invocation.method( + #updateHeatmaps, + [ + toAdd, + toChange, + idsToRemove, + ], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + @override _i3.Future updateMarkers( List<_i2.PlatformMarker?>? toAdd, From 57fcda0070eff9f079fdb48874628e5241f23758 Mon Sep 17 00:00:00 2001 From: Rexios Date: Tue, 6 Aug 2024 10:05:04 -0400 Subject: [PATCH 11/12] [google_maps_flutter_web] Web changes to support heatmaps (#7315) Sequel to: - https://github.com/flutter/packages/pull/7312 Prequel to: - https://github.com/flutter/packages/pull/3257 --- .../google_maps_flutter_web/CHANGELOG.md | 4 + .../google_maps_flutter_web/README.md | 13 ++ .../google_maps_controller_test.dart | 105 +++++++++++++ .../google_maps_controller_test.mocks.dart | 89 +++++++++++ .../google_maps_plugin_test.dart | 10 ++ .../google_maps_plugin_test.mocks.dart | 11 ++ .../example/integration_test/shape_test.dart | 49 ++++++ .../example/integration_test/shapes_test.dart | 140 ++++++++++++++++++ .../example/pubspec.yaml | 2 +- .../example/web/index.html | 2 +- .../lib/google_maps_flutter_web.dart | 3 + .../lib/src/convert.dart | 32 ++++ .../lib/src/google_maps_controller.dart | 26 +++- .../lib/src/google_maps_flutter_web.dart | 9 ++ .../lib/src/heatmap.dart | 34 +++++ .../lib/src/heatmaps.dart | 57 +++++++ .../google_maps_flutter_web/pubspec.yaml | 4 +- 17 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmap.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmaps.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 91d16d00e1a4..1fc48b49315b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.10 + +* Adds support for heatmap layers. + ## 0.5.9+2 * Restores support for Dart `^3.3.0` and Flutter `^3.19.0`. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/README.md b/packages/google_maps_flutter/google_maps_flutter_web/README.md index 1873645d7633..e53fe3506b07 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/README.md @@ -63,6 +63,9 @@ If you need marker clustering support, modify the tag to load the [js-mar ``` +## Heatmaps +To use heatmaps, add `&libraries=visualization` to the end of the URL. See [the documentation](https://developers.google.com/maps/documentation/javascript/libraries) for more information. + ## Limitations of the web version The following map options are not available in web, because the map doesn't rotate there: @@ -85,3 +88,13 @@ Indoor and building layers are still not available on the web. Traffic is. Only Android supports "[Lite Mode](https://developers.google.com/maps/documentation/android-sdk/lite)", so the `liteModeEnabled` constructor argument can't be set to `true` on web apps. Google Maps for web uses `HtmlElementView` to render maps. When a `GoogleMap` is stacked below other widgets, [`package:pointer_interceptor`](https://www.pub.dev/packages/pointer_interceptor) must be used to capture mouse events on the Flutter overlays. See issue [#73830](https://github.com/flutter/flutter/issues/73830). + +## Supported Heatmap Options + +| Field | Supported | +| ---------------------------- | :-------: | +| Heatmap.dissipating | ✓ | +| Heatmap.maxIntensity | ✓ | +| Heatmap.minimumZoomIntensity | x | +| Heatmap.maximumZoomIntensity | x | +| HeatmapGradient.colorMapSize | x | diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart index 7a735062c38d..be68cdf1c714 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart @@ -26,6 +26,9 @@ gmaps.Map mapShim() => throw UnimplementedError(); MockSpec( fallbackGenerators: {#googleMap: mapShim}, ), + MockSpec( + fallbackGenerators: {#googleMap: mapShim}, + ), MockSpec( fallbackGenerators: {#googleMap: mapShim}, ), @@ -161,6 +164,20 @@ void main() { }, throwsAssertionError); }); + testWidgets('cannot updateHeatmaps after dispose', + (WidgetTester tester) async { + controller.dispose(); + + expect(() { + controller.updateHeatmaps( + HeatmapUpdates.from( + const {}, + const {}, + ), + ); + }, throwsAssertionError); + }); + testWidgets('cannot updatePolygons after dispose', (WidgetTester tester) async { controller.dispose(); @@ -229,6 +246,7 @@ void main() { group('init', () { late MockCirclesController circles; + late MockHeatmapsController heatmaps; late MockMarkersController markers; late MockPolygonsController polygons; late MockPolylinesController polylines; @@ -237,6 +255,7 @@ void main() { setUp(() { circles = MockCirclesController(); + heatmaps = MockHeatmapsController(); markers = MockMarkersController(); polygons = MockPolygonsController(); polylines = MockPolylinesController(); @@ -249,6 +268,7 @@ void main() { ..debugSetOverrides( createMap: (_, __) => map, circles: circles, + heatmaps: heatmaps, markers: markers, polygons: polygons, polylines: polylines, @@ -287,6 +307,7 @@ void main() { ..debugSetOverrides( createMap: (_, __) => map, circles: circles, + heatmaps: heatmaps, markers: markers, polygons: polygons, polylines: polylines, @@ -295,6 +316,7 @@ void main() { ..init(); verify(circles.bindToMap(mapId, map)); + verify(heatmaps.bindToMap(mapId, map)); verify(markers.bindToMap(mapId, map)); verify(polygons.bindToMap(mapId, map)); verify(polylines.bindToMap(mapId, map)); @@ -307,6 +329,17 @@ void main() { circleId: CircleId('circle-1'), zIndex: 1234, ), + }, heatmaps: { + const Heatmap( + heatmapId: HeatmapId('heatmap-1'), + data: [ + WeightedLatLng(LatLng(43.355114, -5.851333)), + WeightedLatLng(LatLng(43.354797, -5.851860)), + WeightedLatLng(LatLng(43.354469, -5.851318)), + WeightedLatLng(LatLng(43.354762, -5.850824)), + ], + radius: HeatmapRadius.fromPixels(20), + ), }, markers: { const Marker( markerId: MarkerId('marker-1'), @@ -352,6 +385,7 @@ void main() { controller = createController(mapObjects: mapObjects) ..debugSetOverrides( circles: circles, + heatmaps: heatmaps, markers: markers, polygons: polygons, polylines: polylines, @@ -360,6 +394,7 @@ void main() { ..init(); verify(circles.addCircles(mapObjects.circles)); + verify(heatmaps.addHeatmaps(mapObjects.heatmaps)); verify(markers.addMarkers(mapObjects.markers)); verify(polygons.addPolygons(mapObjects.polygons)); verify(polylines.addPolylines(mapObjects.polylines)); @@ -670,6 +705,76 @@ void main() { })); }); + testWidgets('updateHeatmaps', (WidgetTester tester) async { + final MockHeatmapsController mock = MockHeatmapsController(); + controller.debugSetOverrides(heatmaps: mock); + + const List heatmapPoints = [ + WeightedLatLng(LatLng(37.782, -122.447)), + WeightedLatLng(LatLng(37.782, -122.445)), + WeightedLatLng(LatLng(37.782, -122.443)), + WeightedLatLng(LatLng(37.782, -122.441)), + WeightedLatLng(LatLng(37.782, -122.439)), + WeightedLatLng(LatLng(37.782, -122.437)), + WeightedLatLng(LatLng(37.782, -122.435)), + WeightedLatLng(LatLng(37.785, -122.447)), + WeightedLatLng(LatLng(37.785, -122.445)), + WeightedLatLng(LatLng(37.785, -122.443)), + WeightedLatLng(LatLng(37.785, -122.441)), + WeightedLatLng(LatLng(37.785, -122.439)), + WeightedLatLng(LatLng(37.785, -122.437)), + WeightedLatLng(LatLng(37.785, -122.435)) + ]; + + final Set previous = { + const Heatmap( + heatmapId: HeatmapId('to-be-updated'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + const Heatmap( + heatmapId: HeatmapId('to-be-removed'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + }; + + final Set current = { + const Heatmap( + heatmapId: HeatmapId('to-be-updated'), + data: heatmapPoints, + dissipating: false, + radius: HeatmapRadius.fromPixels(20), + ), + const Heatmap( + heatmapId: HeatmapId('to-be-added'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + }; + + controller.updateHeatmaps(HeatmapUpdates.from(previous, current)); + + verify(mock.removeHeatmaps({ + const HeatmapId('to-be-removed'), + })); + verify(mock.addHeatmaps({ + const Heatmap( + heatmapId: HeatmapId('to-be-added'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + })); + verify(mock.changeHeatmaps({ + const Heatmap( + heatmapId: HeatmapId('to-be-updated'), + data: heatmapPoints, + dissipating: false, + radius: HeatmapRadius.fromPixels(20), + ), + })); + }); + testWidgets('updateMarkers', (WidgetTester tester) async { final MockMarkersController mock = MockMarkersController(); controller = createController()..debugSetOverrides(markers: mock); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart index 832b799724e8..ca64a2a9d7f3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart @@ -114,6 +114,95 @@ class MockCirclesController extends _i1.Mock implements _i2.CirclesController { ); } +/// A class which mocks [HeatmapsController]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHeatmapsController extends _i1.Mock + implements _i2.HeatmapsController { + @override + Map<_i3.HeatmapId, _i2.HeatmapController> get heatmaps => (super.noSuchMethod( + Invocation.getter(#heatmaps), + returnValue: <_i3.HeatmapId, _i2.HeatmapController>{}, + returnValueForMissingStub: <_i3.HeatmapId, _i2.HeatmapController>{}, + ) as Map<_i3.HeatmapId, _i2.HeatmapController>); + + @override + _i4.Map get googleMap => (super.noSuchMethod( + Invocation.getter(#googleMap), + returnValue: _i5.mapShim(), + returnValueForMissingStub: _i5.mapShim(), + ) as _i4.Map); + + @override + set googleMap(_i4.Map? _googleMap) => super.noSuchMethod( + Invocation.setter( + #googleMap, + _googleMap, + ), + returnValueForMissingStub: null, + ); + + @override + int get mapId => (super.noSuchMethod( + Invocation.getter(#mapId), + returnValue: 0, + returnValueForMissingStub: 0, + ) as int); + + @override + set mapId(int? _mapId) => super.noSuchMethod( + Invocation.setter( + #mapId, + _mapId, + ), + returnValueForMissingStub: null, + ); + + @override + void addHeatmaps(Set<_i3.Heatmap>? heatmapsToAdd) => super.noSuchMethod( + Invocation.method( + #addHeatmaps, + [heatmapsToAdd], + ), + returnValueForMissingStub: null, + ); + + @override + void changeHeatmaps(Set<_i3.Heatmap>? heatmapsToChange) => super.noSuchMethod( + Invocation.method( + #changeHeatmaps, + [heatmapsToChange], + ), + returnValueForMissingStub: null, + ); + + @override + void removeHeatmaps(Set<_i3.HeatmapId>? heatmapIdsToRemove) => + super.noSuchMethod( + Invocation.method( + #removeHeatmaps, + [heatmapIdsToRemove], + ), + returnValueForMissingStub: null, + ); + + @override + void bindToMap( + int? mapId, + _i4.Map? googleMap, + ) => + super.noSuchMethod( + Invocation.method( + #bindToMap, + [ + mapId, + googleMap, + ], + ), + returnValueForMissingStub: null, + ); +} + /// A class which mocks [PolygonsController]. /// /// See the documentation for Mockito's code generation for more information. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart index de813b2efae6..1e02f576a089 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart @@ -263,6 +263,16 @@ void main() { verify(controller.updateCircles(expectedUpdates)); }); + testWidgets('updateHeatmaps', (WidgetTester tester) async { + final HeatmapUpdates expectedUpdates = HeatmapUpdates.from( + const {}, + const {}, + ); + + await plugin.updateHeatmaps(expectedUpdates, mapId: mapId); + + verify(controller.updateHeatmaps(expectedUpdates)); + }); // Tile Overlays testWidgets('updateTileOverlays', (WidgetTester tester) async { final Set expectedOverlays = { diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart index 2da0a46f13f2..cf5acfcb813c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart @@ -137,6 +137,7 @@ class MockGoogleMapController extends _i1.Mock _i4.DebugSetOptionsFunction? setOptions, _i4.MarkersController? markers, _i4.CirclesController? circles, + _i4.HeatmapsController? heatmaps, _i4.PolygonsController? polygons, _i4.PolylinesController? polylines, _i6.ClusterManagersController? clusterManagers, @@ -151,6 +152,7 @@ class MockGoogleMapController extends _i1.Mock #setOptions: setOptions, #markers: markers, #circles: circles, + #heatmaps: heatmaps, #polygons: polygons, #polylines: polylines, #clusterManagers: clusterManagers, @@ -289,6 +291,15 @@ class MockGoogleMapController extends _i1.Mock returnValueForMissingStub: null, ); + @override + void updateHeatmaps(_i2.HeatmapUpdates? updates) => super.noSuchMethod( + Invocation.method( + #updateHeatmaps, + [updates], + ), + returnValueForMissingStub: null, + ); + @override void updatePolygons(_i2.PolygonUpdates? updates) => super.noSuchMethod( Invocation.method( diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart index b8da855de840..0912fcbaec4d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart @@ -3,9 +3,11 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:js_interop'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_maps/google_maps.dart' as gmaps; +import 'package:google_maps/google_maps_visualization.dart' as visualization; import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; import 'package:integration_test/integration_test.dart'; @@ -216,4 +218,51 @@ void main() { }); }); }); + + group('HeatmapController', () { + late visualization.HeatmapLayer heatmap; + + setUp(() { + heatmap = visualization.HeatmapLayer(); + }); + + testWidgets('update', (WidgetTester tester) async { + final HeatmapController controller = HeatmapController(heatmap: heatmap); + final visualization.HeatmapLayerOptions options = + visualization.HeatmapLayerOptions() + ..data = [gmaps.LatLng(0, 0)].toJS; + + expect(heatmap.data, hasLength(0)); + + controller.update(options); + + expect(heatmap.data, hasLength(1)); + }); + + group('remove', () { + late HeatmapController controller; + + setUp(() { + controller = HeatmapController(heatmap: heatmap); + }); + + testWidgets('drops gmaps instance', (WidgetTester tester) async { + controller.remove(); + + expect(controller.heatmap, isNull); + }); + + testWidgets('cannot call update after remove', + (WidgetTester tester) async { + final visualization.HeatmapLayerOptions options = + visualization.HeatmapLayerOptions()..dissipating = true; + + controller.remove(); + + expect(() { + controller.update(options); + }, throwsAssertionError); + }); + }); + }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart index a6492a5b7bd6..ec8198679d55 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart @@ -9,6 +9,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_maps/google_maps.dart' as gmaps; import 'package:google_maps/google_maps_geometry.dart' as geometry; +import 'package:google_maps/google_maps_visualization.dart' as visualization; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; // ignore: implementation_imports @@ -375,4 +376,143 @@ void main() { closeTo(0.5, _acceptableDelta)); }); }); + + group('HeatmapsController', () { + late HeatmapsController controller; + + const List heatmapPoints = [ + WeightedLatLng(LatLng(37.782, -122.447)), + WeightedLatLng(LatLng(37.782, -122.445)), + WeightedLatLng(LatLng(37.782, -122.443)), + WeightedLatLng(LatLng(37.782, -122.441)), + WeightedLatLng(LatLng(37.782, -122.439)), + WeightedLatLng(LatLng(37.782, -122.437)), + WeightedLatLng(LatLng(37.782, -122.435)), + WeightedLatLng(LatLng(37.785, -122.447)), + WeightedLatLng(LatLng(37.785, -122.445)), + WeightedLatLng(LatLng(37.785, -122.443)), + WeightedLatLng(LatLng(37.785, -122.441)), + WeightedLatLng(LatLng(37.785, -122.439)), + WeightedLatLng(LatLng(37.785, -122.437)), + WeightedLatLng(LatLng(37.785, -122.435)) + ]; + + setUp(() { + controller = HeatmapsController(); + controller.bindToMap(123, map); + }); + + testWidgets('addHeatmaps', (WidgetTester tester) async { + final Set heatmaps = { + const Heatmap( + heatmapId: HeatmapId('1'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + const Heatmap( + heatmapId: HeatmapId('2'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + }; + + controller.addHeatmaps(heatmaps); + + expect(controller.heatmaps.length, 2); + expect(controller.heatmaps, contains(const HeatmapId('1'))); + expect(controller.heatmaps, contains(const HeatmapId('2'))); + expect(controller.heatmaps, isNot(contains(const HeatmapId('66')))); + }); + + testWidgets('changeHeatmaps', (WidgetTester tester) async { + final Set heatmaps = { + const Heatmap( + heatmapId: HeatmapId('1'), + data: [], + radius: HeatmapRadius.fromPixels(20), + ), + }; + controller.addHeatmaps(heatmaps); + + expect( + controller.heatmaps[const HeatmapId('1')]?.heatmap?.data, + hasLength(0), + ); + + final Set updatedHeatmaps = { + const Heatmap( + heatmapId: HeatmapId('1'), + data: [WeightedLatLng(LatLng(0, 0))], + radius: HeatmapRadius.fromPixels(20), + ), + }; + controller.changeHeatmaps(updatedHeatmaps); + + expect(controller.heatmaps.length, 1); + expect( + controller.heatmaps[const HeatmapId('1')]?.heatmap?.data, + hasLength(1), + ); + }); + + testWidgets('removeHeatmaps', (WidgetTester tester) async { + final Set heatmaps = { + const Heatmap( + heatmapId: HeatmapId('1'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + const Heatmap( + heatmapId: HeatmapId('2'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + const Heatmap( + heatmapId: HeatmapId('3'), + data: heatmapPoints, + radius: HeatmapRadius.fromPixels(20), + ), + }; + + controller.addHeatmaps(heatmaps); + + expect(controller.heatmaps.length, 3); + + // Remove some polylines... + final Set heatmapIdsToRemove = { + const HeatmapId('1'), + const HeatmapId('3'), + }; + + controller.removeHeatmaps(heatmapIdsToRemove); + + expect(controller.heatmaps.length, 1); + expect(controller.heatmaps, isNot(contains(const HeatmapId('1')))); + expect(controller.heatmaps, contains(const HeatmapId('2'))); + expect(controller.heatmaps, isNot(contains(const HeatmapId('3')))); + }); + + testWidgets('Converts colors to CSS', (WidgetTester tester) async { + final Set heatmaps = { + const Heatmap( + heatmapId: HeatmapId('1'), + data: heatmapPoints, + gradient: HeatmapGradient( + [HeatmapGradientColor(Color(0xFFFABADA), 0)], + ), + radius: HeatmapRadius.fromPixels(20), + ), + }; + + controller.addHeatmaps(heatmaps); + + final visualization.HeatmapLayer heatmap = + controller.heatmaps.values.first.heatmap!; + + expect( + heatmap.get('gradient'), + ['rgba(250, 186, 218, 0.00)', 'rgba(250, 186, 218, 1.00)'], + ); + }); + }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml index 2b96e1d5cab1..a883f5f75218 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml @@ -8,7 +8,7 @@ environment: dependencies: flutter: sdk: flutter - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 google_maps_flutter_web: path: ../ web: ^1.0.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html b/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html index fc6ddfcfa7b4..51b7253b8e1f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html @@ -6,7 +6,7 @@ Browser Tests - + diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart index cda20cf9f526..f56e0b02bd1b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart @@ -16,6 +16,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_maps/google_maps.dart' as gmaps; +import 'package:google_maps/google_maps_visualization.dart' as visualization; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:sanitize_html/sanitize_html.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -34,6 +35,8 @@ part 'src/circles.dart'; part 'src/convert.dart'; part 'src/google_maps_controller.dart'; part 'src/google_maps_flutter_web.dart'; +part 'src/heatmap.dart'; +part 'src/heatmaps.dart'; part 'src/marker.dart'; part 'src/markers.dart'; part 'src/overlay.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart index dda30f23fa62..b8ec01d3c111 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart @@ -32,6 +32,11 @@ double _getCssOpacity(Color color) { return color.opacity; } +// Converts a [Color] into a valid CSS value rgba(R, G, B, A). +String _getCssColorWithAlpha(Color color) { + return 'rgba(${color.red}, ${color.green}, ${color.blue}, ${(color.alpha / 255).toStringAsFixed(2)})'; +} + // Converts options from the plugin into gmaps.MapOptions that can be used by the JS SDK. // The following options are not handled here, for various reasons: // The following are not available in web, because the map doesn't rotate there: @@ -475,6 +480,33 @@ gmaps.CircleOptions _circleOptionsFromCircle(Circle circle) { return circleOptions; } +visualization.HeatmapLayerOptions _heatmapOptionsFromHeatmap(Heatmap heatmap) { + final Iterable? gradientColors = + heatmap.gradient?.colors.map((HeatmapGradientColor e) => e.color); + final visualization.HeatmapLayerOptions heatmapOptions = + visualization.HeatmapLayerOptions() + ..data = heatmap.data + .map( + (WeightedLatLng e) => visualization.WeightedLocation() + ..location = gmaps.LatLng(e.point.latitude, e.point.longitude) + ..weight = e.weight, + ) + .toList() + .toJS + ..dissipating = heatmap.dissipating + ..gradient = gradientColors == null + ? null + : [ + // Web needs a first color with 0 alpha + gradientColors.first.withAlpha(0), + ...gradientColors, + ].map(_getCssColorWithAlpha).toList() + ..maxIntensity = heatmap.maxIntensity + ..opacity = heatmap.opacity + ..radius = heatmap.radius.radius; + return heatmapOptions; +} + gmaps.PolygonOptions _polygonOptionsFromPolygon( gmaps.Map googleMap, Polygon polygon) { // Convert all points to GmLatLng diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart index 3baeef8851ad..89f5e33e2387 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart @@ -30,9 +30,11 @@ class GoogleMapController { _polylines = mapObjects.polylines, _circles = mapObjects.circles, _clusterManagers = mapObjects.clusterManagers, + _heatmaps = mapObjects.heatmaps, _tileOverlays = mapObjects.tileOverlays, _lastMapConfiguration = mapConfiguration { _circlesController = CirclesController(stream: _streamController); + _heatmapsController = HeatmapsController(); _polygonsController = PolygonsController(stream: _streamController); _polylinesController = PolylinesController(stream: _streamController); _clusterManagersController = @@ -66,6 +68,7 @@ class GoogleMapController { final Set _polylines; final Set _circles; final Set _clusterManagers; + final Set _heatmaps; Set _tileOverlays; // The configuration passed by the user, before converting to gmaps. @@ -122,6 +125,7 @@ class GoogleMapController { // Geometry controllers, for different features of the map. CirclesController? _circlesController; + HeatmapsController? _heatmapsController; PolygonsController? _polygonsController; PolylinesController? _polylinesController; MarkersController? _markersController; @@ -146,6 +150,7 @@ class GoogleMapController { DebugSetOptionsFunction? setOptions, MarkersController? markers, CirclesController? circles, + HeatmapsController? heatmaps, PolygonsController? polygons, PolylinesController? polylines, ClusterManagersController? clusterManagers, @@ -155,6 +160,7 @@ class GoogleMapController { _overrideSetOptions = setOptions; _markersController = markers ?? _markersController; _circlesController = circles ?? _circlesController; + _heatmapsController = heatmaps ?? _heatmapsController; _polygonsController = polygons ?? _polygonsController; _polylinesController = polylines ?? _polylinesController; _clusterManagersController = clusterManagers ?? _clusterManagersController; @@ -263,6 +269,8 @@ class GoogleMapController { // null. assert(_circlesController != null, 'Cannot attach a map to a null CirclesController instance.'); + assert(_heatmapsController != null, + 'Cannot attach a map to a null HeatmapsController instance.'); assert(_polygonsController != null, 'Cannot attach a map to a null PolygonsController instance.'); assert(_polylinesController != null, @@ -275,6 +283,7 @@ class GoogleMapController { 'Cannot attach a map to a null TileOverlaysController instance.'); _circlesController!.bindToMap(_mapId, map); + _heatmapsController!.bindToMap(_mapId, map); _polygonsController!.bindToMap(_mapId, map); _polylinesController!.bindToMap(_mapId, map); _markersController!.bindToMap(_mapId, map); @@ -301,6 +310,7 @@ class GoogleMapController { _markersController!.addMarkers(_markers); _circlesController!.addCircles(_circles); + _heatmapsController!.addHeatmaps(_heatmaps); _polygonsController!.addPolygons(_polygons); _polylinesController!.addPolylines(_polylines); _tileOverlaysController!.addTileOverlays(_tileOverlays); @@ -439,12 +449,25 @@ class GoogleMapController { /// Applies [CircleUpdates] to the currently managed circles. void updateCircles(CircleUpdates updates) { assert( - _circlesController != null, 'Cannot update circles after dispose().'); + _circlesController != null, + 'Cannot update circles after dispose().', + ); _circlesController?.addCircles(updates.circlesToAdd); _circlesController?.changeCircles(updates.circlesToChange); _circlesController?.removeCircles(updates.circleIdsToRemove); } + /// Applies [HeatmapUpdates] to the currently managed heatmaps. + void updateHeatmaps(HeatmapUpdates updates) { + assert( + _heatmapsController != null, + 'Cannot update heatmaps after dispose().', + ); + _heatmapsController?.addHeatmaps(updates.heatmapsToAdd); + _heatmapsController?.changeHeatmaps(updates.heatmapsToChange); + _heatmapsController?.removeHeatmaps(updates.heatmapIdsToRemove); + } + /// Applies [PolygonUpdates] to the currently managed polygons. void updatePolygons(PolygonUpdates updates) { assert( @@ -531,6 +554,7 @@ class GoogleMapController { _widget = null; _googleMap = null; _circlesController = null; + _heatmapsController = null; _polygonsController = null; _polylinesController = null; _markersController = null; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart index 16618b59a6e8..c49b5ed67392 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart @@ -90,6 +90,15 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { _map(mapId).updateCircles(circleUpdates); } + /// Applies the passed in `heatmapUpdates` to the `mapId`. + @override + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + required int mapId, + }) async { + _map(mapId).updateHeatmaps(heatmapUpdates); + } + @override Future updateTileOverlays({ required Set newTileOverlays, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmap.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmap.dart new file mode 100644 index 000000000000..1e792f44ca17 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmap.dart @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../google_maps_flutter_web.dart'; + +/// The `HeatmapController` class wraps a [visualization.HeatmapLayer] and its `onTap` behavior. +class HeatmapController { + /// Creates a `HeatmapController`, which wraps a [visualization.HeatmapLayer] object and its `onTap` behavior. + HeatmapController({required visualization.HeatmapLayer heatmap}) + : _heatmap = heatmap; + + visualization.HeatmapLayer? _heatmap; + + /// Returns the wrapped [visualization.HeatmapLayer]. Only used for testing. + @visibleForTesting + visualization.HeatmapLayer? get heatmap => _heatmap; + + /// Updates the options of the wrapped [visualization.HeatmapLayer] object. + /// + /// This cannot be called after [remove]. + void update(visualization.HeatmapLayerOptions options) { + assert(_heatmap != null, 'Cannot `update` Heatmap after calling `remove`.'); + _heatmap!.options = options; + } + + /// Disposes of the currently wrapped [visualization.HeatmapLayer]. + void remove() { + if (_heatmap != null) { + _heatmap!.map = null; + _heatmap = null; + } + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmaps.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmaps.dart new file mode 100644 index 000000000000..2d235515a297 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/heatmaps.dart @@ -0,0 +1,57 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../google_maps_flutter_web.dart'; + +/// This class manages all the [HeatmapController]s associated to a [GoogleMapController]. +class HeatmapsController extends GeometryController { + /// Initialize the cache + HeatmapsController() + : _heatmapIdToController = {}; + + // A cache of [HeatmapController]s indexed by their [HeatmapId]. + final Map _heatmapIdToController; + + /// Returns the cache of [HeatmapController]s. Test only. + @visibleForTesting + Map get heatmaps => _heatmapIdToController; + + /// Adds a set of [Heatmap] objects to the cache. + /// + /// Wraps each [Heatmap] into its corresponding [HeatmapController]. + void addHeatmaps(Set heatmapsToAdd) { + heatmapsToAdd.forEach(_addHeatmap); + } + + void _addHeatmap(Heatmap heatmap) { + final visualization.HeatmapLayerOptions heatmapOptions = + _heatmapOptionsFromHeatmap(heatmap); + final visualization.HeatmapLayer gmHeatmap = + visualization.HeatmapLayer(heatmapOptions); + gmHeatmap.map = googleMap; + final HeatmapController controller = HeatmapController(heatmap: gmHeatmap); + _heatmapIdToController[heatmap.heatmapId] = controller; + } + + /// Updates a set of [Heatmap] objects with new options. + void changeHeatmaps(Set heatmapsToChange) { + heatmapsToChange.forEach(_changeHeatmap); + } + + void _changeHeatmap(Heatmap heatmap) { + final HeatmapController? heatmapController = + _heatmapIdToController[heatmap.heatmapId]; + heatmapController?.update(_heatmapOptionsFromHeatmap(heatmap)); + } + + /// Removes a set of [HeatmapId]s from the cache. + void removeHeatmaps(Set heatmapIdsToRemove) { + for (final HeatmapId heatmapId in heatmapIdsToRemove) { + final HeatmapController? heatmapController = + _heatmapIdToController[heatmapId]; + heatmapController?.remove(); + _heatmapIdToController.remove(heatmapId); + } + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index a07eab5ed6bd..8240c1b632f3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.5.9+2 +version: 0.5.10 environment: sdk: ^3.3.0 @@ -23,7 +23,7 @@ dependencies: flutter_web_plugins: sdk: flutter google_maps: ^8.0.0 - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_platform_interface: ^2.9.0 sanitize_html: ^2.0.0 stream_transform: ^2.0.0 web: ">=0.5.1 <2.0.0" From 551bde53f6ea4a39a7076423796e2be1b19bc8ed Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Tue, 6 Aug 2024 10:41:18 -0400 Subject: [PATCH 12/12] Manual roll Flutter from 383282300c1e to 1dd71413f4ad (21 revisions) (#7317) Manual roll requested by tarrinneal@google.com https://github.com/flutter/flutter/compare/383282300c1e...1dd71413f4ad 2024-08-06 engine-flutter-autoroll@skia.org Roll Flutter Engine from 376a853846ce to f5f6e966e7e7 (1 revision) (flutter/flutter#152911) 2024-08-06 zanderso@users.noreply.github.com Pass --no-dds to some integration tests driven by flutter drive (flutter/flutter#152898) 2024-08-06 kevmoo@users.noreply.github.com Manual dependency bump (flutter/flutter#152881) 2024-08-06 32538273+ValentinVignal@users.noreply.github.com Add tests for ordered_traversal_group.0.dart (flutter/flutter#152849) 2024-08-06 nate.w5687@gmail.com Implement `on` clauses (flutter/flutter#152706) 2024-08-06 engine-flutter-autoroll@skia.org Roll Flutter Engine from bc140a0124b7 to 376a853846ce (2 revisions) (flutter/flutter#152905) 2024-08-06 engine-flutter-autoroll@skia.org Roll Flutter Engine from e073ad2e3ad4 to bc140a0124b7 (1 revision) (flutter/flutter#152895) 2024-08-05 42016383+DBowen33@users.noreply.github.com added functionality to where SR will communicate button clicked (flutter/flutter#152185) 2024-08-05 goderbauer@google.com more docImports (flutter/flutter#151951) 2024-08-05 goderbauer@google.com Bump dartdoc to 8.0.13 (flutter/flutter#152896) 2024-08-05 tessertaha@gmail.com [Reland] Introduce `double` `Flex.spacing` parameter for `Row`/`Column` spacing (flutter/flutter#152890) 2024-08-05 36861262+QuncCccccc@users.noreply.github.com Fix CarouselView rebuild (flutter/flutter#152791) 2024-08-05 engine-flutter-autoroll@skia.org Roll Flutter Engine from 1bfd06cda313 to e073ad2e3ad4 (4 revisions) (flutter/flutter#152889) 2024-08-05 737941+loic-sharma@users.noreply.github.com Add migration to git ignore SwiftPM build directories (flutter/flutter#152766) 2024-08-05 137456488+flutter-pub-roller-bot@users.noreply.github.com Roll pub packages (flutter/flutter#152127) 2024-08-05 engine-flutter-autoroll@skia.org Roll Flutter Engine from 0aac60342005 to 1bfd06cda313 (2 revisions) (flutter/flutter#152868) 2024-08-05 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Introduce `double` `Flex.spacing` parameter for `Row`/`Column` spacing (#152472)" (flutter/flutter#152885) 2024-08-05 pateltirth454@gmail.com [Docs] DeviceOrientation Enum Correction (flutter/flutter#152876) 2024-08-05 zanderso@users.noreply.github.com Move Linux_build_test tests from staging to prod (flutter/flutter#152877) 2024-08-05 tessertaha@gmail.com Introduce `double` `Flex.spacing` parameter for `Row`/`Column` spacing (flutter/flutter#152472) 2024-08-05 zanderso@users.noreply.github.com Mark Linux_android_emu tests bringup: true (flutter/flutter#152867) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages Please CC rmistry@google.com,stuartmorgan@google.com,tarrinneal@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- .ci/flutter_master.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/flutter_master.version b/.ci/flutter_master.version index 3eae0b0f48ed..d399e604cdce 100644 --- a/.ci/flutter_master.version +++ b/.ci/flutter_master.version @@ -1 +1 @@ -383282300c1e395105fd8de73b38031f04402f45 +1dd71413f4ad805b9a0556c51e8bb92b37d4e405