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