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 0427818ca5..6a9b6ad8d5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.3 + +* Initial support for custom overlays. [#98596](https://github.com/flutter/flutter/issues/98596). + ## 0.5.2 * Adds options for gesture handling and tilt controls. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart index 00a448a979..fda38f29b5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart @@ -14,14 +14,14 @@ import 'package:integration_test/integration_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'google_maps_controller_test.mocks.dart'; - -@GenerateMocks([], customMocks: >[ - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), +@GenerateNiceMocks(>[ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), ]) +import 'google_maps_controller_test.mocks.dart'; /// Test Google Map Controller void main() { @@ -194,6 +194,15 @@ void main() { }, throwsAssertionError); }); + testWidgets('cannot updateTileOverlays after dispose', + (WidgetTester tester) async { + controller.dispose(); + + expect(() { + controller.updateTileOverlays(const {}); + }, throwsAssertionError); + }); + testWidgets('isInfoWindowShown defaults to false', (WidgetTester tester) async { controller.dispose(); @@ -208,6 +217,7 @@ void main() { late MockMarkersController markers; late MockPolygonsController polygons; late MockPolylinesController polylines; + late MockTileOverlaysController tileOverlays; late gmaps.GMap map; setUp(() { @@ -215,20 +225,20 @@ void main() { markers = MockMarkersController(); polygons = MockPolygonsController(); polylines = MockPolylinesController(); + tileOverlays = MockTileOverlaysController(); map = gmaps.GMap(html.DivElement()); }); testWidgets('listens to map events', (WidgetTester tester) async { - controller = createController(); - controller.debugSetOverrides( - createMap: (_, __) => map, - circles: circles, - markers: markers, - polygons: polygons, - polylines: polylines, - ); - - controller.init(); + controller = createController() + ..debugSetOverrides( + createMap: (_, __) => map, + circles: circles, + markers: markers, + polygons: polygons, + polylines: polylines, + ) + ..init(); // Trigger events on the map, and verify they've been broadcast to the stream final Stream> capturedEvents = stream.stream.take(5); @@ -258,26 +268,26 @@ void main() { testWidgets("binds geometry controllers to map's", (WidgetTester tester) async { - controller = createController(); - controller.debugSetOverrides( - createMap: (_, __) => map, - circles: circles, - markers: markers, - polygons: polygons, - polylines: polylines, - ); - - controller.init(); + controller = createController() + ..debugSetOverrides( + createMap: (_, __) => map, + circles: circles, + markers: markers, + polygons: polygons, + polylines: polylines, + tileOverlays: tileOverlays, + ) + ..init(); verify(circles.bindToMap(mapId, map)); verify(markers.bindToMap(mapId, map)); verify(polygons.bindToMap(mapId, map)); verify(polylines.bindToMap(mapId, map)); + verify(tileOverlays.bindToMap(mapId, map)); }); testWidgets('renders initial geometry', (WidgetTester tester) async { - controller = createController( - mapObjects: MapObjects(circles: { + final MapObjects mapObjects = MapObjects(circles: { const Circle( circleId: CircleId('circle-1'), zIndex: 1234, @@ -320,57 +330,25 @@ void main() { LatLng(43.354469, -5.851318), LatLng(43.354762, -5.850824), ]) - })); + }, tileOverlays: { + const TileOverlay(tileOverlayId: TileOverlayId('overlay-1')) + }); - controller.debugSetOverrides( - circles: circles, - markers: markers, - polygons: polygons, - polylines: polylines, - ); + controller = createController(mapObjects: mapObjects) + ..debugSetOverrides( + circles: circles, + markers: markers, + polygons: polygons, + polylines: polylines, + tileOverlays: tileOverlays, + ) + ..init(); - controller.init(); - - final Set capturedCircles = - verify(circles.addCircles(captureAny)).captured[0] as Set; - final Set capturedMarkers = - verify(markers.addMarkers(captureAny)).captured[0] as Set; - final Set capturedPolygons = - verify(polygons.addPolygons(captureAny)).captured[0] - as Set; - final Set capturedPolylines = - verify(polylines.addPolylines(captureAny)).captured[0] - as Set; - - expect(capturedCircles.first.circleId.value, 'circle-1'); - expect(capturedCircles.first.zIndex, 1234); - expect(capturedMarkers.first.markerId.value, 'marker-1'); - expect(capturedMarkers.first.infoWindow.snippet, 'snippet for test'); - expect(capturedMarkers.first.infoWindow.title, 'title for test'); - expect(capturedPolygons.first.polygonId.value, 'polygon-1'); - expect(capturedPolygons.elementAt(1).polygonId.value, - 'polygon-2-with-holes'); - expect(capturedPolygons.elementAt(1).holes, isNot(null)); - expect(capturedPolylines.first.polylineId.value, 'polyline-1'); - }); - - testWidgets('empty infoWindow does not create InfoWindow instance.', - (WidgetTester tester) async { - controller = createController( - mapObjects: MapObjects(markers: { - const Marker(markerId: MarkerId('marker-1')), - })); - - controller.debugSetOverrides( - markers: markers, - ); - - controller.init(); - - final Set capturedMarkers = - verify(markers.addMarkers(captureAny)).captured[0] as Set; - - expect(capturedMarkers.first.infoWindow, InfoWindow.noText); + verify(circles.addCircles(mapObjects.circles)); + verify(markers.addMarkers(mapObjects.markers)); + verify(polygons.addPolygons(mapObjects.polygons)); + verify(polylines.addPolylines(mapObjects.polylines)); + verify(tileOverlays.addTileOverlays(mapObjects.tileOverlays)); }); group('Initialization options', () { @@ -449,15 +427,12 @@ void main() { target: LatLng(43.308, -5.6910), zoom: 12, ), - ); - - controller.debugSetOverrides( - createMap: (_, gmaps.MapOptions options) { - capturedOptions = options; - return map; - }); - - controller.init(); + ) + ..debugSetOverrides(createMap: (_, gmaps.MapOptions options) { + capturedOptions = options; + return map; + }) + ..init(); expect(capturedOptions, isNotNull); expect(capturedOptions!.zoom, 12); @@ -467,8 +442,7 @@ void main() { group('Traffic Layer', () { testWidgets('by default is disabled', (WidgetTester tester) async { - controller = createController(); - controller.init(); + controller = createController()..init(); expect(controller.trafficLayer, isNull); }); @@ -477,9 +451,9 @@ void main() { controller = createController( mapConfiguration: const MapConfiguration( trafficEnabled: true, - )); - controller.debugSetOverrides(createMap: (_, __) => map); - controller.init(); + )) + ..debugSetOverrides(createMap: (_, __) => map) + ..init(); expect(controller.trafficLayer, isNotNull); }); }); @@ -496,9 +470,9 @@ void main() { ..zoom = 10 ..center = gmaps.LatLng(0, 0), ); - controller = createController(); - controller.debugSetOverrides(createMap: (_, __) => map); - controller.init(); + controller = createController() + ..debugSetOverrides(createMap: (_, __) => map) + ..init(); }); group('updateRawOptions', () { @@ -556,13 +530,9 @@ void main() { // These are the methods that get forwarded to other controllers, so we just verify calls. group('Pass-through methods', () { - setUp(() { - controller = createController(); - }); - testWidgets('updateCircles', (WidgetTester tester) async { final MockCirclesController mock = MockCirclesController(); - controller.debugSetOverrides(circles: mock); + controller = createController()..debugSetOverrides(circles: mock); final Set previous = { const Circle(circleId: CircleId('to-be-updated')), @@ -589,7 +559,7 @@ void main() { testWidgets('updateMarkers', (WidgetTester tester) async { final MockMarkersController mock = MockMarkersController(); - controller.debugSetOverrides(markers: mock); + controller = createController()..debugSetOverrides(markers: mock); final Set previous = { const Marker(markerId: MarkerId('to-be-updated')), @@ -616,7 +586,7 @@ void main() { testWidgets('updatePolygons', (WidgetTester tester) async { final MockPolygonsController mock = MockPolygonsController(); - controller.debugSetOverrides(polygons: mock); + controller = createController()..debugSetOverrides(polygons: mock); final Set previous = { const Polygon(polygonId: PolygonId('to-be-updated')), @@ -643,7 +613,7 @@ void main() { testWidgets('updatePolylines', (WidgetTester tester) async { final MockPolylinesController mock = MockPolylinesController(); - controller.debugSetOverrides(polylines: mock); + controller = createController()..debugSetOverrides(polylines: mock); final Set previous = { const Polyline(polylineId: PolylineId('to-be-updated')), @@ -674,11 +644,38 @@ void main() { })); }); + testWidgets('updateTileOverlays', (WidgetTester tester) async { + final MockTileOverlaysController mock = MockTileOverlaysController(); + controller = createController( + mapObjects: MapObjects(tileOverlays: { + const TileOverlay(tileOverlayId: TileOverlayId('to-be-updated')), + const TileOverlay(tileOverlayId: TileOverlayId('to-be-removed')), + })) + ..debugSetOverrides(tileOverlays: mock); + + controller.updateTileOverlays({ + const TileOverlay( + tileOverlayId: TileOverlayId('to-be-updated'), visible: false), + const TileOverlay(tileOverlayId: TileOverlayId('to-be-added')), + }); + + verify(mock.removeTileOverlays({ + const TileOverlayId('to-be-removed'), + })); + verify(mock.addTileOverlays({ + const TileOverlay(tileOverlayId: TileOverlayId('to-be-added')), + })); + verify(mock.changeTileOverlays({ + const TileOverlay( + tileOverlayId: TileOverlayId('to-be-updated'), visible: false), + })); + }); + testWidgets('infoWindow visibility', (WidgetTester tester) async { final MockMarkersController mock = MockMarkersController(); const MarkerId markerId = MarkerId('marker-with-infowindow'); when(mock.isInfoWindowShown(markerId)).thenReturn(true); - controller.debugSetOverrides(markers: mock); + controller = createController()..debugSetOverrides(markers: mock); controller.showInfoWindow(markerId); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart index 4bc5f365d2..f73b3d734a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart @@ -403,3 +403,65 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { returnValueForMissingStub: null, ); } + +/// A class which mocks [TileOverlaysController]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTileOverlaysController extends _i1.Mock + implements _i3.TileOverlaysController { + @override + _i2.GMap get googleMap => (super.noSuchMethod( + Invocation.getter(#googleMap), + returnValue: _FakeGMap_0( + this, + Invocation.getter(#googleMap), + ), + returnValueForMissingStub: _FakeGMap_0( + this, + Invocation.getter(#googleMap), + ), + ) as _i2.GMap); + @override + set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod( + Invocation.setter( + #googleMap, + _googleMap, + ), + returnValueForMissingStub: null, + ); + @override + void addTileOverlays(Set<_i4.TileOverlay>? tileOverlays) => + super.noSuchMethod( + Invocation.method( + #addTileOverlays, + [tileOverlays], + ), + returnValueForMissingStub: null, + ); + @override + void changeTileOverlays(Set<_i4.TileOverlay>? tileOverlays) => + super.noSuchMethod( + Invocation.method( + #changeTileOverlays, + [tileOverlays], + ), + returnValueForMissingStub: null, + ); + @override + void removeTileOverlays(Set<_i4.TileOverlayId>? tileOverlayIds) => + super.noSuchMethod( + Invocation.method( + #removeTileOverlays, + [tileOverlayIds], + ), + returnValueForMissingStub: null, + ); + @override + void clearTileCache(_i4.TileOverlayId? tileOverlayId) => super.noSuchMethod( + Invocation.method( + #clearTileCache, + [tileOverlayId], + ), + returnValueForMissingStub: null, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart index c773596d9b..36b4d11e07 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart @@ -14,12 +14,9 @@ import 'package:integration_test/integration_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +@GenerateNiceMocks(>[MockSpec()]) import 'google_maps_plugin_test.mocks.dart'; -@GenerateMocks([], customMocks: >[ - MockSpec(onMissingStub: OnMissingStub.returnDefault), -]) - /// Test GoogleMapsPlugin void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -208,28 +205,6 @@ void main() { }); }); - group('Noop methods:', () { - const int mapId = 0; - setUp(() { - plugin.debugSetMapById({mapId: controller}); - }); - // Options - testWidgets('updateTileOverlays', (WidgetTester tester) async { - final Future update = plugin.updateTileOverlays( - mapId: mapId, - newTileOverlays: {}, - ); - expect(update, completion(null)); - }); - testWidgets('updateTileOverlays', (WidgetTester tester) async { - final Future update = plugin.clearTileCache( - const TileOverlayId('any'), - mapId: mapId, - ); - expect(update, completion(null)); - }); - }); - // These methods only pass-through values from the plugin to the controller // so we verify them all together here... group('Pass-through methods:', () { @@ -287,6 +262,24 @@ void main() { verify(controller.updateCircles(expectedUpdates)); }); + // Tile Overlays + testWidgets('updateTileOverlays', (WidgetTester tester) async { + final Set expectedOverlays = { + const TileOverlay(tileOverlayId: TileOverlayId('overlay')) + }; + + await plugin.updateTileOverlays( + newTileOverlays: expectedOverlays, mapId: mapId); + + verify(controller.updateTileOverlays(expectedOverlays)); + }); + testWidgets('clearTileCache', (WidgetTester tester) async { + const TileOverlayId tileOverlayId = TileOverlayId('Dory'); + + await plugin.clearTileCache(tileOverlayId, mapId: mapId); + + verify(controller.clearTileCache(tileOverlayId)); + }); // Camera testWidgets('animateCamera', (WidgetTester tester) async { final CameraUpdate expectedUpdates = CameraUpdate.newLatLng( diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart index 36e6052a21..831bda161e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart @@ -126,6 +126,7 @@ class MockGoogleMapController extends _i1.Mock _i4.CirclesController? circles, _i4.PolygonsController? polygons, _i4.PolylinesController? polylines, + _i4.TileOverlaysController? tileOverlays, }) => super.noSuchMethod( Invocation.method( @@ -137,6 +138,7 @@ class MockGoogleMapController extends _i1.Mock #circles: circles, #polygons: polygons, #polylines: polylines, + #tileOverlays: tileOverlays, }, ), returnValueForMissingStub: null, @@ -286,6 +288,23 @@ class MockGoogleMapController extends _i1.Mock returnValueForMissingStub: null, ); @override + void updateTileOverlays(Set<_i2.TileOverlay>? newOverlays) => + super.noSuchMethod( + Invocation.method( + #updateTileOverlays, + [newOverlays], + ), + returnValueForMissingStub: null, + ); + @override + void clearTileCache(_i2.TileOverlayId? id) => super.noSuchMethod( + Invocation.method( + #clearTileCache, + [id], + ), + returnValueForMissingStub: null, + ); + @override void showInfoWindow(_i2.MarkerId? markerId) => super.noSuchMethod( Invocation.method( #showInfoWindow, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlay_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlay_test.dart new file mode 100644 index 0000000000..29f902f7f1 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlay_test.dart @@ -0,0 +1,119 @@ +// 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 'dart:convert'; +import 'dart:html' as html; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps/google_maps.dart' as gmaps; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'resources/tile16_base64.dart'; + +class NoTileProvider implements TileProvider { + const NoTileProvider(); + + @override + Future getTile(int x, int y, int? zoom) async => TileProvider.noTile; +} + +class TestTileProvider implements TileProvider { + const TestTileProvider(); + + @override + Future getTile(int x, int y, int? zoom) async => + Tile(16, 16, const Base64Decoder().convert(tile16Base64)); +} + +/// Test Overlays +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('TileOverlayController', () { + const TileOverlayId id = TileOverlayId(''); + + testWidgets('minimal initialization', (WidgetTester tester) async { + final TileOverlayController controller = TileOverlayController( + tileOverlay: const TileOverlay(tileOverlayId: id), + ); + + final gmaps.Size size = controller.gmMapType.tileSize!; + expect(size.width, TileOverlayController.logicalTileSize); + expect(size.height, TileOverlayController.logicalTileSize); + expect(controller.gmMapType.getTile!(gmaps.Point(0, 0), 0, html.document), + null); + }); + + testWidgets('produces image tiles', (WidgetTester tester) async { + final TileOverlayController controller = TileOverlayController( + tileOverlay: const TileOverlay( + tileOverlayId: id, + tileProvider: TestTileProvider(), + ), + ); + + final html.ImageElement img = + controller.gmMapType.getTile!(gmaps.Point(0, 0), 0, html.document)! + as html.ImageElement; + expect(img.naturalWidth, 0); + expect(img.naturalHeight, 0); + expect(img.hidden, true); + + // Wait until the image is fully loaded and decoded before re-reading its attributes. + await img.onLoad.first; + await img.decode(); + + expect(img.hidden, false); + expect(img.naturalWidth, 16); + expect(img.naturalHeight, 16); + }); + + testWidgets('update', (WidgetTester tester) async { + final TileOverlayController controller = TileOverlayController( + tileOverlay: const TileOverlay( + tileOverlayId: id, + tileProvider: NoTileProvider(), + ), + ); + { + final html.ImageElement img = + controller.gmMapType.getTile!(gmaps.Point(0, 0), 0, html.document)! + as html.ImageElement; + await null; // let `getTile` `then` complete + expect( + img.src, + isEmpty, + reason: 'The NoTileProvider never updates the img src', + ); + } + + controller.update(const TileOverlay( + tileOverlayId: id, + tileProvider: TestTileProvider(), + )); + { + final html.ImageElement img = + controller.gmMapType.getTile!(gmaps.Point(0, 0), 0, html.document)! + as html.ImageElement; + await img.onLoad.first; + expect( + img.src, + isNotEmpty, + reason: 'The img `src` should eventually become the Blob URL.', + ); + } + + controller.update(const TileOverlay(tileOverlayId: id)); + { + expect( + controller.gmMapType.getTile!(gmaps.Point(0, 0), 0, html.document), + null, + reason: 'Setting a null tileProvider should work.', + ); + } + }); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlays_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlays_test.dart new file mode 100644 index 0000000000..8b6b34694f --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlays_test.dart @@ -0,0 +1,172 @@ +// 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 'dart:async'; +import 'dart:html' as html; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps/google_maps.dart' as gmaps; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:google_maps_flutter_web/google_maps_flutter_web.dart' + hide GoogleMapController; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks(>[MockSpec()]) +import 'overlays_test.mocks.dart'; + +MockTileProvider neverTileProvider() { + final MockTileProvider tileProvider = MockTileProvider(); + when(tileProvider.getTile(any, any, any)) + .thenAnswer((_) => Completer().future); + return tileProvider; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('TileOverlaysController', () { + late TileOverlaysController controller; + late gmaps.GMap map; + late List tileProviders; + late List tileOverlays; + + /// Queries the current overlay map types for tiles at x = 0, y = 0, zoom = + /// 0. + void probeTiles() { + for (final gmaps.MapType? mapType in map.overlayMapTypes!.array!) { + mapType?.getTile!(gmaps.Point(0, 0), 0, html.document); + } + } + + setUp(() { + controller = TileOverlaysController(); + map = gmaps.GMap(html.DivElement()); + controller.googleMap = map; + + tileProviders = [ + for (int i = 0; i < 3; ++i) neverTileProvider() + ]; + + tileOverlays = [ + for (int i = 0; i < 3; ++i) + TileOverlay( + tileOverlayId: TileOverlayId('$i'), + tileProvider: tileProviders[i], + zIndex: i) + ]; + }); + + testWidgets('addTileOverlays', (WidgetTester tester) async { + controller.addTileOverlays({...tileOverlays}); + probeTiles(); + verifyInOrder([ + tileProviders[0].getTile(any, any, any), + tileProviders[1].getTile(any, any, any), + tileProviders[2].getTile(any, any, any), + ]); + verifyNoMoreInteractions(tileProviders[0]); + verifyNoMoreInteractions(tileProviders[1]); + verifyNoMoreInteractions(tileProviders[2]); + }); + + testWidgets('changeTileOverlays', (WidgetTester tester) async { + controller.addTileOverlays({...tileOverlays}); + + // Set overlay 0 visiblity to false; flip z ordering of 1 and 2, leaving 1 + // unchanged. + controller.changeTileOverlays({ + tileOverlays[0].copyWith(visibleParam: false), + tileOverlays[2].copyWith(zIndexParam: 0), + }); + + probeTiles(); + + verifyInOrder([ + tileProviders[2].getTile(any, any, any), + tileProviders[1].getTile(any, any, any), + ]); + verifyZeroInteractions(tileProviders[0]); + verifyNoMoreInteractions(tileProviders[1]); + verifyNoMoreInteractions(tileProviders[2]); + + // Re-enable overlay 0. + controller.changeTileOverlays( + {tileOverlays[0].copyWith(visibleParam: true)}); + + probeTiles(); + + verify(tileProviders[2].getTile(any, any, any)); + verifyInOrder([ + tileProviders[0].getTile(any, any, any), + tileProviders[1].getTile(any, any, any), + ]); + verifyNoMoreInteractions(tileProviders[0]); + verifyNoMoreInteractions(tileProviders[1]); + verifyNoMoreInteractions(tileProviders[2]); + }); + + testWidgets( + 'updating the z index of a hidden layer does not make it visible', + (WidgetTester tester) async { + controller.addTileOverlays({...tileOverlays}); + + controller.changeTileOverlays({ + tileOverlays[0].copyWith(zIndexParam: -1, visibleParam: false), + }); + + probeTiles(); + verifyZeroInteractions(tileProviders[0]); + }); + + testWidgets('removeTileOverlays', (WidgetTester tester) async { + controller.addTileOverlays({...tileOverlays}); + + controller.removeTileOverlays({ + tileOverlays[0].tileOverlayId, + tileOverlays[2].tileOverlayId, + }); + + probeTiles(); + + verify(tileProviders[1].getTile(any, any, any)); + verifyZeroInteractions(tileProviders[0]); + verifyZeroInteractions(tileProviders[2]); + }); + + testWidgets('clearTileCache', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: GoogleMap( + initialCameraPosition: const CameraPosition( + target: LatLng(43.3078, -5.6958), + zoom: 14, + ), + tileOverlays: {...tileOverlays.take(2)}, + onMapCreated: (GoogleMapController value) { + controllerCompleter.complete(value); + addTearDown(() => value.dispose()); + }, + )))); + + // This is needed to kick-off the rendering of the JS Map flutter widget + await tester.pump(); + final GoogleMapController controller = await controllerCompleter.future; + + await tester.pump(); + verify(tileProviders[0].getTile(any, any, any)); + verify(tileProviders[1].getTile(any, any, any)); + + await controller.clearTileCache(tileOverlays[0].tileOverlayId); + + await tester.pump(); + verify(tileProviders[0].getTile(any, any, any)); + verifyNoMoreInteractions(tileProviders[1]); + }); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlays_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlays_test.mocks.dart new file mode 100644 index 0000000000..126f7c5dc7 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/overlays_test.mocks.dart @@ -0,0 +1,77 @@ +// Mocks generated by Mockito 5.4.1 from annotations +// in google_maps_flutter_web_integration_tests/integration_test/overlays_test.dart. +// Do not manually edit this file. + +// @dart=2.19 + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:google_maps_flutter_platform_interface/src/types/types.dart' + as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeTile_0 extends _i1.SmartFake implements _i2.Tile { + _FakeTile_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [TileProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTileProvider extends _i1.Mock implements _i2.TileProvider { + @override + _i3.Future<_i2.Tile> getTile( + int? x, + int? y, + int? zoom, + ) => + (super.noSuchMethod( + Invocation.method( + #getTile, + [ + x, + y, + zoom, + ], + ), + returnValue: _i3.Future<_i2.Tile>.value(_FakeTile_0( + this, + Invocation.method( + #getTile, + [ + x, + y, + zoom, + ], + ), + )), + returnValueForMissingStub: _i3.Future<_i2.Tile>.value(_FakeTile_0( + this, + Invocation.method( + #getTile, + [ + x, + y, + zoom, + ], + ), + )), + ) as _i3.Future<_i2.Tile>); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/resources/tile16_base64.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/resources/tile16_base64.dart new file mode 100644 index 0000000000..0728b17e48 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/resources/tile16_base64.dart @@ -0,0 +1,9 @@ +// 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. + +/// 16x16 transparent png. +const String tile16Base64 = + 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAA' + 'Cxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAATSURBVDhPYxgFo2AUjAIwYGAAAAQQAAGn' + 'RHxjAAAAAElFTkSuQmCC'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart index df0b8de4c4..65448abecd 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart @@ -9,6 +9,7 @@ import 'dart:convert'; import 'dart:html'; import 'dart:js_util'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -31,6 +32,8 @@ part 'src/google_maps_controller.dart'; part 'src/google_maps_flutter_web.dart'; part 'src/marker.dart'; part 'src/markers.dart'; +part 'src/overlay.dart'; +part 'src/overlays.dart'; part 'src/polygon.dart'; part 'src/polygons.dart'; part 'src/polyline.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart index f49a6878df..fbb1942240 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart @@ -25,11 +25,13 @@ class GoogleMapController { _polygons = mapObjects.polygons, _polylines = mapObjects.polylines, _circles = mapObjects.circles, + _tileOverlays = mapObjects.tileOverlays, _lastMapConfiguration = mapConfiguration { _circlesController = CirclesController(stream: _streamController); _polygonsController = PolygonsController(stream: _streamController); _polylinesController = PolylinesController(stream: _streamController); _markersController = MarkersController(stream: _streamController); + _tileOverlaysController = TileOverlaysController(); // Register the view factory that will hold the `_div` that holds the map in the DOM. // The `_div` needs to be created outside of the ViewFactory (and cached!) so we can @@ -53,6 +55,7 @@ class GoogleMapController { final Set _polygons; final Set _polylines; final Set _circles; + Set _tileOverlays; // The configuration passed by the user, before converting to gmaps. // Caching this allows us to re-create the map faithfully when needed. MapConfiguration _lastMapConfiguration = const MapConfiguration(); @@ -108,6 +111,7 @@ class GoogleMapController { PolygonsController? _polygonsController; PolylinesController? _polylinesController; MarkersController? _markersController; + TileOverlaysController? _tileOverlaysController; // Keeps track if _attachGeometryControllers has been called or not. bool _controllersBoundToMap = false; @@ -122,12 +126,14 @@ class GoogleMapController { CirclesController? circles, PolygonsController? polygons, PolylinesController? polylines, + TileOverlaysController? tileOverlays, }) { _overrideCreateMap = createMap; _markersController = markers ?? _markersController; _circlesController = circles ?? _circlesController; _polygonsController = polygons ?? _polygonsController; _polylinesController = polylines ?? _polylinesController; + _tileOverlaysController = tileOverlays ?? _tileOverlaysController; } DebugCreateMapFunction? _overrideCreateMap; @@ -182,13 +188,7 @@ class GoogleMapController { _attachGeometryControllers(map); // Now attach the geometry, traffic and any other layers... - _renderInitialGeometry( - markers: _markers, - circles: _circles, - polygons: _polygons, - polylines: _polylines, - ); - + _renderInitialGeometry(); _setTrafficLayer(map, _lastMapConfiguration.trafficEnabled ?? false); } @@ -241,22 +241,20 @@ class GoogleMapController { 'Cannot attach a map to a null PolylinesController instance.'); assert(_markersController != null, 'Cannot attach a map to a null MarkersController instance.'); + assert(_tileOverlaysController != null, + 'Cannot attach a map to a null TileOverlaysController instance.'); _circlesController!.bindToMap(_mapId, map); _polygonsController!.bindToMap(_mapId, map); _polylinesController!.bindToMap(_mapId, map); _markersController!.bindToMap(_mapId, map); + _tileOverlaysController!.bindToMap(_mapId, map); _controllersBoundToMap = true; } // Renders the initial sets of geometry. - void _renderInitialGeometry({ - Set markers = const {}, - Set circles = const {}, - Set polygons = const {}, - Set polylines = const {}, - }) { + void _renderInitialGeometry() { assert( _controllersBoundToMap, 'Geometry controllers must be bound to a map before any geometry can ' @@ -266,10 +264,11 @@ class GoogleMapController { // in the [_attachGeometryControllers] method, which ensures that all these // controllers below are *not* null. - _markersController!.addMarkers(markers); - _circlesController!.addCircles(circles); - _polygonsController!.addPolygons(polygons); - _polylinesController!.addPolylines(polylines); + _markersController!.addMarkers(_markers); + _circlesController!.addCircles(_circles); + _polygonsController!.addPolygons(_polygons); + _polylinesController!.addPolylines(_polylines); + _tileOverlaysController!.addTileOverlays(_tileOverlays); } // Merges new options coming from the plugin into _lastConfiguration. @@ -407,6 +406,25 @@ class GoogleMapController { _markersController?.removeMarkers(updates.markerIdsToRemove); } + /// Updates the set of [TileOverlay]s. + void updateTileOverlays(Set newOverlays) { + final MapsObjectUpdates updates = + MapsObjectUpdates.from(_tileOverlays, newOverlays, + objectName: 'tileOverlay'); + assert(_tileOverlaysController != null, + 'Cannot update tile overlays after dispose().'); + _tileOverlaysController?.addTileOverlays(updates.objectsToAdd); + _tileOverlaysController?.changeTileOverlays(updates.objectsToChange); + _tileOverlaysController + ?.removeTileOverlays(updates.objectIdsToRemove.cast()); + _tileOverlays = newOverlays; + } + + /// Clears the tile cache associated with the given [TileOverlayId]. + void clearTileCache(TileOverlayId id) { + _tileOverlaysController?.clearTileCache(id); + } + /// Shows the [InfoWindow] of the marker identified by its [MarkerId]. void showInfoWindow(MarkerId markerId) { assert(_markersController != null, @@ -439,6 +457,7 @@ class GoogleMapController { _polygonsController = null; _polylinesController = null; _markersController = null; + _tileOverlaysController = null; _streamController.close(); } } 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 049a6a25de..6b91e9481e 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 @@ -95,7 +95,7 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { required Set newTileOverlays, required int mapId, }) async { - return; // Noop for now! + _map(mapId).updateTileOverlays(newTileOverlays); } @override @@ -103,7 +103,7 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { TileOverlayId tileOverlayId, { required int mapId, }) async { - return; // Noop for now! + _map(mapId).clearTileCache(tileOverlayId); } /// Applies the given `cameraUpdate` to the current viewport (with animation). diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/overlay.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/overlay.dart new file mode 100644 index 0000000000..86f5387910 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/overlay.dart @@ -0,0 +1,76 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of google_maps_flutter_web; + +/// This wraps a [TileOverlay] in a [gmaps.MapType]. +class TileOverlayController { + /// Creates a `TileOverlayController` that wraps a [TileOverlay] object and its corresponding [gmaps.MapType]. + TileOverlayController({ + required TileOverlay tileOverlay, + }) { + update(tileOverlay); + } + + /// The size in pixels of the (square) tiles passed to the Maps SDK. + /// + /// Even though the web supports any size, and rectangular tiles, for + /// for consistency with mobile, this is not configurable on the web. + /// (Both Android and iOS prefer square 256px tiles @ 1x DPI) + /// + /// For higher DPI screens, the Tile that is actually returned can be larger + /// than 256px square. + static const int logicalTileSize = 256; + + /// Updates the [gmaps.MapType] and cached properties with an updated + /// [TileOverlay]. + void update(TileOverlay tileOverlay) { + _tileOverlay = tileOverlay; + _gmMapType = gmaps.MapType() + ..tileSize = gmaps.Size(logicalTileSize, logicalTileSize) + ..getTile = _getTile; + } + + /// Renders a Tile for gmaps; delegating to the configured [TileProvider]. + HtmlElement? _getTile( + gmaps.Point? tileCoord, + num? zoom, + Document? ownerDocument, + ) { + if (_tileOverlay.tileProvider == null) { + return null; + } + + final ImageElement img = + ownerDocument!.createElement('img') as ImageElement; + img.width = img.height = logicalTileSize; + img.hidden = true; + img.setAttribute('decoding', 'async'); + + _tileOverlay.tileProvider! + .getTile(tileCoord!.x!.toInt(), tileCoord.y!.toInt(), zoom?.toInt()) + .then((Tile tile) { + if (tile.data == null) { + return; + } + // Using img lets us take advantage of native decoding. + final String src = Url.createObjectUrl(Blob([tile.data])); + img.src = src; + img.addEventListener('load', (_) { + img.hidden = false; + Url.revokeObjectUrl(src); + }); + }); + + return img; + } + + /// The [gmaps.MapType] produced by this controller. + gmaps.MapType get gmMapType => _gmMapType; + late gmaps.MapType _gmMapType; + + /// The [TileOverlay] providing data for this controller. + TileOverlay get tileOverlay => _tileOverlay; + late TileOverlay _tileOverlay; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/overlays.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/overlays.dart new file mode 100644 index 0000000000..aa6c19173a --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/overlays.dart @@ -0,0 +1,99 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of google_maps_flutter_web; + +/// This class manages all the [TileOverlayController]s associated to a [GoogleMapController]. +class TileOverlaysController extends GeometryController { + final Map _tileOverlays = + {}; + final List _visibleTileOverlays = + []; + + // Inserts `tileOverlayController` into the list of visible overlays, and the current [googleMap]. + // + // After insertion, the arrays stay sorted by ascending z-index. + void _insertZSorted(TileOverlayController tileOverlayController) { + final int index = _visibleTileOverlays.lowerBoundBy( + tileOverlayController, + (TileOverlayController c) => c.tileOverlay.zIndex); + + googleMap.overlayMapTypes!.insertAt(index, tileOverlayController.gmMapType); + _visibleTileOverlays.insert(index, tileOverlayController); + } + + // Removes `tileOverlayController` from the list of visible overlays. + void _remove(TileOverlayController tileOverlayController) { + final int index = _visibleTileOverlays.indexOf(tileOverlayController); + if (index < 0) { + return; + } + + googleMap.overlayMapTypes!.removeAt(index); + _visibleTileOverlays.removeAt(index); + } + + /// Adds new [TileOverlay]s to this controller. + /// + /// Wraps the [TileOverlay]s in corresponding [TileOverlayController]s. + void addTileOverlays(Set tileOverlaysToAdd) { + tileOverlaysToAdd.forEach(_addTileOverlay); + } + + void _addTileOverlay(TileOverlay tileOverlay) { + final TileOverlayController controller = TileOverlayController( + tileOverlay: tileOverlay, + ); + _tileOverlays[tileOverlay.tileOverlayId] = controller; + + if (tileOverlay.visible) { + _insertZSorted(controller); + } + } + + /// Updates [TileOverlay]s with new options. + void changeTileOverlays(Set tileOverlays) { + tileOverlays.forEach(_changeTileOverlay); + } + + void _changeTileOverlay(TileOverlay tileOverlay) { + final TileOverlayController controller = + _tileOverlays[tileOverlay.tileOverlayId]!; + + final bool wasVisible = controller.tileOverlay.visible; + final bool isVisible = tileOverlay.visible; + + controller.update(tileOverlay); + + if (wasVisible) { + _remove(controller); + } + if (isVisible) { + _insertZSorted(controller); + } + } + + /// Removes the tile overlays associated with the given [TileOverlayId]s. + void removeTileOverlays(Set tileOverlayIds) { + tileOverlayIds.forEach(_removeTileOverlay); + } + + void _removeTileOverlay(TileOverlayId tileOverlayId) { + final TileOverlayController? controller = + _tileOverlays.remove(tileOverlayId); + if (controller != null) { + _remove(controller); + } + } + + /// Invalidates the tile overlay associated with the given [TileOverlayId]. + void clearTileCache(TileOverlayId tileOverlayId) { + final TileOverlayController? controller = _tileOverlays[tileOverlayId]; + if (controller != null && controller.tileOverlay.visible) { + final int i = _visibleTileOverlays.indexOf(controller); + // This causes the map to reload the overlay. + googleMap.overlayMapTypes!.setAt(i, controller.gmMapType); + } + } +} 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 f1eb09c6c5..642c2e4574 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.5.2 +version: 0.5.3 environment: sdk: ">=2.18.0 <4.0.0" @@ -17,6 +17,7 @@ flutter: fileName: google_maps_flutter_web.dart dependencies: + collection: ^1.16.0 flutter: sdk: flutter flutter_web_plugins: