1import { useFocusEffect } from '@react-navigation/native';
2import { BlurView } from 'expo-blur';
3import * as Location from 'expo-location';
4import * as Notifications from 'expo-notifications';
5import * as TaskManager from 'expo-task-manager';
6import React from 'react';
7import { NativeSyntheticEvent, StyleSheet, Text, View } from 'react-native';
8import MapView from 'react-native-maps';
9
10import Button from '../../components/Button';
11
12const GEOFENCING_TASK = 'geofencing';
13
14interface GeofencingRegion {
15  identifier: string;
16  latitude: number;
17  longitude: number;
18  radius: number;
19}
20
21export interface MapEvent<T = object>
22  extends NativeSyntheticEvent<
23    T & {
24      coordinate: {
25        latitude: number;
26        longitude: number;
27      };
28      position: {
29        x: number;
30        y: number;
31      };
32    }
33  > {}
34
35type Region = {
36  latitude: number;
37  longitude: number;
38  latitudeDelta: number;
39  longitudeDelta: number;
40};
41
42type State = {
43  isGeofencing: boolean;
44  geofencingRegions: GeofencingRegion[];
45  initialRegion: Region | null;
46};
47
48const initialState: State = { isGeofencing: false, geofencingRegions: [], initialRegion: null };
49
50function reducer(
51  state: State,
52  action:
53    | { type: 'toggle' }
54    | { type: 'clearRegions' }
55    | ({ type: 'update' } & State)
56    | ({ type: 'updateRegions' } & Pick<State, 'geofencingRegions'>)
57): State {
58  switch (action.type) {
59    case 'update':
60      return {
61        isGeofencing: action.isGeofencing,
62        geofencingRegions: action.geofencingRegions,
63        initialRegion: action.initialRegion,
64      };
65    case 'updateRegions':
66      return {
67        ...state,
68        geofencingRegions: action.geofencingRegions,
69      };
70    case 'clearRegions':
71      return { ...state, geofencingRegions: [] };
72    case 'toggle':
73      return { ...state, isGeofencing: !state.isGeofencing };
74  }
75}
76
77export default function GeofencingScreen() {
78  const mapViewRef = React.useRef<MapView>(null);
79  const [state, dispatch] = React.useReducer(reducer, initialState);
80
81  const onFocus = React.useCallback(() => {
82    let isActive = true;
83
84    (async () => {
85      await Location.requestForegroundPermissionsAsync();
86
87      const { coords } = await Location.getCurrentPositionAsync();
88      const isGeofencing = await Location.hasStartedGeofencingAsync(GEOFENCING_TASK);
89      const geofencingRegions = await getSavedRegions();
90
91      if (isActive) {
92        dispatch({
93          type: 'update',
94          isGeofencing,
95          geofencingRegions,
96          initialRegion: {
97            latitude: coords.latitude,
98            longitude: coords.longitude,
99            latitudeDelta: 0.004,
100            longitudeDelta: 0.002,
101          },
102        });
103      }
104    })();
105    return () => (isActive = false);
106  }, []);
107
108  useFocusEffect(onFocus);
109
110  const toggleGeofencing = React.useCallback(async () => {
111    if (state.isGeofencing) {
112      await Location.stopGeofencingAsync(GEOFENCING_TASK);
113      dispatch({ type: 'clearRegions' });
114    } else {
115      await Location.startGeofencingAsync(GEOFENCING_TASK, state.geofencingRegions);
116    }
117    dispatch({ type: 'toggle' });
118  }, [state.isGeofencing, state.geofencingRegions]);
119
120  const centerMap = React.useCallback(async () => {
121    const { coords } = await Location.getCurrentPositionAsync();
122    const mapView = mapViewRef.current;
123
124    if (mapView) {
125      mapView.animateToRegion({
126        latitude: coords.latitude,
127        longitude: coords.longitude,
128        latitudeDelta: 0.004,
129        longitudeDelta: 0.002,
130      });
131    }
132  }, []);
133
134  const onMapPress = async ({ nativeEvent: { coordinate } }: MapEvent) => {
135    const next = [...state.geofencingRegions];
136    next.push({
137      identifier: `${coordinate.latitude},${coordinate.longitude}`,
138      latitude: coordinate.latitude,
139      longitude: coordinate.longitude,
140      radius: 50,
141    });
142    dispatch({ type: 'updateRegions', geofencingRegions: next });
143
144    if (await Location.hasStartedGeofencingAsync(GEOFENCING_TASK)) {
145      // update existing geofencing task
146      await Location.startGeofencingAsync(GEOFENCING_TASK, next);
147    }
148  };
149
150  const renderRegions = React.useCallback(() => {
151    return state.geofencingRegions.map(region => {
152      return (
153        // @ts-ignore
154        <MapView.Circle
155          key={region.identifier}
156          center={region}
157          radius={region.radius}
158          strokeColor="rgba(78,155,222,0.8)"
159          fillColor="rgba(78,155,222,0.2)"
160        />
161      );
162    });
163  }, [state.geofencingRegions]);
164
165  if (!state.initialRegion) {
166    return null;
167  }
168
169  return (
170    <View style={styles.screen}>
171      <View style={styles.heading}>
172        <BlurView tint="light" intensity={70} style={styles.blurView}>
173          <Text style={styles.headingText}>
174            {state.isGeofencing
175              ? 'You will be receiving notifications when the device enters or exits from selected regions.'
176              : // tslint:disable-next-line: max-line-length
177                'Click `Start geofencing` to start getting geofencing notifications. Tap on the map to select geofencing regions.'}
178          </Text>
179        </BlurView>
180      </View>
181
182      <MapView
183        ref={mapViewRef}
184        style={styles.mapView}
185        initialRegion={state.initialRegion}
186        onPress={onMapPress}
187        showsUserLocation>
188        {renderRegions()}
189      </MapView>
190      <View style={styles.buttons}>
191        <View style={styles.leftButtons}>
192          <Button
193            disabled={!state.isGeofencing && state.geofencingRegions.length === 0}
194            buttonStyle={styles.button}
195            title={state.isGeofencing ? 'Stop geofencing' : 'Start geofencing'}
196            onPress={toggleGeofencing}
197          />
198        </View>
199        <Button buttonStyle={styles.button} title="Center" onPress={centerMap} />
200      </View>
201    </View>
202  );
203}
204
205GeofencingScreen.navigationOptions = {
206  title: 'Geofencing Map',
207};
208
209async function getSavedRegions(): Promise<GeofencingRegion[]> {
210  const tasks = await TaskManager.getRegisteredTasksAsync();
211  const task = tasks.find(({ taskName }) => taskName === GEOFENCING_TASK);
212  return task ? task.options.regions : [];
213}
214
215TaskManager.defineTask(GEOFENCING_TASK, async ({ data: { region } }: { data: any }) => {
216  const stateString = Location.GeofencingRegionState[region.state].toLowerCase();
217
218  // tslint:disable-next-line no-console
219  console.log(`${stateString} region ${region.identifier}`);
220
221  await Notifications.scheduleNotificationAsync({
222    content: {
223      title: 'Expo Geofencing',
224      body: `You're ${stateString} a region ${region.identifier}`,
225      data: region,
226    },
227    trigger: null,
228  });
229});
230
231const styles = StyleSheet.create({
232  screen: {
233    flex: 1,
234  },
235  heading: {
236    backgroundColor: 'rgba(255, 255, 0, 0.1)',
237    position: 'absolute',
238    top: 0,
239    right: 0,
240    left: 0,
241    zIndex: 2,
242  },
243  blurView: {
244    flex: 1,
245    padding: 5,
246  },
247  headingText: {
248    textAlign: 'center',
249  },
250  mapView: {
251    flex: 1,
252  },
253  buttons: {
254    flexDirection: 'row',
255    justifyContent: 'space-between',
256    alignItems: 'flex-end',
257    padding: 10,
258    position: 'absolute',
259    right: 0,
260    bottom: 0,
261    left: 0,
262  },
263  leftButtons: {
264    alignItems: 'flex-start',
265  },
266  button: {
267    padding: 10,
268    marginVertical: 5,
269  },
270});
271