src/components/views/map-view.js
import React, {Component} from 'react';
import {Platform, StyleSheet, Text, View, Button, Alert, TouchableOpacity, Dimensions, NativeModules, ScrollView} from 'react-native';
import {Icon} from 'react-native-elements';
import {default as RNMapView} from 'react-native-maps';
import { Marker, Callout, Polygon, Circle } from 'react-native-maps';
import SectionedMultiSelect from 'react-native-sectioned-multi-select';
import { GooglePlacesAutocomplete } from 'react-native-google-places-autocomplete';
import { styles, colours, map_styles, autocomplete_styles, STATUSBAR_HEIGHT } from './stylesheets/map-styles';
import MapPresenter from '../presenters/map-presenter';
import BaseView from './view';
import SafeArea from './helpers/safearea';
import ProfileButton from './helpers/profilebutton';
import ActionButton from './helpers/ActionButton/ActionButton';
import TimeUtil from '../../util/timeutility';
/**
* Class for the Map view
* @extends BaseView
*/
class MapView extends BaseView {
/**
* Create an instance of LoginView
*
* @constructor
* @param {Object} props - Component properties
*/
constructor(props){
super(props);
this.MapP = new MapPresenter(this);
this.resetState();
}
/**
* Resets the state with default variables
*/
resetState = () => {
this.state = {
refresh: true,
circleRadius : 500,
x: {
latitude: 44.257424,
longitude: -76.5231,
},
showCircle: false,
showMarker: false,
markerCreated:[],
markers: [],
markerRefs: {},
tempMarkers: [],
tempMarkerRefs: [],
foundMarker: null,
foundCalloutOpened: false,
selectedFilters: [0],
profileData: {},
};
}
/**
* Refreshes the state of the component so new data is fetched.
*/
refreshState = () => {
this.setState({
refresh: !this.state.refresh
});
}
componentWillMount = () => {
this._setProfileImage();
}
/**
* Triggers when the component is mounted.
*/
componentDidMount = () => {
this.MapP.forceRequestData();
this._setUserLocation();
this._setMarkers(this.state.selectedFilters);
};
/**
* Component will receive new properties via the props attribute
*/
componentWillReceiveProps = () => {
const { navigation } = this.props;
const data = navigation.getParam('data', 'NO-DATA');
const found = navigation.getParam('found', false);
if (found) {
const foundMarker = this._createNewMarker(data);
this.setState({foundCalloutOpened: true});
this.state.tempMarkers = [foundMarker]; // We just overwrite because best to only keep track of one
} else if (data !== 'NO-DATA') {
// Only set the location if the marker wasn't clicked from the Alerts page
this._setLocationToMarkerItem(this.state.markerRefs, data, true);
}
}
/**
* Component is about to unmount, do any cleanup here.
* Call viewUnmounting in base class so it can do any cleanup for the view before calling the presenter destroy method
*/
componentWillUnmount = () => {
this.viewUnmounting(this.MapP);
}
/**
* Component's state has updated
*/
componentDidUpdate = () => {
if (this.state.tempMarkers.length !== 0 && this.state.foundCalloutOpened) {
this.setState({foundCalloutOpened: false})
this._setLocationToMarkerItem(this.state.tempMarkerRefs, this.state.tempMarkers[0].data, true);
}
}
/**
* Sets markers according to the filter, if any, that is set.
*
* @param {List} selectedFilters - A list of selected filters. Indices correspond to places in the list in the 'filters' constant
*/
_setMarkers = (selectedFilters) => {
this.setState({
markers : this.MapP.filterMarkers(this.MapP.getData(), selectedFilters.length > 0 ? filters[selectedFilters[0]] : null)
});
}
/**
* Add the new selected items to the state and update
*
* @param {List} selectedFilters - List of selected items
*/
_onSelectedItemsChange = (selectedFilters) => {
this.setState({ selectedFilters });
this._setMarkers(selectedFilters);
}
/**
* Creates a new marker from the data.
*
* @param {Object} data - The data from the marker to be created
* @return {Object} A new marker object with data, key and coordinate properties
*/
_createNewMarker = (data) => {
const newMarker = {
data: data,
key: data.id,
coordinate: {
latitude: data.found_latitude,
longitude: data.found_longitude,
}
}
return newMarker;
}
/**
* Sets the location of the map to the marker's position and open its callout.
*
* @param {List} refs - A list of marker references
* @param {Object} item - The marker's data
* @param {Boolean} shouldReshowCallout - If the callout should be triggered twice on a delay. default=false
*/
_setLocationToMarkerItem = (refs, item, shouldReshowCallout=false) => {
if (item.hasOwnProperty('longitude') && item.hasOwnProperty('latitude')) {
const location = {
latitude: item.latitude,
longitude: item.longitude,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
};
this.onRegionChange(location);
refs[item.id].showCallout();
// Sometimes the callouts don't popup immediately so we have to call it again after 50 milliseconds
if (shouldReshowCallout) {
setTimeout(() => {refs[item.id].showCallout();}, 50);
}
}
}
/**
* Sets the user's location to their current location.
*/
_setUserLocation = () => {
this.MapP.getUserLocation().then(position => {
if (position) {
const location = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
};
this.setState({
x: location,
user: location,
region: location
});
}
});
}
/**
* Render "save/delete" button after clicking "create lost report" button
*/
// renderSDButton(){
// if (this.state.showButton){
// return(
// <View style={map_styles.saveDeleteButton}>
// <View style={map_styles.Buttons}>
// <Button
// onPress={()=>{this.sendNewMarker()}}
// title="save"/></View>
// <View style={map_styles.Buttons}>
// <Button
// onPress={()=>{
// this.deleteItem()
// }}
// title="delete"/></View>
// </View>
// )
// }
// }
/**
* Render a searchbar for user to search location after clicking "search location" button
*/
renderSearchbar = () => {
if (this.state.showSearchbar){
return (
<GooglePlacesAutocomplete
placeholder='Enter location'
minLength={2} // minimum length of text to search
autoFocus={false}
returnKeyType={'search'} // Can be left out for default return key https://facebook.github.io/react-native/docs/textinput.html#returnkeytype
listViewDisplayed='true' // true/false/undefined
fetchDetails={true}
onPress={(data, details = null) => { // 'details' is provided when fetchDetails = true
console.log(data, details);
this.setState({
showSearchbar:false,
region: {
latitude: details.geometry.location.lat,
longitude: details.geometry.location.lng,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
}
});
}}
getDefaultValue={() => ''}
query={{
// available options: https://developers.google.com/places/web-service/autocomplete
key: 'AIzaSyCS9j9HB64sW9w8LgvtxVET6LqoET78OcA',
language: 'en', // language of the results
}}
styles={autocomplete_styles}
debounce={200} // debounce the requests in ms. Set to 0 to remove debounce. By default 0ms.
/>
);
}
}
/**
* Save data of created marker or circle after clicking save button
*/
saveItem(){
if (this.state.showCircle){
this.sendCircle();
this.setState({
showCircle: false,
showMarker: false
});
}
if (this.state.showMarker){
this.sendNewMarker();
this.setState({
showCircle: false,
showMarker: false
});
}
}
/**
* Delete created marker or circle after clicking delete button
*/
deleteItem(){
this.setState()
if (this.state.showCircle){
this.setState({
x:{
latitude: 44.257424,
longitude: -76.5231,
},
circleRadius: 500,
showCircle: false,
showMarker: false
});
} else if (this.state.showMarker) {
this.setState({showMarker:false, showCircle: false, markerCreated:[]})
}
}
/**
* Render buttons that can adjust circle's radius
*/
// renderForCircle(){
// if (this.state.showCircle){
// return(
// <View style={map_styles.circleRadiusButton}>
// <View style={map_styles.Buttons}>
// <Button
// onPress={()=>{this.setState({circleRadius: this.state.circleRadius+200})}}
// title="more"/></View>
// <View style={map_styles.Buttons}>
// <Button
// onPress={()=>{if (this.state.circleRadius>200){this.setState({circleRadius: this.state.circleRadius-200})}}}
// title="less"/></View>
// </View>
// )
// }
// }
/**
* Render a circle to set notification receiving area
*/
renderCircle(){
if (this.state.showCircle){
return (
<Circle
center = {{latitude:this.state.x.latitude,longitude:this.state.x.longitude}}
radius = {this.state.circleRadius}
strokeColor = "#4F6D7A"
strokeWidth = { 2 }
fillColor = 'rgba(200,0,0,0.5)'/>
)
}
}
/**
* Save data of circle to notification settings
*/
sendCircle(){
//nothing
newData = {
data: {
circleLatitude: this.state.x.latitude,
circleLongitude: this.state.x.longitude,
radius: this.state.circleRadius,
}
}
this.MapP.updateCircle(newData);
// console.log(newData);
}
/**
* Save data of created marker to report lost page
*/
sendNewMarker() {
newData={
data:
{
latitude: this.state.markerCreated[0].coordinate.latitude,
longitude: this.state.markerCreated[0].coordinate.longitude,
}
}
this.navigate('ReportLost', newData);
}
/**
* Long press the map to change the coordinate of circle
*
* @param {Object} e - The event of long press on the map
*/
setCircleLat(e) {
let cor = e.nativeEvent.coordinate;
if (this.state.showCircle){
this.setState({
x: {
latitude: cor.latitude,
longitude: cor.longitude,
}
})
}
}
/**
* handle click event after clicking "create marker" button
*/
_onPinMarkerPress=()=> {
if (this.state.showMarker) {
this.setState({
showMarker: false,
showCircle: false,
markerCreated: []
});
this.circleABRef.reset();
} else {
this.setState({
showMarker: true,
showCircle: false,
markerCreated: [this.newMarker(this.state.region.latitude,this.state.region.longitude)],
});
this.circleABRef.reset();
}
}
/**
* helper function of _onPinMarkerPress
*
* @param {Integer} latitude and longitude of a marker
*/
newMarker = (lat,long) => {
const key = this.state.markers.length-1
return({key, coordinate: {latitude: lat,longitude: long}});
};
/**
* Change region state as moving the map
*
* @param {region} A presenter class instance
*/
onRegionChange = (region) => {
this.setState({region: region });
};
/**
* Sets the state to the profile data retrieved from the model.
*/
_setProfileImage = () => {
this.MapP.getProfileImage((result) => this.setState({profileData: result}));
}
/**
* Get the string to display from the colours list.
*
* @param {List} colours - A list of colours
* @return {string} A string obtained from the colours in the list
*/
_getColourString = (colours) => {
let colourString = '';
if (colours != null && colours != undefined) {
colourString = 'Colour';
colourString = colourString + ((colours.length > 1) ? "s: " : ": ") + colours.join(', ');
}
return colourString
}
/**
* Renders the callout for a marker.
*
* @param {Object} item - The data to display for a marker
*/
_renderCallout = (item) => (
<Callout onPress={() => {this.navigate('BikeDetails', item.data)}}>
<ScrollView>
<ScrollView horizontal>
<View style={map_styles.calloutColumn}>
<View style={map_styles.calloutRow}>
<Text style={map_styles.brandText} numberOfLines={1} ellipsizeMode ={'tail'}>
{item.data.brand == undefined || item.data.brand === '' ? 'Brand Unknown' : item.data.brand}
</Text>
<Text numberOfLines={1} ellipsizeMode={'tail'}>
{' '}
</Text>
<View style={map_styles.timeago}>
<Text style={[map_styles.mapText, map_styles.timeagoText]} numberOfLines={1} ellipsizeMode ={'tail'}>
{item.data.timeago}
</Text>
</View>
</View>
{
item.data.model != undefined && item.data.model !== '' &&
<Text style={map_styles.mapText}>
{item.data.model != '' ? "Model: " + item.data.model : ''}
</Text>
}
{
item.data.colour != undefined && item.data.colour.length !== 0 &&
<Text style={map_styles.mapText}>
{this._getColourString(item.data.colour)}
</Text>
}
</View>
</ScrollView>
</ScrollView>
</Callout>
);
/**
* Renders the save action button
*/
_renderSaveActionButton = () => (
<ActionButton.Item
onPress={()=>{this.saveItem(); this.circleABRef.reset(); this.pinABRef.reset();}}
buttonColor={colours.ppPinGreen}
style={map_styles.iconButton}
title="Save">
<Icon name="check" type="font-awesome" size={22} color={colours.ppWhite} />
</ActionButton.Item>
);
/**
* Renders the cancel action button
*/
_renderCancelActionButton = () => (
<ActionButton.Item
onPress={()=>{this.deleteItem(); this.circleABRef.reset(); this.pinABRef.reset();}}
buttonColor={colours.ppPinRed}
style={map_styles.iconButton}
title="Cancel">
<Icon name="times" type="font-awesome" size={22} color={colours.ppWhite} />
</ActionButton.Item>
);
/**
* Renders the action button icon pin.
*/
_renderActionButtonPinIcon = () => (
<Icon name="pin-drop" type="MaterialIcons" size={35} color={this.state.showMarker ? colours.ppWhite : colours.ppBlue} />
);
/**
* Renders the action button add icon.
*/
_renderActionButtonAddIcon = () => (
<Icon name="circle-o-notch" type="font-awesome" size={35} color={this.state.showCircle ? colours.ppWhite : colours.ppBlue}/>
);
/**
* Toggle the notification area.
*/
_toggleCircle = () => {
if (this.state.showCircle) {
this.setState({
showCircle: false, showMarker: false
});
this.pinABRef.reset();
} else {
this.setState({showCircle: true, showMarker:false, markerCreated:[]});
this.pinABRef.reset();
}
}
/**
* Triggers after a marker being dragged has stopped being dragged.
*
* @param {Object} coord - The coordinate position of the marker
*/
onMarkerDragEnd = (coord) => {
const { latLng } = coord;
const lat = latLng.lat();
const lng = latLng.lng();
const marker = this.state.markerCreated;
marker[0].coordinate = { latitude: lat, longitude: lng };
this.setState({markerCreated: marker});
}
/**
* Navigate to a page with a title.
* This method is used over the commented out line below because successive touches of a bike item
* would not add the data because data is only received in process in the componentWillMount function.
* So adding the 'key' property to navigate makes it see that the new page is unique.
*
* @param {string} screen - The route to navigate to. See navigation.js stacks and screens
*/
navigate = (screen, data) => {
this.props.navigation.navigate({
routeName: screen,
params: {
data: data,
from: 'Map'
},
key: screen + TimeUtil.getDateTime()
});
}
/**
* Extract data from the component's view and send an update to the presenter to do any logic before sending it to the model
*/
render() {
const { height: windowHeight, width: windowWidth } = Dimensions.get('window');
const varTop = windowHeight - 100;
const highestIcon = 50;
const hitSlop = {
top: 15,
bottom: 15,
left: 15,
right: 15,
}
let bbStyle = (vheight, start=false) => {
let style = {
position: 'absolute',
top: vheight-20,
left: start ? 5 : windowWidth-60,
right: start ? 5 : windowWidth-60,
width: map_styles.iconButton.width,
backgroundColor: 'transparent',
alignItems: start ? 'flex-start' : 'flex-end',
alignSelf: start ? 'flex-start' : 'flex-end',
};
return style;
}
return (
<View style={{ flex: 1 }}>
{
this.state.showSearchbar &&
<SafeArea overrideColour={colours.ppGrey}/>
}
<RNMapView
style={{flex:1}}
region={this.state.region}
style={map_styles.map}
showsUserLocation={true}
showsMyLocationButton={true}
rotateEnabled={true}
onRegionChangeComplete={this.onRegionChange.bind(this)}
onLongPress = {e => this.setCircleLat(e)}
onPress = {() => {this.setState({showSearchbar:false})}}>
{this.state.markers.map(marker => (
<Marker
{...marker}
ref={(ref) => this.state.markerRefs[marker.key] = ref}
pinColor={marker.data.stolen ? colours.ppPinRed : colours.ppPinGreen}>
{this._renderCallout(marker)}
</Marker>
))}
{this.state.tempMarkers.map(marker => (
<Marker
{...marker}
ref={(ref) => this.state.tempMarkerRefs[marker.key] = ref}
pinColor={marker.data.stolen ? colours.ppPinRed : colours.ppPinGreen}>
{this._renderCallout(marker)}
</Marker>
))}
{this.state.markerCreated.map(marker => (<Marker draggable{...marker} onDragend={(t, map, coord) => this.onMarkerDragEnd(coord)} />))}
{this.renderCircle(this)}
</RNMapView>
{this.renderSearchbar()}
{/* Height of search bar container covers the profile button and the search button so we don't */}
{/* need to use this.state.showSearchbar to block rendering. If we block rendering with that, then */}
{/* profile button will re-render and there will be a visible flicker of the profile picture. */}
<View style={[bbStyle(highestIcon, true), {zIndex: 11, width: map_styles.iconButton.width+2}]}>
<ProfileButton
hitSlop={hitSlop}
profilePicture={this.state.profileData.profilePicture}
numNotifications={this.MapP.getNotificationCount()}/>
</View>
<View style={bbStyle(highestIcon)}>
<TouchableOpacity
style={map_styles.iconButton}
accessibilityLabel="Search"
hitSlop={hitSlop}
onPress={() => {this.setState({showSearchbar:true})}}>
<Icon name="search" type="MaterialIcons" size={35} color={colours.ppBlue} />
</TouchableOpacity>
</View>
<View style={bbStyle(125)}>
<TouchableOpacity
style={map_styles.iconButton}
accessibilityLabel="Filter"
hitSlop={hitSlop}
onPress={() => this.sectionedMultiSelect._toggleSelector()}>
<Icon name="filter-list" type="MaterialIcons" size={35} color={colours.ppBlue} />
</TouchableOpacity>
{/* This is hidden */}
<SectionedMultiSelect
style={{zIndex: -5}}
items={filters}
displayKey='name'
confirmText='Cancel'
uniqueKey={'id'}
colors={{ primary: this.state.selectedFilters.length ? 'forestgreen' : 'crimson' }}
selectText=''
hideSelect
showDropDowns
single
showChips={false}
alwaysShowSelectText={false}
showCancelButton={false}
onSelectedItemsChange={this._onSelectedItemsChange}
selectedItems={this.state.selectedFilters}
ref={(SectionedMultiSelect) => this.sectionedMultiSelect = SectionedMultiSelect}/>
</View>
<View style={[bbStyle(varTop-map_styles.iconButton.height-35), {marginLeft: 10}]}>
<ActionButton
active={this.state.showMarker}
buttonColor={colours.ppWhite}
btnOutRange={colours.ppBlue}
style={map_styles.iconButton}
radius={65}
autoInactive={false}
position={'right'}
startDegree={0}
refer={(ref) => this.pinABRef = ref}
size={map_styles.iconButton.width}
icon={this._renderActionButtonPinIcon()}
onPress={this._onPinMarkerPress}>
{this._renderSaveActionButton()}
{this._renderCancelActionButton()}
</ActionButton>
</View>
<View style={[bbStyle(varTop-map_styles.iconButton.height*2-37*3), {marginLeft: 10}]}>
<ActionButton
active={this.state.showCircle}
buttonColor={colours.ppWhite}
btnOutRange={colours.ppBlue}
style={map_styles.iconButton}
radius={65}
autoInactive={false}
position={'right'}
startDegree={90}
refer={(ref) => this.circleABRef = ref}
size={map_styles.iconButton.width}
icon={this._renderActionButtonAddIcon()}
onPress={this._toggleCircle}>
<ActionButton.Item
onPress={()=>{if (this.state.circleRadius>200){this.setState({circleRadius: this.state.circleRadius-200})}}}
buttonColor={colours.ppBlue}
style={map_styles.iconButton}
title="Less">
<Icon name="minus" type="font-awesome" size={22} color={colours.ppWhite} />
</ActionButton.Item>
<ActionButton.Item
onPress={()=>{this.setState({circleRadius: this.state.circleRadius+200})}}
buttonColor={colours.ppBlue}
style={map_styles.iconButton}
title="More">
<Icon name="plus" type="font-awesome" size={22} color={colours.ppWhite} />
</ActionButton.Item>
{this._renderSaveActionButton()}
{this._renderCancelActionButton()}
</ActionButton>
</View>
<View style={bbStyle(varTop)}>
<TouchableOpacity
hitSlop={hitSlop}
accessibilityLabel="Current Location"
style={map_styles.iconButton}
onPress={ () => this._setUserLocation() }>
<Icon name="location-arrow" type="font-awesome" size={20} color={colours.ppBlue} />
</TouchableOpacity>
</View>
{/*this.renderSDButton(this)*/}
{/*this.renderForCircle(this)*/}
</View>
);
}
}
export default MapView;
const filters = [
{
id: 0,
name: 'None'
},
{
id: 1,
name: '< 1 min ago',
},
{
id: 2,
name: '< 1 hour ago',
},
{
id: 3,
name: '< 12 hours ago',
},
{
id: 4,
name: '< 1 day ago',
},
{
id: 5,
name: '< 7 days ago',
},
{
id: 6,
name: '< 1 month ago',
},
{
id: 7,
name: '< 1 year ago',
}
]