1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-08 13:58:47 +00:00

Add Google Maps Place Tracker sample app to flutter/samples. (#20)

Sample app that displays places on a map. Add/edit places. Interact with map. Iterations to follow.
This commit is contained in:
Kenzie Schmoll
2018-10-26 13:11:10 -07:00
committed by GitHub
parent 4b4d5fef9c
commit 7ab277fad4
58 changed files with 2069 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'place_map.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class _Home extends StatelessWidget {
const _Home({ Key key }) : super(key: key);
@override
Widget build(BuildContext context) {
return PlaceMap(
center: const LatLng(45.521563, -122.677433),
);
}
}
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Place Tracker',
home: _Home(),
)
);
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
enum PlaceCategory {
favorite,
visited,
wantToGo,
}
class Place {
const Place({
@required this.latLng,
@required this.name,
@required this.category,
this.description,
this.starRating = 0,
}) : assert(latLng != null),
assert(name != null),
assert(category != null),
assert(starRating != null && starRating >= 0 && starRating <= 5);
final LatLng latLng;
final String name;
final PlaceCategory category;
final String description;
final int starRating;
double get latitude => latLng.latitude;
double get longitude => latLng.longitude;
Place copyWith({
LatLng latLng,
String name,
PlaceCategory category,
String description,
int starRating,
}) {
return Place(
latLng: latLng ?? this.latLng,
name: name ?? this.name,
category: category ?? this.category,
description: description ?? this.description,
starRating: starRating ?? this.starRating,
);
}
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'place.dart';
class PlaceDetails extends StatefulWidget {
const PlaceDetails({
Key key,
@required this.place,
@required this.onChanged,
}) : assert(place != null),
assert(onChanged != null),
super(key: key);
final Place place;
final ValueChanged<Place> onChanged;
@override
PlaceDetailsState createState() => PlaceDetailsState();
}
class PlaceDetailsState extends State<PlaceDetails> {
Place _place;
GoogleMapController _mapController;
final TextEditingController _nameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
@override
void initState() {
_place = widget.place;
_nameController.text = _place.name;
_descriptionController.text = _place.description;
return super.initState();
}
void _onMapCreated(GoogleMapController controller) {
setState(() {
_mapController = controller;
_mapController.addMarker(MarkerOptions(position: _place.latLng));
});
}
Widget _detailsBody() {
return ListView(
padding: const EdgeInsets.fromLTRB(24.0, 12.0, 24.0, 12.0),
children: <Widget>[
_NameTextField(
controller: _nameController,
onChanged: (String value) {
setState(() {
_place = _place.copyWith(name: value);
});
},
),
_DescriptionTextField(
controller: _descriptionController,
onChanged: (String value) {
setState(() {
_place = _place.copyWith(description: value);
});
},
),
_StarBar(
rating: _place.starRating,
onChanged: (int value) {
setState(() {
_place = _place.copyWith(starRating: value);
});
},
),
_Map(
center: _place.latLng,
mapController: _mapController,
onMapCreated: _onMapCreated,
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${_place.name}'),
backgroundColor: Colors.green[700],
actions: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 0.0),
child: IconButton(
icon: const Icon(Icons.save, size: 30.0),
onPressed: () {
widget.onChanged(_place);
Navigator.pop(context);
},
),
),
],
),
body: GestureDetector(
onTap: () {
FocusScope.of(context).requestFocus(FocusNode());
},
child: _detailsBody(),
),
);
}
}
class _NameTextField extends StatelessWidget {
_NameTextField({
@required this.controller,
@required this.onChanged,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 16.0),
child: TextField(
decoration: InputDecoration(
labelText: 'Name',
labelStyle: const TextStyle(fontSize: 18.0),
),
style: const TextStyle(fontSize: 20.0, color: Colors.black87),
autocorrect: true,
controller: controller,
onChanged: (String value) {
onChanged(value);
},
),
);
}
}
class _DescriptionTextField extends StatelessWidget {
_DescriptionTextField({
@required this.controller,
@required this.onChanged,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 16.0),
child: TextField(
decoration: InputDecoration(
labelText: 'Description',
labelStyle: const TextStyle(fontSize: 18.0),
),
style: const TextStyle(fontSize: 20.0, color: Colors.black87),
maxLines: null,
autocorrect: true,
controller: controller,
onChanged: (String value) {
onChanged(value);
},
),
);
}
}
class _StarBar extends StatelessWidget {
const _StarBar({
Key key,
@required this.rating,
@required this.onChanged,
}) : assert(rating != null && rating >= 0 && rating <= 5),
super(key: key);
static const int maxStars = 5;
final int rating;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(maxStars, (int index) {
return IconButton(
icon: const Icon(Icons.star),
iconSize: 40.0,
color: rating > index ? Colors.amber : Colors.grey[400],
onPressed: () {
onChanged(index + 1);
},
);
}).toList(),
);
}
}
class _Map extends StatelessWidget {
_Map({
Key key,
@required this.center,
@required this.mapController,
@required this.onMapCreated,
}) : assert(center != null);
final LatLng center;
final GoogleMapController mapController;
final ArgumentCallback<GoogleMapController> onMapCreated;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 16.0),
elevation: 4.0,
child: SizedBox(
width: 340.0,
height: 240.0,
child: GoogleMap(
onMapCreated: onMapCreated,
options: GoogleMapOptions(
cameraPosition: CameraPosition(
target: center,
zoom: 16.0,
),
zoomGesturesEnabled: false,
rotateGesturesEnabled: false,
tiltGesturesEnabled: false,
scrollGesturesEnabled: false,
),
),
),
);
}
}

View File

@@ -0,0 +1,433 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'place.dart';
import 'place_stub_data.dart';
import 'place_details.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<PlaceMap> {
static BitmapDescriptor _getPlaceMarkerIcon(PlaceCategory category) {
// TODO(kenzieschmoll): use custom marker assets.
double markerHue;
switch (category) {
case PlaceCategory.favorite:
markerHue = BitmapDescriptor.hueRed;
break;
case PlaceCategory.visited:
markerHue = BitmapDescriptor.hueViolet;
break;
case PlaceCategory.wantToGo:
default:
markerHue = BitmapDescriptor.hueAzure;
}
return BitmapDescriptor.defaultMarkerWithHue(markerHue);
}
static List<Place> _getPlacesForCategory(PlaceCategory category, Map<Marker, Place> places) {
return places.values.where((Place place) => place.category == category).toList();
}
GoogleMapController mapController;
PlaceCategory _selectedPlaceCategory = PlaceCategory.favorite;
Map<Marker, Place> _places = Map<Marker, Place>();
Marker _pendingMarker;
void onMapCreated(GoogleMapController controller) async {
mapController = controller;
mapController.onInfoWindowTapped.add(_onInfoWindowTapped);
// Add stub data on creation so we have something interesting to look at.
final Map<Marker, Place> places = await _initializeStubPlaces();
_zoomToFitPlaces(_getPlacesForCategory(_selectedPlaceCategory, places));
}
Future<Map<Marker, Place>> _initializeStubPlaces() async {
await Future.wait(PlaceStubData.places.map((Place place) => _initializeStubPlace(place)));
return _places;
}
Future<void> _initializeStubPlace(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 == _selectedPlaceCategory,
),
);
_places[marker] = place;
}
void _onInfoWindowTapped(Marker marker) async {
_pushPlaceDetailsScreen(marker);
}
Future<void> _pushPlaceDetailsScreen(Marker marker) async {
assert(marker != null);
Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return PlaceDetails(
place: _places[marker],
onChanged: (Place value) {
_updatePlaceAndMarker(value, marker);
},
);
}),
);
}
Future<void> _updatePlaceAndMarker(Place place, Marker marker) async {
_places[marker] = place;
// 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,
),
);
}
void _updatePlaces(PlaceCategory category) {
setState(() {
_selectedPlaceCategory = category;
_showPlacesForSelectedCategory();
});
}
void _showPlacesForSelectedCategory() {
_places.forEach((Marker marker, Place place) {
mapController.updateMarker(
marker,
MarkerOptions(
visible: place.category == _selectedPlaceCategory,
),
);
});
_zoomToFitPlaces(_getPlacesForCategory(_selectedPlaceCategory, _places));
}
void _zoomToFitPlaces(List<Place> 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 _onAddPlaceFabPressed() 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(_selectedPlaceCategory),
infoWindowText: InfoWindowText('New Place', null),
draggable: false,
),
);
// Store a reference to the new marker so that we can pass it to the
// snackbar action. We cannot pass [_pendingMarker] since it will get
// reset to null.
Marker newMarker = _pendingMarker;
Scaffold.of(context).showSnackBar(
SnackBar(
duration: Duration(seconds: 3),
content: const Text(
'New place added.',
style: const TextStyle(fontSize: 16.0)
),
action: SnackBarAction(
label: 'Edit',
onPressed: () async {
_pushPlaceDetailsScreen(newMarker);
},
),
),
);
setState(() {
// Create a new Place and map it to the marker we just added.
_places[_pendingMarker] = Place(
latLng: _pendingMarker.options.position,
name: _pendingMarker.options.infoWindowText.title,
category: _selectedPlaceCategory,
);
_pendingMarker = null;
});
}
}
void _cancelAddPlace() {
if (_pendingMarker != null) {
mapController.removeMarker(_pendingMarker);
setState(() {
_pendingMarker = null;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: const <Widget>[
Padding(
padding: EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 0.0),
child: Icon(Icons.pin_drop, size: 24.0),
),
Text('Place Tracker'),
],
),
backgroundColor: Colors.green[700],
),
// 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.
body: Builder(builder: (BuildContext context) {
return Center(
child: Stack(
children: <Widget>[
GoogleMap(
onMapCreated: onMapCreated,
options: GoogleMapOptions(
trackCameraPosition: true,
cameraPosition: CameraPosition(
target: widget.center,
zoom: 11.0,
),
),
),
_CategoryButtonBar(
selectedPlaceCategory: _selectedPlaceCategory,
visible: _pendingMarker == null,
onChanged: _updatePlaces,
),
_AddPlaceButtonBar(
visible: _pendingMarker != null,
onSavePressed: () => _confirmAddPlace(context),
onCancelPressed: _cancelAddPlace,
),
_AddPlaceFab(
visible: _pendingMarker == null,
onPressed: _onAddPlaceFabPressed,
),
],
),
);
}),
);
}
}
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<PlaceCategory> onChanged;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: visible ? 1.0 : 0.0,
child: Container(
padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 14.0),
alignment: Alignment.bottomCenter,
child: ButtonBar(
alignment: MainAxisAlignment.center,
children: <Widget>[
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 Opacity(
opacity: visible ? 1.0 : 0.0,
child: Container(
padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 14.0),
alignment: Alignment.bottomCenter,
child: ButtonBar(
alignment: MainAxisAlignment.center,
children: <Widget>[
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 _AddPlaceFab extends StatelessWidget {
const _AddPlaceFab({
Key key,
@required this.visible,
@required this.onPressed,
}) : assert(visible != null),
assert(onPressed != null),
super(key: key);
final bool visible;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: visible ? 1.0 : 0.0,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.topRight,
child: FloatingActionButton(
onPressed: onPressed,
materialTapTargetSize: MaterialTapTargetSize.padded,
backgroundColor: Colors.green,
child: const Icon(Icons.add_location, size: 36.0),
),
),
),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'place.dart';
class PlaceStubData {
static const List<Place> places = [
Place(
latLng: LatLng(45.524676, -122.681922),
name: 'Deschutes Brewery',
description:
'Beers brewed on-site & gourmet pub grub in a converted auto-body shop with a fireplace & wood beams.',
category: PlaceCategory.favorite,
starRating: 5),
Place(
latLng: LatLng(45.525253, -122.684423),
name: 'TILT',
description:
'This stylish American eatery offers unfussy breakfast fare, cocktails & burgers in industrial-themed digs.',
category: PlaceCategory.favorite,
starRating: 4),
Place(
latLng: LatLng(45.528952, -122.698344),
name: 'Salt & Straw',
description:
'Quirky flavors & handmade waffle cones draw crowds to this artisinal ice cream maker\'s 3 parlors.',
category: PlaceCategory.favorite,
starRating: 5),
Place(
latLng: LatLng(45.513485, -122.657982),
name: 'White Owl Social Club',
description:
'Chill haunt with local beers, burgers & vegan eats, plus live music & an airy patio with a fire pit.',
category: PlaceCategory.favorite,
starRating: 4),
Place(
latLng: LatLng(45.383030, -122.758372),
name: 'Thai Cuisine',
description:
'Informal restaurant offering Thai standards in a modest setting, plus takeout & delivery.',
category: PlaceCategory.visited,
starRating: 4),
Place(
latLng: LatLng(45.416986, -122.743171),
name: 'Chevys',
description:
'Lively, informal Mexican chain with a colorful, family-friendly setting plus tequilas & margaritas.',
category: PlaceCategory.visited,
starRating: 4),
Place(
latLng: LatLng(45.430489, -122.831802),
name: 'Cinetopia',
description:
'Moviegoers can take food from the on-site eatery to their seats, with table service in 21+ theaters.',
category: PlaceCategory.visited,
starRating: 4),
Place(
latLng: LatLng(45.487137, -122.799940),
name: 'Buffalo Wild Wings',
description:
'Lively sports-bar chain dishing up wings & other American pub grub amid lots of large-screen TVs.',
category: PlaceCategory.visited,
starRating: 5),
Place(
latLng: LatLng(45.493321, -122.669330),
name: 'The Old Spaghetti Factory',
description:
'Family-friendly chain eatery featuring traditional Italian entrees amid turn-of-the-century decor.',
category: PlaceCategory.visited,
starRating: 4),
Place(
latLng: LatLng(45.548606, -122.675286),
name: 'Mississippi Pizza',
description:
'Music, trivia & other all-ages events featured at pizzeria with lounge & vegan & gluten-free pies.',
category: PlaceCategory.wantToGo,
starRating: 4),
Place(
latLng: LatLng(45.559783, -122.924103),
name: 'TopGolf',
description:
'Sprawling entertainment venue with a high-tech driving range & swanky lounge with drinks & games.',
category: PlaceCategory.wantToGo,
starRating: 5),
Place(
latLng: LatLng(45.420226, -122.740347),
name: 'Oswego Grill',
description:
'Wood-grilled steakhouse favorites served in a casual, romantic restaurant with a popular happy hour.',
category: PlaceCategory.wantToGo,
starRating: 4),
Place(
latLng: LatLng(45.541202, -122.676432),
name: 'The Widmer Brothers Brewery',
description:
'Popular, enduring gastropub serving craft beers, sandwiches & eclectic entrees in a laid-back space.',
category: PlaceCategory.wantToGo,
starRating: 4),
];
}