diff --git a/CHANGELOG.md b/CHANGELOG.md index ad47e49c7..0f2e29111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,50 @@ # Changelog -## [6.0.1] - 2023/10/XX +Please consider [donating](https://docs.fleaflet.dev/supporters#support-us) or [contributing](https://docs.fleaflet.dev/credits#contributing) if you're a fan of what we're doing and you'd like to support future releases! + +## [6.1.0] - 2023/12/02 + +Contains the following user-affecting changes: + +- 🟢 Added option to draw labels on top of all `Polygon`s in a layer - [#1707](https://github.com/fleaflet/flutter_map/pull/1707) +- (Released 'flutter_map_cancellable_tile_provider' [v2.0.0](https://pub.dev/packages/flutter_map_cancellable_tile_provider/changelog)) + +Contains the following user-affecting bug fixes: + +- Removed flutter_map text attribution when `RichAttributionWidget.showFlutterMapAttribution` is `false` - [#1712](https://github.com/fleaflet/flutter_map/pull/1712) +- Repaired `TileLayer.tileBounds` - [#1713](https://github.com/fleaflet/flutter_map/pull/1713) for [#1710](https://github.com/fleaflet/flutter_map/issues/1710) +- Added `doubleTapDragZoom` and `scrollWheelZoom` to `InteractiveFlag.all` - [#1726](https://github.com/fleaflet/flutter_map/pull/1726) for [#1725](https://github.com/fleaflet/flutter_map/issues/1725) +- Repaired correct movement for `CircleLayer` when panning - [#1735](https://github.com/fleaflet/flutter_map/pull/1735) +- Improved value distribution for `MapPosition.hashCode` - [#1747](https://github.com/fleaflet/flutter_map/pull/1747) + +Contains the following user-affecting performance improvements: + +- Optimized `Bounds` - [#1706](https://github.com/fleaflet/flutter_map/pull/1706) +- Avoided creating a new list on every frame unnecessarily - [#1708](https://github.com/fleaflet/flutter_map/pull/1708) +- Cull `Polygon` labels seperately (more strongly), and cache the `TextPainter` - [#1716](https://github.com/fleaflet/flutter_map/pull/1716) +- Avoided caching a single commonly-used multiplication - [#1743](https://github.com/fleaflet/flutter_map/pull/1743) + +Many thanks to these contributors (in no particular order): + +- @ignatz +- @amal-stack +- @Robbendebiene +- ... and all the maintainers + +And an additional special thanks to @ignatz for investing so much of their time into this project recently - we appreciate it! + +In other news: + +- A warm welcome to @josxha, who's recently joined the maintainer team +- We've also made some widespread minor improvmenents to the example application +- Check out the new [Showcase](https://docs.fleaflet.dev/showcase) page on our docs for some awesome projects +- We're working on some very exciting reworked features and performance improvements for v7 - keep an ear out by joining our [Discord server](https://discord.gg/BwpEsjqMAH) + +## [6.0.1] - 2023/10/24 Contains the following user-affecting bug fixes: -- Fixed `CircleMarker`'s incorrect appearance and size - [#1692](https://github.com/fleaflet/flutter_map/pull/1692) for [#1688](https://github.com/fleaflet/flutter_map/issues/1688) +- Fixed `CircleMarker`'s incorrect appearance and size - [#1692](https://github.com/fleaflet/flutter_map/pull/1692) for [#1688](https://github.com/fleaflet/flutter_map/issues/1688) - Fixed `LateInitializationError` when specifying `initialCameraFit` - [#1691](https://github.com/fleaflet/flutter_map/pull/1691) for [#1684](https://github.com/fleaflet/flutter_map/issues/1684) - Fixed incorrect behaviour issues caused by the adjusted default `MapOptions.cameraConstraint` - [#1700](https://github.com/fleaflet/flutter_map/pull/1700) for [#1682](https://github.com/fleaflet/flutter_map/issues/1682) & [#1699](https://github.com/fleaflet/flutter_map/issues/1699) - Fixed bug where not providing either `wmsOptions` nor `urlTemplate` caused a null exception in `getTileUrl` and when `retinaMode` is `true` - [#1701](https://github.com/fleaflet/flutter_map/pull/1701) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8c11770b..63998ca1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,15 @@ # Contributing -'flutter_map' is only as great as it is because of generous open-source contributors! - We're always happy to receive improvements and fixes, so please submit them whenever you can! A few key points are listed below. > Many feature additions are more suitable for plugins, instead of being added to the core. This is aimed to reduce the future maintenance burden/cost on the maintainers. If we deny your PR for this reason, please do consider publishing a plugin, and we'll be happy to add it to the [Plugins List](https://docs.fleaflet.dev/plugins/list)! See [Making A Plugin](https://docs.fleaflet.dev/plugins/making-a-plugin) for more information. -* If your PR will add a major or breaking change, please discuss it with us first, via the Issue Tracker -* Always link your PR to at least one issue, and as many as are resolved -* Create a draft PR as soon as work starts, and take it out of draft status when ready for review -* Avoid changing the package version or GitHub workflows -* Fix issues reported by the GitHub workflows (such as formatting) yourself +* **If your PR will add a major or breaking change, please discuss it with us first, via the Issue Tracker** +We don't want to waste your time if we think it's more appropriate for a plugin, and it helps to make a clear plan before starting work +* **Create a draft PR as soon as work starts, and take it out of draft status when ready for review** +Keep everyone in the loop, so no-one tries working on the same thing as you +* **Don't change the package version, GitHub workflows, lints, or any other meta files without clarification** +We rely on a standardized process and procedure to ensure top-quality releases +* **Use a clear (preferably [Conventional](https://www.conventionalcommits.org/)) PR title** +This makes it easier for us to group commits for release and write correct CHANGELOGs diff --git a/analysis_options.yaml b/analysis_options.yaml index 285cf1fb5..14a938d0e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -36,7 +36,6 @@ linter: noop_primitive_operations: true avoid_void_async: true avoid_redundant_argument_values: true - avoid_types_on_closure_parameters: true unnecessary_null_checks: true prefer_single_quotes: true unnecessary_parenthesis: true diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index de1ae64a5..71eb09906 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -19,7 +19,7 @@ if (flutterVersionCode == null) { def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - flutterVersionName = '6.0.1' + flutterVersionName = '6.1.0' } android { diff --git a/example/lib/pages/plugin_scalebar.dart b/example/lib/pages/plugin_scalebar.dart index 42b16e3a9..4d82ef567 100644 --- a/example/lib/pages/plugin_scalebar.dart +++ b/example/lib/pages/plugin_scalebar.dart @@ -15,22 +15,20 @@ class PluginScaleBar extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('Scale Bar Plugin')), drawer: const MenuDrawer(PluginScaleBar.route), - body: Flexible( - child: FlutterMap( - options: const MapOptions( - initialCenter: LatLng(51.5, -0.09), - initialZoom: 5, - ), - children: [ - openStreetMapTileLayer, - const FlutterMapScaleLayer( - lineColor: Colors.black, - lineWidth: 3, - textStyle: TextStyle(color: Colors.black, fontSize: 14), - padding: EdgeInsets.all(10), - ), - ], + body: FlutterMap( + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), + children: [ + openStreetMapTileLayer, + const FlutterMapScaleLayer( + lineColor: Colors.black, + lineWidth: 3, + textStyle: TextStyle(color: Colors.black, fontSize: 14), + padding: EdgeInsets.all(10), + ), + ], ), ); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b6b0839e9..7298a7546 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_example description: Example application for 'flutter_map' package publish_to: "none" -version: 6.0.1 +version: 6.1.0 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index aad7d80b8..1351c3793 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -49,8 +49,8 @@ export 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; export 'package:flutter_map/src/map/camera/camera.dart'; export 'package:flutter_map/src/map/camera/camera_constraint.dart'; export 'package:flutter_map/src/map/camera/camera_fit.dart'; -export 'package:flutter_map/src/map/controller/impl.dart'; export 'package:flutter_map/src/map/controller/map_controller.dart'; +export 'package:flutter_map/src/map/controller/map_controller_impl.dart'; export 'package:flutter_map/src/map/options/cursor_keyboard_rotation.dart'; export 'package:flutter_map/src/map/options/interaction.dart'; export 'package:flutter_map/src/map/options/options.dart'; diff --git a/lib/src/geo/latlng_bounds.dart b/lib/src/geo/latlng_bounds.dart index ddf841c00..70f862448 100644 --- a/lib/src/geo/latlng_bounds.dart +++ b/lib/src/geo/latlng_bounds.dart @@ -110,7 +110,7 @@ class LatLngBounds { final lambda3 = lambda1 + math.atan2(by, math.cos(phi1) + bx); // phi3 and lambda3 are actually in radians and LatLng wants degrees - return LatLng(radianToDeg(phi3), radianToDeg(lambda3)); + return LatLng(phi3 * radians2Degrees, lambda3 * radians2Degrees); } /// Checks whether [point] is inside bounds diff --git a/lib/src/gestures/flutter_map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart similarity index 94% rename from lib/src/gestures/flutter_map_interactive_viewer.dart rename to lib/src/gestures/map_interactive_viewer.dart index b1786dd92..9f1af5562 100644 --- a/lib/src/gestures/flutter_map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -4,17 +4,7 @@ import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/gestures/interactive_flag.dart'; -import 'package:flutter_map/src/gestures/latlng_tween.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; -import 'package:flutter_map/src/gestures/multi_finger_gesture.dart'; -import 'package:flutter_map/src/gestures/positioned_tap_detector_2.dart'; -import 'package:flutter_map/src/map/camera/camera.dart'; -import 'package:flutter_map/src/map/controller/internal.dart'; -import 'package:flutter_map/src/map/options/cursor_keyboard_rotation.dart'; -import 'package:flutter_map/src/map/options/interaction.dart'; -import 'package:flutter_map/src/map/options/options.dart'; -import 'package:flutter_map/src/misc/point_extensions.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:vector_math/vector_math_64.dart'; @@ -26,23 +16,22 @@ typedef InteractiveViewerBuilder = Widget Function( /// Applies interactions (gestures/scroll/taps etc) to the current [MapCamera] /// via the internal [controller]. -class FlutterMapInteractiveViewer extends StatefulWidget { +class MapInteractiveViewer extends StatefulWidget { final InteractiveViewerBuilder builder; - final FlutterMapInternalController controller; + final MapControllerImpl controller; - const FlutterMapInteractiveViewer({ + const MapInteractiveViewer({ super.key, required this.builder, required this.controller, }); @override - State createState() => - FlutterMapInteractiveViewerState(); + State createState() => MapInteractiveViewerState(); } -class FlutterMapInteractiveViewerState - extends State with TickerProviderStateMixin { +class MapInteractiveViewerState extends State + with TickerProviderStateMixin { static const int _kMinFlingVelocity = 800; static const _kDoubleTapZoomDuration = 200; static const doubleTapDelay = Duration(milliseconds: 250); @@ -336,11 +325,10 @@ class FlutterMapInteractiveViewerState if (_interactionOptions.cursorKeyboardRotationOptions.setNorthOnClick && _ckrTriggered.value && _ckrInitialDegrees == _camera.rotation) { - widget.controller.rotate( + widget.controller.rotateRaw( getCursorRotationDegrees(event.localPosition), hasGesture: true, source: MapEventSource.cursorKeyboardRotation, - id: null, ); } @@ -372,14 +360,13 @@ class FlutterMapInteractiveViewerState final baseSetNorth = getCursorRotationDegrees(event.localPosition) - _ckrClickDegrees; - widget.controller.rotate( + widget.controller.rotateRaw( _interactionOptions.cursorKeyboardRotationOptions.behaviour == CursorRotationBehaviour.setNorth ? baseSetNorth : (_ckrInitialDegrees + baseSetNorth) % 360, hasGesture: true, source: MapEventSource.cursorKeyboardRotation, - id: null, ); if (_interactionOptions.cursorKeyboardRotationOptions.behaviour == @@ -408,13 +395,11 @@ class FlutterMapInteractiveViewerState pointerSignal.localPosition.toPoint(), newZoom, ); - widget.controller.move( + widget.controller.moveRaw( newCenter, newZoom, - offset: Offset.zero, hasGesture: true, source: MapEventSource.scrollWheel, - id: null, ); }, ); @@ -501,7 +486,7 @@ class FlutterMapInteractiveViewerState return; } - final currentRotation = radianToDeg(details.rotation); + final currentRotation = details.rotation * radians2Degrees; if (_dragMode) { _handleScaleDragUpdate(details); } else if (InteractiveFlag.hasMultiFinger(_interactionOptions.flags)) { @@ -619,13 +604,11 @@ class FlutterMapInteractiveViewerState } if (_pinchZoomStarted || _pinchMoveStarted) { - widget.controller.move( + widget.controller.moveRaw( newCenter, newZoom, - offset: Offset.zero, hasGesture: true, source: MapEventSource.onMultiFinger, - id: null, ); } } @@ -664,14 +647,13 @@ class FlutterMapInteractiveViewerState final rotatedVector = vector.rotate(degrees2Radians * rotationDiff); final newCenter = rotationCenter + rotatedVector; - widget.controller.moveAndRotate( + widget.controller.moveAndRotateRaw( _camera.unproject(newCenter), _camera.zoom, _camera.rotation + rotationDiff, offset: Offset.zero, hasGesture: true, source: MapEventSource.onMultiFinger, - id: null, ); } } @@ -844,13 +826,11 @@ class FlutterMapInteractiveViewerState } void _handleDoubleTapZoomAnimation() { - widget.controller.move( + widget.controller.moveRaw( _doubleTapCenterAnimation.value, _doubleTapZoomAnimation.value, - offset: Offset.zero, hasGesture: true, source: MapEventSource.doubleTapZoomAnimationController, - id: null, ); } @@ -874,13 +854,11 @@ class FlutterMapInteractiveViewerState final max = _options.maxZoom ?? double.infinity; final actualZoom = math.max(min, math.min(max, newZoom)); - widget.controller.move( + widget.controller.moveRaw( _camera.center, actualZoom, - offset: Offset.zero, hasGesture: true, source: MapEventSource.doubleTapHold, - id: null, ); } } @@ -896,13 +874,11 @@ class FlutterMapInteractiveViewerState _flingAnimation.value.toPoint().rotate(_camera.rotationRad); final newCenter = _camera.unproject(newCenterPoint); - widget.controller.move( + widget.controller.moveRaw( newCenter, _camera.zoom, - offset: Offset.zero, hasGesture: true, source: MapEventSource.flingAnimationController, - id: null, ); } diff --git a/lib/src/layer/marker_layer.dart b/lib/src/layer/marker_layer.dart index 6b8e231a3..e5365f28f 100644 --- a/lib/src/layer/marker_layer.dart +++ b/lib/src/layer/marker_layer.dart @@ -104,7 +104,6 @@ class MarkerLayer extends StatelessWidget { return MobileLayerTransformer( child: Stack( - // ignore: avoid_types_on_closure_parameters children: (List markers) sync* { for (final m in markers) { // Resolve real alignment diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index cb8502001..93ce0b543 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -222,7 +222,6 @@ class TileImage extends ChangeNotifier { if (evictImageFromCache) { try { - // ignore: avoid_types_on_closure_parameters imageProvider.evict().catchError((Object e) { debugPrint(e.toString()); return false; diff --git a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart index 3683386b0..805dd0e5e 100644 --- a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart @@ -124,6 +124,9 @@ abstract class TileProvider { } /// Called when the [TileLayer] is disposed + /// + /// When disposing resources, ensure that they are not currently being used + /// by tiles in progress. void dispose() {} /// Regex that describes the format of placeholders in a `urlTemplate` diff --git a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart index c98f81605..c69e7c783 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart'; /// Dedicated [ImageProvider] to fetch tiles from the network @@ -11,8 +12,7 @@ import 'package:http/http.dart'; /// Note that specifying a [fallbackUrl] will prevent this image provider from /// being cached. @immutable -class FlutterMapNetworkImageProvider - extends ImageProvider { +class MapNetworkImageProvider extends ImageProvider { /// The URL to fetch the tile from (GET request) final String url; @@ -24,84 +24,99 @@ class FlutterMapNetworkImageProvider /// image provider will not be cached in memory. final String? fallbackUrl; + /// The headers to include with the tile fetch request + /// + /// Not included in [operator==]. + final Map headers; + /// The HTTP client to use to make network requests /// /// Not included in [operator==]. final BaseClient httpClient; - /// The headers to include with the tile fetch request + /// Whether to ignore exceptions and errors that occur whilst fetching tiles + /// over the network, and just return a transparent tile + final bool silenceExceptions; + + /// Function invoked when the image starts loading (not from cache) /// - /// Not included in [operator==]. - final Map headers; + /// Used with [finishedLoadingBytes] to safely dispose of the [httpClient] only + /// after all tiles have loaded. + final void Function() startedLoading; + + /// Function invoked when the image completes loading bytes from the network + /// + /// Used with [finishedLoadingBytes] to safely dispose of the [httpClient] only + /// after all tiles have loaded. + final void Function() finishedLoadingBytes; /// Create a dedicated [ImageProvider] to fetch tiles from the network /// /// Supports falling back to a secondary URL, if the primary URL fetch fails. /// Note that specifying a [fallbackUrl] will prevent this image provider from /// being cached. - const FlutterMapNetworkImageProvider({ + const MapNetworkImageProvider({ required this.url, required this.fallbackUrl, required this.headers, required this.httpClient, + required this.silenceExceptions, + required this.startedLoading, + required this.finishedLoadingBytes, }); @override ImageStreamCompleter loadImage( - FlutterMapNetworkImageProvider key, + MapNetworkImageProvider key, ImageDecoderCallback decode, - ) { - final chunkEvents = StreamController(); + ) => + MultiFrameImageStreamCompleter( + codec: _load(key, decode), + scale: 1, + debugLabel: url, + informationCollector: () => [ + DiagnosticsProperty('URL', url), + DiagnosticsProperty('Fallback URL', fallbackUrl), + DiagnosticsProperty('Current provider', key), + ], + ); + + Future _load( + MapNetworkImageProvider key, + ImageDecoderCallback decode, { + bool useFallback = false, + }) { + startedLoading(); - return MultiFrameImageStreamCompleter( - codec: _loadAsync(key, chunkEvents, decode), - chunkEvents: chunkEvents.stream, - scale: 1, - debugLabel: url, - informationCollector: () => [ - DiagnosticsProperty('URL', url), - DiagnosticsProperty('Fallback URL', fallbackUrl), - DiagnosticsProperty('Current provider', key), - ], - ); + return httpClient + .readBytes( + Uri.parse(useFallback ? fallbackUrl ?? '' : url), + headers: headers, + ) + .whenComplete(finishedLoadingBytes) + .then(ImmutableBuffer.fromUint8List) + .then(decode) + .onError((err, stack) { + scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); + if (useFallback || fallbackUrl == null) { + if (!silenceExceptions) throw err; + return ImmutableBuffer.fromUint8List(TileProvider.transparentImage) + .then(decode); + } + return _load(key, decode, useFallback: true); + }); } @override - Future obtainKey( + SynchronousFuture obtainKey( ImageConfiguration configuration, ) => - SynchronousFuture(this); - - Future _loadAsync( - FlutterMapNetworkImageProvider key, - StreamController chunkEvents, - ImageDecoderCallback decode, { - bool useFallback = false, - }) async { - try { - return decode( - await ImmutableBuffer.fromUint8List( - await httpClient.readBytes( - Uri.parse(useFallback ? fallbackUrl ?? '' : url), - headers: headers, - ), - ), - ).catchError((dynamic e) { - // ignore: only_throw_errors - if (useFallback || fallbackUrl == null) throw e as Object; - return _loadAsync(key, chunkEvents, decode, useFallback: true); - }); - } catch (_) { - // This redundancy necessary, do not remove - if (useFallback || fallbackUrl == null) rethrow; - return _loadAsync(key, chunkEvents, decode, useFallback: true); - } - } + SynchronousFuture(this); @override bool operator ==(Object other) => identical(this, other) || - (other is FlutterMapNetworkImageProvider && + (other is MapNetworkImageProvider && fallbackUrl == null && url == other.url); diff --git a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart index 208892787..206de0bb3 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:collection'; + import 'package:flutter/rendering.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; @@ -34,23 +37,48 @@ class NetworkTileProvider extends TileProvider { NetworkTileProvider({ super.headers, BaseClient? httpClient, - }) : httpClient = httpClient ?? RetryClient(Client()); + this.silenceExceptions = false, + }) : _httpClient = httpClient ?? RetryClient(Client()); + + /// Whether to ignore exceptions and errors that occur whilst fetching tiles + /// over the network, and just return a transparent tile + final bool silenceExceptions; + + /// Long living client used to make all tile requests by + /// [FlutterMapNetworkImageProvider] for the duration that this provider is + /// alive + final BaseClient _httpClient; - /// The HTTP client used to make network requests for tiles - final BaseClient httpClient; + /// Each [Completer] is completed once the corresponding tile has finished + /// loading + /// + /// Used to avoid disposing of [_httpClient] whilst HTTP requests are still + /// underway. + /// + /// Does not include tiles loaded from session cache. + final _tilesInProgress = HashMap>(); @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => - FlutterMapNetworkImageProvider( + MapNetworkImageProvider( url: getTileUrl(coordinates, options), fallbackUrl: getTileFallbackUrl(coordinates, options), headers: headers, - httpClient: httpClient, + httpClient: _httpClient, + silenceExceptions: silenceExceptions, + startedLoading: () => _tilesInProgress[coordinates] = Completer(), + finishedLoadingBytes: () { + _tilesInProgress[coordinates]?.complete(); + _tilesInProgress.remove(coordinates); + }, ); @override - void dispose() { - httpClient.close(); + Future dispose() async { + if (_tilesInProgress.isNotEmpty) { + await Future.wait(_tilesInProgress.values.map((c) => c.future)); + } + _httpClient.close(); super.dispose(); } } diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index f5469433f..64374bda7 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -79,7 +79,7 @@ class MapCamera { /// The camera of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor null, is returned. static MapCamera? maybeOf(BuildContext context) => - FlutterMapInheritedModel.maybeCameraOf(context); + MapInheritedModel.maybeCameraOf(context); /// The camera of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor a [StateError] will be thrown. diff --git a/lib/src/map/controller/impl.dart b/lib/src/map/controller/impl.dart deleted file mode 100644 index 900cacff6..000000000 --- a/lib/src/map/controller/impl.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; -import 'package:flutter_map/src/map/camera/camera.dart'; -import 'package:flutter_map/src/map/camera/camera_fit.dart'; -import 'package:flutter_map/src/map/controller/internal.dart'; -import 'package:flutter_map/src/map/controller/map_controller.dart'; -import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; -import 'package:latlong2/latlong.dart'; - -/// Implements [MapController] whilst exposing methods for internal use which -/// should not be visible to the user (e.g. for setting the current camera or -/// linking the internal controller). -class MapControllerImpl implements MapController { - late FlutterMapInternalController _internalController; - final _mapEventStreamController = StreamController.broadcast(); - - MapControllerImpl(); - - set internalController(FlutterMapInternalController internalController) { - _internalController = internalController; - } - - StreamSink get mapEventSink => _mapEventStreamController.sink; - - @override - Stream get mapEventStream => _mapEventStreamController.stream; - - @override - bool move( - LatLng center, - double zoom, { - Offset offset = Offset.zero, - String? id, - }) => - _internalController.move( - center, - zoom, - offset: offset, - hasGesture: false, - source: MapEventSource.mapController, - id: id, - ); - - @override - bool rotate(double degree, {String? id}) => _internalController.rotate( - degree, - hasGesture: false, - source: MapEventSource.mapController, - id: id, - ); - - @override - MoveAndRotateResult rotateAroundPoint( - double degree, { - Point? point, - Offset? offset, - String? id, - }) => - _internalController.rotateAroundPoint( - degree, - point: point, - offset: offset, - hasGesture: false, - source: MapEventSource.mapController, - id: id, - ); - - @override - MoveAndRotateResult moveAndRotate( - LatLng center, - double zoom, - double degree, { - String? id, - }) => - _internalController.moveAndRotate( - center, - zoom, - degree, - offset: Offset.zero, - hasGesture: false, - source: MapEventSource.mapController, - id: id, - ); - - @override - bool fitCamera(CameraFit cameraFit) => _internalController.fitCamera( - cameraFit, - offset: Offset.zero, - ); - - @override - MapCamera get camera => _internalController.camera; - - @override - void dispose() { - _mapEventStreamController.close(); - } -} diff --git a/lib/src/map/controller/map_controller.dart b/lib/src/map/controller/map_controller.dart index 9ffb23bae..4a6113fee 100644 --- a/lib/src/map/controller/map_controller.dart +++ b/lib/src/map/controller/map_controller.dart @@ -2,13 +2,8 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; -import 'package:flutter_map/src/map/camera/camera.dart'; -import 'package:flutter_map/src/map/camera/camera_fit.dart'; -import 'package:flutter_map/src/map/controller/impl.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; -import 'package:flutter_map/src/map/widget.dart'; -import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; import 'package:latlong2/latlong.dart'; /// Controller to programmatically interact with [FlutterMap], such as @@ -31,7 +26,7 @@ abstract class MapController { /// from a context with no [FlutterMap] ancestor a [StateError] will be /// thrown. static MapController? maybeOf(BuildContext context) => - FlutterMapInheritedModel.maybeControllerOf(context); + MapInheritedModel.maybeControllerOf(context); /// The controller for the closest [FlutterMap] ancestor. If this is called /// from a context with no [FlutterMap] ancestor a [StateError] will be diff --git a/lib/src/map/controller/internal.dart b/lib/src/map/controller/map_controller_impl.dart similarity index 71% rename from lib/src/map/controller/internal.dart rename to lib/src/map/controller/map_controller_impl.dart index 35bdec601..5117be15d 100644 --- a/lib/src/map/controller/internal.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -1,67 +1,129 @@ +import 'dart:async'; import 'dart:math'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_map/src/gestures/flutter_map_interactive_viewer.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; -import 'package:flutter_map/src/gestures/positioned_tap_detector_2.dart'; -import 'package:flutter_map/src/map/camera/camera.dart'; -import 'package:flutter_map/src/map/camera/camera_fit.dart'; -import 'package:flutter_map/src/map/controller/impl.dart'; -import 'package:flutter_map/src/map/options/options.dart'; -import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; -import 'package:flutter_map/src/misc/point_extensions.dart'; -import 'package:flutter_map/src/misc/position.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; import 'package:latlong2/latlong.dart'; import 'package:vector_math/vector_math_64.dart'; +/// Implements [MapController] whilst exposing methods for internal use which +/// should not be visible to the user (e.g. for setting the current camera). /// This controller is for internal use. All updates to the state should be done /// by calling methods of this class to ensure consistency. -class FlutterMapInternalController extends ValueNotifier<_InternalState> { - late final FlutterMapInteractiveViewerState _interactiveViewerState; - late MapControllerImpl _mapControllerImpl; +class MapControllerImpl extends ValueNotifier<_MapControllerState> + implements MapController { + final _mapEventStreamController = StreamController.broadcast(); - FlutterMapInternalController(MapOptions options) + late final MapInteractiveViewerState _interactiveViewerState; + + MapControllerImpl([MapOptions? options]) : super( - _InternalState( + _MapControllerState( options: options, - camera: MapCamera.initialCamera(options), + camera: options == null ? null : MapCamera.initialCamera(options), ), ); /// Link the viewer state with the controller. This should be done once when /// the FlutterMapInteractiveViewerState is initialized. set interactiveViewerState( - FlutterMapInteractiveViewerState interactiveViewerState, + MapInteractiveViewerState interactiveViewerState, ) => _interactiveViewerState = interactiveViewerState; - MapOptions get options => value.options; + StreamSink get _mapEventSink => _mapEventStreamController.sink; + + @override + Stream get mapEventStream => _mapEventStreamController.stream; - MapCamera get camera => value.camera; + MapOptions get options { + return value.options ?? + (throw Exception('You need to have the FlutterMap widget rendered at ' + 'least once before using the MapController.')); + } - void linkMapController(MapControllerImpl mapControllerImpl) { - _mapControllerImpl = mapControllerImpl; - _mapControllerImpl.internalController = this; + @override + MapCamera get camera { + return value.camera ?? + (throw Exception('You need to have the FlutterMap widget rendered at ' + 'least once before using the MapController.')); } /// This setter should only be called in this class or within tests. Changes - /// to the [FlutterMapInternalState] should be done via methods in this class. + /// to the [_MapControllerState] should be done via methods in this class. @visibleForTesting @override // ignore: library_private_types_in_public_api - set value(_InternalState value) => super.value = value; + set value(_MapControllerState value) => super.value = value; - /// Note: All named parameters are required to prevent inconsistent default - /// values since this method can be called by MapController which declares - /// defaults. + @override bool move( + LatLng center, + double zoom, { + Offset offset = Offset.zero, + String? id, + }) => + moveRaw( + center, + zoom, + offset: offset, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + bool rotate(double degree, {String? id}) => rotateRaw( + degree, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + MoveAndRotateResult rotateAroundPoint( + double degree, { + Point? point, + Offset? offset, + String? id, + }) => + rotateAroundPointRaw( + degree, + point: point, + offset: offset, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + MoveAndRotateResult moveAndRotate( + LatLng center, + double zoom, + double degree, { + String? id, + }) => + moveAndRotateRaw( + center, + zoom, + degree, + offset: Offset.zero, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + bool fitCamera(CameraFit cameraFit) => fitCameraRaw(cameraFit); + + bool moveRaw( LatLng newCenter, double newZoom, { - required Offset offset, + Offset offset = Offset.zero, required bool hasGesture, required MapEventSource source, - required String? id, + String? id, }) { // Algorithm thanks to https://github.com/tlserver/flutter_map_location_marker if (offset != Offset.zero) { @@ -111,14 +173,11 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { return true; } - /// Note: All named parameters are required to prevent inconsistent default - /// values since this method can be called by MapController which declares - /// defaults. - bool rotate( + bool rotateRaw( double newRotation, { required bool hasGesture, required MapEventSource source, - required String? id, + String? id, }) { if (newRotation != camera.rotation) { final newCamera = options.cameraConstraint.constrain( @@ -145,16 +204,13 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { return false; } - /// Note: All named parameters are required to prevent inconsistent default - /// values since this method can be called by MapController which declares - /// defaults. - MoveAndRotateResult rotateAroundPoint( + MoveAndRotateResult rotateAroundPointRaw( double degree, { required Point? point, required Offset? offset, required bool hasGesture, required MapEventSource source, - required String? id, + String? id, }) { if (point != null && offset != null) { throw ArgumentError('Only one of `point` or `offset` may be non-null'); @@ -170,7 +226,7 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { if (offset == Offset.zero) { return ( moveSuccess: true, - rotateSuccess: rotate( + rotateSuccess: rotateRaw( degree, hasGesture: hasGesture, source: source, @@ -187,19 +243,18 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { .rotate(camera.rotationRad); return ( - moveSuccess: move( + moveSuccess: moveRaw( camera.unproject( rotationCenter + (camera.project(camera.center) - rotationCenter) .rotate(degrees2Radians * rotationDiff), ), camera.zoom, - offset: Offset.zero, hasGesture: hasGesture, source: source, id: id, ), - rotateSuccess: rotate( + rotateSuccess: rotateRaw( camera.rotation + rotationDiff, hasGesture: hasGesture, source: source, @@ -208,20 +263,17 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { ); } - /// Note: All named parameters are required to prevent inconsistent default - /// values since this method can be called by MapController which declares - /// defaults. - MoveAndRotateResult moveAndRotate( + MoveAndRotateResult moveAndRotateRaw( LatLng newCenter, double newZoom, double newRotation, { required Offset offset, required bool hasGesture, required MapEventSource source, - required String? id, + String? id, }) => ( - moveSuccess: move( + moveSuccess: moveRaw( newCenter, newZoom, offset: offset, @@ -229,26 +281,25 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { source: source, id: id, ), - rotateSuccess: - rotate(newRotation, id: id, source: source, hasGesture: hasGesture), + rotateSuccess: rotateRaw( + newRotation, + id: id, + source: source, + hasGesture: hasGesture, + ), ); - /// Note: All named parameters are required to prevent inconsistent default - /// values since this method can be called by MapController which declares - /// defaults. - bool fitCamera( + bool fitCameraRaw( CameraFit cameraFit, { - required Offset offset, + Offset offset = Offset.zero, }) { final fitted = cameraFit.fit(camera); - - return move( + return moveRaw( fitted.center, fitted.zoom, offset: offset, hasGesture: false, source: MapEventSource.mapController, - id: null, ); } @@ -264,27 +315,29 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { return false; } - void setOptions(MapOptions newOptions) { + set options(MapOptions newOptions) { assert( newOptions != value.options, 'Should not update options unless they change', ); - final newCamera = camera.withOptions(newOptions); + final newCamera = value.camera?.withOptions(newOptions) ?? + MapCamera.initialCamera(newOptions); assert( newOptions.cameraConstraint.constrain(newCamera) == newCamera, 'MapCamera is no longer within the cameraConstraint after an option change.', ); - if (options.interactionOptions != newOptions.interactionOptions) { + if (value.options != null && + value.options!.interactionOptions != newOptions.interactionOptions) { _interactiveViewerState.updateGestures( - options.interactionOptions, + value.options!.interactionOptions, newOptions.interactionOptions, ); } - value = _InternalState( + value = _MapControllerState( options: newOptions, camera: newCamera, ); @@ -307,13 +360,11 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { final newCenterPt = oldCenterPt + offset.toPoint(); final newCenter = camera.unproject(newCenterPt); - move( + moveRaw( newCenter, camera.zoom, - offset: Offset.zero, hasGesture: true, source: source, - id: null, ); } @@ -464,21 +515,27 @@ class FlutterMapInternalController extends ValueNotifier<_InternalState> { options.onMapEvent?.call(event); - _mapControllerImpl.mapEventSink.add(event); + _mapEventSink.add(event); + } + + @override + void dispose() { + _mapEventStreamController.close(); + super.dispose(); } } @immutable -class _InternalState { - final MapCamera camera; - final MapOptions options; +class _MapControllerState { + final MapCamera? camera; + final MapOptions? options; - const _InternalState({ + const _MapControllerState({ required this.options, required this.camera, }); - _InternalState withMapCamera(MapCamera camera) => _InternalState( + _MapControllerState withMapCamera(MapCamera camera) => _MapControllerState( options: options, camera: camera, ); diff --git a/lib/src/map/inherited_model.dart b/lib/src/map/inherited_model.dart index 1a3ff0979..a076d09cf 100644 --- a/lib/src/map/inherited_model.dart +++ b/lib/src/map/inherited_model.dart @@ -10,10 +10,10 @@ import 'package:flutter_map/src/map/options/options.dart'; /// Using an [InheritedModel] means dependent widgets will only rebuild when /// the aspect they reference is updated. @immutable -class FlutterMapInheritedModel extends InheritedModel<_FlutterMapAspect> { +class MapInheritedModel extends InheritedModel<_FlutterMapAspect> { final FlutterMapData data; - FlutterMapInheritedModel({ + MapInheritedModel({ super.key, required MapCamera camera, required MapController controller, @@ -29,8 +29,7 @@ class FlutterMapInheritedModel extends InheritedModel<_FlutterMapAspect> { BuildContext context, [ _FlutterMapAspect? aspect, ]) => - InheritedModel.inheritFrom(context, - aspect: aspect) + InheritedModel.inheritFrom(context, aspect: aspect) ?.data; static MapCamera? maybeCameraOf(BuildContext context) => @@ -43,12 +42,12 @@ class FlutterMapInheritedModel extends InheritedModel<_FlutterMapAspect> { _maybeOf(context, _FlutterMapAspect.options)?.options; @override - bool updateShouldNotify(FlutterMapInheritedModel oldWidget) => + bool updateShouldNotify(MapInheritedModel oldWidget) => data != oldWidget.data; @override bool updateShouldNotifyDependent( - covariant FlutterMapInheritedModel oldWidget, + covariant MapInheritedModel oldWidget, Set dependencies, ) { for (final dependency in dependencies) { diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index 900e1b8f0..3d5822b43 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -134,7 +134,7 @@ class MapOptions { /// The options of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor, null is returned. static MapOptions? maybeOf(BuildContext context) => - FlutterMapInheritedModel.maybeOptionsOf(context); + MapInheritedModel.maybeOptionsOf(context); /// The options of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor a [StateError] will be thrown. diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 622a4e5e2..76d7291fa 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -2,15 +2,9 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/gestures/flutter_map_interactive_viewer.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; -import 'package:flutter_map/src/layer/general/mobile_layer_transformer.dart'; -import 'package:flutter_map/src/layer/general/translucent_pointer.dart'; -import 'package:flutter_map/src/map/controller/impl.dart'; -import 'package:flutter_map/src/map/controller/internal.dart'; -import 'package:flutter_map/src/map/controller/map_controller.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; -import 'package:flutter_map/src/map/options/options.dart'; import 'package:logger/logger.dart'; /// An interactive geographical map @@ -60,16 +54,14 @@ class _FlutterMapStateContainer extends State with AutomaticKeepAliveClientMixin { bool _initialCameraFitApplied = false; - late final FlutterMapInternalController _flutterMapInternalController; late MapControllerImpl _mapController; - late bool _mapControllerCreatedInternally; + + bool get _controllerCreatedInternally => widget.mapController == null; @override void initState() { super.initState(); - _flutterMapInternalController = - FlutterMapInternalController(widget.options); - _initializeAndLinkMapController(); + _setMapController(); WidgetsBinding.instance .addPostFrameCallback((_) => widget.options.onMapReady?.call()); @@ -86,29 +78,21 @@ class _FlutterMapStateContainer extends State @override void didUpdateWidget(FlutterMap oldWidget) { - if (oldWidget.options != widget.options) { - _flutterMapInternalController.setOptions(widget.options); - } if (oldWidget.mapController != widget.mapController) { - _initializeAndLinkMapController(); + _setMapController(); + } + if (oldWidget.options != widget.options) { + _mapController.options = widget.options; } super.didUpdateWidget(oldWidget); } @override void dispose() { - if (_mapControllerCreatedInternally) _mapController.dispose(); - _flutterMapInternalController.dispose(); + if (_controllerCreatedInternally) _mapController.dispose(); super.dispose(); } - void _initializeAndLinkMapController() { - _mapController = - (widget.mapController ?? MapController()) as MapControllerImpl; - _mapControllerCreatedInternally = widget.mapController == null; - _flutterMapInternalController.linkMapController(_mapController); - } - @override Widget build(BuildContext context) { super.build(context); @@ -133,10 +117,10 @@ class _FlutterMapStateContainer extends State builder: (context, constraints) { _updateAndEmitSizeIfConstraintsChanged(constraints); - return FlutterMapInteractiveViewer( - controller: _flutterMapInternalController, + return MapInteractiveViewer( + controller: _mapController, builder: (context, options, camera) { - return FlutterMapInheritedModel( + return MapInheritedModel( controller: _mapController, options: options, camera: camera, @@ -157,10 +141,7 @@ class _FlutterMapStateContainer extends State _parentConstraintsAreSet(context, constraints)) { _initialCameraFitApplied = true; - _flutterMapInternalController.fitCamera( - widget.options.initialCameraFit!, - offset: Offset.zero, - ); + _mapController.fitCamera(widget.options.initialCameraFit!); } } @@ -169,16 +150,15 @@ class _FlutterMapStateContainer extends State constraints.maxWidth, constraints.maxHeight, ); - final oldCamera = _flutterMapInternalController.camera; - if (_flutterMapInternalController - .setNonRotatedSizeWithoutEmittingEvent(nonRotatedSize)) { - final newMapCamera = _flutterMapInternalController.camera; + final oldCamera = _mapController.camera; + if (_mapController.setNonRotatedSizeWithoutEmittingEvent(nonRotatedSize)) { + final newMapCamera = _mapController.camera; // Avoid emitting the event during build otherwise if the user calls // setState in the onMapEvent callback it will throw. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - _flutterMapInternalController.nonRotatedSizeChange( + _mapController.nonRotatedSizeChange( MapEventSource.nonRotatedSizeChange, oldCamera, newMapCamera, @@ -201,4 +181,13 @@ class _FlutterMapStateContainer extends State @override bool get wantKeepAlive => widget.options.keepAlive; + + void _setMapController() { + if (_controllerCreatedInternally) { + _mapController = MapControllerImpl(widget.options); + } else { + _mapController = widget.mapController! as MapControllerImpl; + _mapController.options = widget.options; + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 83a24aeb2..987e6898e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_map description: A versatile mapping package for Flutter, that's simple and easy to learn, yet completely customizable and configurable -version: 6.0.1 +version: 6.1.0 repository: https://github.com/fleaflet/flutter_map issue_tracker: https://github.com/fleaflet/flutter_map/issues diff --git a/test/layer/tile_layer/tile_provider/network_image_provider_test.dart b/test/layer/tile_layer/tile_provider/network_image_provider_test.dart index 53b22a30a..7bddfb1d3 100644 --- a/test/layer/tile_layer/tile_provider/network_image_provider_test.dart +++ b/test/layer/tile_layer/tile_provider/network_image_provider_test.dart @@ -17,17 +17,12 @@ class MockHttpClient extends Mock implements BaseClient {} Future getImageInfo(ImageProvider provider) { final completer = Completer(); - final ImageStream stream = provider.resolve(ImageConfiguration.empty); - stream.addListener( - ImageStreamListener( - (imageInfo, _) { - return completer.complete(imageInfo); - }, - onError: (exception, stackTrace) { - return completer.completeError(exception, stackTrace); - }, - ), - ); + provider.resolve(ImageConfiguration.empty).addListener( + ImageStreamListener( + (imageInfo, _) => completer.complete(imageInfo), + onError: completer.completeError, + ), + ); return completer.future; } @@ -35,6 +30,7 @@ Future getImageInfo(ImageProvider provider) { /// Returns a random URL to use for testing. Due to Flutter caching images /// we need to use a different URL each time. int _urlId = 0; + Uri randomUrl({bool fallback = false}) { _urlId++; if (fallback) { @@ -52,174 +48,553 @@ void main() { const defaultTimeout = Timeout(Duration(seconds: 1)); + final mockClient = MockHttpClient(); + setUpAll(() { // Ensure the Mock library has example values for Uri. registerFallbackValue(Uri()); }); // We expect a request to be made to the correct URL with the appropriate headers. - testWidgets('test load with correct url/headers', (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - }, timeout: defaultTimeout); + testWidgets( + 'Valid/expected response', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => testWhiteTileBytes); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, + timeout: defaultTimeout, + ); // We expect the request to be made, and a HTTP ClientException to be bubbled // up to the caller. - testWidgets('test load with server failure (no fallback)', (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - throw ClientException( - 'Server error', - ); - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - }, timeout: defaultTimeout); + testWidgets( + 'Server failure - no fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - no fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: true, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, + timeout: defaultTimeout, + ); // We expect the regular URL to be called once, then the fallback URL. - testWidgets('test load with server error (with successful fallback)', - (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) - .thenAnswer((_) async { - throw ClientException( - 'Server error', - ); - }); - final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); - }, timeout: defaultTimeout); - - testWidgets('test load with server error (with failed fallback)', - (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - final fallbackUrl = randomUrl(fallback: true); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - throw ClientException( - 'Server error', - ); - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); - }, timeout: defaultTimeout); - - testWidgets('test load with invalid response (no fallback)', (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - }, timeout: defaultTimeout); - - testWidgets('test load with invalid response (with successful fallback)', - (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); - final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); - }, timeout: defaultTimeout); + testWidgets( + 'Server failure - successful fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + final fallbackUrl = randomUrl(fallback: true); + when(() => + mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) + .thenAnswer((_) async { + return testWhiteTileBytes; + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - successful fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + final fallbackUrl = randomUrl(fallback: true); + when(() => + mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) + .thenAnswer((_) async { + return testWhiteTileBytes; + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: true, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - failed fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - failed fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: true, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - no fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNull); + final exception = tester.takeException(); + expect(exception, isInstanceOf()); + expect( + (exception as Exception).toString(), + equals('Exception: Invalid image data'), + ); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - no fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: true, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - successful fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + final fallbackUrl = randomUrl(fallback: true); + when(() => + mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) + .thenAnswer((_) async { + return testWhiteTileBytes; + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - successful fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + final fallbackUrl = randomUrl(fallback: true); + when(() => + mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) + .thenAnswer((_) async { + return testWhiteTileBytes; + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - failed fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNull); + final exception = tester.takeException(); + expect(exception, isInstanceOf()); + expect( + (exception as Exception).toString(), + equals('Exception: Invalid image data'), + ); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - failed fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = MapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: true, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(startedLoadingTriggered, true); + expect(finishedLoadingTriggered, true); + + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + tearDownAll(() => mockClient.close()); } diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index ce62377a7..e84dd6624 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "flutter_map Demo" -#define MyAppVersion "for 6.0.1" +#define MyAppVersion "for 6.1.0" #define MyAppPublisher "fleaflet" #define MyAppURL "https://github.com/fleaflet/flutter_map" #define MyAppSupportURL "https://github.com/fleaflet/flutter_map/issues"