import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/map/attribution.dart'; import 'package:aves/widgets/common/map/buttons.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/google/map.dart'; import 'package:aves/widgets/common/map/leaflet/map.dart'; import 'package:aves/widgets/common/map/marker.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:equatable/equatable.dart'; import 'package:fluster/fluster.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { final List entries; final bool interactive; final double? mapHeight; final ValueNotifier isAnimatingNotifier; final UserZoomChangeCallback? onUserZoomChange; static const markerImageExtent = 48.0; static const pointerSize = Size(8, 6); const GeoMap({ Key? key, required this.entries, required this.interactive, this.mapHeight, required this.isAnimatingNotifier, this.onUserZoomChange, }) : super(key: key); @override _GeoMapState createState() => _GeoMapState(); } class _GeoMapState extends State with TickerProviderStateMixin { // as of google_maps_flutter v2.0.6, Google Maps initialization is blocking // cf https://github.com/flutter/flutter/issues/28493 // it is especially severe the first time, but still significant afterwards // so we prevent loading it while scrolling or animating bool _googleMapsLoaded = false; late ValueNotifier boundsNotifier; List get entries => widget.entries; bool get interactive => widget.interactive; double? get mapHeight => widget.mapHeight; @override void initState() { super.initState(); boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints( points: entries.map((v) => v.latLng!).toSet(), collocationZoom: settings.infoMapZoom, )); } @override Widget build(BuildContext context) { final markers = entries.map((entry) { var latLng = entry.latLng!; return GeoEntry( entry: entry, latitude: latLng.latitude, longitude: latLng.longitude, markerId: entry.uri, ); }).toList(); final markerCluster = Fluster( // we keep clustering on the whole range of zooms (including the maximum) // to avoid collocated entries overlapping minZoom: 0, maxZoom: 22, // TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent? // (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9) radius: 240, extent: 2 << 9, nodeSize: 64, points: markers, createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), ); return FutureBuilder( future: availability.isConnected, builder: (context, snapshot) { if (snapshot.data != true) return const SizedBox(); return Selector( selector: (context, s) => s.infoMapStyle, builder: (context, mapStyle, child) { final isGoogleMaps = mapStyle.isGoogleMaps; final progressive = !isGoogleMaps; Widget _buildMarker(MarkerKey key) => ImageMarker( key: key, entry: key.entry, count: key.count, extent: GeoMap.markerImageExtent, pointerSize: GeoMap.pointerSize, progressive: progressive, ); Widget child = isGoogleMaps ? EntryGoogleMap( boundsNotifier: boundsNotifier, interactive: interactive, style: mapStyle, markerBuilder: _buildMarker, markerCluster: markerCluster, markerEntries: entries, onUserZoomChange: widget.onUserZoomChange, ) : EntryLeafletMap( boundsNotifier: boundsNotifier, interactive: interactive, style: mapStyle, markerBuilder: _buildMarker, markerCluster: markerCluster, markerEntries: entries, markerSize: Size( GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height, ), onUserZoomChange: widget.onUserZoomChange, ); child = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ mapHeight != null ? SizedBox( height: mapHeight, child: child, ) : Expanded(child: child), Attribution(style: mapStyle), ], ); return AnimatedSize( alignment: Alignment.topCenter, curve: Curves.easeInOutCubic, duration: Durations.mapStyleSwitchAnimation, vsync: this, child: ValueListenableBuilder( valueListenable: widget.isAnimatingNotifier, builder: (context, animating, child) { if (!animating && isGoogleMaps) { _googleMapsLoaded = true; } Widget replacement = Stack( children: [ MapDecorator( interactive: interactive, ), MapButtonPanel( latLng: boundsNotifier.value.center, ), ], ); if (mapHeight != null) { replacement = SizedBox( height: mapHeight, child: replacement, ); } return Visibility( visible: !isGoogleMaps || _googleMapsLoaded, replacement: replacement, child: child!, ); }, child: child, ), ); }, ); }, ); } } @immutable class MarkerKey extends LocalKey with EquatableMixin { final AvesEntry entry; final int? count; @override List get props => [entry, count]; const MarkerKey(this.entry, this.count); } typedef EntryMarkerBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom);