1import React from 'react'; 2import { EventEmitter, EventSubscription } from 'fbemitter'; 3import { NavigationEvents, NavigationScreenProp } from 'react-navigation'; 4import { AppState, AsyncStorage, Platform, StyleSheet, Text, View } from 'react-native'; 5import { FontAwesome, MaterialIcons } from '@expo/vector-icons'; 6 7import MapView from 'react-native-maps'; 8import * as Location from 'expo-location'; 9import * as Permissions from 'expo-permissions'; 10import * as TaskManager from 'expo-task-manager'; 11 12import Button from '../../components/Button'; 13import Colors from '../../constants/Colors'; 14 15const STORAGE_KEY = 'expo-home-locations'; 16const LOCATION_UPDATES_TASK = 'location-updates'; 17 18const locationEventsEmitter = new EventEmitter(); 19 20const locationAccuracyStates: { [key in Location.Accuracy]: Location.Accuracy } = { 21 [Location.Accuracy.Lowest]: Location.Accuracy.Low, 22 [Location.Accuracy.Low]: Location.Accuracy.Balanced, 23 [Location.Accuracy.Balanced]: Location.Accuracy.High, 24 [Location.Accuracy.High]: Location.Accuracy.Highest, 25 [Location.Accuracy.Highest]: Location.Accuracy.BestForNavigation, 26 [Location.Accuracy.BestForNavigation]: Location.Accuracy.Lowest, 27}; 28 29const locationActivityTypes: { [key in Location.ActivityType]: Location.ActivityType | undefined } = { 30 [Location.ActivityType.Other]: Location.ActivityType.AutomotiveNavigation, 31 [Location.ActivityType.AutomotiveNavigation]: Location.ActivityType.Fitness, 32 [Location.ActivityType.Fitness]: Location.ActivityType.OtherNavigation, 33 [Location.ActivityType.OtherNavigation]: Location.ActivityType.Airborne, 34 [Location.ActivityType.Airborne]: undefined, 35}; 36 37interface Props { 38 navigation: NavigationScreenProp<{}, any>; 39} 40 41interface State { 42 accuracy: Location.Accuracy; 43 activityType?: Location.ActivityType; 44 isTracking: boolean; 45 savedLocations: []; 46 geofencingRegions: []; 47 initialRegion?: any; 48 showsBackgroundLocationIndicator: boolean; 49 error?: string; 50} 51 52export default class BackgroundLocationMapScreen extends React.Component<Props, State> { 53 static navigationOptions = { 54 title: 'Background location', 55 }; 56 57 mapViewRef = React.createRef<MapView>(); 58 59 readonly state: State = { 60 accuracy: Location.Accuracy.High, 61 isTracking: false, 62 showsBackgroundLocationIndicator: false, 63 savedLocations: [], 64 geofencingRegions: [], 65 }; 66 67 eventSubscription?: EventSubscription; 68 69 didFocus = async () => { 70 if (!await Location.isBackgroundLocationAvailableAsync()) { 71 alert('Background location is not available in this application.'); 72 this.props.navigation.goBack(); 73 return; 74 } 75 76 const { status } = await Permissions.askAsync(Permissions.LOCATION); 77 78 if (status !== 'granted') { 79 AppState.addEventListener('change', this.handleAppStateChange); 80 this.setState({ 81 // tslint:disable-next-line max-line-length 82 error: 'Location permissions are required in order to use this feature. You can manually enable them at any time in the "Location Services" section of the Settings app.', 83 }); 84 return; 85 } else { 86 this.setState({ error: undefined }); 87 } 88 89 const { coords } = await Location.getCurrentPositionAsync(); 90 const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_UPDATES_TASK); 91 const task = (await TaskManager.getRegisteredTasksAsync()).find( 92 ({ taskName }) => taskName === LOCATION_UPDATES_TASK 93 ); 94 const savedLocations = await getSavedLocations(); 95 const accuracy = (task && task.options.accuracy) || this.state.accuracy; 96 97 this.eventSubscription = locationEventsEmitter.addListener('update', (locations: any) => { 98 this.setState({ savedLocations: locations }); 99 }); 100 101 if (!isTracking) { 102 alert('Click `Start tracking` to start getting location updates.'); 103 } 104 105 this.setState({ 106 accuracy, 107 activityType: task && task.options.activityType, 108 isTracking, 109 showsBackgroundLocationIndicator: task && task.options.showsBackgroundLocationIndicator, 110 savedLocations, 111 initialRegion: { 112 latitude: coords.latitude, 113 longitude: coords.longitude, 114 latitudeDelta: 0.004, 115 longitudeDelta: 0.002, 116 }, 117 }); 118 } 119 120 handleAppStateChange = (nextAppState: string) => { 121 if (nextAppState !== 'active') { 122 return; 123 } 124 125 if (this.state.initialRegion) { 126 AppState.removeEventListener('change', this.handleAppStateChange); 127 return; 128 } 129 130 this.didFocus(); 131 } 132 133 componentWillUnmount() { 134 if (this.eventSubscription) { 135 this.eventSubscription.remove(); 136 } 137 AppState.removeEventListener('change', this.handleAppStateChange); 138 } 139 140 async startLocationUpdates(accuracy = this.state.accuracy) { 141 await Location.startLocationUpdatesAsync(LOCATION_UPDATES_TASK, { 142 accuracy, 143 activityType: this.state.activityType, 144 pausesUpdatesAutomatically: this.state.activityType != null, 145 showsBackgroundLocationIndicator: this.state.showsBackgroundLocationIndicator, 146 deferredUpdatesInterval: 60 * 1000, // 1 minute 147 deferredUpdatesDistance: 100, // 100 meters 148 foregroundService: { 149 notificationTitle: 'expo-location-demo', 150 notificationBody: 'Background location is running...', 151 notificationColor: Colors.tintColor, 152 }, 153 }); 154 155 if (!this.state.isTracking) { 156 alert( 157 // tslint:disable-next-line max-line-length 158 'Now you can send app to the background, go somewhere and come back here! You can even terminate the app and it will be woken up when the new significant location change comes out.' 159 ); 160 } 161 this.setState({ isTracking: true }); 162 } 163 164 async stopLocationUpdates() { 165 await Location.stopLocationUpdatesAsync(LOCATION_UPDATES_TASK); 166 this.setState({ isTracking: false }); 167 } 168 169 clearLocations = async () => { 170 await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify([])); 171 this.setState({ savedLocations: [] }); 172 } 173 174 toggleTracking = async () => { 175 await AsyncStorage.removeItem(STORAGE_KEY); 176 177 if (this.state.isTracking) { 178 await this.stopLocationUpdates(); 179 } else { 180 await this.startLocationUpdates(); 181 } 182 this.setState({ savedLocations: [] }); 183 } 184 185 onAccuracyChange = () => { 186 const accuracy = locationAccuracyStates[this.state.accuracy]; 187 188 this.setState({ accuracy }); 189 190 if (this.state.isTracking) { 191 // Restart background task with the new accuracy. 192 this.startLocationUpdates(accuracy); 193 } 194 } 195 196 toggleLocationIndicator = async () => { 197 const showsBackgroundLocationIndicator = !this.state.showsBackgroundLocationIndicator; 198 199 this.setState({ showsBackgroundLocationIndicator }, async () => { 200 if (this.state.isTracking) { 201 await this.startLocationUpdates(); 202 } 203 }); 204 } 205 206 toggleActivityType = () => { 207 if (this.state.activityType) { 208 const nextActivityType = locationActivityTypes[this.state.activityType]; 209 this.setState({ activityType: nextActivityType }); 210 } else { 211 this.setState({ activityType: Location.ActivityType.Other }); 212 } 213 214 if (this.state.isTracking) { 215 // Restart background task with the new activity type 216 this.startLocationUpdates(); 217 } 218 } 219 220 onCenterMap = async () => { 221 const { coords } = await Location.getCurrentPositionAsync(); 222 const mapView = this.mapViewRef.current; 223 224 if (mapView) { 225 mapView.animateToRegion({ 226 latitude: coords.latitude, 227 longitude: coords.longitude, 228 latitudeDelta: 0.004, 229 longitudeDelta: 0.002, 230 }); 231 } 232 } 233 234 renderPolyline() { 235 const { savedLocations } = this.state; 236 237 if (savedLocations.length === 0) { 238 return null; 239 } 240 return ( 241 // @ts-ignore 242 <MapView.Polyline 243 coordinates={savedLocations} 244 strokeWidth={3} 245 strokeColor={Colors.tintColor} 246 /> 247 ); 248 } 249 250 render() { 251 if (this.state.error) { 252 return <Text style={styles.errorText}>{this.state.error}</Text>; 253 } 254 255 if (!this.state.initialRegion) { 256 return <NavigationEvents onDidFocus={this.didFocus} />; 257 } 258 259 return ( 260 <View style={styles.screen}> 261 <MapView 262 ref={this.mapViewRef} 263 style={styles.mapView} 264 initialRegion={this.state.initialRegion} 265 showsUserLocation 266 > 267 {this.renderPolyline()} 268 </MapView> 269 <View style={styles.buttons} pointerEvents="box-none"> 270 <View style={styles.topButtons}> 271 <View style={styles.buttonsColumn}> 272 {Platform.OS === 'android' ? null : ( 273 <Button style={styles.button} onPress={this.toggleLocationIndicator}> 274 <View style={styles.buttonContentWrapper}> 275 <Text style={styles.text}>{this.state.showsBackgroundLocationIndicator ? 'Hide' : 'Show'}</Text> 276 <Text style={styles.text}> background </Text> 277 <FontAwesome name="location-arrow" size={20} color="white" /> 278 <Text style={styles.text}> indicator</Text> 279 </View> 280 </Button> 281 )} 282 {Platform.OS === 'android' ? null : ( 283 <Button style={styles.button} 284 onPress={this.toggleActivityType} 285 title={this.state.activityType ? `Activity type: ${Location.ActivityType[this.state.activityType]}` : 'No activity type'} 286 /> 287 )} 288 <Button 289 title={`Accuracy: ${Location.Accuracy[this.state.accuracy]}`} 290 style={styles.button} 291 onPress={this.onAccuracyChange} 292 /> 293 </View> 294 <View style={styles.buttonsColumn}> 295 <Button style={styles.button} onPress={this.onCenterMap}> 296 <MaterialIcons name="my-location" size={20} color="white" /> 297 </Button> 298 </View> 299 </View> 300 301 <View style={styles.bottomButtons}> 302 <Button title="Clear locations" style={styles.button} onPress={this.clearLocations} /> 303 <Button 304 title={this.state.isTracking ? 'Stop tracking' : 'Start tracking'} 305 style={styles.button} 306 onPress={this.toggleTracking} 307 /> 308 </View> 309 </View> 310 </View> 311 ); 312 } 313} 314 315async function getSavedLocations() { 316 try { 317 const item = await AsyncStorage.getItem(STORAGE_KEY); 318 return item ? JSON.parse(item) : []; 319 } catch (e) { 320 return []; 321 } 322} 323 324TaskManager.defineTask(LOCATION_UPDATES_TASK, async ({ data: { locations } }: any) => { 325 if (locations && locations.length > 0) { 326 const savedLocations = await getSavedLocations(); 327 const newLocations = locations.map(({ coords }: any) => ({ 328 latitude: coords.latitude, 329 longitude: coords.longitude, 330 })); 331 332 // tslint:disable-next-line no-console 333 console.log(`Received new locations at ${new Date()}:`, locations); 334 335 savedLocations.push(...newLocations); 336 await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations)); 337 338 locationEventsEmitter.emit('update', savedLocations); 339 } 340}); 341 342const styles = StyleSheet.create({ 343 screen: { 344 flex: 1, 345 }, 346 mapView: { 347 flex: 1, 348 }, 349 buttons: { 350 flex: 1, 351 flexDirection: 'column', 352 justifyContent: 'space-between', 353 padding: 10, 354 position: 'absolute', 355 top: 0, 356 right: 0, 357 bottom: 0, 358 left: 0, 359 }, 360 topButtons: { 361 flexDirection: 'row', 362 justifyContent: 'space-between', 363 }, 364 bottomButtons: { 365 flexDirection: 'column', 366 alignItems: 'flex-end', 367 }, 368 buttonsColumn: { 369 flexDirection: 'column', 370 alignItems: 'flex-start', 371 }, 372 button: { 373 paddingVertical: 5, 374 paddingHorizontal: 10, 375 marginVertical: 5, 376 }, 377 buttonContentWrapper: { 378 flexDirection: 'row', 379 }, 380 text: { 381 color: 'white', 382 fontWeight: '700', 383 }, 384 errorText: { 385 fontSize: 15, 386 color: 'rgba(0,0,0,0.7)', 387 margin: 20, 388 }, 389}); 390