You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

536 lines
19 KiB

import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:google_api_headers/google_api_headers.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:google_maps_webservice/places.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import 'package:watermanagement/google_maps_place_picker_mb/google_maps_place_picker.dart';
import 'package:watermanagement/google_maps_place_picker_mb/providers/place_provider.dart';
import 'package:watermanagement/google_maps_place_picker_mb/src/autocomplete_search.dart';
import 'package:watermanagement/google_maps_place_picker_mb/src/controllers/autocomplete_search_controller.dart';
import 'package:watermanagement/google_maps_place_picker_mb/src/google_map_place_picker.dart';
typedef IntroModalWidgetBuilder = Widget Function(
BuildContext context,
Function? close,
);
enum PinState { Preparing, Idle, Dragging }
enum SearchingState { Idle, Searching }
class PlacePicker extends StatefulWidget {
const PlacePicker({
Key? key,
required this.apiKey,
this.onPlacePicked,
required this.initialPosition,
this.useCurrentLocation,
this.desiredLocationAccuracy = LocationAccuracy.high,
this.onMapCreated,
this.hintText,
this.searchingText,
this.selectText,
this.outsideOfPickAreaText,
this.onAutoCompleteFailed,
this.onGeocodingSearchFailed,
this.proxyBaseUrl,
this.httpClient,
this.selectedPlaceWidgetBuilder,
this.pinBuilder,
this.introModalWidgetBuilder,
this.autoCompleteDebounceInMilliseconds = 500,
this.cameraMoveDebounceInMilliseconds = 750,
this.initialMapType = MapType.normal,
this.enableMapTypeButton = true,
this.enableMyLocationButton = true,
this.myLocationButtonCooldown = 10,
this.usePinPointingSearch = true,
this.usePlaceDetailSearch = false,
this.autocompleteOffset,
this.autocompleteRadius,
this.autocompleteLanguage,
this.autocompleteComponents,
this.autocompleteTypes,
this.strictbounds,
this.region,
this.pickArea,
this.selectInitialPosition = false,
this.resizeToAvoidBottomInset = true,
this.initialSearchString,
this.searchForInitialValue = false,
this.forceAndroidLocationManager = false,
this.forceSearchOnZoomChanged = false,
this.automaticallyImplyAppBarLeading = true,
this.autocompleteOnTrailingWhitespace = false,
this.hidePlaceDetailsWhenDraggingPin = true,
this.onTapBack,
this.onCameraMoveStarted,
this.onCameraMove,
this.onCameraIdle,
this.onMapTypeChanged,
this.zoomGesturesEnabled = true,
this.zoomControlsEnabled = false,
}) : super(key: key);
final String apiKey;
final LatLng initialPosition;
final bool? useCurrentLocation;
final LocationAccuracy desiredLocationAccuracy;
final String? hintText;
final String? searchingText;
final String? selectText;
final String? outsideOfPickAreaText;
final ValueChanged<String>? onAutoCompleteFailed;
final ValueChanged<String>? onGeocodingSearchFailed;
final int autoCompleteDebounceInMilliseconds;
final int cameraMoveDebounceInMilliseconds;
final MapType initialMapType;
final bool enableMapTypeButton;
final bool enableMyLocationButton;
final int myLocationButtonCooldown;
final bool usePinPointingSearch;
final bool usePlaceDetailSearch;
final num? autocompleteOffset;
final num? autocompleteRadius;
final String? autocompleteLanguage;
final List<String>? autocompleteTypes;
final List<Component>? autocompleteComponents;
final bool? strictbounds;
final String? region;
/// If set the picker can only pick addresses in the given circle area.
/// The section will be highlighted.
final CircleArea? pickArea;
/// If true the [body] and the scaffold's floating widgets should size
/// themselves to avoid the onscreen keyboard whose height is defined by the
/// ambient [MediaQuery]'s [MediaQueryData.viewInsets] `bottom` property.
///
/// For example, if there is an onscreen keyboard displayed above the
/// scaffold, the body can be resized to avoid overlapping the keyboard, which
/// prevents widgets inside the body from being obscured by the keyboard.
///
/// Defaults to true.
final bool resizeToAvoidBottomInset;
final bool selectInitialPosition;
/// By using default setting of Place Picker, it will result result when user hits the select here button.
///
/// If you managed to use your own [selectedPlaceWidgetBuilder], then this WILL NOT be invoked, and you need use data which is
/// being sent with [selectedPlaceWidgetBuilder].
final ValueChanged<PickResult>? onPlacePicked;
/// optional - builds selected place's UI
///
/// It is provided by default if you leave it as a null.
/// INPORTANT: If this is non-null, [onPlacePicked] will not be invoked, as there will be no default 'Select here' button.
final SelectedPlaceWidgetBuilder? selectedPlaceWidgetBuilder;
/// optional - builds customized pin widget which indicates current pointing position.
///
/// It is provided by default if you leave it as a null.
final PinBuilder? pinBuilder;
/// optional - builds customized introduction panel.
///
/// None is provided / the map is instantly accessible if you leave it as a null.
final IntroModalWidgetBuilder? introModalWidgetBuilder;
/// optional - sets 'proxy' value in google_maps_webservice
///
/// In case of using a proxy the baseUrl can be set.
/// The apiKey is not required in case the proxy sets it.
/// (Not storing the apiKey in the app is good practice)
final String? proxyBaseUrl;
/// optional - set 'client' value in google_maps_webservice
///
/// In case of using a proxy url that requires authentication
/// or custom configuration
final BaseClient? httpClient;
/// Initial value of autocomplete search
final String? initialSearchString;
/// Whether to search for the initial value or not
final bool searchForInitialValue;
/// On Android devices you can set [forceAndroidLocationManager]
/// to true to force the plugin to use the [LocationManager] to determine the
/// position instead of the [FusedLocationProviderClient]. On iOS this is ignored.
final bool forceAndroidLocationManager;
/// Allow searching place when zoom has changed. By default searching is disabled when zoom has changed in order to prevent unwilling API usage.
final bool forceSearchOnZoomChanged;
/// Whether to display appbar backbutton. Defaults to true.
final bool automaticallyImplyAppBarLeading;
/// Will perform an autocomplete search, if set to true. Note that setting
/// this to true, while providing a smoother UX experience, may cause
/// additional unnecessary queries to the Places API.
///
/// Defaults to false.
final bool autocompleteOnTrailingWhitespace;
final bool hidePlaceDetailsWhenDraggingPin;
// Raised when clicking on the back arrow.
// This will not listen for the system back button on Android devices.
// If this is not set, but the back button is visible through automaticallyImplyLeading,
// the Navigator will try to pop instead.
final VoidCallback? onTapBack;
/// GoogleMap pass-through events:
/// Callback method for when the map is ready to be used.
///
/// Used to receive a [GoogleMapController] for this [GoogleMap].
final MapCreatedCallback? onMapCreated;
/// Called when the camera starts moving.
///
/// This can be initiated by the following:
/// 1. Non-gesture animation initiated in response to user actions.
/// For example: zoom buttons, my location button, or marker clicks.
/// 2. Programmatically initiated animation.
/// 3. Camera motion initiated in response to user gestures on the map.
/// For example: pan, tilt, pinch to zoom, or rotate.
final Function(PlaceProvider)? onCameraMoveStarted;
/// Called repeatedly as the camera continues to move after an
/// onCameraMoveStarted call.
///
/// This may be called as often as once every frame and should
/// not perform expensive operations.
final CameraPositionCallback? onCameraMove;
/// Called when camera movement has ended, there are no pending
/// animations and the user has stopped interacting with the map.
final Function(PlaceProvider)? onCameraIdle;
/// Called when the map type has been changed.
final Function(MapType)? onMapTypeChanged;
/// Allow user to make visible the zoom button & toggle on & off zoom gestures
final bool zoomGesturesEnabled;
final bool zoomControlsEnabled;
@override
_PlacePickerState createState() => _PlacePickerState();
}
class _PlacePickerState extends State<PlacePicker> {
GlobalKey appBarKey = GlobalKey();
late final Future<PlaceProvider> _futureProvider;
PlaceProvider? provider;
SearchBarController searchBarController = SearchBarController();
bool showIntroModal = true;
@override
void initState() {
super.initState();
_futureProvider = _initPlaceProvider();
}
@override
void dispose() {
searchBarController.dispose();
super.dispose();
}
Future<PlaceProvider> _initPlaceProvider() async {
final headers = await const GoogleApiHeaders().getHeaders();
final provider = PlaceProvider(
widget.apiKey,
widget.proxyBaseUrl,
widget.httpClient,
headers,
);
provider.sessionToken = const Uuid().v4();
provider.desiredAccuracy = widget.desiredLocationAccuracy;
provider.setMapType(widget.initialMapType);
if (widget.useCurrentLocation != null && widget.useCurrentLocation!) {
await provider.updateCurrentLocation(widget.forceAndroidLocationManager);
}
return provider;
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () {
searchBarController.clearOverlay();
return Future.value(true);
},
child: FutureBuilder<PlaceProvider>(
future: _futureProvider,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasData) {
provider = snapshot.data;
return MultiProvider(
providers: [
ChangeNotifierProvider<PlaceProvider>.value(value: provider!),
],
child: Stack(children: [
Scaffold(
key: ValueKey<int>(provider.hashCode),
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
extendBodyBehindAppBar: true,
appBar: AppBar(
key: appBarKey,
automaticallyImplyLeading: false,
iconTheme: Theme.of(context).iconTheme,
elevation: 0,
backgroundColor: Colors.transparent,
titleSpacing: 0.0,
title: _buildSearchBar(context),
),
body: _buildMapWithLocation(),
),
_buildIntroModal(context),
]),
);
}
final children = <Widget>[];
if (snapshot.hasError) {
children.addAll([
Icon(
Icons.error_outline,
color: Theme.of(context).errorColor,
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text('Error: ${snapshot.error}'),
)
]);
} else {
children.add(CircularProgressIndicator());
}
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
),
),
);
},
));
}
Widget _buildSearchBar(BuildContext context) {
return Row(
children: <Widget>[
widget.automaticallyImplyAppBarLeading || widget.onTapBack != null
? IconButton(
onPressed: () {
if (!showIntroModal ||
widget.introModalWidgetBuilder == null) {
if (widget.onTapBack != null) {
widget.onTapBack!();
return;
}
Navigator.maybePop(context);
}
},
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios : Icons.arrow_back,
),
color: Colors.black.withAlpha(128),
padding: EdgeInsets.zero)
: SizedBox(width: 15),
Expanded(
child: AutoCompleteSearch(
appBarKey: appBarKey,
searchBarController: searchBarController,
sessionToken: provider!.sessionToken,
hintText: widget.hintText,
searchingText: widget.searchingText,
debounceMilliseconds: widget.autoCompleteDebounceInMilliseconds,
onPicked: (prediction) {
_pickPrediction(prediction);
},
onSearchFailed: (status) {
if (widget.onAutoCompleteFailed != null) {
widget.onAutoCompleteFailed!(status);
}
},
autocompleteOffset: widget.autocompleteOffset,
autocompleteRadius: widget.autocompleteRadius,
autocompleteLanguage: widget.autocompleteLanguage,
autocompleteComponents: widget.autocompleteComponents,
autocompleteTypes: widget.autocompleteTypes,
strictbounds: widget.strictbounds,
region: widget.region,
initialSearchString: widget.initialSearchString,
searchForInitialValue: widget.searchForInitialValue,
autocompleteOnTrailingWhitespace:
widget.autocompleteOnTrailingWhitespace),
),
SizedBox(width: 5),
],
);
}
_pickPrediction(Prediction prediction) async {
provider!.placeSearchingState = SearchingState.Searching;
final PlacesDetailsResponse response =
await provider!.places.getDetailsByPlaceId(
prediction.placeId!,
sessionToken: provider!.sessionToken,
language: widget.autocompleteLanguage,
);
if (response.errorMessage?.isNotEmpty == true ||
response.status == "REQUEST_DENIED") {
if (widget.onAutoCompleteFailed != null) {
widget.onAutoCompleteFailed!(response.status);
}
return;
}
provider!.selectedPlace = PickResult.fromPlaceDetailResult(response.result);
// Prevents searching again by camera movement.
provider!.isAutoCompleteSearching = true;
await _moveTo(provider!.selectedPlace!.geometry!.location.lat,
provider!.selectedPlace!.geometry!.location.lng);
provider!.placeSearchingState = SearchingState.Idle;
}
_moveTo(double latitude, double longitude) async {
GoogleMapController? controller = provider!.mapController;
if (controller == null) return;
await controller.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: LatLng(latitude, longitude),
zoom: 16,
),
),
);
}
_moveToCurrentPosition() async {
if (provider!.currentPosition != null) {
await _moveTo(provider!.currentPosition!.latitude,
provider!.currentPosition!.longitude);
}
}
Widget _buildMapWithLocation() {
if (provider!.currentPosition == null) {
return _buildMap(widget.initialPosition);
}
return _buildMap(LatLng(provider!.currentPosition!.latitude,
provider!.currentPosition!.longitude));
}
Widget _buildMap(LatLng initialTarget) {
return GoogleMapPlacePicker(
fullMotion: !widget.resizeToAvoidBottomInset,
initialTarget: initialTarget,
appBarKey: appBarKey,
selectedPlaceWidgetBuilder: widget.selectedPlaceWidgetBuilder,
pinBuilder: widget.pinBuilder,
onSearchFailed: widget.onGeocodingSearchFailed,
debounceMilliseconds: widget.cameraMoveDebounceInMilliseconds,
enableMapTypeButton: widget.enableMapTypeButton,
enableMyLocationButton: widget.enableMyLocationButton,
usePinPointingSearch: widget.usePinPointingSearch,
usePlaceDetailSearch: widget.usePlaceDetailSearch,
onMapCreated: widget.onMapCreated,
selectInitialPosition: widget.selectInitialPosition,
language: widget.autocompleteLanguage,
pickArea: widget.pickArea,
forceSearchOnZoomChanged: widget.forceSearchOnZoomChanged,
hidePlaceDetailsWhenDraggingPin: widget.hidePlaceDetailsWhenDraggingPin,
selectText: widget.selectText,
outsideOfPickAreaText: widget.outsideOfPickAreaText,
onToggleMapType: () {
provider!.switchMapType();
if (widget.onMapTypeChanged != null) {
widget.onMapTypeChanged!(provider!.mapType);
}
},
onMyLocation: () async {
// Prevent to click many times in short period.
if (provider!.isOnUpdateLocationCooldown == false) {
provider!.isOnUpdateLocationCooldown = true;
Timer(Duration(seconds: widget.myLocationButtonCooldown), () {
provider!.isOnUpdateLocationCooldown = false;
});
await provider!
.updateCurrentLocation(widget.forceAndroidLocationManager);
await _moveToCurrentPosition();
}
},
onMoveStart: () {
searchBarController.reset();
},
onPlacePicked: widget.onPlacePicked,
onCameraMoveStarted: widget.onCameraMoveStarted,
onCameraMove: widget.onCameraMove,
onCameraIdle: widget.onCameraIdle,
zoomGesturesEnabled: widget.zoomGesturesEnabled,
zoomControlsEnabled: widget.zoomControlsEnabled,
);
}
Widget _buildIntroModal(BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return showIntroModal && widget.introModalWidgetBuilder != null
? Stack(children: [
Positioned(
top: 0,
right: 0,
bottom: 0,
left: 0,
child: Material(
type: MaterialType.canvas,
color: Color.fromARGB(128, 0, 0, 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: ClipRect(),
),
),
widget.introModalWidgetBuilder!(context, () {
setState(() {
showIntroModal = false;
});
})
])
: Container();
});
}
}