diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f636fb75..45953f84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,29 @@ jobs: # cache: true # - run: flutter pub get # - run: flutter test integration_test --no-pub -r expanded + unit-test-android: + name: "[Android] Unit tests" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [ '3.24.3', '' ] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: "Setup Flutter SDK" + uses: subosito/flutter-action@v2 + with: + cache: true + - name: "Get Flutter dependencies" + run: flutter pub get + - name: "Run unit tests" + run: flutter test --timeout=600s --coverage + - name: "Run Codecov" + uses: codecov/codecov-action@v4 + if: ${{ matrix.sdk == '' }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} integration-test-android: name: "[Android] Integration tests" runs-on: ubuntu-latest @@ -71,9 +94,6 @@ jobs: matrix: api-level: [ 23, 34 ] # TODO: add 21 timeout-minutes: 30 - defaults: - run: - working-directory: example steps: - uses: actions/checkout@v4 - name: "Setup Flutter SDK" @@ -99,7 +119,7 @@ jobs: api-level: ${{ matrix.api-level }} arch: x86_64 emulator-boot-timeout: 1800 # 30 minutes - script: cd example && flutter test integration_test -r expanded --timeout=none --coverage --coverage-package maplibre + script: cd example && flutter test integration_test/main.dart --timeout=1800s -r expanded --coverage --coverage-package maplibre - name: "Run Codecov" uses: codecov/codecov-action@v4 if: ${{ matrix.api-level == '34' }} diff --git a/codecov.yml b/codecov.yml index 2b38a000..2b54d3e9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,12 @@ +comment: + # this posts no PR comment if there are no coverage changes + require_changes: true + status: + project: + default: + target: 0% + threshold: 0% + ignore: - "**/*.g.dart" - "lib/src/native/jni" diff --git a/example/integration_test/controller_test.dart b/example/integration_test/controller_test.dart index bc4d000c..66300027 100644 --- a/example/integration_test/controller_test.dart +++ b/example/integration_test/controller_test.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:maplibre/maplibre.dart'; @@ -8,135 +11,165 @@ import 'app.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('controller', () { - testWidgets( - 'render map', - (tester) async { - await tester.pumpWidget(const App()); - await tester.pumpAndSettle(); - expect(tester.allWidgets.any((w) => w is MapLibreMap), isTrue); - }, + testWidgets('getCamera', (tester) async { + final ctrlCompleter = Completer(); + final app = App( + onMapCreated: ctrlCompleter.complete, + options: MapOptions(initCenter: Position(1, 2)), + ); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + await ctrl.moveCamera( + center: Position(1, 1), + bearing: 1, + zoom: 1, + pitch: 1, + ); + await tester.pumpAndSettle(); + final camera = ctrl.getCamera(); + expect(camera.center.lng, closeTo(1, 0.00001)); + expect(camera.center.lat, closeTo(1, 0.00001)); + expect(camera.zoom, closeTo(1, 0.00001)); + expect(camera.bearing, closeTo(1, 0.00001)); + expect(camera.pitch, closeTo(1, 0.00001)); + }); + + testWidgets('toScreenLocation', (tester) async { + final ctrlCompleter = Completer(); + final app = App( + onMapCreated: ctrlCompleter.complete, + options: MapOptions(initCenter: Position(1, 2)), + ); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + final offset = await ctrl.toScreenLocation(Position(1, 2)); + // Different devices have different screen sizes. + expect(offset.dx, greaterThanOrEqualTo(0)); + expect(offset.dy, greaterThanOrEqualTo(0)); + }); + + testWidgets('moveCamera', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + await ctrl.moveCamera( + center: Position(1, 2), + bearing: 1, + zoom: 1, + pitch: 1, + ); + await tester.pumpAndSettle(); + final camera = ctrl.getCamera(); + expect(camera.center.lng, closeTo(1, 0.00001)); + expect(camera.center.lat, closeTo(2, 0.00001)); + expect(camera.zoom, closeTo(1, 0.00001)); + expect(camera.bearing, closeTo(1, 0.00001)); + expect(camera.pitch, closeTo(1, 0.00001)); + }); + }); + + testWidgets('add ImageSource', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + final source = ImageSource( + id: '1', + url: + 'https://raw.githubusercontent.com/josxha/flutter-maplibre/57396548693857a80083303f56aa83b4901dad48/docs/static/img/favicon-32x32.png', + coordinates: [Position(0, 0), Position(1, 1)], ); - testWidgets( - 'getCamera', - (tester) async { - final ctrlCompleter = Completer(); - final app = App( - onMapCreated: ctrlCompleter.complete, - options: MapOptions(initCenter: Position(1, 2)), - ); - await tester.pumpWidget(app); - final ctrl = await ctrlCompleter.future; - await ctrl.moveCamera( - center: Position(1, 1), - bearing: 1, - zoom: 1, - pitch: 1, - ); - await tester.pumpAndSettle(); - final camera = ctrl.getCamera(); - expect(camera.center.lng, closeTo(1, 0.00001)); - expect(camera.center.lat, closeTo(1, 0.00001)); - expect(camera.zoom, closeTo(1, 0.00001)); - expect(camera.bearing, closeTo(1, 0.00001)); - expect(camera.pitch, closeTo(1, 0.00001)); - }, + await ctrl.addSource(source); + await tester.pumpAndSettle(); + }); + + testWidgets('add GeoJsonSource', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + final source = GeoJsonSource( + id: '1', + data: jsonEncode( + GeometryCollection( + geometries: [Point(coordinates: Position(12, 2))], + ).toJson(), + ), + ); + await ctrl.addSource(source); + await tester.pumpAndSettle(); + }); + + testWidgets('add VideoSource', (tester) async { + if (!kIsWeb) return; // VideoSource is only supported on web. + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + final source = VideoSource( + id: '1', + coordinates: [Position(0, 0), Position(10, 10)], + urls: [ + 'https://file-examples.com/storage/fefd65c2506728a13a07e72/2017/04/file_example_MP4_480_1_5MG.mp4', + ], ); - testWidgets( - 'moveCamera', - (tester) async { - final ctrlCompleter = Completer(); - final app = App(onMapCreated: ctrlCompleter.complete); - await tester.pumpWidget(app); - final ctrl = await ctrlCompleter.future; - await ctrl.moveCamera( - center: Position(1, 2), - bearing: 1, - zoom: 1, - pitch: 1, - ); - await tester.pumpAndSettle(); - final camera = ctrl.getCamera(); - expect(camera.center.lng, closeTo(1, 0.00001)); - expect(camera.center.lat, closeTo(2, 0.00001)); - expect(camera.zoom, closeTo(1, 0.00001)); - expect(camera.bearing, closeTo(1, 0.00001)); - expect(camera.pitch, closeTo(1, 0.00001)); - }, + await ctrl.addSource(source); + await tester.pumpAndSettle(); + }); + + testWidgets('add RasterDemSource', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + const source = RasterDemSource( + id: '1', + url: 'https://demotiles.maplibre.org/terrain-tiles/tiles.json', + tileSize: 256, ); - /*testWidgets( - 'animateCamera', - (tester) async { - final ctrlCompleter = Completer(); - final app = App(onMapCreated: ctrlCompleter.complete); - await tester.pumpWidget(app); - final ctrl = await ctrlCompleter.future; - await ctrl.animateCamera( - center: Position(2, 1), - bearing: 2, - zoom: 2, - pitch: 2, - webSpeed: 100, - nativeDuration: Duration.zero, - ); - await tester.pumpAndSettle(); - final camera = ctrl.getCamera(); - expect(camera.center.lng, closeTo(2, 0.00001)); - expect(camera.center.lat, closeTo(1, 0.00001)); - expect(camera.zoom, closeTo(2, 0.00001)); - expect(camera.bearing, closeTo(2, 0.00001)); - expect(camera.pitch, closeTo(2, 0.00001)); - }, - );*/ - /*testWidgets( - 'animateCamera cancel', - (tester) async { - late final MapController ctrl; - final app = App(onMapCreated: (controller) => ctrl = controller); - await tester.pumpWidget(app); - await tester.pumpAndSettle(); - final future = ctrl.flyTo( - center: Position(2, 2), - bearing: 2, - zoom: 2, - pitch: 2, - webSpeed: 0.1, - nativeDuration: const Duration(days: 1), - ); - // TODO perform gesture - await expectLater( - future, - throwsA(isA()), - ); - }, + await ctrl.addSource(source); + await tester.pumpAndSettle(); + }); + + testWidgets('add RasterSource', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + const source = RasterSource( + id: '1', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + maxZoom: 20, + tileSize: 256, + attribution: + 'OpenStreetMap', ); - testWidgets( - 'getMetersPerPixelAtLatitude', - (tester) async { - late final MapController ctrl; - final app = App(onMapCreated: (controller) => ctrl = controller); - await tester.pumpWidget(app); - await tester.pumpAndSettle(); - final meters = await ctrl.getMetersPerPixelAtLatitude(23); - // TODO adjust value - expect(meters, closeTo(12345, 0.00001)); - }, + await ctrl.addSource(source); + await tester.pumpAndSettle(); + }); + + testWidgets('add VectorSource', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + const source = VectorSource( + id: '1', + url: 'https://demotiles.maplibre.org/tiles/tiles.json', ); - testWidgets( - 'getVisibleRegion', - (tester) async { - late final MapController ctrl; - final app = App(onMapCreated: (controller) => ctrl = controller); - await tester.pumpWidget(app); - await tester.pumpAndSettle(); - final region = await ctrl.getVisibleRegion(); - // TODO adjust values - expect(region.latitudeNorth, closeTo(85.05112862791722, 0.00001)); - expect(region.latitudeSouth, closeTo(12345, 0.00001)); - expect(region.longitudeEast, closeTo(12345, 0.00001)); - expect(region.longitudeWest, closeTo(12345, 0.00001)); - }, - );*/ + await ctrl.addSource(source); + await tester.pumpAndSettle(); + }); + + testWidgets('add BackgroundLayer', (tester) async { + final ctrlCompleter = Completer(); + final app = App(onMapCreated: ctrlCompleter.complete); + await tester.pumpWidget(app); + final ctrl = await ctrlCompleter.future; + const layer = BackgroundLayer(id: '1', color: Colors.black); + await ctrl.addLayer(layer); + await tester.pumpAndSettle(); }); } diff --git a/example/integration_test/general_test.dart b/example/integration_test/general_test.dart new file mode 100644 index 00000000..1471c224 --- /dev/null +++ b/example/integration_test/general_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:maplibre/maplibre.dart'; + +import 'app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('General', () { + testWidgets('render map with tlhc_vd', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + expect(tester.allWidgets.any((w) => w is MapLibreMap), isTrue); + }); + + testWidgets('render map with tlhc_hc', (tester) async { + await tester.pumpWidget( + App( + options: MapOptions( + initCenter: Position(0, 0), + androidMode: AndroidPlatformViewMode.tlhc_hc, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.allWidgets.any((w) => w is MapLibreMap), isTrue); + }); + + testWidgets('render map with hc', (tester) async { + await tester.pumpWidget( + App( + options: MapOptions( + initCenter: Position(0, 0), + androidMode: AndroidPlatformViewMode.hc, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.allWidgets.any((w) => w is MapLibreMap), isTrue); + }); + + testWidgets('render map with vd', (tester) async { + await tester.pumpWidget( + App( + options: MapOptions( + initCenter: Position(0, 0), + androidMode: AndroidPlatformViewMode.vd, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.allWidgets.any((w) => w is MapLibreMap), isTrue); + }); + + testWidgets('update map options', (tester) async { + await tester.pumpWidget( + App( + options: MapOptions( + initCenter: Position(0, 0), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.pump(); + // TODO: better checks + expect(tester.allWidgets.any((w) => w is MapLibreMap), isTrue); + }); + }); +} diff --git a/example/integration_test/main.dart b/example/integration_test/main.dart new file mode 100644 index 00000000..a514af98 --- /dev/null +++ b/example/integration_test/main.dart @@ -0,0 +1,14 @@ +import 'package:integration_test/integration_test.dart'; + +import 'controller_test.dart' as controller; +import 'general_test.dart' as general; +import 'offline_manager_test.dart' as offline; +import 'permission_manager_test.dart' as permission; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + controller.main(); + general.main(); + offline.main(); + permission.main(); +} diff --git a/example/integration_test/map_camera_test.dart b/example/integration_test/map_camera_test.dart new file mode 100644 index 00000000..83c974f8 --- /dev/null +++ b/example/integration_test/map_camera_test.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:maplibre/maplibre.dart'; +import 'package:maplibre_example/styled_map_page.dart'; + +import 'app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('MapCamera', () { + testWidgets('get from map', (tester) async { + final options = MapOptions( + minZoom: 1, + maxZoom: 2, + initZoom: 1, + initCenter: Position(1, 2), + initStyle: StyledMapPage.styleUrlDark, + ); + final completer = Completer(); + await tester.pumpWidget( + App( + options: options, + onMapCreated: completer.complete, + ), + ); + await tester.pumpAndSettle(); + final ctrl = await completer.future; + final camera = ctrl.camera; + expect(camera, isNot(isNull)); + expect(camera!.zoom, equals(1)); + expect(camera.center, equals(Position(1, 2))); + }); + }); +} diff --git a/example/integration_test/map_options_test.dart b/example/integration_test/map_options_test.dart new file mode 100644 index 00000000..f75fc7b9 --- /dev/null +++ b/example/integration_test/map_options_test.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:maplibre/maplibre.dart'; +import 'package:maplibre_example/styled_map_page.dart'; + +import 'app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('MapOptions', () { + testWidgets('get from map', (tester) async { + final options = MapOptions( + minZoom: 1, + maxZoom: 2, + initZoom: 1, + initCenter: Position(1, 2), + initStyle: StyledMapPage.styleUrlDark, + ); + final completer = Completer(); + await tester.pumpWidget( + App( + options: options, + onMapCreated: completer.complete, + ), + ); + await tester.pumpAndSettle(); + final ctrl = await completer.future; + final options2 = ctrl.options; + expect(options2, equals(options)); + expect(options2.hashCode, equals(options.hashCode)); + }); + }); +} diff --git a/example/integration_test/offline_manager_test.dart b/example/integration_test/offline_manager_test.dart new file mode 100644 index 00000000..db544314 --- /dev/null +++ b/example/integration_test/offline_manager_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:maplibre/maplibre.dart'; + +import 'app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('OfflineManager', () { + testWidgets('backgroundLocationOfflineGranted', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + manager.setOfflineTileCountLimit(amount: 1000); + manager.dispose(); + }); + testWidgets('setMaximumAmbientCacheSize', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + await manager.setMaximumAmbientCacheSize(bytes: 1000); + manager.dispose(); + }); + testWidgets('clearAmbientCache', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + await manager.clearAmbientCache(); + manager.dispose(); + }); + testWidgets('invalidateAmbientCache', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + await manager.invalidateAmbientCache(); + manager.dispose(); + }); + testWidgets('packDatabase', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + await manager.packDatabase(); + manager.dispose(); + }); + testWidgets('resetDatabase', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + await manager.resetDatabase(); + manager.dispose(); + }); + testWidgets('runPackDatabaseAutomatically', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + manager.runPackDatabaseAutomatically(enabled: true); + manager.dispose(); + }); + /*testWidgets('downloadRegion', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = await OfflineManager.createInstance(); + await manager.resetDatabase(); + + const styleUrl = 'https://demotiles.maplibre.org/style.json'; + final stream = manager.downloadRegion( + maxZoom: 1, + minZoom: 0, + pixelDensity: 1, + mapStyleUrl: styleUrl, + bounds: const LngLatBounds( + longitudeWest: -180, + longitudeEast: 180, + latitudeSouth: -90, + latitudeNorth: 90, + ), + ); + await for (final event in stream) { + expect(event.region.styleUrl, equals(styleUrl)); + expect(event.region.minZoom, equals(0)); + expect(event.region.maxZoom, equals(1)); + } + final last = await stream.last; + expect(last.progress, closeTo(1, 0.1)); + + // get regions + final regions = await manager.listOfflineRegions(); + expect(regions, hasLength(1)); + final region = regions.first; + expect( + region.styleUrl, + equals('https://demotiles.maplibre.org/style.json'), + ); + expect(region.minZoom, 0); + expect(region.maxZoom, 0); + + // get region + final region2 = await manager.getOfflineRegion(regionId: 1); + expect(region, equals(region2)); + expect(region.hashCode, equals(region2.hashCode)); + + manager.dispose(); + });*/ + }); +} diff --git a/example/integration_test/permission_manager_test.dart b/example/integration_test/permission_manager_test.dart new file mode 100644 index 00000000..9cfd970b --- /dev/null +++ b/example/integration_test/permission_manager_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:maplibre/maplibre.dart'; + +import 'app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('PermissionManager', () { + testWidgets('backgroundLocationPermissionGranted', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = PermissionManager(); + expect(manager.backgroundLocationPermissionGranted, isFalse); + }); + + testWidgets('locationPermissionsGranted', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = PermissionManager(); + expect(manager.locationPermissionsGranted, isFalse); + }); + + testWidgets('runtimePermissionsRequired', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = PermissionManager(); + expect(manager.runtimePermissionsRequired, isTrue); + }); + + // can't interact with the native permission dialog: https://github.com/flutter/flutter/issues/86295 + /*testWidgets('runtimePermissionsRequired', (tester) async { + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + final manager = PermissionManager(); + await expectLater( + manager.requestLocationPermissions(explanation: 'explanation'), + isTrue, + ); + });*/ + }); +} diff --git a/example/lib/offline_page.dart b/example/lib/offline_page.dart index 639b1e7c..34a02028 100644 --- a/example/lib/offline_page.dart +++ b/example/lib/offline_page.dart @@ -49,8 +49,8 @@ class _OfflinePageState extends State { onPressed: () async { final stream = manager.downloadRegion( minZoom: 0, - maxZoom: 14, - bounds: _boundsBregenz, + maxZoom: 2, + bounds: _boundsWorld, mapStyleUrl: StyledMapPage.styleUrl, pixelDensity: 1, ); @@ -90,7 +90,7 @@ class _OfflinePageState extends State { bounds: _boundsWorld, zoom: 1, center: Position(0, 0), - maxZoom: 3, + maxZoom: 2, ), ), ); diff --git a/lib/src/map_camera.dart b/lib/src/map_camera.dart index 81c16493..031df1e7 100644 --- a/lib/src/map_camera.dart +++ b/lib/src/map_camera.dart @@ -31,7 +31,7 @@ class MapCamera { MapLibreInheritedModel.maybeMapCameraOf(context); /// Find the [MapCamera] of the closest [MapLibreMap] in the widget tree. - /// Returns null if called outside of the [MapLibreMap.children]. + /// Throws an [StateError] if called outside of the [MapLibreMap.children]. static MapCamera of(BuildContext context) => maybeOf(context) ?? (throw StateError('Unable to find an instance of MapCamera')); diff --git a/lib/src/map_controller.dart b/lib/src/map_controller.dart index 03d1f843..d8379f5d 100644 --- a/lib/src/map_controller.dart +++ b/lib/src/map_controller.dart @@ -13,11 +13,14 @@ abstract interface class MapController { MapLibreInheritedModel.maybeMapControllerOf(context); /// Find the [MapController] of the closest [MapLibreMap] in the widget tree. - /// Returns null if called outside of the [MapLibreMap.children]. + /// Throws an [StateError] if called outside of the [MapLibreMap.children]. static MapController of(BuildContext context) => maybeOf(context) ?? (throw StateError('Unable to find an instance of MapController')); + /// Get the [MapOptions] from [MapLibreMap.options]. + MapOptions get options; + /// Convert a latitude/longitude coordinate to a screen location. // TODO: can be made sync when flutter raster and ui thread are merged Future toScreenLocation(Position lngLat); diff --git a/lib/src/map_options.dart b/lib/src/map_options.dart index d9a5bd7f..f83f3e02 100644 --- a/lib/src/map_options.dart +++ b/lib/src/map_options.dart @@ -1,5 +1,6 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:maplibre/maplibre.dart'; +import 'package:maplibre/src/inherited_model.dart'; /// The [MapOptions] class is used to set default values for the [MapLibreMap] /// widget. @@ -27,6 +28,17 @@ class MapOptions { this.androidMode = AndroidPlatformViewMode.tlhc_vd, }) : initPitch = pitch ?? initPitch; + /// Find the [MapOptions] of the closest [MapLibreMap] in the widget tree. + /// Returns null if called outside of the [MapLibreMap.children]. + static MapController? maybeOf(BuildContext context) => + MapLibreInheritedModel.maybeMapControllerOf(context); + + /// Find the [MapOptions] of the closest [MapLibreMap] in the widget tree. + /// Throws an [StateError] if called outside of the [MapLibreMap.children]. + static MapController of(BuildContext context) => + maybeOf(context) ?? + (throw StateError('Unable to find an instance of MapController')); + /// The style URL that should get used. If not set, the default MapLibre style /// is used (https://demotiles.maplibre.org/style.json). final String initStyle; diff --git a/lib/src/map_state.dart b/lib/src/map_state.dart index 1cb46d60..01ecd125 100644 --- a/lib/src/map_state.dart +++ b/lib/src/map_state.dart @@ -17,6 +17,7 @@ abstract class MapLibreMapState extends State AnnotationManager? annotationManager; /// Get the [MapOptions] from [MapLibreMap.options]. + @override MapOptions get options => widget.options; @override diff --git a/lib/src/offline/download_progress.dart b/lib/src/offline/download_progress.dart index c9f5ae94..f15e5f0f 100644 --- a/lib/src/offline/download_progress.dart +++ b/lib/src/offline/download_progress.dart @@ -71,13 +71,11 @@ class DownloadProgress { ); @override - String toString() { - return 'DownloadProgress(' - 'loadedBytes: $loadedBytes, ' - 'loadedTiles: $loadedTiles, ' - 'totalTiles: $totalTiles, ' - 'totalTilesEstimated: $totalTilesEstimated, ' - 'region: $region, ' - 'downloadCompleted: $downloadCompleted)'; - } + String toString() => 'DownloadProgress(' + 'loadedBytes: $loadedBytes, ' + 'loadedTiles: $loadedTiles, ' + 'totalTiles: $totalTiles, ' + 'totalTilesEstimated: $totalTilesEstimated, ' + 'region: $region, ' + 'downloadCompleted: $downloadCompleted)'; } diff --git a/pubspec.yaml b/pubspec.yaml index 593f8271..22dfe582 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dev_dependencies: flutter_test: sdk: flutter jnigen: ^0.12.0 + mocktail: ^1.0.4 pigeon: ^22.0.0 very_good_analysis: ^6.0.0 diff --git a/test/map_compass_test.dart b/test/map_compass_test.dart new file mode 100644 index 00000000..a945ab68 --- /dev/null +++ b/test/map_compass_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre/maplibre.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'shared/ui_app.dart'; + +void main() { + setUpAll(() { + registerFallbackValue(MockDuration()); + }); + + group('MapCompass', () { + testWidgets('MapCompass rotation', (tester) async { + final camera = MapCamera( + center: Position(0, 0), + zoom: 0, + bearing: 12, + pitch: 0, + ); + final controller = MockMapController(); + when(controller.getCamera).thenReturn(camera); + final compassKey = GlobalKey(debugLabel: 'MapCompass'); + final app = App( + camera: camera, + controller: controller, + children: [MapCompass(key: compassKey)], + ); + await tester.pumpWidget(app); + final transform = tester.firstWidget(find.byType(Transform)) as Transform; + expect(transform.transform.storage[0], isNot(isZero)); + expect(transform.transform.storage[1], isNot(isZero)); + }); + + testWidgets('MapCompass reset rotation', (tester) async { + final camera = MapCamera( + center: Position(0, 0), + zoom: 0, + bearing: 100, + pitch: 0, + ); + final controller = MockMapController(); + when(controller.getCamera).thenReturn(camera); + when( + () => controller.animateCamera( + bearing: 0, + webSpeed: any(named: 'webSpeed'), + webMaxDuration: any(named: 'webMaxDuration'), + nativeDuration: any(named: 'nativeDuration'), + pitch: any(named: 'pitch'), + zoom: any(named: 'zoom'), + center: any(named: 'center'), + ), + ).thenAnswer((_) async {}); + final app = App( + camera: camera, + controller: controller, + children: const [ + MapCompass( + alignment: Alignment.topLeft, + padding: EdgeInsets.zero, + ), + ], + ); + await tester.pumpWidget(app); + await tester.pumpAndSettle(); + await tester.tap(find.byType(InkWell)); + verify( + () => controller.animateCamera( + bearing: 0, + center: any(named: 'center'), + zoom: any(named: 'zoom'), + pitch: any(named: 'pitch'), + nativeDuration: any(named: 'nativeDuration'), + webMaxDuration: any(named: 'webMaxDuration'), + webSpeed: any(named: 'webSpeed'), + ), + ).called(1); + }); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart new file mode 100644 index 00000000..41ae88a7 --- /dev/null +++ b/test/models_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre/maplibre.dart'; + +void main() { + group('Model Classes', () { + test('QueriedLayer', () { + const o = QueriedLayer( + layerId: 'layerId', + sourceId: 'sourceId', + sourceLayer: 'sourceLayer', + ); + final o2 = QueriedLayer( + layerId: o.layerId, + sourceId: o.sourceId, + sourceLayer: o.sourceLayer, + ); + + expect(o, equals(o2)); + expect(o.hashCode, equals(o2.hashCode)); + + final oString = o.toString(); + expect(oString, contains(o.layerId)); + expect(oString, contains(o.sourceId)); + expect(oString, contains(o.sourceLayer)); + }); + test('MapGestures', () { + const all = MapGestures.all(); + const all2 = + MapGestures(rotate: true, zoom: true, pitch: true, pan: true); + const none = MapGestures.none(); + + expect(all.allEnabled, isTrue); + expect(none.allEnabled, isFalse); + expect(all, equals(all2)); + expect(all, isNot(equals(none))); + expect(all.hashCode, isNot(equals(none.hashCode))); + expect(all, equals(all2)); + expect(all.hashCode, equals(all2.hashCode)); + }); + test('LngLatBounds', () { + const o = LngLatBounds( + latitudeSouth: -10, + latitudeNorth: 10, + longitudeWest: -10, + longitudeEast: 10, + ); + final o2 = o.copyWith(longitudeEast: 15); + expect(o, isNot(equals(o2))); + expect(o.hashCode, isNot(equals(o2.hashCode))); + + final o3 = o.copyWith(); + expect(o, equals(o3)); + expect(o.hashCode, equals(o3.hashCode)); + + final oString = o.toString(); + expect(oString, contains(o.latitudeSouth.toString())); + expect(oString, contains(o.latitudeNorth.toString())); + expect(oString, contains(o.longitudeEast.toString())); + expect(oString, contains(o.longitudeWest.toString())); + }); + test('MapCamera', () { + final o = MapCamera( + pitch: 12, + zoom: 2, + bearing: 213, + center: Position(12, 2), + ); + final o2 = MapCamera( + pitch: 0, + zoom: 0, + bearing: 0, + center: Position(0, 0), + ); + expect(o, isNot(equals(o2))); + expect(o.hashCode, isNot(equals(o2.hashCode))); + final oString = o.toString(); + expect( + oString, + contains('Position(lng: ${o.center.lng}, lat: ${o.center.lat})'), + ); + expect(oString, contains(o.pitch.toString())); + expect(oString, contains(o.bearing.toString())); + expect(oString, contains(o.zoom.toString())); + }); + }); +} diff --git a/test/shared/ui_app.dart b/test/shared/ui_app.dart new file mode 100644 index 00000000..cdab73df --- /dev/null +++ b/test/shared/ui_app.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:maplibre/maplibre.dart'; +import 'package:maplibre/src/inherited_model.dart'; +import 'package:mocktail/mocktail.dart'; + +class App extends StatelessWidget { + const App({ + required this.camera, + required this.controller, + this.children = const [], + super.key, + }); + + final List children; + final MapController controller; + final MapCamera? camera; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'MapLibre Demo', + home: Scaffold( + body: MapLibreInheritedModel( + mapCamera: camera, + mapController: controller, + child: Stack(children: children), + ), + ), + ); + } +} + +class MockMapController extends Mock implements MapController {} + +class MockDuration extends Mock implements Duration {}