import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:uuid/uuid.dart'; import 'place.dart'; import 'place_details.dart'; import 'place_tracker_app.dart'; class PlaceMap extends StatefulWidget { const PlaceMap({ Key key, this.center, }) : super(key: key); final LatLng center; @override PlaceMapState createState() => PlaceMapState(); } class PlaceMapState extends State { static BitmapDescriptor _getPlaceMarkerIcon(PlaceCategory category) { switch (category) { case PlaceCategory.favorite: return BitmapDescriptor.fromAsset('assets/heart.png'); break; case PlaceCategory.visited: return BitmapDescriptor.fromAsset('assets/visited.png'); break; case PlaceCategory.wantToGo: default: return BitmapDescriptor.defaultMarker; } } static List _getPlacesForCategory( PlaceCategory category, List places) { return places.where((Place place) => place.category == category).toList(); } GoogleMapController mapController; Map _markedPlaces = Map(); Marker _pendingMarker; MapConfiguration _configuration; void onMapCreated(GoogleMapController controller) async { mapController = controller; mapController.onInfoWindowTapped.add(_onInfoWindowTapped); // Draw initial place markers on creation so that we have something // interesting to look at. final Map places = await _markPlaces(); _zoomToFitPlaces( _getPlacesForCategory( AppState.of(context).selectedCategory, places.values.toList(), ), ); } Future> _markPlaces() async { await Future.wait( AppState.of(context).places.map((Place place) => _markPlace(place))); return _markedPlaces; } Future _markPlace(Place place) async { final Marker marker = await mapController.addMarker( MarkerOptions( position: place.latLng, icon: _getPlaceMarkerIcon(place.category), infoWindowText: InfoWindowText( place.name, '${place.starRating} Star Rating', ), visible: place.category == AppState.of(context).selectedCategory, ), ); _markedPlaces[marker] = place; } void _onInfoWindowTapped(Marker marker) { _pushPlaceDetailsScreen(_markedPlaces[marker]); } void _pushPlaceDetailsScreen(Place place) { assert(place != null); Navigator.push( context, MaterialPageRoute(builder: (context) { return PlaceDetails( place: place, onChanged: (Place value) => _onPlaceChanged(value), ); }), ); } void _onPlaceChanged(Place value) { // Replace the place with the modified version. final List newPlaces = List.from(AppState.of(context).places); final int index = newPlaces.indexWhere((Place place) => place.id == value.id); newPlaces[index] = value; _updateExistingPlaceMarker(place: value); // Manually update our map configuration here since our map is already // updated with the new marker. Otherwise, the map would be reconfigured // in the main build method due to a modified AppState. _configuration = MapConfiguration( places: newPlaces, selectedCategory: AppState.of(context).selectedCategory, ); AppState.updateWith(context, places: newPlaces); } Future _updateExistingPlaceMarker({@required Place place}) async { Marker marker = _markedPlaces.keys .singleWhere((Marker value) => _markedPlaces[value].id == place.id); // Set marker visibility to false to ensure the info window is hidden. Once // the plugin fully supports the Google Maps API, use hideInfoWindow() // instead. await mapController.updateMarker( marker, MarkerOptions( visible: false, ), ); await mapController.updateMarker( marker, MarkerOptions( infoWindowText: InfoWindowText( place.name, place.starRating != 0 ? '${place.starRating} Star Rating' : null, ), visible: true, ), ); _markedPlaces[marker] = place; } void _switchSelectedCategory(PlaceCategory category) { AppState.updateWith(context, selectedCategory: category); _showPlacesForSelectedCategory(category); } Future _showPlacesForSelectedCategory(PlaceCategory category) async { await Future.wait( _markedPlaces.keys.map( (Marker marker) => mapController.updateMarker( marker, MarkerOptions( visible: _markedPlaces[marker].category == category, ), ), ), ); _zoomToFitPlaces( _getPlacesForCategory(category, _markedPlaces.values.toList())); } void _zoomToFitPlaces(List places) { // Default min/max values to latitude and longitude of center. double minLat = widget.center.latitude; double maxLat = widget.center.latitude; double minLong = widget.center.longitude; double maxLong = widget.center.longitude; for (Place place in places) { minLat = min(minLat, place.latitude); maxLat = max(maxLat, place.latitude); minLong = min(minLong, place.longitude); maxLong = max(maxLong, place.longitude); } mapController.animateCamera( CameraUpdate.newLatLngBounds( LatLngBounds( southwest: LatLng(minLat, minLong), northeast: LatLng(maxLat, maxLong), ), 48.0, ), ); } void _onAddPlacePressed() async { Marker newMarker = await mapController.addMarker( MarkerOptions( position: LatLng( mapController.cameraPosition.target.latitude, mapController.cameraPosition.target.longitude, ), draggable: true, icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), ), ); setState(() { _pendingMarker = newMarker; }); } void _confirmAddPlace(BuildContext context) async { if (_pendingMarker != null) { await mapController.updateMarker( _pendingMarker, MarkerOptions( icon: _getPlaceMarkerIcon(AppState.of(context).selectedCategory), infoWindowText: InfoWindowText('New Place', null), draggable: false, ), ); // Create a new Place and map it to the marker we just added. final Place newPlace = Place( id: Uuid().v1(), latLng: _pendingMarker.options.position, name: _pendingMarker.options.infoWindowText.title, category: AppState.of(context).selectedCategory, ); _markedPlaces[_pendingMarker] = newPlace; // Show a confirmation snackbar that has an action to edit the new place. Scaffold.of(context).showSnackBar( SnackBar( duration: Duration(seconds: 3), content: const Text('New place added.', style: TextStyle(fontSize: 16.0)), action: SnackBarAction( label: 'Edit', onPressed: () async { _pushPlaceDetailsScreen(newPlace); }, ), ), ); // Add the new place to the places stored in appState. final List newPlaces = List.from(AppState.of(context).places) ..add(newPlace); // Manually update our map configuration here since our map is already // updated with the new marker. Otherwise, the map would be reconfigured // in the main build method due to a modified AppState. _configuration = MapConfiguration( places: newPlaces, selectedCategory: AppState.of(context).selectedCategory, ); AppState.updateWith(context, places: newPlaces); setState(() { _pendingMarker = null; }); } } void _cancelAddPlace() { if (_pendingMarker != null) { mapController.removeMarker(_pendingMarker); setState(() { _pendingMarker = null; }); } } void _onToggleMapTypePressed() { final MapType nextType = MapType.values[ (mapController.options.mapType.index + 1) % MapType.values.length]; mapController.updateMapOptions( GoogleMapOptions(mapType: nextType), ); } Future _maybeUpdateMapConfiguration() async { _configuration ??= MapConfiguration.of(AppState.of(context)); final MapConfiguration newConfiguration = MapConfiguration.of(AppState.of(context)); // Since we manually update [_configuration] when place or selectedCategory // changes come from the [place_map], we should only enter this if statement // when returning to the [place_map] after changes have been made from // [place_list]. if (_configuration != newConfiguration && mapController != null) { if (_configuration.places == newConfiguration.places && _configuration.selectedCategory != newConfiguration.selectedCategory) { // If the configuration change is only a category change, just update // the marker visibilities. _showPlacesForSelectedCategory(newConfiguration.selectedCategory); } else { // At this point, we know the places have been updated from the list // view. We need to reconfigure the map to respect the updates. await Future.wait( newConfiguration.places .where((Place p) => !_configuration.places.contains(p)) .map((Place value) => _updateExistingPlaceMarker(place: value)), ); _zoomToFitPlaces( _getPlacesForCategory( newConfiguration.selectedCategory, newConfiguration.places, ), ); } _configuration = newConfiguration; } } @override Widget build(BuildContext context) { _maybeUpdateMapConfiguration(); return Builder(builder: (BuildContext context) { // We need this additional builder here so that we can pass its context to // _AddPlaceButtonBar's onSavePressed callback. This callback shows a // SnackBar and to do this, we need a build context that has Scaffold as // an ancestor. return Center( child: Stack( children: [ GoogleMap( onMapCreated: onMapCreated, options: GoogleMapOptions( trackCameraPosition: true, cameraPosition: CameraPosition( target: widget.center, zoom: 11.0, ), ), ), _CategoryButtonBar( selectedPlaceCategory: AppState.of(context).selectedCategory, visible: _pendingMarker == null, onChanged: _switchSelectedCategory, ), _AddPlaceButtonBar( visible: _pendingMarker != null, onSavePressed: () => _confirmAddPlace(context), onCancelPressed: _cancelAddPlace, ), _MapFabs( visible: _pendingMarker == null, onAddPlacePressed: _onAddPlacePressed, onToggleMapTypePressed: _onToggleMapTypePressed, ), ], ), ); }); } } class _CategoryButtonBar extends StatelessWidget { const _CategoryButtonBar({ Key key, @required this.selectedPlaceCategory, @required this.visible, @required this.onChanged, }) : assert(selectedPlaceCategory != null), assert(visible != null), assert(onChanged != null), super(key: key); final PlaceCategory selectedPlaceCategory; final bool visible; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Visibility( visible: visible, child: Container( padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 14.0), alignment: Alignment.bottomCenter, child: ButtonBar( alignment: MainAxisAlignment.center, children: [ RaisedButton( color: selectedPlaceCategory == PlaceCategory.favorite ? Colors.green[700] : Colors.lightGreen, child: const Text( 'Favorites', style: TextStyle(color: Colors.white, fontSize: 14.0), ), onPressed: () => onChanged(PlaceCategory.favorite), ), RaisedButton( color: selectedPlaceCategory == PlaceCategory.visited ? Colors.green[700] : Colors.lightGreen, child: const Text( 'Visited', style: TextStyle(color: Colors.white, fontSize: 14.0), ), onPressed: () => onChanged(PlaceCategory.visited), ), RaisedButton( color: selectedPlaceCategory == PlaceCategory.wantToGo ? Colors.green[700] : Colors.lightGreen, child: const Text( 'Want To Go', style: TextStyle(color: Colors.white, fontSize: 14.0), ), onPressed: () => onChanged(PlaceCategory.wantToGo), ), ], ), ), ); } } class _AddPlaceButtonBar extends StatelessWidget { const _AddPlaceButtonBar({ Key key, @required this.visible, @required this.onSavePressed, @required this.onCancelPressed, }) : assert(visible != null), assert(onSavePressed != null), assert(onCancelPressed != null), super(key: key); final bool visible; final VoidCallback onSavePressed; final VoidCallback onCancelPressed; @override Widget build(BuildContext context) { return Visibility( visible: visible, child: Container( padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 14.0), alignment: Alignment.bottomCenter, child: ButtonBar( alignment: MainAxisAlignment.center, children: [ RaisedButton( color: Colors.blue, child: const Text( 'Save', style: TextStyle(color: Colors.white, fontSize: 16.0), ), onPressed: onSavePressed, ), RaisedButton( color: Colors.red, child: const Text( 'Cancel', style: TextStyle(color: Colors.white, fontSize: 16.0), ), onPressed: onCancelPressed, ), ], ), ), ); } } class _MapFabs extends StatelessWidget { const _MapFabs({ Key key, @required this.visible, @required this.onAddPlacePressed, @required this.onToggleMapTypePressed, }) : assert(visible != null), assert(onAddPlacePressed != null), assert(onToggleMapTypePressed != null), super(key: key); final bool visible; final VoidCallback onAddPlacePressed; final VoidCallback onToggleMapTypePressed; @override Widget build(BuildContext context) { return Container( alignment: Alignment.topRight, margin: const EdgeInsets.only(top: 12.0, right: 12.0), child: Visibility( visible: visible, child: Column( children: [ FloatingActionButton( heroTag: 'add_place_button', onPressed: onAddPlacePressed, materialTapTargetSize: MaterialTapTargetSize.padded, backgroundColor: Colors.green, child: const Icon(Icons.add_location, size: 36.0), ), SizedBox(height: 12.0), FloatingActionButton( heroTag: 'toggle_map_type_button', onPressed: onToggleMapTypePressed, materialTapTargetSize: MaterialTapTargetSize.padded, mini: true, backgroundColor: Colors.green, child: const Icon(Icons.layers, size: 28.0), ), ], ), ), ); } } class MapConfiguration { const MapConfiguration({ @required this.places, @required this.selectedCategory, }) : assert(places != null), assert(selectedCategory != null); final List places; final PlaceCategory selectedCategory; @override int get hashCode => places.hashCode ^ selectedCategory.hashCode; @override bool operator ==(Object other) { if (identical(this, other)) { return true; } if (other.runtimeType != runtimeType) { return false; } final MapConfiguration otherConfiguration = other; return otherConfiguration.places == places && otherConfiguration.selectedCategory == selectedCategory; } static MapConfiguration of(AppState appState) { return MapConfiguration( places: appState.places, selectedCategory: appState.selectedCategory, ); } }