diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 3dcd5bd12f93..dcf6c77fabc4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.18.0 + +* Adds support for editable polylines and polygons on web. + ## 2.17.1 * Updates README to link to implementation packages for platform-specific diff --git a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml index 6bd36da0ee9a..6dff6be63c02 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml @@ -32,3 +32,8 @@ flutter: uses-material-design: true assets: - assets/ +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} + google_maps_flutter_web: {path: ../../../../packages/google_maps_flutter/google_maps_flutter_web} diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart index 32ed8a26671f..b9bf23a83570 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart @@ -91,11 +91,23 @@ class GoogleMapController { .onPolylineTap(mapId: mapId) .listen((PolylineTapEvent e) => _googleMapState.onPolylineTap(e.value)), ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance + .onPolylineEdited(mapId: mapId) + .listen((PolylineEditEvent e) => _googleMapState.onPolylineEdited(e.value, e.points)), + ); _streamSubscriptions.add( GoogleMapsFlutterPlatform.instance .onPolygonTap(mapId: mapId) .listen((PolygonTapEvent e) => _googleMapState.onPolygonTap(e.value)), ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance + .onPolygonEdited(mapId: mapId) + .listen( + (PolygonEditEvent e) => _googleMapState.onPolygonEdited(e.value, e.points, e.holes), + ), + ); _streamSubscriptions.add( GoogleMapsFlutterPlatform.instance .onCircleTap(mapId: mapId) diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart index 3bd76b784571..d9f8200bf532 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart @@ -641,6 +641,14 @@ class _GoogleMapState extends State { } } + void onPolygonEdited(PolygonId polygonId, List points, List> holes) { + final Polygon? polygon = _polygons[polygonId]; + if (polygon == null) { + throw UnknownMapObjectIdError('polygon', polygonId, 'onEdited'); + } + polygon.onEdited?.call(points, holes); + } + void onPolylineTap(PolylineId polylineId) { final Polyline? polyline = _polylines[polylineId]; if (polyline == null) { @@ -652,6 +660,14 @@ class _GoogleMapState extends State { } } + void onPolylineEdited(PolylineId polylineId, List points) { + final Polyline? polyline = _polylines[polylineId]; + if (polyline == null) { + throw UnknownMapObjectIdError('polyline', polylineId, 'onEdited'); + } + polyline.onEdited?.call(points); + } + void onCircleTap(CircleId circleId) { final Circle? circle = _circles[circleId]; if (circle == null) { diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 0342292634b8..f51fa52a9000 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.17.1 +version: 2.18.0 environment: sdk: ^3.10.0 @@ -23,8 +23,8 @@ dependencies: sdk: flutter google_maps_flutter_android: ^2.19.1 google_maps_flutter_ios: ^2.18.0 - google_maps_flutter_platform_interface: ^2.15.0 - google_maps_flutter_web: ^0.6.2 + google_maps_flutter_platform_interface: ^2.16.0 + google_maps_flutter_web: ^0.7.0 dev_dependencies: flutter_test: @@ -41,3 +41,8 @@ topics: # The example deliberately includes limited-use secrets. false_secrets: - /example/web/index.html +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} + google_maps_flutter_web: {path: ../../../packages/google_maps_flutter/google_maps_flutter_web} diff --git a/packages/google_maps_flutter/google_maps_flutter/test/fake_google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter/test/fake_google_maps_flutter_platform.dart index fae706bdb4ed..6c5286886f02 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/fake_google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/fake_google_maps_flutter_platform.dart @@ -217,11 +217,21 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { return mapEventStreamController.stream.whereType(); } + @override + Stream onPolylineEdited({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override Stream onPolygonTap({required int mapId}) { return mapEventStreamController.stream.whereType(); } + @override + Stream onPolygonEdited({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override Stream onCircleTap({required int mapId}) { return mapEventStreamController.stream.whereType(); diff --git a/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart index 9ffdd6c5c674..095c7f8ea33e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart @@ -364,6 +364,37 @@ void main() { expect(map.polygonUpdates.last.polygonsToAdd.isEmpty, true); }); + testWidgets('Setting editable triggers change', (WidgetTester tester) async { + const p1 = Polygon(polygonId: PolygonId('polygon_1')); + const p1Editable = Polygon(polygonId: PolygonId('polygon_1'), editable: true); + + await tester.pumpWidget(_mapWithPolygons({p1})); + await tester.pumpWidget(_mapWithPolygons({p1Editable})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.polygonUpdates.last.polygonsToChange.length, 1); + expect(map.polygonUpdates.last.polygonsToChange.first.editable, equals(true)); + expect(map.polygonUpdates.last.polygonIdsToRemove.isEmpty, true); + expect(map.polygonUpdates.last.polygonsToAdd.isEmpty, true); + }); + + testWidgets('Update onEdited does not trigger platform change', (WidgetTester tester) async { + var p1 = const Polygon(polygonId: PolygonId('polygon_1'), editable: true); + await tester.pumpWidget(_mapWithPolygons({p1})); + + p1 = Polygon( + polygonId: const PolygonId('polygon_1'), + editable: true, + onEdited: (List points, List> holes) {}, + ); + await tester.pumpWidget(_mapWithPolygons({p1})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.polygonUpdates.last.polygonsToChange.isEmpty, true); + expect(map.polygonUpdates.last.polygonIdsToRemove.isEmpty, true); + expect(map.polygonUpdates.last.polygonsToAdd.isEmpty, true); + }); + testWidgets('multi-update with delays', (WidgetTester tester) async { platform.simulatePlatformDelay = true; diff --git a/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart index de1012dc01ae..cab0f6c0f0b7 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart @@ -194,6 +194,37 @@ void main() { expect(map.polylineUpdates.last.polylinesToAdd.isEmpty, true); }); + testWidgets('Setting editable triggers change', (WidgetTester tester) async { + const p1 = Polyline(polylineId: PolylineId('polyline_1')); + const p1Editable = Polyline(polylineId: PolylineId('polyline_1'), editable: true); + + await tester.pumpWidget(_mapWithPolylines({p1})); + await tester.pumpWidget(_mapWithPolylines({p1Editable})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.polylineUpdates.last.polylinesToChange.length, 1); + expect(map.polylineUpdates.last.polylinesToChange.first.editable, equals(true)); + expect(map.polylineUpdates.last.polylineIdsToRemove.isEmpty, true); + expect(map.polylineUpdates.last.polylinesToAdd.isEmpty, true); + }); + + testWidgets('Update onEdited does not trigger platform change', (WidgetTester tester) async { + var p1 = const Polyline(polylineId: PolylineId('polyline_1'), editable: true); + await tester.pumpWidget(_mapWithPolylines({p1})); + + p1 = Polyline( + polylineId: const PolylineId('polyline_1'), + editable: true, + onEdited: (List points) {}, + ); + await tester.pumpWidget(_mapWithPolylines({p1})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.polylineUpdates.last.polylinesToChange.isEmpty, true); + expect(map.polylineUpdates.last.polylineIdsToRemove.isEmpty, true); + expect(map.polylineUpdates.last.polylinesToAdd.isEmpty, true); + }); + testWidgets('multi-update with delays', (WidgetTester tester) async { platform.simulatePlatformDelay = true; 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 9a3889d52d48..5acc60e455a8 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 @@ -33,3 +33,7 @@ flutter: uses-material-design: true assets: - assets/ +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} 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 8d34180d23a9..4d5e2dec77a5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml @@ -38,3 +38,7 @@ topics: - google-maps - google-maps-flutter - map +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml index 706a48387c0e..71b0ee5281d4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml @@ -31,3 +31,7 @@ flutter: uses-material-design: true assets: - assets/ +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} 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 46f21568a148..23355123a026 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml @@ -37,3 +37,7 @@ topics: - google-maps - google-maps-flutter - map +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/pubspec.yaml index 7d916be0452d..c778bab150e2 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/pubspec.yaml @@ -31,3 +31,7 @@ flutter: uses-material-design: true assets: - assets/ +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml index 5ccd2b07d828..065ab2cd8920 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml @@ -37,3 +37,7 @@ topics: - google-maps - google-maps-flutter - map +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/pubspec.yaml index d041adc00616..3131c3b49c94 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/pubspec.yaml @@ -31,3 +31,7 @@ flutter: uses-material-design: true assets: - assets/ +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml index 36d9c5a64a66..698f8cdccf74 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml @@ -37,3 +37,7 @@ topics: - google-maps - google-maps-flutter - map +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} 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 1c2d3c2fb1ca..e626e4bf77db 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,5 +1,11 @@ -## NEXT - +## 2.16.0 + +* Adds `editable` property and `onEdited` callback to `Polyline` and `Polygon`. +* Adds `PolylineEditEvent` and `PolygonEditEvent` event types. +* Adds `onPolylineEdited` and `onPolygonEdited` streams to platform interface. + These default to an empty stream rather than throwing, so the app-facing + package can subscribe on every platform without a per-platform implementation + (editing remains web-only). * Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. ## 2.15.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart index 0d3f47cde019..7bb06a9b76af 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart @@ -136,6 +136,18 @@ class PolylineTapEvent extends MapEvent { PolylineTapEvent(super.mapId, super.polylineId); } +/// An event fired when a [Polyline] path is edited by the user. +class PolylineEditEvent extends MapEvent { + /// Build a PolylineEdit Event triggered from the map represented by `mapId`. + /// + /// The `value` of this event is a [PolylineId] object that represents the edited Polyline. + /// [points] contains the updated path after the edit. + PolylineEditEvent(super.mapId, super.polylineId, this.points); + + /// The updated list of points after the edit. + final List points; +} + /// An event fired when a [Polygon] is tapped. class PolygonTapEvent extends MapEvent { /// Build an PolygonTap Event triggered from the map represented by `mapId`. @@ -144,6 +156,22 @@ class PolygonTapEvent extends MapEvent { PolygonTapEvent(super.mapId, super.polygonId); } +/// An event fired when a [Polygon] path is edited by the user. +class PolygonEditEvent extends MapEvent { + /// Build a PolygonEdit Event triggered from the map represented by `mapId`. + /// + /// The `value` of this event is a [PolygonId] object that represents the edited Polygon. + /// [points] contains the updated outer boundary after the edit. + /// [holes] contains the updated holes after the edit. + PolygonEditEvent(super.mapId, super.polygonId, this.points, this.holes); + + /// The updated outer boundary points after the edit. + final List points; + + /// The updated list of holes after the edit. + final List> holes; +} + /// An event fired when a [Circle] is tapped. class CircleTapEvent extends MapEvent { /// Build an CircleTap Event triggered from the map represented by `mapId`. 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 4ca64f66a8c6..b308b0a396cc 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 @@ -327,11 +327,29 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('onPolylineTap() has not been implemented.'); } + /// A [Polyline] path has been edited by the user. + /// + /// Polyline editing is currently only supported on web; platforms that do not + /// support it return an empty stream by default rather than throwing, so the + /// app-facing package can subscribe unconditionally on every platform. + Stream onPolylineEdited({required int mapId}) { + return const Stream.empty(); + } + /// A [Polygon] has been tapped. Stream onPolygonTap({required int mapId}) { throw UnimplementedError('onPolygonTap() has not been implemented.'); } + /// A [Polygon] path has been edited by the user. + /// + /// Polygon editing is currently only supported on web; platforms that do not + /// support it return an empty stream by default rather than throwing, so the + /// app-facing package can subscribe unconditionally on every platform. + Stream onPolygonEdited({required int mapId}) { + return const Stream.empty(); + } + /// A [Circle] has been tapped. Stream onCircleTap({required int mapId}) { throw UnimplementedError('onCircleTap() has not been implemented.'); diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart index ecc249202f95..b2e80ec55c26 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart @@ -33,6 +33,8 @@ class Polygon implements MapsObject { this.visible = true, this.zIndex = 0, this.onTap, + this.editable = false, + this.onEdited, }); /// Uniquely identifies a [Polygon]. @@ -92,6 +94,19 @@ class Polygon implements MapsObject { /// Callbacks to receive tap events for polygon placed on this map. final VoidCallback? onTap; + /// True if the user can edit this polygon by dragging its vertices. + /// + /// When true, the polygon renders with draggable vertex handles. + /// Currently only supported on web. + final bool editable; + + /// Called when the user edits the polygon path by dragging vertices. + /// + /// The callback receives the updated outer boundary [points] and the + /// updated list of [holes]. Only fires when [editable] is true. + /// Currently only supported on web. + final void Function(List points, List> holes)? onEdited; + /// Creates a new [Polygon] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Polygon copyWith({ @@ -105,6 +120,8 @@ class Polygon implements MapsObject { bool? visibleParam, int? zIndexParam, VoidCallback? onTapParam, + bool? editableParam, + void Function(List points, List> holes)? onEditedParam, }) { return Polygon( polygonId: polygonId, @@ -118,6 +135,8 @@ class Polygon implements MapsObject { visible: visibleParam ?? visible, onTap: onTapParam ?? onTap, zIndex: zIndexParam ?? zIndex, + editable: editableParam ?? editable, + onEdited: onEditedParam ?? onEdited, ); } @@ -146,6 +165,7 @@ class Polygon implements MapsObject { addIfPresent('strokeWidth', strokeWidth); addIfPresent('visible', visible); addIfPresent('zIndex', zIndex); + addIfPresent('editable', editable); json['points'] = _pointsToJson(); @@ -172,7 +192,8 @@ class Polygon implements MapsObject { visible == other.visible && strokeColor == other.strokeColor && strokeWidth == other.strokeWidth && - zIndex == other.zIndex; + zIndex == other.zIndex && + editable == other.editable; } @override diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart index 71656d80fd0c..9f49071e2e71 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart @@ -36,6 +36,8 @@ class Polyline implements MapsObject { this.width = 10, this.zIndex = 0, this.onTap, + this.editable = false, + this.onEdited, }); /// Uniquely identifies a [Polyline]. @@ -114,6 +116,18 @@ class Polyline implements MapsObject { /// Callbacks to receive tap events for polyline placed on this map. final VoidCallback? onTap; + /// True if the user can edit this polyline by dragging its vertices. + /// + /// When true, the polyline renders with draggable vertex handles. + /// Currently only supported on web. + final bool editable; + + /// Called when the user edits the polyline path by dragging vertices. + /// + /// The callback receives the updated list of [LatLng] points. + /// Only fires when [editable] is true. Currently only supported on web. + final void Function(List points)? onEdited; + /// Creates a new [Polyline] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Polyline copyWith({ @@ -129,6 +143,8 @@ class Polyline implements MapsObject { int? widthParam, int? zIndexParam, VoidCallback? onTapParam, + bool? editableParam, + void Function(List points)? onEditedParam, }) { return Polyline( polylineId: polylineId, @@ -144,6 +160,8 @@ class Polyline implements MapsObject { width: widthParam ?? width, onTap: onTapParam ?? onTap, zIndex: zIndexParam ?? zIndex, + editable: editableParam ?? editable, + onEdited: onEditedParam ?? onEdited, ); } @@ -178,6 +196,7 @@ class Polyline implements MapsObject { addIfPresent('visible', visible); addIfPresent('width', width); addIfPresent('zIndex', zIndex); + addIfPresent('editable', editable); json['points'] = _pointsToJson(); @@ -206,7 +225,8 @@ class Polyline implements MapsObject { endCap == other.endCap && visible == other.visible && width == other.width && - zIndex == other.zIndex; + zIndex == other.zIndex && + editable == other.editable; } @override 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 226c23f7e39b..deb836ce66b5 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.15.0 +version: 2.16.0 environment: sdk: ^3.10.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart index 2254a30780a9..3acc914ab296 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart @@ -95,6 +95,20 @@ void main() { ); }); + test('onPolylineEdited() returns an empty stream by default', () { + expect( + BuildViewGoogleMapsFlutterPlatform().onPolylineEdited(mapId: 0), + emitsDone, + ); + }); + + test('onPolygonEdited() returns an empty stream by default', () { + expect( + BuildViewGoogleMapsFlutterPlatform().onPolygonEdited(mapId: 0), + emitsDone, + ); + }); + test('default implementation of `getStyleError` returns null', () async { final GoogleMapsFlutterPlatform platform = BuildViewGoogleMapsFlutterPlatform(); expect(await platform.getStyleError(mapId: 0), null); diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/polygon_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/polygon_test.dart new file mode 100644 index 000000000000..b18a346fd0f7 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/polygon_test.dart @@ -0,0 +1,117 @@ +// Copyright 2013 The Flutter Authors +// 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' show Colors; +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('$Polygon', () { + test('constructor defaults', () { + const polygon = Polygon(polygonId: PolygonId('ABC123')); + + expect(polygon.consumeTapEvents, equals(false)); + expect(polygon.fillColor, equals(Colors.black)); + expect(polygon.geodesic, equals(false)); + expect(polygon.visible, equals(true)); + expect(polygon.strokeWidth, equals(10)); + expect(polygon.zIndex, equals(0)); + expect(polygon.points, equals(const [])); + expect(polygon.holes, equals(const >[])); + expect(polygon.onTap, isNull); + expect(polygon.editable, equals(false)); + expect(polygon.onEdited, isNull); + }); + + test('construct with editable', () { + const polygon = Polygon(polygonId: PolygonId('ABC123'), editable: true); + + expect(polygon.editable, equals(true)); + }); + + test('toJson includes editable', () { + const polygon = Polygon(polygonId: PolygonId('ABC123'), editable: true); + + final json = polygon.toJson() as Map; + expect(json['editable'], equals(true)); + }); + + test('clone', () { + const polygon = Polygon(polygonId: PolygonId('ABC123'), editable: true); + final Polygon clone = polygon.clone(); + + expect(identical(clone, polygon), isFalse); + expect(clone, equals(polygon)); + expect(clone.editable, equals(true)); + }); + + test('copyWith editable', () { + const polygon = Polygon(polygonId: PolygonId('ABC123')); + final Polygon copy = polygon.copyWith(editableParam: true); + + expect(copy.polygonId, equals(const PolygonId('ABC123'))); + expect(copy.editable, equals(true)); + }); + + test('copyWith onEdited', () { + const polygon = Polygon(polygonId: PolygonId('ABC123')); + final log = []; + final Polygon copy = polygon.copyWith( + onEditedParam: (List points, List> holes) { + log.add('onEdited'); + }, + ); + + copy.onEdited!([const LatLng(1.0, 2.0)], >[]); + expect(log, contains('onEdited')); + }); + + test('onEdited callback receives holes', () { + List? receivedPoints; + List>? receivedHoles; + final polygon = Polygon( + polygonId: const PolygonId('ABC123'), + editable: true, + onEdited: (List points, List> holes) { + receivedPoints = points; + receivedHoles = holes; + }, + ); + + final testPoints = [const LatLng(0, 0), const LatLng(1, 1), const LatLng(0, 1)]; + final testHoles = >[ + [const LatLng(0.2, 0.2), const LatLng(0.4, 0.4), const LatLng(0.2, 0.4)], + ]; + + polygon.onEdited!(testPoints, testHoles); + + expect(receivedPoints, equals(testPoints)); + expect(receivedHoles, equals(testHoles)); + }); + + test('equality includes editable', () { + const p1 = Polygon(polygonId: PolygonId('ABC123')); + const p2 = Polygon(polygonId: PolygonId('ABC123'), editable: true); + + expect(p1, isNot(equals(p2))); + }); + + test('equality ignores onEdited', () { + final p1 = Polygon( + polygonId: const PolygonId('ABC123'), + editable: true, + onEdited: (List points, List> holes) {}, + ); + final p2 = Polygon( + polygonId: const PolygonId('ABC123'), + editable: true, + onEdited: (List points, List> holes) {}, + ); + + expect(p1, equals(p2)); + }); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/polyline_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/polyline_test.dart new file mode 100644 index 000000000000..f86a1a289c18 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/polyline_test.dart @@ -0,0 +1,101 @@ +// Copyright 2013 The Flutter Authors +// 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' show Colors; +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('$Polyline', () { + test('constructor defaults', () { + const polyline = Polyline(polylineId: PolylineId('ABC123')); + + expect(polyline.consumeTapEvents, equals(false)); + expect(polyline.color, equals(Colors.black)); + expect(polyline.geodesic, equals(false)); + expect(polyline.visible, equals(true)); + expect(polyline.width, equals(10)); + expect(polyline.zIndex, equals(0)); + expect(polyline.points, equals(const [])); + expect(polyline.patterns, equals(const [])); + expect(polyline.onTap, isNull); + expect(polyline.editable, equals(false)); + expect(polyline.onEdited, isNull); + }); + + test('construct with editable', () { + const polyline = Polyline(polylineId: PolylineId('ABC123'), editable: true); + + expect(polyline.editable, equals(true)); + }); + + test('toJson includes editable', () { + const polyline = Polyline(polylineId: PolylineId('ABC123'), editable: true); + + final json = polyline.toJson() as Map; + expect(json['editable'], equals(true)); + }); + + test('toJson excludes editable when false', () { + const polyline = Polyline(polylineId: PolylineId('ABC123')); + + final json = polyline.toJson() as Map; + expect(json['editable'], equals(false)); + }); + + test('clone', () { + const polyline = Polyline(polylineId: PolylineId('ABC123'), editable: true); + final Polyline clone = polyline.clone(); + + expect(identical(clone, polyline), isFalse); + expect(clone, equals(polyline)); + expect(clone.editable, equals(true)); + }); + + test('copyWith editable', () { + const polyline = Polyline(polylineId: PolylineId('ABC123')); + final Polyline copy = polyline.copyWith(editableParam: true); + + expect(copy.polylineId, equals(const PolylineId('ABC123'))); + expect(copy.editable, equals(true)); + }); + + test('copyWith onEdited', () { + const polyline = Polyline(polylineId: PolylineId('ABC123')); + final log = []; + final Polyline copy = polyline.copyWith( + onEditedParam: (List points) { + log.add('onEdited'); + }, + ); + + copy.onEdited!([const LatLng(1.0, 2.0)]); + expect(log, contains('onEdited')); + }); + + test('equality includes editable', () { + const p1 = Polyline(polylineId: PolylineId('ABC123')); + const p2 = Polyline(polylineId: PolylineId('ABC123'), editable: true); + + expect(p1, isNot(equals(p2))); + }); + + test('equality ignores onEdited', () { + final p1 = Polyline( + polylineId: const PolylineId('ABC123'), + editable: true, + onEdited: (List points) {}, + ); + final p2 = Polyline( + polylineId: const PolylineId('ABC123'), + editable: true, + onEdited: (List points) {}, + ); + + expect(p1, equals(p2)); + }); + }); +} 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 c8657c54f8d8..d1a936a8b8d4 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.7.0 + +* Adds support for editable polylines and polygons via the native Google Maps JavaScript API `editable` feature. + ## 0.6.2+3 * Updates README to include setup information. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/3-64/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/3-64/pubspec.yaml index a6080408dd58..37f5a27382f1 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/3-64/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/3-64/pubspec.yaml @@ -19,3 +19,7 @@ dev_dependencies: google_maps: ^8.1.0 integration_test: sdk: flutter +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shape_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shape_test.dart index 3f71d790dcd4..22beefe5b4b9 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shape_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shape_test.dart @@ -3,6 +3,7 @@ // 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; @@ -135,6 +136,119 @@ void main() { }, throwsAssertionError); }); }); + + group('onEdited', () { + late gmaps.Polygon editablePolygon; + + setUp(() { + editablePolygon = gmaps.Polygon( + gmaps.PolygonOptions() + ..editable = true + ..paths = >[ + [gmaps.LatLng(0, 0), gmaps.LatLng(1, 0), gmaps.LatLng(1, 1)].toJS, + [ + gmaps.LatLng(0.1, 0.1), + gmaps.LatLng(0.2, 0.1), + gmaps.LatLng(0.2, 0.2), + ].toJS, + ].toJS, + ); + }); + + testWidgets('fires onEdited on setAt with outer path and holes', (WidgetTester tester) async { + final completer = Completer<({List outer, List> holes})>(); + PolygonController( + polygon: editablePolygon, + onEdited: (List outer, List> holes) { + if (!completer.isCompleted) { + completer.complete((outer: outer, holes: holes)); + } + }, + ); + + editablePolygon.paths.getAt(0).setAt(0, gmaps.LatLng(5, 5)); + + final ({List outer, List> holes}) result = + await completer.future; + expect(result.outer, hasLength(3)); + expect(result.outer.first.lat, 5); + expect(result.outer.first.lng, 5); + expect(result.holes, hasLength(1)); + expect(result.holes.first, hasLength(3)); + }); + + testWidgets('fires onEdited when a hole path is mutated', (WidgetTester tester) async { + final completer = Completer<({List outer, List> holes})>(); + PolygonController( + polygon: editablePolygon, + onEdited: (List outer, List> holes) { + if (!completer.isCompleted) { + completer.complete((outer: outer, holes: holes)); + } + }, + ); + + editablePolygon.paths.getAt(1).setAt(0, gmaps.LatLng(0.9, 0.9)); + + final ({List outer, List> holes}) result = + await completer.future; + expect(result.outer, hasLength(3)); + expect(result.holes, hasLength(1)); + expect(result.holes.first.first.lat, 0.9); + expect(result.holes.first.first.lng, 0.9); + }); + + testWidgets('fires onEdited on insertAt', (WidgetTester tester) async { + final completer = Completer>(); + PolygonController( + polygon: editablePolygon, + onEdited: (List outer, List> holes) { + if (!completer.isCompleted) { + completer.complete(outer); + } + }, + ); + + editablePolygon.paths.getAt(0).insertAt(0, gmaps.LatLng(9, 9)); + + final List result = await completer.future; + expect(result, hasLength(4)); + expect(result.first.lat, 9); + }); + + testWidgets('fires onEdited on removeAt', (WidgetTester tester) async { + final completer = Completer>(); + PolygonController( + polygon: editablePolygon, + onEdited: (List outer, List> holes) { + if (!completer.isCompleted) { + completer.complete(outer); + } + }, + ); + + editablePolygon.paths.getAt(0).removeAt(0); + + final List result = await completer.future; + expect(result, hasLength(2)); + }); + + testWidgets('remove cancels onEdited subscriptions', (WidgetTester tester) async { + var callCount = 0; + final controller = PolygonController( + polygon: editablePolygon, + onEdited: (List outer, List> holes) { + callCount++; + }, + ); + + controller.remove(); + editablePolygon.paths.getAt(0).setAt(0, gmaps.LatLng(7, 7)); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(callCount, 0); + }); + }); }); group('PolylineController', () { @@ -188,5 +302,88 @@ void main() { }, throwsAssertionError); }); }); + + group('onEdited', () { + late gmaps.Polyline editablePolyline; + + setUp(() { + editablePolyline = gmaps.Polyline( + gmaps.PolylineOptions() + ..editable = true + ..path = [gmaps.LatLng(0, 0), gmaps.LatLng(1, 1)].toJS, + ); + }); + + testWidgets('fires onEdited on setAt with updated path', (WidgetTester tester) async { + final completer = Completer>(); + PolylineController( + polyline: editablePolyline, + onEdited: (List path) { + if (!completer.isCompleted) { + completer.complete(path); + } + }, + ); + + editablePolyline.path.setAt(0, gmaps.LatLng(2, 2)); + + final List result = await completer.future; + expect(result, hasLength(2)); + expect(result.first.lat, 2); + expect(result.first.lng, 2); + }); + + testWidgets('fires onEdited on insertAt with updated path', (WidgetTester tester) async { + final completer = Completer>(); + PolylineController( + polyline: editablePolyline, + onEdited: (List path) { + if (!completer.isCompleted) { + completer.complete(path); + } + }, + ); + + editablePolyline.path.insertAt(0, gmaps.LatLng(9, 9)); + + final List result = await completer.future; + expect(result, hasLength(3)); + expect(result.first.lat, 9); + }); + + testWidgets('fires onEdited on removeAt with updated path', (WidgetTester tester) async { + final completer = Completer>(); + PolylineController( + polyline: editablePolyline, + onEdited: (List path) { + if (!completer.isCompleted) { + completer.complete(path); + } + }, + ); + + editablePolyline.path.removeAt(0); + + final List result = await completer.future; + expect(result, hasLength(1)); + expect(result.first.lat, 1); + }); + + testWidgets('remove cancels onEdited subscriptions', (WidgetTester tester) async { + var callCount = 0; + final controller = PolylineController( + polyline: editablePolyline, + onEdited: (List path) { + callCount++; + }, + ); + + controller.remove(); + editablePolyline.path.setAt(0, gmaps.LatLng(3, 3)); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(callCount, 0); + }); + }); }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shapes_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shapes_test.dart index 23c85c058095..3e027c5bd195 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shapes_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/latest/integration_test/shapes_test.dart @@ -307,6 +307,130 @@ void main() { expect(polygon1Clickable, true); expect(polygon2Clickable, false); }); + + testWidgets('addPolygons forwards editable to the gmaps Polygon', (WidgetTester tester) async { + final polygons = { + const Polygon( + polygonId: PolygonId('editable'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + ), + const Polygon( + polygonId: PolygonId('not-editable'), + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + ), + }; + + controller.addPolygons(polygons); + + final gmaps.Polygon editable = controller.polygons[const PolygonId('editable')]!.polygon!; + final gmaps.Polygon notEditable = + controller.polygons[const PolygonId('not-editable')]!.polygon!; + + expect((editable.get('editable')! as JSBoolean).toDart, isTrue); + expect((notEditable.get('editable')! as JSBoolean).toDart, isFalse); + }); + + testWidgets('emits PolygonEditEvent with points and holes on edit', ( + WidgetTester tester, + ) async { + controller.addPolygons({ + const Polygon( + polygonId: PolygonId('p'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + holes: >[ + [LatLng(0.1, 0.1), LatLng(0.2, 0.1), LatLng(0.2, 0.2)], + ], + ), + }); + + final Future received = events.stream + .where((MapEvent e) => e is PolygonEditEvent) + .cast() + .first; + final gmaps.Polygon gmPolygon = controller.polygons[const PolygonId('p')]!.polygon!; + gmPolygon.paths.getAt(0).setAt(0, gmaps.LatLng(5, 5)); + + final PolygonEditEvent event = await received; + expect(event.value, const PolygonId('p')); + expect(event.points, hasLength(3)); + expect(event.points.first, const LatLng(5, 5)); + expect(event.holes, hasLength(1)); + expect(event.holes.first, hasLength(3)); + }); + + testWidgets('emits PolygonEditEvent reflecting hole mutations', (WidgetTester tester) async { + controller.addPolygons({ + const Polygon( + polygonId: PolygonId('p'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + holes: >[ + [LatLng(0.1, 0.1), LatLng(0.2, 0.1), LatLng(0.2, 0.2)], + ], + ), + }); + + final Future received = events.stream + .where((MapEvent e) => e is PolygonEditEvent) + .cast() + .first; + final gmaps.Polygon gmPolygon = controller.polygons[const PolygonId('p')]!.polygon!; + gmPolygon.paths.getAt(1).setAt(0, gmaps.LatLng(0.9, 0.9)); + + final PolygonEditEvent event = await received; + expect(event.holes.first.first, const LatLng(0.9, 0.9)); + }); + + testWidgets('does not emit PolygonEditEvent when not editable', (WidgetTester tester) async { + controller.addPolygons({ + const Polygon( + polygonId: PolygonId('p'), + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + ), + }); + PolygonEditEvent? captured; + final StreamSubscription sub = events.stream + .where((MapEvent e) => e is PolygonEditEvent) + .cast() + .listen((PolygonEditEvent e) => captured = e); + + final gmaps.Polygon gmPolygon = controller.polygons[const PolygonId('p')]!.polygon!; + gmPolygon.paths.getAt(0).setAt(0, gmaps.LatLng(5, 5)); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(captured, isNull); + await sub.cancel(); + }); + + testWidgets('changePolygons rewires listeners when editable is toggled on', ( + WidgetTester tester, + ) async { + controller.addPolygons({ + const Polygon( + polygonId: PolygonId('t'), + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + ), + }); + controller.changePolygons({ + const Polygon( + polygonId: PolygonId('t'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 0), LatLng(1, 1)], + ), + }); + + final Future received = events.stream + .where((MapEvent e) => e is PolygonEditEvent) + .cast() + .first; + final gmaps.Polygon gmPolygon = controller.polygons[const PolygonId('t')]!.polygon!; + gmPolygon.paths.getAt(0).setAt(0, gmaps.LatLng(7, 7)); + + final PolygonEditEvent event = await received; + expect(event.points.first, const LatLng(7, 7)); + }); }); group('PolylinesController', () { @@ -402,5 +526,96 @@ void main() { expect(polyline1Clickable, true); expect(polyline2Clickable, false); }); + + testWidgets('addPolylines forwards editable to the gmaps Polyline', ( + WidgetTester tester, + ) async { + final polylines = { + const Polyline( + polylineId: PolylineId('editable'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 1)], + ), + const Polyline( + polylineId: PolylineId('not-editable'), + points: [LatLng(0, 0), LatLng(1, 1)], + ), + }; + + controller.addPolylines(polylines); + + final gmaps.Polyline editable = controller.lines[const PolylineId('editable')]!.line!; + final gmaps.Polyline notEditable = controller.lines[const PolylineId('not-editable')]!.line!; + + expect((editable.get('editable')! as JSBoolean).toDart, isTrue); + expect((notEditable.get('editable')! as JSBoolean).toDart, isFalse); + }); + + testWidgets('emits PolylineEditEvent on path mutation when editable', ( + WidgetTester tester, + ) async { + controller.addPolylines({ + const Polyline( + polylineId: PolylineId('e'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 1)], + ), + }); + + final Future received = events.stream + .where((MapEvent e) => e is PolylineEditEvent) + .cast() + .first; + final gmaps.Polyline line = controller.lines[const PolylineId('e')]!.line!; + line.path.setAt(0, gmaps.LatLng(5, 5)); + + final PolylineEditEvent event = await received; + expect(event.value, const PolylineId('e')); + expect(event.points, hasLength(2)); + expect(event.points.first, const LatLng(5, 5)); + }); + + testWidgets('does not emit PolylineEditEvent when not editable', (WidgetTester tester) async { + controller.addPolylines({ + const Polyline(polylineId: PolylineId('n'), points: [LatLng(0, 0), LatLng(1, 1)]), + }); + PolylineEditEvent? captured; + final StreamSubscription sub = events.stream + .where((MapEvent e) => e is PolylineEditEvent) + .cast() + .listen((PolylineEditEvent e) => captured = e); + + final gmaps.Polyline line = controller.lines[const PolylineId('n')]!.line!; + line.path.setAt(0, gmaps.LatLng(5, 5)); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(captured, isNull); + await sub.cancel(); + }); + + testWidgets('changePolylines rewires listeners when editable is toggled on', ( + WidgetTester tester, + ) async { + controller.addPolylines({ + const Polyline(polylineId: PolylineId('t'), points: [LatLng(0, 0), LatLng(1, 1)]), + }); + controller.changePolylines({ + const Polyline( + polylineId: PolylineId('t'), + editable: true, + points: [LatLng(0, 0), LatLng(1, 1)], + ), + }); + + final Future received = events.stream + .where((MapEvent e) => e is PolylineEditEvent) + .cast() + .first; + final gmaps.Polyline line = controller.lines[const PolylineId('t')]!.line!; + line.path.setAt(0, gmaps.LatLng(9, 9)); + + final PolylineEditEvent event = await received; + expect(event.points.first, const LatLng(9, 9)); + }); }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/latest/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/latest/pubspec.yaml index b5e0c77bbe65..f55c57a28195 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/latest/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/latest/pubspec.yaml @@ -28,9 +28,8 @@ flutter: assets: - assets/ +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins dependency_overrides: - # Override the google_maps_flutter dependency on google_maps_flutter_web. - # TODO(ditman): Unwind the circular dependency. This will create problems - # if we need to make a breaking change to google_maps_flutter_web. - google_maps_flutter_web: - path: ../.. + google_maps_flutter_platform_interface: {path: ../../../../../packages/google_maps_flutter/google_maps_flutter_platform_interface} + google_maps_flutter_web: {path: ../..} 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 99d7986fe308..9eebe1c31e53 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 @@ -808,7 +808,8 @@ gmaps.PolygonOptions _polygonOptionsFromPolygon(gmaps.Map googleMap, Polygon pol ..visible = polygon.visible ..zIndex = polygon.zIndex ..geodesic = polygon.geodesic - ..clickable = polygon.consumeTapEvents; + ..clickable = polygon.consumeTapEvents + ..editable = polygon.editable; } List _ensureHoleHasReverseWinding( @@ -867,7 +868,8 @@ gmaps.PolylineOptions _polylineOptionsFromPolyline(gmaps.Map googleMap, Polyline ..visible = polyline.visible ..zIndex = polyline.zIndex ..geodesic = polyline.geodesic - ..clickable = polyline.consumeTapEvents; + ..clickable = polyline.consumeTapEvents + ..editable = polyline.editable; // this.endCap = Cap.buttCap, // this.jointType = JointType.mitered, // this.patterns = const [], 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 9e1ce02285d9..b96e175ea285 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 @@ -234,11 +234,21 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onPolylineEdited({required int mapId}) { + return _events(mapId).whereType(); + } + @override Stream onPolygonTap({required int mapId}) { return _events(mapId).whereType(); } + @override + Stream onPolygonEdited({required int mapId}) { + return _events(mapId).whereType(); + } + @override Stream onCircleTap({required int mapId}) { return _events(mapId).whereType(); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart index 6283f84f4594..0335c131db7a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart @@ -11,19 +11,32 @@ class PolygonController { required gmaps.Polygon polygon, bool consumeTapEvents = false, VoidCallback? onTap, + void Function(List points, List> holes)? onEdited, }) : _polygon = polygon, - _consumeTapEvents = consumeTapEvents { + _consumeTapEvents = consumeTapEvents, + _onEdited = onEdited { if (onTap != null) { - polygon.onClick.listen((gmaps.PolyMouseEvent event) { - onTap.call(); - }); + _subscriptions.add( + polygon.onClick.listen((gmaps.PolyMouseEvent event) { + onTap.call(); + }), + ); } + _listenToPathEdits(); } gmaps.Polygon? _polygon; final bool _consumeTapEvents; + final void Function(List points, List> holes)? _onEdited; + + final List> _subscriptions = >[]; + + // Subscriptions to the paths' edit events. Recreated whenever the underlying + // paths are replaced (e.g. after [update]). + final List> _editSubscriptions = >[]; + /// Returns the wrapped [gmaps.Polygon]. Only used for testing. @visibleForTesting gmaps.Polygon? get polygon => _polygon; @@ -31,12 +44,66 @@ class PolygonController { /// Returns `true` if this Controller will use its own `onTap` handler to consume events. bool get consumeTapEvents => _consumeTapEvents; + List _readMvcPath(gmaps.MVCArray mvcPath) { + final points = []; + for (var i = 0; i < mvcPath.length.toInt(); i++) { + points.add(mvcPath.getAt(i)); + } + return points; + } + + // (Re)subscribes to the wrapped polygon's path edit events. + // + // Setting options on a [gmaps.Polygon] replaces its paths, so this must run + // again after every [update] to keep listening to the current paths. The edit + // listeners are always attached; [emitCurrentPaths] only propagates events + // while the polygon is editable, so programmatic updates to a non-editable + // polygon are ignored. + void _listenToPathEdits() { + for (final StreamSubscription sub in _editSubscriptions) { + sub.cancel(); + } + _editSubscriptions.clear(); + + final gmaps.Polygon? polygon = _polygon; + if (_onEdited == null || polygon == null) { + return; + } + + void emitCurrentPaths() { + final editable = polygon.get('editable') as JSBoolean?; + if (editable == null || !editable.toDart) { + return; + } + final gmaps.MVCArray> allPaths = polygon.paths; + final List outerPath = allPaths.length.toInt() > 0 + ? _readMvcPath(allPaths.getAt(0)) + : []; + final holes = >[]; + for (var i = 1; i < allPaths.length.toInt(); i++) { + holes.add(_readMvcPath(allPaths.getAt(i))); + } + _onEdited(outerPath, holes); + } + + // Listen on all paths (outer boundary + holes). + final gmaps.MVCArray> allPaths = polygon.paths; + for (var i = 0; i < allPaths.length.toInt(); i++) { + final gmaps.MVCArray path = allPaths.getAt(i); + _editSubscriptions.add(path.onSetAt.listen((_) => emitCurrentPaths())); + _editSubscriptions.add(path.onInsertAt.listen((_) => emitCurrentPaths())); + _editSubscriptions.add(path.onRemoveAt.listen((_) => emitCurrentPaths())); + } + } + /// Updates the options of the wrapped [gmaps.Polygon] object. /// /// This cannot be called after [remove]. void update(gmaps.PolygonOptions options) { assert(_polygon != null, 'Cannot `update` Polygon after calling `remove`.'); _polygon!.options = options; + // Setting options replaces the native paths, so re-subscribe to them. + _listenToPathEdits(); } /// Disposes of the currently wrapped [gmaps.Polygon]. @@ -45,6 +112,14 @@ class PolygonController { _polygon!.visible = false; _polygon!.map = null; _polygon = null; + for (final StreamSubscription sub in _subscriptions) { + sub.cancel(); + } + _subscriptions.clear(); + for (final StreamSubscription sub in _editSubscriptions) { + sub.cancel(); + } + _editSubscriptions.clear(); } } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart index 86bfbf943c29..14e30c5c91a5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart @@ -37,6 +37,9 @@ class PolygonsController extends GeometryController { onTap: () { _onPolygonTap(polygon.polygonId); }, + onEdited: (List path, List> holes) { + _onPolygonEdited(polygon.polygonId, path, holes); + }, ); _polygonIdToController[polygon.polygonId] = controller; } @@ -70,4 +73,21 @@ class PolygonsController extends GeometryController { _streamController.add(PolygonTapEvent(mapId, polygonId)); return _polygonIdToController[polygonId]?.consumeTapEvents ?? false; } + + void _onPolygonEdited( + PolygonId polygonId, + List path, + List> holes, + ) { + final List points = path + .map((gmaps.LatLng p) => LatLng(p.lat.toDouble(), p.lng.toDouble())) + .toList(); + final List> convertedHoles = holes + .map( + (List hole) => + hole.map((gmaps.LatLng p) => LatLng(p.lat.toDouble(), p.lng.toDouble())).toList(), + ) + .toList(); + _streamController.add(PolygonEditEvent(mapId, polygonId, points, convertedHoles)); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart index ef3430371e26..d81b80d5a0a2 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart @@ -4,26 +4,39 @@ part of '../google_maps_flutter_web.dart'; -/// The `PolygonController` class wraps a [gmaps.Polyline] and its `onTap` behavior. +/// The `PolylineController` class wraps a [gmaps.Polyline] and its `onTap` behavior. class PolylineController { /// Creates a `PolylineController` that wraps a [gmaps.Polyline] object and its `onTap` behavior. PolylineController({ required gmaps.Polyline polyline, bool consumeTapEvents = false, VoidCallback? onTap, + void Function(List path)? onEdited, }) : _polyline = polyline, - _consumeTapEvents = consumeTapEvents { + _consumeTapEvents = consumeTapEvents, + _onEdited = onEdited { if (onTap != null) { - polyline.onClick.listen((gmaps.PolyMouseEvent event) { - onTap.call(); - }); + _subscriptions.add( + polyline.onClick.listen((gmaps.PolyMouseEvent event) { + onTap.call(); + }), + ); } + _listenToPathEdits(); } gmaps.Polyline? _polyline; final bool _consumeTapEvents; + final void Function(List path)? _onEdited; + + final List> _subscriptions = >[]; + + // Subscriptions to the path's edit events. Recreated whenever the underlying + // path is replaced (e.g. after [update]). + final List> _editSubscriptions = >[]; + /// Returns the wrapped [gmaps.Polyline]. Only used for testing. @visibleForTesting gmaps.Polyline? get line => _polyline; @@ -31,12 +44,54 @@ class PolylineController { /// Returns `true` if this Controller will use its own `onTap` handler to consume events. bool get consumeTapEvents => _consumeTapEvents; + List _readPath(gmaps.Polyline polyline) { + final gmaps.MVCArray path = polyline.path; + final points = []; + for (var i = 0; i < path.length.toInt(); i++) { + points.add(path.getAt(i)); + } + return points; + } + + // (Re)subscribes to the wrapped polyline's path edit events. + // + // Setting options on a [gmaps.Polyline] replaces its path, so this must run + // again after every [update] to keep listening to the current path. The edit + // listeners are always attached; [emitCurrentPath] only propagates events + // while the polyline is editable, so programmatic updates to a non-editable + // polyline are ignored. + void _listenToPathEdits() { + for (final StreamSubscription sub in _editSubscriptions) { + sub.cancel(); + } + _editSubscriptions.clear(); + + final gmaps.Polyline? polyline = _polyline; + if (_onEdited == null || polyline == null) { + return; + } + + void emitCurrentPath() { + final editable = polyline.get('editable') as JSBoolean?; + if (editable == null || !editable.toDart) { + return; + } + _onEdited(_readPath(polyline)); + } + + _editSubscriptions.add(polyline.path.onSetAt.listen((_) => emitCurrentPath())); + _editSubscriptions.add(polyline.path.onInsertAt.listen((_) => emitCurrentPath())); + _editSubscriptions.add(polyline.path.onRemoveAt.listen((_) => emitCurrentPath())); + } + /// Updates the options of the wrapped [gmaps.Polyline] object. /// /// This cannot be called after [remove]. void update(gmaps.PolylineOptions options) { assert(_polyline != null, 'Cannot `update` Polyline after calling `remove`.'); _polyline!.options = options; + // Setting options replaces the native path, so re-subscribe to the new one. + _listenToPathEdits(); } /// Disposes of the currently wrapped [gmaps.Polyline]. @@ -45,6 +100,14 @@ class PolylineController { _polyline!.visible = false; _polyline!.map = null; _polyline = null; + for (final StreamSubscription sub in _subscriptions) { + sub.cancel(); + } + _subscriptions.clear(); + for (final StreamSubscription sub in _editSubscriptions) { + sub.cancel(); + } + _editSubscriptions.clear(); } } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart index 1afd553b3c54..6da92c697ec1 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart @@ -37,6 +37,9 @@ class PolylinesController extends GeometryController { onTap: () { _onPolylineTap(polyline.polylineId); }, + onEdited: (List path) { + _onPolylineEdited(polyline.polylineId, path); + }, ); _polylineIdToController[polyline.polylineId] = controller; } @@ -71,4 +74,11 @@ class PolylinesController extends GeometryController { _streamController.add(PolylineTapEvent(mapId, polylineId)); return _polylineIdToController[polylineId]?.consumeTapEvents ?? false; } + + void _onPolylineEdited(PolylineId polylineId, List path) { + final List points = path + .map((gmaps.LatLng p) => LatLng(p.lat.toDouble(), p.lng.toDouble())) + .toList(); + _streamController.add(PolylineEditEvent(mapId, polylineId, points)); + } } 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 9a19bffc4e88..94c57164310c 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.6.2+3 +version: 0.7.0 environment: sdk: ^3.10.0 @@ -23,7 +23,7 @@ dependencies: flutter_web_plugins: sdk: flutter google_maps: ^8.1.0 - google_maps_flutter_platform_interface: ^2.15.0 + google_maps_flutter_platform_interface: ^2.16.0 sanitize_html: ^2.0.0 stream_transform: ^2.0.0 web: ^1.0.0 @@ -40,3 +40,7 @@ topics: # The example deliberately includes limited-use secrets. false_secrets: - /example/**/web/index.html +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_maps_flutter_platform_interface: {path: ../../../packages/google_maps_flutter/google_maps_flutter_platform_interface}