diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 9c0b9a460d42..815987add6cf 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.11.0 + +* Adds support for ground overlays. +* Updates minimum supported SDK version to Flutter 3.27/Dart 3.6. + ## 2.10.1 * Updates READMEs and API docs. diff --git a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/src/tiles_inspector.dart b/packages/google_maps_flutter/google_maps_flutter/example/integration_test/src/tiles_inspector.dart index d115257c07b4..b9b20bf9c0ff 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/src/tiles_inspector.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/integration_test/src/tiles_inspector.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; @@ -22,7 +21,7 @@ void main() { } void runTests() { - const double floatTolerance = 1e-8; + const double floatTolerance = 1e-6; GoogleMapsFlutterPlatform.instance.enableDebugInspection(); @@ -208,269 +207,574 @@ void runTests() { ); }, skip: isWeb /* Tiles not supported on the web */); - /// Check that two lists of [WeightedLatLng] are more or less equal. - void expectHeatmapDataMoreOrLessEquals( - List data1, - List data2, - ) { - expect(data1.length, data2.length); - for (int i = 0; i < data1.length; i++) { - final WeightedLatLng wll1 = data1[i]; - final WeightedLatLng wll2 = data2[i]; - expect(wll1.weight, wll2.weight); - expect(wll1.point.latitude, moreOrLessEquals(wll2.point.latitude)); - expect(wll1.point.longitude, moreOrLessEquals(wll2.point.longitude)); + group('Heatmaps', () { + /// Check that two lists of [WeightedLatLng] are more or less equal. + void expectHeatmapDataMoreOrLessEquals( + List data1, + List data2, + ) { + expect(data1.length, data2.length); + for (int i = 0; i < data1.length; i++) { + final WeightedLatLng wll1 = data1[i]; + final WeightedLatLng wll2 = data2[i]; + expect(wll1.weight, wll2.weight); + expect(wll1.point.latitude, moreOrLessEquals(wll2.point.latitude)); + expect(wll1.point.longitude, moreOrLessEquals(wll2.point.longitude)); + } } - } - /// Check that two [HeatmapGradient]s are more or less equal. - void expectHeatmapGradientMoreOrLessEquals( - HeatmapGradient? gradient1, - HeatmapGradient? gradient2, - ) { - if (gradient1 == null || gradient2 == null) { - expect(gradient1, gradient2); - return; + /// Check that two [HeatmapGradient]s are more or less equal. + void expectHeatmapGradientMoreOrLessEquals( + HeatmapGradient? gradient1, + HeatmapGradient? gradient2, + ) { + if (gradient1 == null || gradient2 == null) { + expect(gradient1, gradient2); + return; + } + expect(gradient2, isNotNull); + + expect(gradient1.colors.length, gradient2.colors.length); + for (int i = 0; i < gradient1.colors.length; i++) { + final HeatmapGradientColor color1 = gradient1.colors[i]; + final HeatmapGradientColor color2 = gradient2.colors[i]; + expect(color1.color, color2.color); + expect( + color1.startPoint, + moreOrLessEquals(color2.startPoint, epsilon: floatTolerance), + ); + } + + expect(gradient1.colorMapSize, gradient2.colorMapSize); } - expect(gradient2, isNotNull); - expect(gradient1.colors.length, gradient2.colors.length); - for (int i = 0; i < gradient1.colors.length; i++) { - final HeatmapGradientColor color1 = gradient1.colors[i]; - final HeatmapGradientColor color2 = gradient2.colors[i]; - expect(color1.color, color2.color); + void expectHeatmapEquals(Heatmap heatmap1, Heatmap heatmap2) { + expectHeatmapDataMoreOrLessEquals(heatmap1.data, heatmap2.data); + expectHeatmapGradientMoreOrLessEquals( + heatmap1.gradient, heatmap2.gradient); + + // Only Android supports `maxIntensity` + // so the platform value is undefined on others. + bool canHandleMaxIntensity() { + return isAndroid; + } + + // Only iOS supports `minimumZoomIntensity` and `maximumZoomIntensity` + // so the platform value is undefined on others. + bool canHandleZoomIntensity() { + return isIOS; + } + + if (canHandleMaxIntensity()) { + expect(heatmap1.maxIntensity, heatmap2.maxIntensity); + } expect( - color1.startPoint, - moreOrLessEquals(color2.startPoint, epsilon: floatTolerance), + heatmap1.opacity, + moreOrLessEquals(heatmap2.opacity, epsilon: floatTolerance), ); + expect(heatmap1.radius, heatmap2.radius); + if (canHandleZoomIntensity()) { + expect(heatmap1.minimumZoomIntensity, heatmap2.minimumZoomIntensity); + expect(heatmap1.maximumZoomIntensity, heatmap2.maximumZoomIntensity); + } } - expect(gradient1.colorMapSize, gradient2.colorMapSize); - } - - void expectHeatmapEquals(Heatmap heatmap1, Heatmap heatmap2) { - expectHeatmapDataMoreOrLessEquals(heatmap1.data, heatmap2.data); - expectHeatmapGradientMoreOrLessEquals(heatmap1.gradient, heatmap2.gradient); - - // Only Android supports `maxIntensity` - // so the platform value is undefined on others. - bool canHandleMaxIntensity() { - return Platform.isAndroid; - } - - // Only iOS supports `minimumZoomIntensity` and `maximumZoomIntensity` - // so the platform value is undefined on others. - bool canHandleZoomIntensity() { - return Platform.isIOS; - } - - if (canHandleMaxIntensity()) { - expect(heatmap1.maxIntensity, heatmap2.maxIntensity); - } - expect( - heatmap1.opacity, - moreOrLessEquals(heatmap2.opacity, epsilon: floatTolerance), + const Heatmap heatmap1 = Heatmap( + heatmapId: HeatmapId('heatmap_1'), + data: [ + 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), weight: 2) + ], + dissipating: false, + gradient: HeatmapGradient( + [ + HeatmapGradientColor( + Color.fromARGB(255, 0, 255, 255), + 0.2, + ), + HeatmapGradientColor( + Color.fromARGB(255, 0, 63, 255), + 0.4, + ), + HeatmapGradientColor( + Color.fromARGB(255, 0, 0, 191), + 0.6, + ), + HeatmapGradientColor( + Color.fromARGB(255, 63, 0, 91), + 0.8, + ), + HeatmapGradientColor( + Color.fromARGB(255, 255, 0, 0), + 1, + ), + ], + ), + maxIntensity: 1, + opacity: 0.5, + radius: HeatmapRadius.fromPixels(40), + minimumZoomIntensity: 1, + maximumZoomIntensity: 20, ); - expect(heatmap1.radius, heatmap2.radius); - if (canHandleZoomIntensity()) { - expect(heatmap1.minimumZoomIntensity, heatmap2.minimumZoomIntensity); - expect(heatmap1.maximumZoomIntensity, heatmap2.maximumZoomIntensity); - } - } - const Heatmap heatmap1 = Heatmap( - heatmapId: HeatmapId('heatmap_1'), - data: [ - 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), weight: 2) - ], - dissipating: false, - gradient: HeatmapGradient( - [ - HeatmapGradientColor( - Color.fromARGB(255, 0, 255, 255), - 0.2, + testWidgets('set heatmap correctly', (WidgetTester tester) async { + final Completer mapIdCompleter = Completer(); + final Heatmap heatmap2 = Heatmap( + heatmapId: const HeatmapId('heatmap_2'), + data: heatmap1.data, + dissipating: heatmap1.dissipating, + gradient: heatmap1.gradient, + maxIntensity: heatmap1.maxIntensity, + opacity: heatmap1.opacity - 0.1, + radius: heatmap1.radius, + minimumZoomIntensity: heatmap1.minimumZoomIntensity, + maximumZoomIntensity: heatmap1.maximumZoomIntensity, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: kInitialCameraPosition, + heatmaps: {heatmap1, heatmap2}, + onMapCreated: (GoogleMapController controller) { + mapIdCompleter.complete(controller.mapId); + }, + ), ), - HeatmapGradientColor( - Color.fromARGB(255, 0, 63, 255), - 0.4, + ); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + final int mapId = await mapIdCompleter.future; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + if (inspector.supportsGettingHeatmapInfo()) { + final Heatmap heatmapInfo1 = + (await inspector.getHeatmapInfo(heatmap1.mapsId, mapId: mapId))!; + final Heatmap heatmapInfo2 = + (await inspector.getHeatmapInfo(heatmap2.mapsId, mapId: mapId))!; + + expectHeatmapEquals(heatmap1, heatmapInfo1); + expectHeatmapEquals(heatmap2, heatmapInfo2); + } + }); + + testWidgets('update heatmaps correctly', (WidgetTester tester) async { + final Completer mapIdCompleter = Completer(); + final Key key = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + heatmaps: {heatmap1}, + onMapCreated: (GoogleMapController controller) { + mapIdCompleter.complete(controller.mapId); + }, + ), ), - HeatmapGradientColor( - Color.fromARGB(255, 0, 0, 191), - 0.6, + ); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + final int mapId = await mapIdCompleter.future; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + final Heatmap heatmap1New = heatmap1.copyWith( + dataParam: heatmap1.data.sublist(5), + dissipatingParam: !heatmap1.dissipating, + gradientParam: heatmap1.gradient, + maxIntensityParam: heatmap1.maxIntensity! + 1, + opacityParam: heatmap1.opacity - 0.1, + radiusParam: HeatmapRadius.fromPixels(heatmap1.radius.radius + 1), + minimumZoomIntensityParam: heatmap1.minimumZoomIntensity + 1, + maximumZoomIntensityParam: heatmap1.maximumZoomIntensity + 1, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + heatmaps: {heatmap1New}, + onMapCreated: (GoogleMapController controller) { + fail('update: OnMapCreated should get called only once.'); + }, + ), ), - HeatmapGradientColor( - Color.fromARGB(255, 63, 0, 91), - 0.8, + ); + + await tester.pumpAndSettle(const Duration(seconds: 3)); + + if (inspector.supportsGettingHeatmapInfo()) { + final Heatmap heatmapInfo1 = + (await inspector.getHeatmapInfo(heatmap1.mapsId, mapId: mapId))!; + + expectHeatmapEquals(heatmap1New, heatmapInfo1); + } + }); + + testWidgets('remove heatmaps correctly', (WidgetTester tester) async { + final Completer mapIdCompleter = Completer(); + final Key key = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + heatmaps: {heatmap1}, + onMapCreated: (GoogleMapController controller) { + mapIdCompleter.complete(controller.mapId); + }, + ), ), - HeatmapGradientColor( - Color.fromARGB(255, 255, 0, 0), - 1, + ); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + final int mapId = await mapIdCompleter.future; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + onMapCreated: (GoogleMapController controller) { + fail('OnMapCreated should get called only once.'); + }, + ), ), - ], - ), - maxIntensity: 1, - opacity: 0.5, - radius: HeatmapRadius.fromPixels(40), - minimumZoomIntensity: 1, - maximumZoomIntensity: 20, - ); + ); + + await tester.pumpAndSettle(const Duration(seconds: 3)); - testWidgets('set heatmap correctly', (WidgetTester tester) async { - final Completer mapIdCompleter = Completer(); - final Heatmap heatmap2 = Heatmap( - heatmapId: const HeatmapId('heatmap_2'), - data: heatmap1.data, - dissipating: heatmap1.dissipating, - gradient: heatmap1.gradient, - maxIntensity: heatmap1.maxIntensity, - opacity: heatmap1.opacity - 0.1, - radius: heatmap1.radius, - minimumZoomIntensity: heatmap1.minimumZoomIntensity, - maximumZoomIntensity: heatmap1.maximumZoomIntensity, + if (inspector.supportsGettingHeatmapInfo()) { + final Heatmap? heatmapInfo1 = + await inspector.getHeatmapInfo(heatmap1.mapsId, mapId: mapId); + + expect(heatmapInfo1, isNull); + } + }); + }); + + group('GroundOverlay', () { + final LatLngBounds kGroundOverlayBounds = LatLngBounds( + southwest: const LatLng(37.77483, -122.41942), + northeast: const LatLng(37.78183, -122.39105), ); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - initialCameraPosition: kInitialCameraPosition, - heatmaps: {heatmap1, heatmap2}, - onMapCreated: (GoogleMapController controller) { - mapIdCompleter.complete(controller.mapId); - }, - ), + final GroundOverlay groundOverlayBounds1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('bounds_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, ), + transparency: 0.7, + bearing: 10, + zIndex: 10, ); - await tester.pumpAndSettle(const Duration(seconds: 3)); - final int mapId = await mapIdCompleter.future; - final GoogleMapsInspectorPlatform inspector = - GoogleMapsInspectorPlatform.instance!; + final GroundOverlay groundOverlayPosition1 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('position_1'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); - if (inspector.supportsGettingHeatmapInfo()) { - final Heatmap heatmapInfo1 = - (await inspector.getHeatmapInfo(heatmap1.mapsId, mapId: mapId))!; - final Heatmap heatmapInfo2 = - (await inspector.getHeatmapInfo(heatmap2.mapsId, mapId: mapId))!; + void expectGroundOverlayEquals( + GroundOverlay source, GroundOverlay response) { + expect(response.groundOverlayId, source.groundOverlayId); + expect( + response.transparency, + moreOrLessEquals(source.transparency, epsilon: floatTolerance), + ); - expectHeatmapEquals(heatmap1, heatmapInfo1); - expectHeatmapEquals(heatmap2, heatmapInfo2); + // Web does not support bearing. + if (!isWeb) { + expect( + response.bearing, + moreOrLessEquals(source.bearing, epsilon: floatTolerance), + ); + } + + // Only test bounds if it was given in the original object. + if (source.bounds != null) { + expect(response.bounds, source.bounds); + } + + // Only test position if it was given in the original object. + if (source.position != null) { + expect(response.position, source.position); + } + + expect(response.clickable, source.clickable); + + // Web does not support zIndex. + if (!isWeb) { + expect(response.zIndex, source.zIndex); + } + + // Only Android supports width and height. + if (isAndroid) { + expect(response.width, source.width); + expect(response.height, source.height); + } + + // Only iOS supports zoomLevel. + if (isIOS) { + expect(response.zoomLevel, source.zoomLevel); + } + + // Only Android (using position) and iOS supports `anchor`. + if ((isAndroid && source.position != null) || isIOS) { + expect( + response.anchor?.dx, + moreOrLessEquals(source.anchor!.dx, epsilon: floatTolerance), + ); + expect( + response.anchor?.dy, + moreOrLessEquals(source.anchor!.dy, epsilon: floatTolerance), + ); + } } - }); - - testWidgets('update heatmaps correctly', (WidgetTester tester) async { - final Completer mapIdCompleter = Completer(); - final Key key = GlobalKey(); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: kInitialCameraPosition, - heatmaps: {heatmap1}, - onMapCreated: (GoogleMapController controller) { - mapIdCompleter.complete(controller.mapId); - }, - ), - ), - ); - final int mapId = await mapIdCompleter.future; - final GoogleMapsInspectorPlatform inspector = - GoogleMapsInspectorPlatform.instance!; - - final Heatmap heatmap1New = heatmap1.copyWith( - dataParam: heatmap1.data.sublist(5), - dissipatingParam: !heatmap1.dissipating, - gradientParam: heatmap1.gradient, - maxIntensityParam: heatmap1.maxIntensity! + 1, - opacityParam: heatmap1.opacity - 0.1, - radiusParam: HeatmapRadius.fromPixels(heatmap1.radius.radius + 1), - minimumZoomIntensityParam: heatmap1.minimumZoomIntensity + 1, - maximumZoomIntensityParam: heatmap1.maximumZoomIntensity + 1, - ); + testWidgets('set ground overlays correctly', (WidgetTester tester) async { + final Completer mapIdCompleter = Completer(); + final GroundOverlay groundOverlayBounds2 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('bounds_2'), + bounds: groundOverlayBounds1.bounds!, + image: groundOverlayBounds1.image, + ); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: kInitialCameraPosition, - heatmaps: {heatmap1New}, - onMapCreated: (GoogleMapController controller) { - fail('update: OnMapCreated should get called only once.'); - }, + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: kInitialCameraPosition, + groundOverlays: { + groundOverlayBounds1, + groundOverlayBounds2, + // Web does not support position-based ground overlays. + if (!isWeb) groundOverlayPosition1, + }, + onMapCreated: (GoogleMapController controller) { + mapIdCompleter.complete(controller.mapId); + }, + ), ), - ), - ); + ); + await tester.pumpAndSettle(const Duration(seconds: 3)); - await tester.pumpAndSettle(const Duration(seconds: 3)); + final int mapId = await mapIdCompleter.future; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; - if (inspector.supportsGettingHeatmapInfo()) { - final Heatmap heatmapInfo1 = - (await inspector.getHeatmapInfo(heatmap1.mapsId, mapId: mapId))!; + if (inspector.supportsGettingGroundOverlayInfo()) { + final GroundOverlay groundOverlayBoundsInfo1 = (await inspector + .getGroundOverlayInfo(groundOverlayBounds1.mapsId, mapId: mapId))!; + final GroundOverlay groundOverlayBoundsInfo2 = (await inspector + .getGroundOverlayInfo(groundOverlayBounds2.mapsId, mapId: mapId))!; - expectHeatmapEquals(heatmap1New, heatmapInfo1); - } - }); + expectGroundOverlayEquals( + groundOverlayBounds1, + groundOverlayBoundsInfo1, + ); + expectGroundOverlayEquals( + groundOverlayBounds2, + groundOverlayBoundsInfo2, + ); - testWidgets('remove heatmaps correctly', (WidgetTester tester) async { - final Completer mapIdCompleter = Completer(); - final Key key = GlobalKey(); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: kInitialCameraPosition, - heatmaps: {heatmap1}, - onMapCreated: (GoogleMapController controller) { - mapIdCompleter.complete(controller.mapId); - }, + // Web does not support position-based ground overlays. + if (!isWeb) { + final GroundOverlay groundOverlayPositionInfo1 = (await inspector + .getGroundOverlayInfo(groundOverlayPosition1.mapsId, + mapId: mapId))!; + expectGroundOverlayEquals( + groundOverlayPosition1, + groundOverlayPositionInfo1, + ); + } + } + }); + + testWidgets('update ground overlays correctly', + (WidgetTester tester) async { + final Completer mapIdCompleter = Completer(); + final Key key = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + groundOverlays: { + groundOverlayBounds1, + // Web does not support position-based ground overlays. + if (!isWeb) groundOverlayPosition1 + }, + onMapCreated: (GoogleMapController controller) { + mapIdCompleter.complete(controller.mapId); + }, + ), ), - ), - ); + ); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + final int mapId = await mapIdCompleter.future; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + final GroundOverlay groundOverlayBounds1New = + groundOverlayBounds1.copyWith( + bearingParam: 10, + clickableParam: false, + visibleParam: false, + transparencyParam: 0.5, + zIndexParam: 10, + ); - final int mapId = await mapIdCompleter.future; - final GoogleMapsInspectorPlatform inspector = - GoogleMapsInspectorPlatform.instance!; - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: kInitialCameraPosition, - onMapCreated: (GoogleMapController controller) { - fail('OnMapCreated should get called only once.'); - }, + final GroundOverlay groundOverlayPosition1New = + groundOverlayPosition1.copyWith( + bearingParam: 10, + clickableParam: false, + visibleParam: false, + transparencyParam: 0.5, + zIndexParam: 10, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + groundOverlays: { + groundOverlayBounds1New, + // Web does not support position-based ground overlays. + if (!isWeb) groundOverlayPosition1New + }, + onMapCreated: (GoogleMapController controller) { + fail('update: OnMapCreated should get called only once.'); + }, + ), ), - ), - ); + ); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 3)); - if (inspector.supportsGettingHeatmapInfo()) { - final Heatmap? heatmapInfo1 = - await inspector.getHeatmapInfo(heatmap1.mapsId, mapId: mapId); + if (inspector.supportsGettingGroundOverlayInfo()) { + final GroundOverlay groundOverlayBounds1Info = (await inspector + .getGroundOverlayInfo(groundOverlayBounds1.mapsId, mapId: mapId))!; - expect(heatmapInfo1, isNull); - } + expectGroundOverlayEquals( + groundOverlayBounds1New, + groundOverlayBounds1Info, + ); + + // Web does not support position-based ground overlays. + if (!isWeb) { + final GroundOverlay groundOverlayPosition1Info = (await inspector + .getGroundOverlayInfo(groundOverlayPosition1.mapsId, + mapId: mapId))!; + + expectGroundOverlayEquals( + groundOverlayPosition1New, + groundOverlayPosition1Info, + ); + } + } + }); + + testWidgets('remove ground overlays correctly', + (WidgetTester tester) async { + final Completer mapIdCompleter = Completer(); + final Key key = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + groundOverlays: { + groundOverlayBounds1, + // Web does not support position-based ground overlays. + if (!isWeb) groundOverlayPosition1 + }, + onMapCreated: (GoogleMapController controller) { + mapIdCompleter.complete(controller.mapId); + }, + ), + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + final int mapId = await mapIdCompleter.future; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: kInitialCameraPosition, + onMapCreated: (GoogleMapController controller) { + fail('OnMapCreated should get called only once.'); + }, + ), + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 3)); + + if (inspector.supportsGettingGroundOverlayInfo()) { + final GroundOverlay? groundOverlayBounds1Info = await inspector + .getGroundOverlayInfo(groundOverlayBounds1.mapsId, mapId: mapId); + expect(groundOverlayBounds1Info, isNull); + + // Web does not support position-based ground overlays. + if (!isWeb) { + final GroundOverlay? groundOverlayPositionInfo = await inspector + .getGroundOverlayInfo(groundOverlayPosition1.mapsId, + mapId: mapId); + expect(groundOverlayPositionInfo, isNull); + } + } + }); }); } diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/ground_overlay.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/ground_overlay.dart new file mode 100644 index 000000000000..748eb04590ca --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/ground_overlay.dart @@ -0,0 +1,333 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'page.dart'; + +enum _GroundOverlayPlacing { position, bounds } + +class GroundOverlayPage extends GoogleMapExampleAppPage { + const GroundOverlayPage({Key? key}) + : super(const Icon(Icons.map), 'Ground overlay', key: key); + + @override + Widget build(BuildContext context) { + return const GroundOverlayBody(); + } +} + +class GroundOverlayBody extends StatefulWidget { + const GroundOverlayBody({super.key}); + + @override + State createState() => GroundOverlayBodyState(); +} + +class GroundOverlayBodyState extends State { + GroundOverlayBodyState(); + + GoogleMapController? controller; + GroundOverlay? _groundOverlay; + + final LatLng _mapCenter = const LatLng(37.422026, -122.085329); + + _GroundOverlayPlacing _placingType = _GroundOverlayPlacing.bounds; + + // Positions for demonstranting placing ground overlays with position, and + // changing positions. + final LatLng _groundOverlayPos1 = const LatLng(37.422026, -122.085329); + final LatLng _groundOverlayPos2 = const LatLng(37.42, -122.08); + late LatLng _currentGroundOverlayPos; + + // Bounds for demonstranting placing ground overlays with bounds, and + // changing bounds. + final LatLngBounds _groundOverlayBounds1 = LatLngBounds( + southwest: const LatLng(37.42, -122.09), + northeast: const LatLng(37.423, -122.084)); + final LatLngBounds _groundOverlayBounds2 = LatLngBounds( + southwest: const LatLng(37.421, -122.091), + northeast: const LatLng(37.424, -122.08)); + late LatLngBounds _currentGroundOverlayBounds; + + Offset _anchor = const Offset(0.5, 0.5); + + Offset _dimensions = const Offset(1000, 1000); + + // Index to be used as identifier for the ground overlay. + // If position is changed to bounds and vice versa, the ground overlay will + // be removed and added again with the new type. Also anchor can be given only + // when the ground overlay is created with position and cannot be changed + // after the ground overlay is created. + int _groundOverlayIndex = 0; + + @override + void initState() { + _currentGroundOverlayPos = _groundOverlayPos1; + _currentGroundOverlayBounds = _groundOverlayBounds1; + super.initState(); + } + + // ignore: use_setters_to_change_properties + void _onMapCreated(GoogleMapController controller) { + this.controller = controller; + } + + void _removeGroundOverlay() { + setState(() { + _groundOverlay = null; + }); + } + + Future _addGroundOverlay() async { + final AssetMapBitmap assetMapBitmap = await AssetMapBitmap.create( + createLocalImageConfiguration(context), + 'assets/red_square.png', + bitmapScaling: MapBitmapScaling.none, + ); + + _groundOverlayIndex += 1; + + final GroundOverlayId id = + GroundOverlayId('ground_overlay_$_groundOverlayIndex'); + + final GroundOverlay groundOverlay = switch (_placingType) { + _GroundOverlayPlacing.position => GroundOverlay.fromPosition( + groundOverlayId: id, + image: assetMapBitmap, + position: _currentGroundOverlayPos, + width: _dimensions.dx, // Android only + height: _dimensions.dy, // Android only + zoomLevel: 14.0, // iOS only + anchor: _anchor, + onTap: () { + _onGroundOverlayTapped(); + }, + ), + _GroundOverlayPlacing.bounds => GroundOverlay.fromBounds( + groundOverlayId: id, + image: assetMapBitmap, + bounds: _currentGroundOverlayBounds, + anchor: _anchor, + onTap: () { + _onGroundOverlayTapped(); + }, + ), + }; + + setState(() { + _groundOverlay = groundOverlay; + }); + } + + void _onGroundOverlayTapped() { + _changePosition(); + } + + void _setBearing() { + assert(_groundOverlay != null); + setState(() { + _groundOverlay = _groundOverlay!.copyWith( + bearingParam: _groundOverlay!.bearing >= 350 + ? 0 + : _groundOverlay!.bearing + 10); + }); + } + + void _changeTransparency() { + assert(_groundOverlay != null); + setState(() { + final double transparency = + _groundOverlay!.transparency == 0.0 ? 0.5 : 0.0; + _groundOverlay = + _groundOverlay!.copyWith(transparencyParam: transparency); + }); + } + + Future _changeDimensions() async { + assert(_groundOverlay != null); + assert(_placingType == _GroundOverlayPlacing.position); + setState(() { + _dimensions = _dimensions == const Offset(1000, 1000) + ? const Offset(1500, 500) + : const Offset(1000, 1000); + }); + + // Re-add the ground overlay to apply the new position, as the position + // cannot be changed after the ground overlay is created on all platforms. + await _addGroundOverlay(); + } + + Future _changePosition() async { + assert(_groundOverlay != null); + assert(_placingType == _GroundOverlayPlacing.position); + setState(() { + _currentGroundOverlayPos = _currentGroundOverlayPos == _groundOverlayPos1 + ? _groundOverlayPos2 + : _groundOverlayPos1; + }); + + // Re-add the ground overlay to apply the new position, as the position + // cannot be changed after the ground overlay is created on all platforms. + await _addGroundOverlay(); + } + + Future _changeBounds() async { + assert(_groundOverlay != null); + assert(_placingType == _GroundOverlayPlacing.bounds); + setState(() { + _currentGroundOverlayBounds = + _currentGroundOverlayBounds == _groundOverlayBounds1 + ? _groundOverlayBounds2 + : _groundOverlayBounds1; + }); + + // Re-add the ground overlay to apply the new bounds as the bounds cannot be + // changed after the ground overlay is created on all platforms. + await _addGroundOverlay(); + } + + void _toggleVisible() { + assert(_groundOverlay != null); + setState(() { + _groundOverlay = + _groundOverlay!.copyWith(visibleParam: !_groundOverlay!.visible); + }); + } + + void _changeZIndex() { + assert(_groundOverlay != null); + final int current = _groundOverlay!.zIndex; + final int zIndex = current == 12 ? 0 : current + 1; + setState(() { + _groundOverlay = _groundOverlay!.copyWith(zIndexParam: zIndex); + }); + } + + Future _changeType() async { + setState(() { + _placingType = _placingType == _GroundOverlayPlacing.position + ? _GroundOverlayPlacing.bounds + : _GroundOverlayPlacing.position; + }); + + // Re-add the ground overlay to change the positioning type. + await _addGroundOverlay(); + } + + Future _changeAnchor() async { + assert(_groundOverlay != null); + setState(() { + _anchor = _groundOverlay!.anchor == const Offset(0.5, 0.5) + ? const Offset(1.0, 1.0) + : const Offset(0.5, 0.5); + }); + + // Re-add the ground overlay to apply the new anchor as the anchor cannot be + // changed after the ground overlay is created. + await _addGroundOverlay(); + } + + @override + Widget build(BuildContext context) { + final Set overlays = { + if (_groundOverlay != null) _groundOverlay!, + }; + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: _mapCenter, + zoom: 14.0, + ), + groundOverlays: overlays, + onMapCreated: _onMapCreated, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: _groundOverlay == null ? _addGroundOverlay : null, + child: const Text('Add'), + ), + TextButton( + onPressed: _groundOverlay != null ? _removeGroundOverlay : null, + child: const Text('Remove'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: + _groundOverlay == null ? null : () => _changeTransparency(), + child: const Text('change transparency'), + ), + if (!kIsWeb) + TextButton( + onPressed: _groundOverlay == null ? null : () => _setBearing(), + child: const Text('change bearing'), + ), + TextButton( + onPressed: _groundOverlay == null ? null : () => _toggleVisible(), + child: const Text('toggle visible'), + ), + if (!kIsWeb) + TextButton( + onPressed: + _groundOverlay == null ? null : () => _changeZIndex(), + child: const Text('change zIndex'), + ), + if (!kIsWeb) + TextButton( + onPressed: + _groundOverlay == null ? null : () => _changeAnchor(), + child: const Text('change anchor'), + ), + if (!kIsWeb) + TextButton( + onPressed: _groundOverlay == null ? null : () => _changeType(), + child: Text(_placingType == _GroundOverlayPlacing.position + ? 'use bounds' + : 'use position'), + ), + if (!kIsWeb) + TextButton( + onPressed: _placingType != _GroundOverlayPlacing.position || + _groundOverlay == null + ? null + : () => _changePosition(), + child: const Text('change position'), + ), + if (defaultTargetPlatform == TargetPlatform.android) + TextButton( + onPressed: _placingType != _GroundOverlayPlacing.position || + _groundOverlay == null + ? null + : () => _changeDimensions(), + child: const Text('change dimensions'), + ), + TextButton( + onPressed: _placingType != _GroundOverlayPlacing.bounds || + _groundOverlay == null + ? null + : () => _changeBounds(), + child: const Text('change bounds'), + ), + ], + ), + ], + ); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart index 73a47db723e8..db7f38f9f8ab 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart @@ -10,6 +10,7 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf import 'animate_camera.dart'; import 'clustering.dart'; +import 'ground_overlay.dart'; import 'heatmap.dart'; import 'lite_mode.dart'; import 'map_click.dart'; @@ -44,6 +45,7 @@ final List _allPages = [ const SnapshotPage(), const LiteModePage(), const TileOverlayPage(), + const GroundOverlayPage(), const ClusteringPage(), const MapIdPage(), const HeatmapPage(), 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 0a949e262bbe..dcc3af07cfbf 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml @@ -18,8 +18,8 @@ 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_android: ^2.9.0 - google_maps_flutter_platform_interface: ^2.7.0 + google_maps_flutter_android: ^2.15.0 + google_maps_flutter_platform_interface: ^2.10.0 dev_dependencies: build_runner: ^2.1.10 diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart index 66952bbe062d..d24f6f0995ff 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart @@ -29,6 +29,8 @@ export 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf Cluster, ClusterManager, ClusterManagerId, + GroundOverlay, + GroundOverlayId, Heatmap, HeatmapGradient, HeatmapGradientColor, 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 5dd1cfdfd2ad..589058989989 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 @@ -74,6 +74,9 @@ class GoogleMapController { GoogleMapsFlutterPlatform.instance .onCircleTap(mapId: mapId) .listen((CircleTapEvent e) => _googleMapState.onCircleTap(e.value)); + GoogleMapsFlutterPlatform.instance.onGroundOverlayTap(mapId: mapId).listen( + (GroundOverlayTapEvent e) => + _googleMapState.onGroundOverlayTap(e.value)); GoogleMapsFlutterPlatform.instance .onTap(mapId: mapId) .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); @@ -118,6 +121,18 @@ class GoogleMapController { .updateClusterManagers(clusterManagerUpdates, mapId: mapId); } + /// Updates ground overlay 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 _updateGroundOverlays( + GroundOverlayUpdates groundOverlayUpdates) { + return GoogleMapsFlutterPlatform.instance + .updateGroundOverlays(groundOverlayUpdates, mapId: mapId); + } + /// Updates polygon configuration. /// /// Change listeners are notified once the update has been made on the 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 9ef26405214c..76bcad6e26c6 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 @@ -122,6 +122,7 @@ class GoogleMap extends StatefulWidget { this.heatmaps = const {}, this.onCameraMoveStarted, this.tileOverlays = const {}, + this.groundOverlays = const {}, this.onCameraMove, this.onCameraIdle, this.onTap, @@ -228,6 +229,34 @@ class GoogleMap extends StatefulWidget { /// See https://pub.dev/packages/google_maps_flutter_web. final Set clusterManagers; + /// Ground overlays to be initialized for the map. + /// + /// Support table for Ground Overlay features: + /// | Feature | Android | iOS | Web | + /// |-----------------------------|--------------------------|--------------------------|-----| + /// | [GroundOverlay.image] | Yes | Yes | Yes | + /// | [GroundOverlay.bounds] | Yes | Yes | Yes | + /// | [GroundOverlay.position] | Yes | Yes | No | + /// | [GroundOverlay.width] | Yes (with position only) | No | No | + /// | [GroundOverlay.height] | Yes (with position only) | No | No | + /// | [GroundOverlay.anchor] | Yes | Yes | No | + /// | [GroundOverlay.zoomLevel] | No | Yes (with position only) | No | + /// | [GroundOverlay.bearing] | Yes | Yes | No | + /// | [GroundOverlay.transparency]| Yes | Yes | Yes | + /// | [GroundOverlay.zIndex] | Yes | Yes | No | + /// | [GroundOverlay.visible] | Yes | Yes | Yes | + /// | [GroundOverlay.clickable] | Yes | Yes | Yes | + /// | [GroundOverlay.onTap] | Yes | Yes | Yes | + /// + /// - On Android, [GroundOverlay.width] is required if + /// [GroundOverlay.position] is set. + /// - On iOS, [GroundOverlay.zoomLevel] is required if + /// [GroundOverlay.position] is set. + /// - [GroundOverlay.image] must be a [MapBitmap]. See [AssetMapBitmap] and + /// [BytesMapBitmap]. [MapBitmap.bitmapScaling] must be set to + /// [MapBitmapScaling.none]. + final Set groundOverlays; + /// Called when the camera starts moving. /// /// This can be initiated by the following: @@ -344,6 +373,8 @@ class _GoogleMapState extends State { Map _clusterManagers = {}; Map _heatmaps = {}; + Map _groundOverlays = + {}; late MapConfiguration _mapConfiguration; @override @@ -365,6 +396,7 @@ class _GoogleMapState extends State { circles: widget.circles, clusterManagers: widget.clusterManagers, heatmaps: widget.heatmaps, + groundOverlays: widget.groundOverlays, ), mapConfiguration: _mapConfiguration, ); @@ -380,6 +412,7 @@ class _GoogleMapState extends State { _polylines = keyByPolylineId(widget.polylines); _circles = keyByCircleId(widget.circles); _heatmaps = keyByHeatmapId(widget.heatmaps); + _groundOverlays = keyByGroundOverlayId(widget.groundOverlays); } @override @@ -404,6 +437,7 @@ class _GoogleMapState extends State { _updateCircles(); _updateHeatmaps(); _updateTileOverlays(); + _updateGroundOverlays(); } Future _updateOptions() async { @@ -431,6 +465,13 @@ class _GoogleMapState extends State { _clusterManagers = keyByClusterManagerId(widget.clusterManagers); } + Future _updateGroundOverlays() async { + final GoogleMapController controller = await _controller.future; + unawaited(controller._updateGroundOverlays(GroundOverlayUpdates.from( + _groundOverlays.values.toSet(), widget.groundOverlays))); + _groundOverlays = keyByGroundOverlayId(widget.groundOverlays); + } + Future _updatePolygons() async { final GoogleMapController controller = await _controller.future; unawaited(controller._updatePolygons( @@ -558,6 +599,17 @@ class _GoogleMapState extends State { } } + void onGroundOverlayTap(GroundOverlayId groundOverlayId) { + final GroundOverlay? groundOverlay = _groundOverlays[groundOverlayId]; + if (groundOverlay == null) { + throw UnknownMapObjectIdError('groundOverlay', groundOverlayId, 'onTap'); + } + final VoidCallback? onTap = groundOverlay.onTap; + if (onTap != null) { + onTap(); + } + } + void onInfoWindowTap(MarkerId markerId) { final Marker? marker = _markers[markerId]; if (marker == null) { diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 1c91065a66b5..dd9c3f3a4bea 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,11 +2,11 @@ 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.10.1 +version: 2.11.0 environment: - sdk: ^3.4.0 - flutter: ">=3.22.0" + sdk: ^3.6.0 + flutter: ">=3.27.0" flutter: plugin: @@ -21,10 +21,10 @@ flutter: dependencies: flutter: sdk: flutter - google_maps_flutter_android: ^2.13.0 - google_maps_flutter_ios: ^2.12.0 - google_maps_flutter_platform_interface: ^2.9.0 - google_maps_flutter_web: ^0.5.10 + google_maps_flutter_android: ^2.15.0 + google_maps_flutter_ios: ^2.14.0 + google_maps_flutter_platform_interface: ^2.10.0 + google_maps_flutter_web: ^0.5.12 dev_dependencies: flutter_test: 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 df83400c416b..07c99b53807d 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 @@ -112,6 +112,15 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { await _fakeDelay(); } + @override + Future updateGroundOverlays( + GroundOverlayUpdates groundOverlayUpdates, { + required int mapId, + }) async { + mapInstances[mapId]?.groundOverlayUpdates.add(groundOverlayUpdates); + await _fakeDelay(); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -264,6 +273,11 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { return mapEventStreamController.stream.whereType(); } + @override + Stream onGroundOverlayTap({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override void dispose({required int mapId}) { disposed = true; @@ -307,6 +321,8 @@ class PlatformMapStateRecorder { }) { clusterManagerUpdates.add(ClusterManagerUpdates.from( const {}, mapObjects.clusterManagers)); + groundOverlayUpdates.add(GroundOverlayUpdates.from( + const {}, mapObjects.groundOverlays)); markerUpdates.add(MarkerUpdates.from(const {}, mapObjects.markers)); polygonUpdates .add(PolygonUpdates.from(const {}, mapObjects.polygons)); @@ -330,4 +346,6 @@ class PlatformMapStateRecorder { final List> tileOverlaySets = >[]; final List clusterManagerUpdates = []; + final List groundOverlayUpdates = + []; } diff --git a/packages/google_maps_flutter/google_maps_flutter/test/groundoverlay_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/groundoverlay_updates_test.dart new file mode 100644 index 000000000000..79b551021086 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/test/groundoverlay_updates_test.dart @@ -0,0 +1,473 @@ +// 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/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +import 'fake_google_maps_flutter_platform.dart'; + +Widget _mapWithMarkers(Set groundOverlays) { + return Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: const CameraPosition(target: LatLng(10.0, 15.0)), + groundOverlays: groundOverlays, + ), + ); +} + +void main() { + final LatLngBounds kGroundOverlayBounds = LatLngBounds( + southwest: const LatLng(37.77483, -122.41942), + northeast: const LatLng(37.78183, -122.39105), + ); + + late FakeGoogleMapsFlutterPlatform platform; + + setUp(() { + platform = FakeGoogleMapsFlutterPlatform(); + GoogleMapsFlutterPlatform.instance = platform; + }); + + testWidgets('Initializing a groundOverlay', (WidgetTester tester) async { + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + final GroundOverlay go2 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_2'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + await tester.pumpWidget(_mapWithMarkers({go1, go2})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.length, 2); + + final Set initializedGroundOverlays = + map.groundOverlayUpdates.last.groundOverlaysToAdd; + + expect(initializedGroundOverlays.first, equals(go1)); + expect(initializedGroundOverlays.last, equals(go2)); + expect( + map.groundOverlayUpdates.last.groundOverlayIdsToRemove.isEmpty, true); + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.isEmpty, true); + }); + + testWidgets('Adding a groundOverlay', (WidgetTester tester) async { + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + final GroundOverlay go2 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_2'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + await tester.pumpWidget(_mapWithMarkers({go1})); + await tester.pumpWidget(_mapWithMarkers({go1, go2})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.length, 1); + + final GroundOverlay addedMarker = + map.groundOverlayUpdates.last.groundOverlaysToAdd.first; + expect(addedMarker, equals(go2)); + + expect( + map.groundOverlayUpdates.last.groundOverlayIdsToRemove.isEmpty, true); + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.isEmpty, true); + }); + + testWidgets('Removing a groundOverlay', (WidgetTester tester) async { + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + await tester.pumpWidget(_mapWithMarkers({go1})); + await tester.pumpWidget(_mapWithMarkers({})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.groundOverlayUpdates.last.groundOverlayIdsToRemove.length, 1); + expect(map.groundOverlayUpdates.last.groundOverlayIdsToRemove.first, + equals(go1.groundOverlayId)); + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.isEmpty, true); + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.isEmpty, true); + }); + + testWidgets('Updating a groundOverlay', (WidgetTester tester) async { + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + final GroundOverlay go2 = go1.copyWith(visibleParam: false); + + await tester.pumpWidget(_mapWithMarkers({go1})); + await tester.pumpWidget(_mapWithMarkers({go2})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.length, 1); + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.first, + equals(go2)); + + expect( + map.groundOverlayUpdates.last.groundOverlayIdsToRemove.isEmpty, true); + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.isEmpty, true); + }); + + testWidgets('Multi Update', (WidgetTester tester) async { + GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + GroundOverlay go2 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_2'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + final Set prev = {go1, go2}; + go1 = go1.copyWith(visibleParam: false); + go2 = go2.copyWith(clickableParam: false); + final Set cur = {go1, go2}; + + await tester.pumpWidget(_mapWithMarkers(prev)); + await tester.pumpWidget(_mapWithMarkers(cur)); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange, cur); + expect( + map.groundOverlayUpdates.last.groundOverlayIdsToRemove.isEmpty, true); + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.isEmpty, true); + }); + + testWidgets('Multi Update', (WidgetTester tester) async { + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + GroundOverlay go2 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_2'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + final GroundOverlay go3 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_3'), + position: kGroundOverlayBounds.southwest, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + final Set prev = {go2, go3}; + + // go1 is added, go2 is updated, go3 is removed. + go2 = go2.copyWith(clickableParam: false); + final Set cur = {go1, go2}; + + await tester.pumpWidget(_mapWithMarkers(prev)); + await tester.pumpWidget(_mapWithMarkers(cur)); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.length, 1); + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.length, 1); + expect(map.groundOverlayUpdates.last.groundOverlayIdsToRemove.length, 1); + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.first, + equals(go2)); + expect( + map.groundOverlayUpdates.last.groundOverlaysToAdd.first, equals(go1)); + expect(map.groundOverlayUpdates.last.groundOverlayIdsToRemove.first, + equals(go3.groundOverlayId)); + }); + + testWidgets('Partial Update', (WidgetTester tester) async { + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + final GroundOverlay go2 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_2'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + GroundOverlay go3 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_3'), + position: kGroundOverlayBounds.southwest, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + final Set prev = {go1, go2, go3}; + go3 = go3.copyWith(visibleParam: false); + final Set cur = {go1, go2, go3}; + + await tester.pumpWidget(_mapWithMarkers(prev)); + await tester.pumpWidget(_mapWithMarkers(cur)); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange, + {go3}); + expect( + map.groundOverlayUpdates.last.groundOverlayIdsToRemove.isEmpty, true); + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.isEmpty, true); + }); + + testWidgets('Update non platform related attr', (WidgetTester tester) async { + GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + final Set prev = {go1}; + go1 = go1.copyWith( + onTapParam: () {}, + ); + final Set cur = {go1}; + + await tester.pumpWidget(_mapWithMarkers(prev)); + await tester.pumpWidget(_mapWithMarkers(cur)); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + + expect(map.groundOverlayUpdates.last.groundOverlaysToChange.isEmpty, true); + expect( + map.groundOverlayUpdates.last.groundOverlayIdsToRemove.isEmpty, true); + expect(map.groundOverlayUpdates.last.groundOverlaysToAdd.isEmpty, true); + }); + + testWidgets('multi-update with delays', (WidgetTester tester) async { + platform.simulatePlatformDelay = true; + + final GroundOverlay go1 = GroundOverlay.fromBounds( + groundOverlayId: const GroundOverlayId('go_1'), + bounds: kGroundOverlayBounds, + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + ); + + final GroundOverlay go2 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_2'), + position: kGroundOverlayBounds.northeast, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + final GroundOverlay go3 = GroundOverlay.fromPosition( + groundOverlayId: const GroundOverlayId('go_3'), + position: kGroundOverlayBounds.southwest, + width: 100, + height: 100, + anchor: const Offset(0.1, 0.2), + image: AssetMapBitmap( + 'assets/red_square.png', + imagePixelRatio: 1.0, + bitmapScaling: MapBitmapScaling.none, + ), + transparency: 0.7, + bearing: 10, + zIndex: 10, + zoomLevel: 14.0, + ); + + final GroundOverlay go3updated = go3.copyWith(visibleParam: false); + + // First remove one and add another, then update the new one. + await tester.pumpWidget(_mapWithMarkers({go1, go2})); + await tester.pumpWidget(_mapWithMarkers({go1, go3})); + await tester.pumpWidget(_mapWithMarkers({go1, go3updated})); + + final PlatformMapStateRecorder map = platform.lastCreatedMap; + + expect(map.groundOverlayUpdates.length, 3); + + expect(map.groundOverlayUpdates[0].groundOverlaysToChange.isEmpty, true); + expect(map.groundOverlayUpdates[0].groundOverlaysToAdd, + {go1, go2}); + expect(map.groundOverlayUpdates[0].groundOverlayIdsToRemove.isEmpty, true); + + expect(map.groundOverlayUpdates[1].groundOverlaysToChange.isEmpty, true); + expect( + map.groundOverlayUpdates[1].groundOverlaysToAdd, {go3}); + expect(map.groundOverlayUpdates[1].groundOverlayIdsToRemove, + {go2.groundOverlayId}); + + expect(map.groundOverlayUpdates[2].groundOverlaysToChange, + {go3updated}); + expect(map.groundOverlayUpdates[2].groundOverlaysToAdd.isEmpty, true); + expect(map.groundOverlayUpdates[2].groundOverlayIdsToRemove.isEmpty, true); + + await tester.pumpAndSettle(); + }); +}