import 'dart:async'; import 'dart:typed_data'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/map/buttons.dart'; import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/map/google/marker_generator.dart'; import 'package:aves/widgets/common/map/marker.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:latlong2/latlong.dart' as ll; import 'package:provider/provider.dart'; class EntryGoogleMap extends StatefulWidget { final AvesMapController? controller; final Listenable clusterListenable; final ValueNotifier boundsNotifier; final double? minZoom, maxZoom; final EntryMapStyle style; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; final ValueNotifier? dotEntryNotifier; final UserZoomChangeCallback? onUserZoomChange; final VoidCallback? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; final MapOpener? openMapPage; const EntryGoogleMap({ Key? key, this.controller, required this.clusterListenable, required this.boundsNotifier, this.minZoom, this.maxZoom, required this.style, required this.markerClusterBuilder, required this.markerWidgetBuilder, required this.dotEntryNotifier, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, this.openMapPage, }) : super(key: key); @override State createState() => _EntryGoogleMapState(); } class _EntryGoogleMapState extends State with WidgetsBindingObserver { GoogleMapController? _googleMapController; final List _subscriptions = []; Map _geoEntryByMarkerKey = {}; final Map _markerBitmaps = {}; final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); Uint8List? _dotMarkerBitmap; ValueNotifier get boundsNotifier => widget.boundsNotifier; ZoomedBounds get bounds => boundsNotifier.value; static const uninitializedLatLng = LatLng(0, 0); @override void initState() { super.initState(); WidgetsBinding.instance!.addObserver(this); _registerWidget(widget); } @override void didUpdateWidget(covariant EntryGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); } @override void dispose() { _unregisterWidget(widget); _googleMapController?.dispose(); WidgetsBinding.instance!.removeObserver(this); super.dispose(); } void _registerWidget(EntryGoogleMap widget) { final avesMapController = widget.controller; if (avesMapController != null) { _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng)))); } widget.clusterListenable.addListener(_updateMarkers); } void _unregisterWidget(EntryGoogleMap widget) { widget.clusterListenable.removeListener(_updateMarkers); _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.inactive: case AppLifecycleState.paused: case AppLifecycleState.detached: break; case AppLifecycleState.resumed: // workaround for blank Google map when resuming app // cf https://github.com/flutter/flutter/issues/40284 _googleMapController?.setMapStyle(null); break; } } @override Widget build(BuildContext context) { return Stack( children: [ MarkerGeneratorWidget( markers: const [DotMarker(key: Key('dot'))], isReadyToRender: (key) => true, onRendered: (key, bitmap) => _dotMarkerBitmap = bitmap, ), MarkerGeneratorWidget( markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(), isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), onRendered: (key, bitmap) { _markerBitmaps[key] = bitmap; _markerBitmapChangeNotifier.notifyListeners(); }, ), MapDecorator( child: _buildMap(), ), MapButtonPanel( boundsNotifier: boundsNotifier, zoomBy: _zoomBy, openMapPage: widget.openMapPage, resetRotation: _resetRotation, ), ], ); } Widget _buildMap() { return AnimatedBuilder( animation: _markerBitmapChangeNotifier, builder: (context, child) { final markers = {}; _geoEntryByMarkerKey.forEach((markerKey, geoEntry) { final bytes = _markerBitmaps[markerKey]; if (bytes != null) { final point = LatLng(geoEntry.latitude!, geoEntry.longitude!); markers.add(Marker( markerId: MarkerId(geoEntry.markerId!), consumeTapEvents: true, icon: BitmapDescriptor.fromBytes(bytes), position: point, onTap: () => widget.onMarkerTap?.call(geoEntry), )); } }); final interactive = context.select((v) => v.interactive); return ValueListenableBuilder( valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), builder: (context, dotEntry, child) { return GoogleMap( initialCameraPosition: CameraPosition( bearing: -bounds.rotation, target: _toGoogleLatLng(bounds.center), zoom: bounds.zoom, ), onMapCreated: (controller) async { _googleMapController = controller; final zoom = await controller.getZoomLevel(); await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation); setState(() {}); }, // compass disabled to use provider agnostic controls compassEnabled: false, mapToolbarEnabled: false, mapType: _toMapType(widget.style), minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), rotateGesturesEnabled: true, scrollGesturesEnabled: interactive, // zoom controls disabled to use provider agnostic controls zoomControlsEnabled: false, zoomGesturesEnabled: interactive, // lite mode disabled because it lacks camera animation liteModeEnabled: false, // tilt disabled to match leaflet tiltGesturesEnabled: false, myLocationEnabled: false, myLocationButtonEnabled: false, markers: { ...markers, if (dotEntry != null && _dotMarkerBitmap != null) Marker( markerId: const MarkerId('dot'), anchor: const Offset(.5, .5), consumeTapEvents: true, icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), position: _toGoogleLatLng(dotEntry.latLng!), zIndex: 1, ) }, onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), onCameraIdle: _onIdle, onTap: (position) => widget.onMapTap?.call(), ); }, ); }, ); } void _onIdle() { if (!mounted) return; widget.controller?.notifyIdle(bounds); _updateMarkers(); } void _updateMarkers() { setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); } Future _updateVisibleRegion({required double zoom, required double rotation}) async { if (!mounted) return; final bounds = await _googleMapController?.getVisibleRegion(); if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) { final sw = bounds.southwest; final ne = bounds.northeast; boundsNotifier.value = ZoomedBounds( sw: ll.LatLng(sw.latitude, sw.longitude), ne: ll.LatLng(ne.latitude, ne.longitude), zoom: zoom, rotation: rotation, ); } else { // the visible region is sometimes uninitialized when queried right after creation, // so we query it again next frame WidgetsBinding.instance!.addPostFrameCallback((_) { _updateVisibleRegion(zoom: zoom, rotation: rotation); }); } } Future _resetRotation() async { final controller = _googleMapController; if (controller == null) return; await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( target: _toGoogleLatLng(bounds.center), zoom: bounds.zoom, ))); } Future _zoomBy(double amount) async { final controller = _googleMapController; if (controller == null) return; widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount); await controller.animateCamera(CameraUpdate.zoomBy(amount)); } Future _moveTo(LatLng point) async { final controller = _googleMapController; if (controller == null) return; await controller.animateCamera(CameraUpdate.newLatLng(point)); } // `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude); MapType _toMapType(EntryMapStyle style) { switch (style) { case EntryMapStyle.googleNormal: return MapType.normal; case EntryMapStyle.googleHybrid: return MapType.hybrid; case EntryMapStyle.googleTerrain: return MapType.terrain; default: return MapType.none; } } }