diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d987c6364..6a2e525a5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + + + diff --git a/app.json b/app.json index d9463baab..1d42af137 100644 --- a/app.json +++ b/app.json @@ -1,4 +1,14 @@ { "name": "CoMapeo", - "plugins": ["expo-localization", "expo-secure-store"] + "plugins": [ + "expo-localization", + "expo-secure-store", + [ + "expo-location", + { + "isIosBackgroundLocationEnabled": true, + "isAndroidBackgroundLocationEnabled": true + } + ] + ] } diff --git a/babel.config.js b/babel.config.js index 3c181ebfc..63f23328d 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,7 @@ module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ + 'transform-inline-environment-variables', // react-native-reanimated/plugin has to be last 'react-native-reanimated/plugin', ], diff --git a/messages/en.json b/messages/en.json index 8534d05ca..cc2876899 100644 --- a/messages/en.json +++ b/messages/en.json @@ -19,6 +19,27 @@ "description": "Title of dialog that shows when cancelling a new observation", "message": "Discard observation?" }, + "Modal.GPSDisable.button": { + "message": "Enable" + }, + "Modal.GPSDisable.description": { + "message": "To create a Track CoMapeo needs access to your location and GPS." + }, + "Modal.GPSDisable.title": { + "message": "GPS Disabled" + }, + "Modal.GPSEnable.button.default": { + "message": "Start Tracks" + }, + "Modal.GPSEnable.button.loading": { + "message": "Loading…" + }, + "Modal.GPSEnable.button.stop": { + "message": "Stop Tracks" + }, + "Modal.GPSEnable.trackingDescription": { + "message": "You’ve been recording for" + }, "Screens.Settings.AppSettings.coordinateSystem": { "message": "Coordinate System" }, diff --git a/package-lock.json b/package-lock.json index 715342036..176b62f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@react-navigation/native-stack": "^6.9.13", "@rnmapbox/maps": "^10.1.16", "@tanstack/react-query": "^5.12.2", + "@types/luxon": "^3.4.2", "assert": "^2.0.0", "buffer": "^6.0.3", "cheap-ruler": "^3.0.2", @@ -34,11 +35,14 @@ "expo-camera": "~14.0.5", "expo-crypto": "~12.8.1", "expo-localization": "~14.8.3", - "expo-location": "~16.5.4", + "expo-location": "~16.5.5", "expo-secure-store": "~12.8.1", "expo-sensors": "~12.9.1", + "expo-task-manager": "~11.7.2", + "geojson": "^0.5.0", "geojson-geometries-lookup": "^0.5.0", "lodash.isequal": "^4.5.0", + "luxon": "^3.4.4", "nanoid": "^5.0.1", "nodejs-mobile-react-native": "^18.17.7", "react": "18.2.0", @@ -99,6 +103,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "babel-jest": "^29.6.3", + "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "^8.19.0", "eslint-config-prettier": "^9.1.0", "execa": "^8.0.1", @@ -8687,6 +8692,11 @@ "@types/lodash": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "node_modules/@types/ms": { "version": "0.7.31", "dev": true, @@ -9709,6 +9719,12 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-plugin-transform-inline-environment-variables": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-inline-environment-variables/-/babel-plugin-transform-inline-environment-variables-0.4.4.tgz", + "integrity": "sha512-bJILBtn5a11SmtR2j/3mBOjX4K3weC6cq+NNZ7hG22wCAqpc3qtj/iN7dSe9HDiS46lgp1nHsQgeYrea/RUe+g==", + "dev": true + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "dev": true, @@ -12795,9 +12811,9 @@ } }, "node_modules/expo-location": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-16.5.4.tgz", - "integrity": "sha512-jJ675jhL5N3azaWIu2v585ivERIBMoBBKYMrAbxrACsAmU2qMpEkQMcJ1pAFClUZhNBsONoa06NTr2/JZbGDMw==", + "version": "16.5.5", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-16.5.5.tgz", + "integrity": "sha512-dXEd1HaZgdi6yHVF8R+SMnGlKDYrD+Hkkzd/b9edjMSUBLxF2y824AFSSNUf6BVOM53tJBOFEELneXkU1uj9nA==", "peerDependencies": { "expo": "*" } @@ -12943,6 +12959,17 @@ "expo": "*" } }, + "node_modules/expo-task-manager": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-11.7.2.tgz", + "integrity": "sha512-cmn7xg8+mGP7gX6deYZhvrCkKMkoBRJ+E4o5aL17Z/4ihXMfo/PFcQsrpuSYRLXzgidEw0kpppxhmYm21Jswwg==", + "dependencies": { + "unimodules-app-loader": "~4.5.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.1", "license": "Apache-2.0" @@ -13584,6 +13611,14 @@ "node": ">=6.9.0" } }, + "node_modules/geojson": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", + "integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/geojson-geometries": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/geojson-geometries/-/geojson-geometries-2.0.0.tgz", @@ -17423,6 +17458,14 @@ "version": "2.3.9", "license": "MIT" }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-bytes.js": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz", @@ -23635,6 +23678,11 @@ "node": ">=4" } }, + "node_modules/unimodules-app-loader": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-4.5.0.tgz", + "integrity": "sha512-q/Xug4K6/20876Xac+tjOLOOAeHEu2zF66LNN/5c8EV4WPEe/+RYZEljN/woQt17KPIB2eyel9dc+d6qUMjUOg==" + }, "node_modules/unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", diff --git a/package.json b/package.json index 1f3615c01..1ca04dd4f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@react-navigation/native-stack": "^6.9.13", "@rnmapbox/maps": "^10.1.16", "@tanstack/react-query": "^5.12.2", + "@types/luxon": "^3.4.2", "assert": "^2.0.0", "buffer": "^6.0.3", "cheap-ruler": "^3.0.2", @@ -48,11 +49,14 @@ "expo-camera": "~14.0.5", "expo-crypto": "~12.8.1", "expo-localization": "~14.8.3", - "expo-location": "~16.5.4", + "expo-location": "~16.5.5", "expo-secure-store": "~12.8.1", "expo-sensors": "~12.9.1", + "expo-task-manager": "~11.7.2", + "geojson": "^0.5.0", "geojson-geometries-lookup": "^0.5.0", "lodash.isequal": "^4.5.0", + "luxon": "^3.4.4", "nanoid": "^5.0.1", "nodejs-mobile-react-native": "^18.17.7", "react": "18.2.0", @@ -113,6 +117,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "babel-jest": "^29.6.3", + "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "^8.19.0", "eslint-config-prettier": "^9.1.0", "execa": "^8.0.1", diff --git a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx index 919b29e19..500788984 100644 --- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx +++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx @@ -1,9 +1,9 @@ -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import { + BottomTabNavigationProp, + createBottomTabNavigator, +} from '@react-navigation/bottom-tabs'; import {NavigatorScreenParams} from '@react-navigation/native'; import * as React from 'react'; -import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import {useForegroundPermissions} from 'expo-location'; - import {HomeHeader} from '../../sharedComponents/HomeHeader'; import {RootStack} from '../AppStack'; import {MessageDescriptor} from 'react-intl'; @@ -50,15 +50,20 @@ import { GpsModal, createNavigationOptions as createGpsModalNavigationOptions, } from '../../screens/GpsModal'; -import {useLocation} from '../../hooks/useLocation'; -import {useLocationProviderStatus} from '../../hooks/useLocationProviderStatus'; -import {getLocationStatus} from '../../lib/utils'; +import {useCurrentTab} from '../../hooks/useCurrentTab'; +import {TrackingTabBarIcon} from './TabBar/TrackingTabBarIcon'; +import {TabName} from '../types'; +import {CameraTabBarIcon} from './TabBar/CameraTabBarIcon'; +import {MapTabBarIcon} from './TabBar/MapTabBarIcon'; import {InviteDeclined} from '../../screens/Settings/ProjectSettings/YourTeam/InviteDeclined'; import {UnableToCancelInvite} from '../../screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite'; +export const TAB_BAR_HEIGHT = 70; + export type HomeTabsList = { Map: undefined; Camera: undefined; + Tracking: undefined; }; type InviteProps = { @@ -136,42 +141,56 @@ export type AppList = { const Tab = createBottomTabNavigator(); const HomeTabs = () => { - const locationState = useLocation({maxDistanceInterval: 0}); - const [permissions] = useForegroundPermissions(); - const locationProviderStatus = useLocationProviderStatus(); - - const precision = locationState.location?.coords.accuracy; - - const locationStatus = - !!locationState.error || !permissions?.granted - ? 'error' - : getLocationStatus({ - location: locationState.location, - providerStatus: locationProviderStatus, - }); + const {handleTabPress} = useCurrentTab(); return ( ({ - tabBarIcon: ({color}) => { - const iconName = route.name === 'Map' ? 'map' : 'photo-camera'; - return ; - }, - header: () => ( - - ), + tabBarStyle: {height: TAB_BAR_HEIGHT}, + tabBarShowLabel: false, headerTransparent: true, tabBarTestID: 'tabBarButton' + route.name, + header: HomeHeader, })} - initialRouteName="Map" + initialRouteName={TabName.Map} backBehavior="initialRoute"> - - + + + {process.env.FEATURE_TRACKS && ( + ; + }) => ({ + tabPress: e => { + e.preventDefault(); + navigation.navigate(TabName.Map); + }, + })} + children={() => <>} + /> + )} ); }; diff --git a/src/frontend/Navigation/ScreenGroups/TabBar/CameraTabBarIcon.tsx b/src/frontend/Navigation/ScreenGroups/TabBar/CameraTabBarIcon.tsx new file mode 100644 index 000000000..25694426c --- /dev/null +++ b/src/frontend/Navigation/ScreenGroups/TabBar/CameraTabBarIcon.tsx @@ -0,0 +1,16 @@ +import React, {FC} from 'react'; +import {TabBarIconProps, TabName} from '../../types'; +import {TabBarIcon} from './TabBarIcon'; +import {useTabNavigationStore} from '../../../hooks/useTabNavigationStore.ts'; + +export const CameraTabBarIcon: FC = props => { + const {currentTab} = useTabNavigationStore(); + + return ( + + ); +}; diff --git a/src/frontend/Navigation/ScreenGroups/TabBar/MapTabBarIcon.tsx b/src/frontend/Navigation/ScreenGroups/TabBar/MapTabBarIcon.tsx new file mode 100644 index 000000000..a7c7dbd82 --- /dev/null +++ b/src/frontend/Navigation/ScreenGroups/TabBar/MapTabBarIcon.tsx @@ -0,0 +1,16 @@ +import React, {FC} from 'react'; +import {TabBarIconProps, TabName} from '../../types'; +import {TabBarIcon} from './TabBarIcon'; +import {useTabNavigationStore} from '../../../hooks/useTabNavigationStore.ts'; + +export const MapTabBarIcon: FC = props => { + const {currentTab} = useTabNavigationStore(); + + return ( + + ); +}; diff --git a/src/frontend/Navigation/ScreenGroups/TabBar/TabBarIcon.tsx b/src/frontend/Navigation/ScreenGroups/TabBar/TabBarIcon.tsx new file mode 100644 index 000000000..e2d6604ea --- /dev/null +++ b/src/frontend/Navigation/ScreenGroups/TabBar/TabBarIcon.tsx @@ -0,0 +1,18 @@ +import React, {FC} from 'react'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import {TabBarIconProps} from '../../types'; +import {COMAPEO_BLUE, MEDIUM_GREY} from '../../../lib/styles'; + +export interface TabBarIcon extends TabBarIconProps { + iconName: string; +} + +export const TabBarIcon: FC = ({size, iconName, focused}) => { + return ( + + ); +}; diff --git a/src/frontend/Navigation/ScreenGroups/TabBar/TrackingTabBarIcon.tsx b/src/frontend/Navigation/ScreenGroups/TabBar/TrackingTabBarIcon.tsx new file mode 100644 index 000000000..d18095e80 --- /dev/null +++ b/src/frontend/Navigation/ScreenGroups/TabBar/TrackingTabBarIcon.tsx @@ -0,0 +1,49 @@ +import React, {FC} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {TabBarIcon} from './TabBarIcon'; +import {useTracking} from '../../../hooks/tracks/useTracking'; +import {Text} from '../../../sharedComponents/Text'; +import {TabBarIconProps, TabName} from '../../types'; +import {useTrackTimerContext} from '../../../contexts/TrackTimerContext'; +import {useTabNavigationStore} from '../../../hooks/useTabNavigationStore.ts'; + +export const TrackingTabBarIcon: FC = props => { + const {isTracking} = useTracking(); + const {timer} = useTrackTimerContext(); + const {currentTab} = useTabNavigationStore(); + + return ( + <> + {isTracking && ( + + + {timer} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + runtimeWrapper: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + indicator: { + marginRight: 5, + height: 10, + width: 10, + borderRadius: 99, + backgroundColor: '#59A553', + }, + timer: { + marginLeft: 5, + fontSize: 12, + }, +}); diff --git a/src/frontend/Navigation/types.ts b/src/frontend/Navigation/types.ts new file mode 100644 index 000000000..581d18cb6 --- /dev/null +++ b/src/frontend/Navigation/types.ts @@ -0,0 +1,11 @@ +export interface TabBarIconProps { + size: number; + focused: boolean; + color: string; +} + +export enum TabName { + Map = 'Map', + Camera = 'Camera', + Tracking = 'Tracking', +} diff --git a/src/frontend/contexts/ExternalProviders.tsx b/src/frontend/contexts/ExternalProviders.tsx index 61a09d03d..7611df536 100644 --- a/src/frontend/contexts/ExternalProviders.tsx +++ b/src/frontend/contexts/ExternalProviders.tsx @@ -11,6 +11,9 @@ import { // See https://github.com/gorhom/react-native-bottom-sheet/issues/1157 import {BottomSheetModalProvider} from '@gorhom/bottom-sheet'; import {AppStackList} from '../Navigation/AppStack'; +import {GPSModalContextProvider} from './GPSModalContext'; +import {TrackTimerContextProvider} from './TrackTimerContext'; +import {SharedLocationContextProvider} from './SharedLocationContext'; type ExternalProvidersProp = { children: React.ReactNode; @@ -26,9 +29,17 @@ export const ExternalProviders = ({ return ( - - {children} - + + + + + + {children} + + + + + ); diff --git a/src/frontend/contexts/GPSModalContext.tsx b/src/frontend/contexts/GPSModalContext.tsx new file mode 100644 index 000000000..cdbdaa338 --- /dev/null +++ b/src/frontend/contexts/GPSModalContext.tsx @@ -0,0 +1,31 @@ +import {BottomSheetModal} from '@gorhom/bottom-sheet'; +import {BottomSheetModalMethods} from '@gorhom/bottom-sheet/lib/typescript/types'; +import React, {createContext, useContext, useRef} from 'react'; + +interface GPSModalContext { + bottomSheetRef: React.RefObject; +} + +const GPSModalContext = createContext(null); + +const GPSModalContextProvider = ({children}: {children: React.ReactNode}) => { + const bottomSheetRef = useRef(null); + + return ( + + {children} + + ); +}; + +function useGPSModalContext() { + const context = useContext(GPSModalContext); + if (!context) { + throw new Error( + 'useGPSModalContext must be used within a GPSModalContextProvider', + ); + } + return context; +} + +export {GPSModalContextProvider, useGPSModalContext}; diff --git a/src/frontend/contexts/SharedLocationContext.tsx b/src/frontend/contexts/SharedLocationContext.tsx new file mode 100644 index 000000000..bf9bfceb2 --- /dev/null +++ b/src/frontend/contexts/SharedLocationContext.tsx @@ -0,0 +1,74 @@ +import {createContext, useContext, useEffect, useRef, useState} from 'react'; +import {LocationState, useLocation} from '../hooks/useLocation'; +import React from 'react'; +import { + getBackgroundPermissionsAsync, + getForegroundPermissionsAsync, +} from 'expo-location'; +import {AppState} from 'react-native'; + +interface SharedLocationContext { + locationState: LocationState; + bgPermissions: boolean | null; + fgPermissions: boolean | null; +} + +const SharedLocationContext = createContext(null); + +const SharedLocationContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const location = useLocation({maxDistanceInterval: 3}); + const appState = useRef(AppState.currentState); + const [bgPermissions, setBgPermissions] = useState(null); + const [fgPermissions, setFgPermissions] = useState(null); + + const refreshPermissionState = () => { + getBackgroundPermissionsAsync().then(({granted}) => + setBgPermissions(granted), + ); + getForegroundPermissionsAsync().then(({granted}) => + setFgPermissions(granted), + ); + }; + + useEffect(refreshPermissionState, []); + useEffect(() => { + const subscription = AppState.addEventListener('change', newState => { + if ( + appState.current.match(/inactive|background/) && + newState === 'active' + ) { + refreshPermissionState(); + } + appState.current = newState; + }); + + return () => subscription.remove(); + }, []); + + return ( + + {children} + + ); +}; + +function useSharedLocationContext() { + const context = useContext(SharedLocationContext); + if (!context) { + throw new Error( + 'useSharedLocationContext must be used within a SharedLocationContextProvider', + ); + } + return context; +} + +export {SharedLocationContextProvider, useSharedLocationContext}; diff --git a/src/frontend/contexts/TrackTimerContext.tsx b/src/frontend/contexts/TrackTimerContext.tsx new file mode 100644 index 000000000..a95048160 --- /dev/null +++ b/src/frontend/contexts/TrackTimerContext.tsx @@ -0,0 +1,32 @@ +import React, {createContext, useContext} from 'react'; +import {useCurrentTrackStore} from '../hooks/tracks/useCurrentTrackStore'; +import {useFormattedTimeSince} from '../hooks/useFormattedTimeSince'; + +interface TrackTimerContext { + timer: string; +} + +const TrackTimerContext = createContext(null); + +const TrackTimerContextProvider = ({children}: {children: React.ReactNode}) => { + const trackingSince = useCurrentTrackStore(state => state.trackingSince); + const timer = useFormattedTimeSince(trackingSince, 1000); + + return ( + + {children} + + ); +}; + +function useTrackTimerContext() { + const context = useContext(TrackTimerContext); + if (!context) { + throw new Error( + 'useTrackTimerContext must be used within a TrackTimerContextProvider', + ); + } + return context; +} + +export {TrackTimerContextProvider, useTrackTimerContext}; diff --git a/src/frontend/hooks/tracks/useCurrentTrackStore.ts b/src/frontend/hooks/tracks/useCurrentTrackStore.ts new file mode 100644 index 000000000..2febd86ab --- /dev/null +++ b/src/frontend/hooks/tracks/useCurrentTrackStore.ts @@ -0,0 +1,64 @@ +import {create} from 'zustand'; +import {calculateTotalDistance} from '../../utils/distance'; +import {LocationHistoryPoint} from '../../sharedTypes/location'; + +type TracksStoreState = { + locationHistory: LocationHistoryPoint[]; + observations: string[]; + distance: number; + addNewObservation: (observationId: string) => void; + addNewLocations: (locationData: LocationHistoryPoint[]) => void; + clearLocationHistory: () => void; + setTracking: (val: boolean) => void; +} & ( + | { + isTracking: true; + trackingSince: Date; + } + | { + isTracking: false; + trackingSince: null; + } +); + +export const useCurrentTrackStore = create(set => ({ + isTracking: false, + locationHistory: [], + observations: [], + distance: 0, + trackingSince: null, + addNewObservation: (id: string) => + set(state => ({observations: [...state.observations, id]})), + addNewLocations: data => + set(({locationHistory, distance}) => { + if (data.length > 1) { + return { + locationHistory: [...locationHistory, ...data], + distance: distance + calculateTotalDistance(data), + }; + } + + if (locationHistory.length < 1) { + return { + locationHistory: [...locationHistory, ...data], + }; + } + + const lastLocation = locationHistory[locationHistory.length - 1]; + if (!lastLocation) { + throw Error('No lastLocation for state.locationHistory.length > 1'); + } + + return { + locationHistory: [...locationHistory, ...data], + distance: distance + calculateTotalDistance([lastLocation, ...data]), + }; + }), + clearLocationHistory: () => set(() => ({locationHistory: []})), + setTracking: (val: boolean) => + set(() => + val + ? {isTracking: true, trackingSince: new Date()} + : {isTracking: false, trackingSince: null}, + ), +})); diff --git a/src/frontend/hooks/tracks/useTracking.ts b/src/frontend/hooks/tracks/useTracking.ts new file mode 100644 index 000000000..51994c40f --- /dev/null +++ b/src/frontend/hooks/tracks/useTracking.ts @@ -0,0 +1,67 @@ +import * as Location from 'expo-location'; +import * as TaskManager from 'expo-task-manager'; +import {useCallback, useState} from 'react'; +import {useCurrentTrackStore} from './useCurrentTrackStore'; +import React from 'react'; +import {FullLocationData} from '../../sharedTypes/location'; + +export const LOCATION_TASK_NAME = 'background-location-task'; + +type LocationCallbackInfo = { + data: {locations: FullLocationData[]} | null; + error: TaskManager.TaskManagerError | null; +}; + +export function useTracking() { + const [loading, setLoading] = useState(false); + const addNewLocations = useCurrentTrackStore(state => state.addNewLocations); + const setTracking = useCurrentTrackStore(state => state.setTracking); + const isTracking = useCurrentTrackStore(state => state.isTracking); + + const addNewTrackLocations = useCallback( + ({data, error}: LocationCallbackInfo) => { + if (error) { + console.error('Error while processing location update callback', error); + } + if (data?.locations) { + addNewLocations( + data.locations.map(loc => ({ + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + timestamp: loc.timestamp, + })), + ); + } + }, + [addNewLocations], + ); + + React.useEffect(() => { + TaskManager.defineTask(LOCATION_TASK_NAME, addNewTrackLocations); + }, [addNewTrackLocations]); + + const startTracking = useCallback(async () => { + if (isTracking) { + console.warn('Start tracking attempt while tracking already enabled'); + setLoading(false); + return; + } + + setLoading(true); + + await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, { + accuracy: Location.Accuracy.Highest, + activityType: Location.LocationActivityType.Fitness, + }); + + setTracking(true); + setLoading(false); + }, [isTracking, setTracking]); + + const cancelTracking = useCallback(async () => { + await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME); + setTracking(false); + }, [setTracking]); + + return {isTracking, startTracking, cancelTracking, loading}; +} diff --git a/src/frontend/hooks/useCurrentTab.ts b/src/frontend/hooks/useCurrentTab.ts new file mode 100644 index 000000000..33520bd85 --- /dev/null +++ b/src/frontend/hooks/useCurrentTab.ts @@ -0,0 +1,21 @@ +import {EventArg} from '@react-navigation/native'; +import {useGPSModalContext} from '../contexts/GPSModalContext'; +import {useTabNavigationStore} from './useTabNavigationStore.ts'; +import {TabName} from '../Navigation/types'; + +export const useCurrentTab = () => { + const {setCurrentTab} = useTabNavigationStore(); + const {bottomSheetRef} = useGPSModalContext(); + + const handleTabPress = ({target}: EventArg<'tabPress', true, undefined>) => { + const targetTab = target?.split('-')[0]; + if (targetTab === TabName.Tracking) { + bottomSheetRef.current?.present(); + } else { + bottomSheetRef.current?.close(); + } + setCurrentTab(targetTab as TabName); + }; + + return {handleTabPress}; +}; diff --git a/src/frontend/hooks/useFormattedTimeSince.ts b/src/frontend/hooks/useFormattedTimeSince.ts new file mode 100644 index 000000000..363baf2ff --- /dev/null +++ b/src/frontend/hooks/useFormattedTimeSince.ts @@ -0,0 +1,18 @@ +import {useEffect, useState} from 'react'; +import {Duration} from 'luxon'; + +export const useFormattedTimeSince = (start: Date | null, interval: number) => { + const [currentTime, setCurrentTime] = useState(new Date()); + let startDate = start ? start : new Date(); + + useEffect(() => { + setCurrentTime(new Date()); + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, interval); + return () => clearInterval(timer); + }, [interval]); + + const millisPassed = Math.abs(currentTime.getTime() - startDate.getTime()); + return Duration.fromMillis(millisPassed).toFormat('hh:mm:ss'); +}; diff --git a/src/frontend/hooks/useLocation.ts b/src/frontend/hooks/useLocation.ts index bd6a2f5ac..aa1c5b546 100644 --- a/src/frontend/hooks/useLocation.ts +++ b/src/frontend/hooks/useLocation.ts @@ -1,4 +1,3 @@ -import {useFocusEffect} from '@react-navigation/native'; import CheapRuler from 'cheap-ruler'; import { watchPositionAsync, @@ -6,7 +5,7 @@ import { type LocationObject, Accuracy, } from 'expo-location'; -import React from 'react'; +import React, {useEffect} from 'react'; interface LocationOptions { /** Only update location if it has changed by at least this distance in meters (or maxTimeInterval has passed) */ @@ -37,46 +36,44 @@ export function useLocation({ const [permissions] = useForegroundPermissions(); - useFocusEffect( - React.useCallback(() => { - if (!permissions || !permissions.granted) return; + useEffect(() => { + if (!permissions || !permissions.granted) return; - let ignore = false; - const locationSubscriptionProm = watchPositionAsync( - { - accuracy: Accuracy.BestForNavigation, - distanceInterval, - }, - debounceLocation({ - minTimeInterval, - maxTimeInterval, - maxDistanceInterval, - })(location => { - if (ignore) return; - setLocation({location, error: undefined}); - }), - ); - - // Should not happen because we are checking permissions above, but just in case - locationSubscriptionProm.catch(error => { + let ignore = false; + const locationSubscriptionProm = watchPositionAsync( + { + accuracy: Accuracy.BestForNavigation, + distanceInterval, + }, + debounceLocation({ + minTimeInterval, + maxTimeInterval, + maxDistanceInterval, + })(location => { if (ignore) return; - setLocation(({location}) => { - return {location, error}; - }); + setLocation({location, error: undefined}); + }), + ); + + // Should not happen because we are checking permissions above, but just in case + locationSubscriptionProm.catch(error => { + if (ignore) return; + setLocation(({location}) => { + return {location, error}; }); + }); - return () => { - ignore = true; - locationSubscriptionProm.then(sub => sub.remove()); - }; - }, [ - permissions, - distanceInterval, - minTimeInterval, - maxTimeInterval, - maxDistanceInterval, - ]), - ); + return () => { + ignore = true; + locationSubscriptionProm.then(sub => sub.remove()); + }; + }, [ + distanceInterval, + maxDistanceInterval, + maxTimeInterval, + minTimeInterval, + permissions, + ]); return location; } diff --git a/src/frontend/hooks/useTabNavigationStore.ts b/src/frontend/hooks/useTabNavigationStore.ts new file mode 100644 index 000000000..3918e2b04 --- /dev/null +++ b/src/frontend/hooks/useTabNavigationStore.ts @@ -0,0 +1,14 @@ +import {create} from 'zustand'; +import {TabName} from '../Navigation/types'; + +type NavigationStoreState = { + currentTab: TabName; + initialRouteName: TabName.Map; + setCurrentTab: (tab: TabName) => void; +}; + +export const useTabNavigationStore = create(set => ({ + initialRouteName: TabName.Map, + currentTab: TabName.Map, + setCurrentTab: (tab: TabName) => set(() => ({currentTab: tab})), +})); diff --git a/src/frontend/images/StartTracking.svg b/src/frontend/images/StartTracking.svg new file mode 100644 index 000000000..0f52c9c71 --- /dev/null +++ b/src/frontend/images/StartTracking.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/images/StopTracking.svg b/src/frontend/images/StopTracking.svg new file mode 100644 index 000000000..2e5139a24 --- /dev/null +++ b/src/frontend/images/StopTracking.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/images/alert-icon.png b/src/frontend/images/alert-icon.png new file mode 100644 index 000000000..8bcb094e8 Binary files /dev/null and b/src/frontend/images/alert-icon.png differ diff --git a/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsDisabled.tsx b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsDisabled.tsx new file mode 100644 index 000000000..29b409653 --- /dev/null +++ b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsDisabled.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import {Image, Linking, StyleSheet, View} from 'react-native'; +import {Button} from '../../../sharedComponents/Button'; +import {Text} from '../../../sharedComponents/Text'; +import * as Location from 'expo-location'; +import {defineMessages, useIntl} from 'react-intl'; + +const handleOpenSettings = () => { + Linking.sendIntent('android.settings.LOCATION_SOURCE_SETTINGS'); +}; + +const m = defineMessages({ + gpsDisabledTitle: { + id: 'Modal.GPSDisable.title', + defaultMessage: 'GPS Disabled', + }, + gpsDisabledDescription: { + id: 'Modal.GPSDisable.description', + defaultMessage: + 'To create a Track CoMapeo needs access to your location and GPS.', + }, + gpsDisabledButtonText: { + id: 'Modal.GPSDisable.button', + defaultMessage: 'Enable', + }, +}); + +interface GPSPermissionsDisabled { + setIsGranted: React.Dispatch>; +} +export const GPSPermissionsDisabled: React.FC = ({ + setIsGranted, +}) => { + const {formatMessage} = useIntl(); + const requestForLocationPermissions = async () => { + const [foregroundPermission, backgroundPermission] = await Promise.all([ + Location.requestForegroundPermissionsAsync(), + Location.requestBackgroundPermissionsAsync(), + ]); + if (foregroundPermission.granted && backgroundPermission.granted) { + setIsGranted(true); + } else if ( + !foregroundPermission.canAskAgain || + !backgroundPermission.canAskAgain + ) { + handleOpenSettings(); + } + }; + + return ( + + + + {formatMessage(m.gpsDisabledTitle)} + + {formatMessage(m.gpsDisabledDescription)} + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + padding: 30, + zIndex: 11, + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + }, + image: {marginBottom: 30}, + title: {fontSize: 24, fontWeight: 'bold', textAlign: 'center'}, + description: {fontSize: 20, textAlign: 'center', marginBottom: 30}, + button: {marginBottom: 20, marginVertical: 8.5}, + buttonText: {fontWeight: '500', color: '#fff'}, +}); diff --git a/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx new file mode 100644 index 000000000..9ef41fd40 --- /dev/null +++ b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx @@ -0,0 +1,111 @@ +import React, {useCallback} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Button} from '../../../sharedComponents/Button'; +import {Text} from '../../../sharedComponents/Text'; +import {useTracking} from '../../../hooks/tracks/useTracking'; +import StartTrackingIcon from '../../../images/StartTracking.svg'; +import StopTrackingIcon from '../../../images/StopTracking.svg'; +import {useTrackTimerContext} from '../../../contexts/TrackTimerContext'; +import {defineMessages, useIntl} from 'react-intl'; + +const m = defineMessages({ + defaultButtonText: { + id: 'Modal.GPSEnable.button.default', + defaultMessage: 'Start Tracks', + }, + stopButtonText: { + id: 'Modal.GPSEnable.button.stop', + defaultMessage: 'Stop Tracks', + }, + loadingButtonText: { + id: 'Modal.GPSEnable.button.loading', + defaultMessage: 'Loading...', + }, + trackingDescription: { + id: 'Modal.GPSEnable.trackingDescription', + defaultMessage: 'You’ve been recording for', + }, +}); + +export const GPSPermissionsEnabled = () => { + const {formatMessage} = useIntl(); + const {isTracking, cancelTracking, startTracking, loading} = useTracking(); + const {timer} = useTrackTimerContext(); + + const styles = getStyles(isTracking); + + const handleTracking = useCallback(() => { + isTracking ? cancelTracking() : startTracking(); + }, [cancelTracking, isTracking, startTracking]); + + const getButtonTitle = () => { + if (loading) return m.loadingButtonText; + if (isTracking) return m.stopButtonText; + return m.defaultButtonText; + }; + + return ( + + + {isTracking && ( + + + + {formatMessage(m.trackingDescription)} + + {timer} + + )} + + ); +}; + +const getStyles = (isTracking: boolean) => { + return StyleSheet.create({ + button: {backgroundColor: isTracking ? '#D92222' : '#0066FF'}, + container: {paddingHorizontal: 20, paddingVertical: 30, height: 140}, + buttonWrapper: { + flexDirection: 'row', + display: 'flex', + alignItems: 'center', + width: '100%', + }, + buttonText: { + fontWeight: '500', + color: '#fff', + width: '100%', + flex: 1, + textAlign: 'center', + }, + runtimeWrapper: { + paddingTop: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + indicator: { + marginRight: 5, + height: 10, + width: 10, + borderRadius: 99, + backgroundColor: '#59A553', + }, + text: {fontSize: 16}, + timer: { + marginLeft: 5, + fontWeight: 'bold', + fontSize: 16, + }, + }); +}; diff --git a/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsModal.tsx b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsModal.tsx new file mode 100644 index 000000000..6a87124a2 --- /dev/null +++ b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsModal.tsx @@ -0,0 +1,59 @@ +import React, {useEffect, useState} from 'react'; +import {GPSPermissionsDisabled} from './GPSPermissionsDisabled'; +import {GPSPermissionsEnabled} from './GPSPermissionsEnabled'; +import * as Location from 'expo-location'; +import {useGPSModalContext} from '../../../contexts/GPSModalContext'; +import {useTabNavigationStore} from '../../../hooks/useTabNavigationStore.ts'; +import {BottomSheetModal, BottomSheetView} from '@gorhom/bottom-sheet'; +import {TAB_BAR_HEIGHT} from '../../../Navigation/ScreenGroups/AppScreens'; +import {StyleSheet} from 'react-native'; +import {TabName} from '../../../Navigation/types'; +import {useFocusEffect} from '@react-navigation/native'; + +export const GPSPermissionsModal = React.memo(() => { + const {setCurrentTab} = useTabNavigationStore(); + const [backgroundStatus] = Location.useBackgroundPermissions(); + const [foregroundStatus] = Location.useForegroundPermissions(); + + const [isGranted, setIsGranted] = useState(null); + const {bottomSheetRef} = useGPSModalContext(); + + useEffect(() => { + if (backgroundStatus && foregroundStatus && isGranted === null) { + setIsGranted(backgroundStatus.granted && foregroundStatus.granted); + } + }, [backgroundStatus, foregroundStatus, isGranted]); + + const onBottomSheetDismiss = () => { + setCurrentTab(TabName.Map); + }; + useFocusEffect(() => { + return () => bottomSheetRef?.current?.close(); + }); + + return ( + null}> + + {isGranted ? ( + + ) : ( + + )} + + + ); +}); + +const styles = StyleSheet.create({ + modal: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + minHeight: 140, + }, +}); diff --git a/src/frontend/screens/MapScreen/ObsevationMapLayer.tsx b/src/frontend/screens/MapScreen/ObsevationMapLayer.tsx index e5e09e5f0..98185be5a 100644 --- a/src/frontend/screens/MapScreen/ObsevationMapLayer.tsx +++ b/src/frontend/screens/MapScreen/ObsevationMapLayer.tsx @@ -3,6 +3,7 @@ import React from 'react'; import MapboxGL from '@rnmapbox/maps'; import {useAllObservations} from '../../hooks/useAllObservations'; import {useNavigationFromHomeTabs} from '../../hooks/useNavigationWithTypes'; +import {useCurrentTrackStore} from '../../hooks/tracks/useCurrentTrackStore'; const DEFAULT_MARKER_COLOR = '#F29D4B'; @@ -16,7 +17,7 @@ const layerStyles = { export const ObservationMapLayer = () => { const observations = useAllObservations(); const {navigate} = useNavigationFromHomeTabs(); - + const isTracking = useCurrentTrackStore(state => state.isTracking); const featureCollection: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: mapObservationsToFeatures(observations), @@ -33,7 +34,11 @@ export const ObservationMapLayer = () => { }} id="observations-source" shape={featureCollection}> - + ); }; diff --git a/src/frontend/screens/MapScreen/UserLocation.tsx b/src/frontend/screens/MapScreen/UserLocation.tsx index a5596b073..60607db30 100644 --- a/src/frontend/screens/MapScreen/UserLocation.tsx +++ b/src/frontend/screens/MapScreen/UserLocation.tsx @@ -1,19 +1,21 @@ -import MapboxGL from '@rnmapbox/maps'; +import {UserLocation as MBUserLocation} from '@rnmapbox/maps'; import * as React from 'react'; -// import {useExperiments} from '../../hooks/useExperiments'; +import {useCurrentTrackStore} from '../../hooks/tracks/useCurrentTrackStore'; +import {useIsFullyFocused} from '../../hooks/useIsFullyFocused'; +import {UserTooltipMarker} from './track/UserTooltipMarker'; + interface UserLocationProps { - visible: boolean; minDisplacement: number; } -export const UserLocation = ({visible, minDisplacement}: UserLocationProps) => { - // const [{directionalArrow}] = useExperiments(); +export const UserLocation = ({minDisplacement}: UserLocationProps) => { + const isTracking = useCurrentTrackStore(state => state.isTracking); + const isFocused = useIsFullyFocused(); return ( - + <> + + {isTracking && } + ); }; diff --git a/src/frontend/screens/MapScreen/index.tsx b/src/frontend/screens/MapScreen/index.tsx index 2ab24bf32..2d80fbd3e 100644 --- a/src/frontend/screens/MapScreen/index.tsx +++ b/src/frontend/screens/MapScreen/index.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import Mapbox, {UserLocation} from '@rnmapbox/maps'; +import Mapbox from '@rnmapbox/maps'; import config from '../../../config.json'; import {IconButton} from '../../sharedComponents/IconButton'; import { LocationFollowingIcon, LocationNoFollowIcon, } from '../../sharedComponents/icons'; + import {View, StyleSheet} from 'react-native'; import {ObservationMapLayer} from './ObsevationMapLayer'; import {AddButton} from '../../sharedComponents/AddButton'; @@ -13,10 +14,13 @@ import {useNavigationFromHomeTabs} from '../../hooks/useNavigationWithTypes'; import {useDraftObservation} from '../../hooks/useDraftObservation'; // @ts-ignore import ScaleBar from 'react-native-scale-bar'; -import {getCoords, useLocation} from '../../hooks/useLocation'; -import {useIsFullyFocused} from '../../hooks/useIsFullyFocused'; +import {getCoords} from '../../hooks/useLocation'; import {useLastKnownLocation} from '../../hooks/useLastSavedLocation'; import {useLocationProviderStatus} from '../../hooks/useLocationProviderStatus'; +import {GPSPermissionsModal} from './GPSPermissions/GPSPermissionsModal'; +import {TrackPathLayer} from './track/TrackPathLayer'; +import {UserLocation} from './UserLocation'; +import {useSharedLocationContext} from '../../contexts/SharedLocationContext'; // This is the default zoom used when the map first loads, and also the zoom // that the map will zoom to if the user clicks the "Locate" button and the @@ -24,21 +28,19 @@ import {useLocationProviderStatus} from '../../hooks/useLocationProviderStatus'; const DEFAULT_ZOOM = 12; Mapbox.setAccessToken(config.mapboxAccessToken); -const MIN_DISPLACEMENT = 15; +const MIN_DISPLACEMENT = 3; export const MAP_STYLE = Mapbox.StyleURL.Outdoors; export const MapScreen = () => { const [zoom, setZoom] = React.useState(DEFAULT_ZOOM); - - const isFocused = useIsFullyFocused(); const [isFinishedLoading, setIsFinishedLoading] = React.useState(false); const [following, setFollowing] = React.useState(true); const {newDraft} = useDraftObservation(); const {navigate} = useNavigationFromHomeTabs(); - const {location} = useLocation({maxDistanceInterval: MIN_DISPLACEMENT}); + const {locationState} = useSharedLocationContext(); const savedLocation = useLastKnownLocation(); - const coords = location && getCoords(location); + const coords = locationState.location && getCoords(locationState.location); const locationProviderStatus = useLocationProviderStatus(); const locationServicesEnabled = !!locationProviderStatus?.locationServicesEnabled; @@ -97,21 +99,18 @@ export const MapScreen = () => { followUserLocation={false} /> - {isFinishedLoading && } - {coords !== undefined && locationServicesEnabled && ( - + {coords && locationServicesEnabled && ( + )} + {isFinishedLoading && } + {isFinishedLoading && } - - {coords !== undefined && locationServicesEnabled && ( + {coords && locationServicesEnabled && ( {following ? : } @@ -123,6 +122,7 @@ export const MapScreen = () => { onPress={handleAddPress} isLoading={!isFinishedLoading} /> + ); }; diff --git a/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx b/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx new file mode 100644 index 000000000..da9650783 --- /dev/null +++ b/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx @@ -0,0 +1,58 @@ +import {LineJoin, LineLayer, ShapeSource} from '@rnmapbox/maps'; +import {useCurrentTrackStore} from '../../../hooks/tracks/useCurrentTrackStore'; +import * as React from 'react'; +import {StyleSheet} from 'react-native'; +import {LineString} from 'geojson'; +import {useLocation} from '../../../hooks/useLocation'; +import {LocationHistoryPoint} from '../../../sharedTypes/location'; + +export const TrackPathLayer = () => { + const locationHistory = useCurrentTrackStore(state => state.locationHistory); + const isTracking = useCurrentTrackStore(state => state.isTracking); + const {location} = useLocation({maxDistanceInterval: 3}); + const finalLocationHistory = location?.coords + ? [ + ...locationHistory, + { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + timestamp: new Date().getTime(), + }, + ] + : locationHistory; + + return ( + locationHistory.length > 1 && + isTracking && ( + console.log('display bottom sheet')} + id="routeSource" + shape={toRoute(finalLocationHistory)}> + + + ) + ); +}; + +const toRoute = (locations: LocationHistoryPoint[]): LineString => { + return { + type: 'LineString', + coordinates: locations.map(location => [ + location.longitude, + location.latitude, + ]), + }; +}; + +const styles = StyleSheet.create({ + lineLayer: { + lineColor: '#000000', + lineWidth: 5, + lineCap: LineJoin.Round, + lineOpacity: 1.84, + }, +} as any); diff --git a/src/frontend/screens/MapScreen/track/UserTooltipMarker.tsx b/src/frontend/screens/MapScreen/track/UserTooltipMarker.tsx new file mode 100644 index 000000000..32bbccf0b --- /dev/null +++ b/src/frontend/screens/MapScreen/track/UserTooltipMarker.tsx @@ -0,0 +1,84 @@ +import {MarkerView} from '@rnmapbox/maps'; +import {StyleSheet, Text, View} from 'react-native'; +import React from 'react'; +import {useCurrentTrackStore} from '../../../hooks/tracks/useCurrentTrackStore'; +import {useTrackTimerContext} from '../../../contexts/TrackTimerContext'; +import {useSharedLocationContext} from '../../../contexts/SharedLocationContext'; + +export const UserTooltipMarker = () => { + const {timer} = useTrackTimerContext(); + const {locationState} = useSharedLocationContext(); + const totalDistance = useCurrentTrackStore(state => state.distance); + + return ( + locationState.location?.coords && ( + + + + + {totalDistance.toFixed(2)}km + + + + {timer} + + + + + + + ) + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + marginBottom: 13, + }, + wrapper: { + backgroundColor: '#FFF', + padding: 10, + borderRadius: 5, + alignItems: 'center', + justifyContent: 'center', + color: 'black', + display: 'flex', + flexDirection: 'row', + }, + text: { + color: '#333333', + }, + separator: { + marginLeft: 10, + marginRight: 10, + height: 12, + borderColor: '#CCCCD6', + borderLeftWidth: 1, + color: '#CCCCD6', + }, + indicator: { + marginLeft: 5, + height: 10, + width: 10, + borderRadius: 99, + backgroundColor: '#59A553', + }, + arrow: { + alignItems: 'center', + justifyContent: 'center', + borderTopWidth: 15, + borderLeftWidth: 10, + borderRightWidth: 10, + borderTopColor: '#FFF', + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + }, +}); diff --git a/src/frontend/screens/ObservationEdit/SaveButton.tsx b/src/frontend/screens/ObservationEdit/SaveButton.tsx index c3632111f..5b4a2f724 100644 --- a/src/frontend/screens/ObservationEdit/SaveButton.tsx +++ b/src/frontend/screens/ObservationEdit/SaveButton.tsx @@ -13,6 +13,7 @@ import {UIActivityIndicator} from 'react-native-indicators'; import {useCreateBlobMutation} from '../../hooks/server/media'; import {DraftPhoto, Photo} from '../../contexts/PhotoPromiseContext/types'; import {useDraftObservation} from '../../hooks/useDraftObservation'; +import {useCurrentTrackStore} from '../../hooks/tracks/useCurrentTrackStore'; const m = defineMessages({ noGpsTitle: { @@ -73,6 +74,12 @@ export const SaveButton = ({ const createObservationMutation = useCreateObservation(); const editObservationMutation = useEditObservation(); const createBlobMutation = useCreateBlobMutation(); + const addNewTrackLocation = useCurrentTrackStore( + state => state.addNewLocations, + ); + const addNewTrackObservation = useCurrentTrackStore( + state => state.addNewObservation, + ); function createObservation() { if (!value) throw new Error('no observation saved in persisted state '); @@ -127,9 +134,21 @@ export const SaveButton = ({ onError: () => { if (openErrorModal) openErrorModal(); }, - onSuccess: () => { + onSuccess: data => { clearDraft(); navigation.navigate('Home', {screen: 'Map'}); + if (value.lat && value.lon) { + addNewTrackLocation([ + { + timestamp: new Date().getTime(), + latitude: value.lat, + longitude: value.lon, + }, + ]); + } + if (data.docId) { + addNewTrackObservation(data.docId); + } }, }, ); @@ -151,6 +170,16 @@ export const SaveButton = ({ onSuccess: () => { clearDraft(); navigation.pop(); + if (value.lat && value.lon) { + addNewTrackLocation([ + { + timestamp: new Date().getTime(), + latitude: value.lat, + longitude: value.lon, + }, + ]); + } + addNewTrackObservation(observationId); }, }, ); diff --git a/src/frontend/screens/ObservationsList/ObservationListItem.tsx b/src/frontend/screens/ObservationsList/ObservationListItem.tsx index 81a4fa4e9..26eee8c50 100644 --- a/src/frontend/screens/ObservationsList/ObservationListItem.tsx +++ b/src/frontend/screens/ObservationsList/ObservationListItem.tsx @@ -4,10 +4,7 @@ import {Text} from '../../sharedComponents/Text'; import {TouchableHighlight} from '../../sharedComponents/Touchables'; import {CategoryCircleIcon} from '../../sharedComponents/icons/CategoryIcon'; -//import PhotoView from "../../sharedComponents/PhotoView"; -// import useDeviceId from "../../hooks/useDeviceId"; import {Attachment, ViewStyleProp} from '../../sharedTypes'; -import {filterPhotosFromAttachments} from '../../hooks/persistedState/usePersistedDraftObservation/photosMethods'; import {BLACK} from '../../lib/styles'; import {Observation} from '@mapeo/schema'; import { diff --git a/src/frontend/sharedComponents/GPSPill.tsx b/src/frontend/sharedComponents/GPSPill.tsx new file mode 100644 index 000000000..3296e7420 --- /dev/null +++ b/src/frontend/sharedComponents/GPSPill.tsx @@ -0,0 +1,98 @@ +import React, {FC, useMemo} from 'react'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; +import {Text} from './Text'; +import {ParamListBase, useIsFocused} from '@react-navigation/native'; +import {useLocationProviderStatus} from '../hooks/useLocationProviderStatus'; +import {getLocationStatus} from '../lib/utils'; +import {defineMessages, useIntl} from 'react-intl'; +import {GpsIcon} from './icons'; +import {useSharedLocationContext} from '../contexts/SharedLocationContext'; +import {BLACK, WHITE} from '../lib/styles'; +import {BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; + +const m = defineMessages({ + noGps: { + id: 'sharedComponents.GpsPill.noGps', + defaultMessage: 'No GPS', + }, + searching: { + id: 'sharedComponents.GpsPill.searching', + defaultMessage: 'Searching…', + }, +}); + +interface GPSPill { + navigation: BottomTabNavigationProp; +} + +export const GPSPill: FC = ({navigation}) => { + const isFocused = useIsFocused(); + const {formatMessage: t} = useIntl(); + const {locationState, fgPermissions} = useSharedLocationContext(); + const locationProviderStatus = useLocationProviderStatus(); + + const precision = locationState?.location?.coords.accuracy; + + const status = useMemo(() => { + const isError = !!locationState.error || !fgPermissions; + + return isError + ? 'error' + : getLocationStatus({ + location: locationState.location, + providerStatus: locationProviderStatus, + }); + }, [ + locationProviderStatus, + locationState.error, + locationState.location, + fgPermissions, + ]); + + const text = useMemo(() => { + if (status === 'error') return t(m.noGps); + else if (status === 'searching' || typeof precision === 'undefined') { + return t(m.searching); + } else return `± ${Math.round(precision!)} m`; + }, [precision, status, t]); + + return ( + navigation.navigate('GpsModal')} + testID="gpsPillButton"> + + + {isFocused && } + + + {text} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 0, + minWidth: 100, + maxWidth: 200, + borderRadius: 18, + height: 36, + paddingLeft: 32, + paddingRight: 20, + borderWidth: 3, + borderColor: '#33333366', + backgroundColor: BLACK, + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row', + }, + error: {backgroundColor: '#FF0000'}, + text: {color: WHITE}, + icon: {position: 'absolute', left: 6}, +}); diff --git a/src/frontend/sharedComponents/GpsPill.tsx b/src/frontend/sharedComponents/GpsPill.tsx deleted file mode 100644 index 0b35f973f..000000000 --- a/src/frontend/sharedComponents/GpsPill.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import * as React from 'react'; -import {View, StyleSheet} from 'react-native'; -import {defineMessages, useIntl} from 'react-intl'; -import {useIsFocused} from '@react-navigation/native'; -import {TouchableOpacity} from 'react-native-gesture-handler'; - -import {BLACK, WHITE} from '../lib/styles'; -import type {LocationStatus} from '../lib/utils'; -import {Text} from './Text'; -import {GpsIcon} from './icons'; - -const m = defineMessages({ - noGps: { - id: 'sharedComponents.GpsPill.noGps', - defaultMessage: 'No GPS', - }, - searching: { - id: 'sharedComponents.GpsPill.searching', - defaultMessage: 'Searching…', - }, -}); - -interface Props { - onPress?: () => void; - precision?: number; - variant: LocationStatus; -} - -export const GpsPill = React.memo( - ({onPress, variant, precision}: Props) => { - const isFocused = useIsFocused(); - const {formatMessage: t} = useIntl(); - let text: string; - if (variant === 'error') text = t(m.noGps); - else if (variant === 'searching' || typeof precision === 'undefined') - text = t(m.searching); - else text = `± ${precision} m`; - return ( - - - - {isFocused && } - - - {text} - - - - ); - }, -); - -const styles = StyleSheet.create({ - container: { - flex: 0, - minWidth: 100, - maxWidth: 200, - borderRadius: 18, - height: 36, - paddingLeft: 32, - paddingRight: 20, - borderWidth: 3, - borderColor: '#33333366', - backgroundColor: BLACK, - justifyContent: 'center', - alignItems: 'center', - flexDirection: 'row', - }, - error: {backgroundColor: '#FF0000'}, - text: {color: WHITE}, - icon: {position: 'absolute', left: 6}, -}); diff --git a/src/frontend/sharedComponents/HomeHeader.tsx b/src/frontend/sharedComponents/HomeHeader.tsx index 428813467..b7b235a5d 100644 --- a/src/frontend/sharedComponents/HomeHeader.tsx +++ b/src/frontend/sharedComponents/HomeHeader.tsx @@ -1,20 +1,12 @@ -import React from 'react'; +import React, {FC} from 'react'; import {View, StyleSheet} from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import {IconButton} from './IconButton'; import {ObservationListIcon} from './icons'; -import {useNavigationFromHomeTabs} from '../hooks/useNavigationWithTypes'; -import {GpsPill} from './GpsPill'; -import {LocationStatus} from '../lib/utils'; - -interface Props { - locationStatus: LocationStatus; - precision?: number; -} - -export const HomeHeader = ({locationStatus, precision}: Props) => { - const navigation = useNavigationFromHomeTabs(); +import {GPSPill} from './GPSPill'; +import {BottomTabHeaderProps} from '@react-navigation/bottom-tabs'; +export const HomeHeader: FC = ({navigation}) => { return ( { colors={['#0006', '#0000']} /> {/* Placeholder for left button */} - { - navigation.navigate('GpsModal'); - }} - /> + { - navigation.navigate('ObservationList'); - }} + onPress={() => navigation.navigate('ObservationList')} testID="observationListButton"> @@ -48,7 +31,6 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: 'transparent', }, - rightButton: {}, leftButton: { width: 60, height: 60, diff --git a/src/frontend/sharedTypes/location.ts b/src/frontend/sharedTypes/location.ts new file mode 100644 index 000000000..b3d800ff2 --- /dev/null +++ b/src/frontend/sharedTypes/location.ts @@ -0,0 +1,21 @@ +export type FullLocationData = { + coords: { + altitude: number; + altitudeAccuracy: number; + latitude: number; + accuracy: number; + longitude: number; + heading: number; + speed: number; + }; + timestamp: number; +}; + +export type LocationHistoryPoint = { + timestamp: number; +} & LonLatData; + +export type LonLatData = { + longitude: number; + latitude: number; +}; diff --git a/src/frontend/utils/distance.ts b/src/frontend/utils/distance.ts new file mode 100644 index 000000000..2aefde679 --- /dev/null +++ b/src/frontend/utils/distance.ts @@ -0,0 +1,14 @@ +import CheapRuler from 'cheap-ruler'; +import {LonLatData} from '../sharedTypes/location'; + +export const calculateTotalDistance = (points: LonLatData[]): number => { + if (points.length <= 1) { + return 0; + } + + const ruler = new CheapRuler(points[0]!.latitude, 'kilometers'); + + return ruler.lineDistance( + points.map(point => [point.longitude, point.latitude]), + ); +};