diff --git a/messages/en.json b/messages/en.json index 4b503feac..ba1064190 100644 --- a/messages/en.json +++ b/messages/en.json @@ -19,12 +19,24 @@ "description": "Title of dialog that shows when cancelling a new observation", "message": "Discard observation?" }, + "Modal.DiscardTrack.description": { + "message": "Your Track will not be saved. This cannot be undone." + }, + "Modal.DiscardTrack.title": { + "message": "Discard Track?" + }, "Modal.GPSDisable.button": { "message": "Enable" }, + "Modal.GPSDisable.defaultButton": { + "message": "Continue Editing" + }, "Modal.GPSDisable.description": { "message": "To create a Track CoMapeo needs access to your location and GPS." }, + "Modal.GPSDisable.discardButton": { + "message": "Discard Track" + }, "Modal.GPSDisable.title": { "message": "GPS Disabled" }, @@ -426,6 +438,26 @@ "description": "message shown whilst observations are loading", "message": "Loading… this can take a while after synchronizing with a new device" }, + "screens.SaveTrack.TrackEditView.descriptionPlaceholder": { + "description": "Placeholder for description/notes field", + "message": "What is happening here?" + }, + "screens.SaveTrack.TrackEditView.saveTrackCamera": { + "description": "Button label for adding photo", + "message": "Camera" + }, + "screens.SaveTrack.TrackEditView.saveTrackDetails": { + "description": "Button label for check details", + "message": "Details" + }, + "screens.SaveTrack.TrackEditView.title": { + "description": "Title for new track screen", + "message": "New Track" + }, + "screens.SaveTrack.track": { + "description": "Category title for new track screen", + "message": "Track" + }, "screens.Security.obscurePassDescriptonPassNotSet": { "message": "To use, enable App Passcode" }, diff --git a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx index 93fd8c817..96a5e2f45 100644 --- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx +++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx @@ -55,8 +55,10 @@ import {TrackingTabBarIcon} from './TabBar/TrackingTabBarIcon'; import {TabName} from '../types'; import {CameraTabBarIcon} from './TabBar/CameraTabBarIcon'; import {MapTabBarIcon} from './TabBar/MapTabBarIcon'; +import {SaveTrackScreen} from '../../screens/MapScreen/track/SaveTrackScreen'; import {InviteDeclined} from '../../screens/Settings/ProjectSettings/YourTeam/InviteDeclined'; import {UnableToCancelInvite} from '../../screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite'; +import {SharedLocationContextProvider} from '../../contexts/SharedLocationContext'; import { SyncScreen, createNavigationOptions as createSyncNavOptions, @@ -139,6 +141,7 @@ export type AppList = { UnableToCancelInvite: InviteProps; DeviceNameDisplay: undefined; DeviceNameEdit: undefined; + SaveTrack: undefined; Sync: undefined; }; @@ -208,7 +211,11 @@ export const createDefaultScreenGroup = ( ( + + + + )} /> + - + - - - - {children} - - - + + {children} + - + ); diff --git a/src/frontend/hooks/server/track.ts b/src/frontend/hooks/server/track.ts new file mode 100644 index 000000000..11939e795 --- /dev/null +++ b/src/frontend/hooks/server/track.ts @@ -0,0 +1,55 @@ +import { + useQueryClient, + useMutation, + useSuspenseQuery, +} from '@tanstack/react-query'; +import {useProject} from './projects'; +import {TrackValue} from '@mapeo/schema'; + +export const TRACK_KEY = 'tracks'; + +export function useCreateTrack() { + const queryClient = useQueryClient(); + const project = useProject(); + return useMutation({ + mutationFn: async (params: TrackValue) => { + return project.track.create(params); + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [TRACK_KEY]}); + }, + }); +} + +export function useTracksQuery() { + const project = useProject(); + return useSuspenseQuery({ + queryKey: [TRACK_KEY], + queryFn: async () => { + return project.track.getMany(); + }, + }); +} + +export function useTrackQuery(docId: string) { + const project = useProject(); + return useSuspenseQuery({ + queryKey: [TRACK_KEY, docId], + queryFn: async () => { + return project.track.getByDocId(docId); + }, + }); +} + +export function useDeleteTrackMutation() { + const queryClient = useQueryClient(); + const project = useProject(); + return useMutation({ + mutationFn: async (docId: string) => { + return project.track.delete(docId); + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [TRACK_KEY]}); + }, + }); +} diff --git a/src/frontend/hooks/tracks/useCurrentTrackStore.ts b/src/frontend/hooks/tracks/useCurrentTrackStore.ts index 2febd86ab..9d74d6124 100644 --- a/src/frontend/hooks/tracks/useCurrentTrackStore.ts +++ b/src/frontend/hooks/tracks/useCurrentTrackStore.ts @@ -8,7 +8,7 @@ type TracksStoreState = { distance: number; addNewObservation: (observationId: string) => void; addNewLocations: (locationData: LocationHistoryPoint[]) => void; - clearLocationHistory: () => void; + clearCurrentTrack: () => void; setTracking: (val: boolean) => void; } & ( | { @@ -54,7 +54,14 @@ export const useCurrentTrackStore = create(set => ({ distance: distance + calculateTotalDistance([lastLocation, ...data]), }; }), - clearLocationHistory: () => set(() => ({locationHistory: []})), + clearCurrentTrack: () => + set(() => ({ + locationHistory: [], + trackingSince: null, + distance: 0, + isTracking: false, + observations: [], + })), setTracking: (val: boolean) => set(() => val diff --git a/src/frontend/hooks/tracks/useTracking.ts b/src/frontend/hooks/tracks/useTracking.ts index 51994c40f..5db003023 100644 --- a/src/frontend/hooks/tracks/useTracking.ts +++ b/src/frontend/hooks/tracks/useTracking.ts @@ -4,6 +4,7 @@ import {useCallback, useState} from 'react'; import {useCurrentTrackStore} from './useCurrentTrackStore'; import React from 'react'; import {FullLocationData} from '../../sharedTypes/location'; +import {useGPSModalContext} from '../../contexts/GPSModalContext'; export const LOCATION_TASK_NAME = 'background-location-task'; @@ -13,32 +14,34 @@ type LocationCallbackInfo = { }; export function useTracking() { + const {bottomSheetRef} = useGPSModalContext(); 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]); + TaskManager.defineTask( + LOCATION_TASK_NAME, + ({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]); const startTracking = useCallback(async () => { if (isTracking) { @@ -60,8 +63,9 @@ export function useTracking() { const cancelTracking = useCallback(async () => { await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME); + bottomSheetRef.current?.close(); setTracking(false); - }, [setTracking]); + }, [bottomSheetRef, setTracking]); return {isTracking, startTracking, cancelTracking, loading}; } diff --git a/src/frontend/images/Track.svg b/src/frontend/images/Track.svg new file mode 100644 index 000000000..cdbb37459 --- /dev/null +++ b/src/frontend/images/Track.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/images/camera.svg b/src/frontend/images/camera.svg new file mode 100644 index 000000000..6822fca4e --- /dev/null +++ b/src/frontend/images/camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/images/close.svg b/src/frontend/images/close.svg new file mode 100644 index 000000000..769a8983d --- /dev/null +++ b/src/frontend/images/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/images/delete.svg b/src/frontend/images/delete.svg new file mode 100644 index 000000000..3ebb3bf2c --- /dev/null +++ b/src/frontend/images/delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/frontend/images/details.svg b/src/frontend/images/details.svg new file mode 100644 index 000000000..f6f833b5f --- /dev/null +++ b/src/frontend/images/details.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx index a39f625d7..a3b4bafc0 100644 --- a/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx +++ b/src/frontend/screens/MapScreen/GPSPermissions/GPSPermissionsEnabled.tsx @@ -7,6 +7,8 @@ import StartTrackingIcon from '../../../images/StartTracking.svg'; import StopTrackingIcon from '../../../images/StopTracking.svg'; import {useTrackTimerContext} from '../../../contexts/TrackTimerContext'; import {defineMessages, useIntl} from 'react-intl'; +import {useCurrentTrackStore} from '../../../hooks/tracks/useCurrentTrackStore'; +import {useNavigationFromHomeTabs} from '../../../hooks/useNavigationWithTypes'; const m = defineMessages({ defaultButtonText: { @@ -30,13 +32,36 @@ const m = defineMessages({ export const GPSPermissionsEnabled = () => { const {formatMessage} = useIntl(); const {isTracking, cancelTracking, startTracking, loading} = useTracking(); + const locationHistory = useCurrentTrackStore(state => state.locationHistory); + const clearCurrentTrack = useCurrentTrackStore( + state => state.clearCurrentTrack, + ); const {timer} = useTrackTimerContext(); - const styles = getStyles(isTracking); + const navigation = useNavigationFromHomeTabs(); const handleTracking = useCallback(() => { - isTracking ? cancelTracking() : startTracking(); - }, [cancelTracking, isTracking, startTracking]); + if (!isTracking) { + startTracking(); + return; + } + + cancelTracking(); + + if (locationHistory.length <= 1) { + clearCurrentTrack(); + navigation.navigate('Map'); + } else { + navigation.navigate('SaveTrack'); + } + }, [ + cancelTracking, + clearCurrentTrack, + locationHistory, + isTracking, + startTracking, + navigation, + ]); const getButtonTitle = () => { if (loading) return m.loadingButtonText; diff --git a/src/frontend/screens/MapScreen/track/SaveTrackScreen.tsx b/src/frontend/screens/MapScreen/track/SaveTrackScreen.tsx new file mode 100644 index 000000000..c83c42b63 --- /dev/null +++ b/src/frontend/screens/MapScreen/track/SaveTrackScreen.tsx @@ -0,0 +1,195 @@ +import React, {useCallback, useState} from 'react'; +import { + BackHandler, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + View, +} from 'react-native'; +import {DiscardModal} from '../../../sharedComponents/DiscardModal.tsx'; +import {BottomSheet} from '../../../sharedComponents/BottomSheet/BottomSheet'; +import PhotoIcon from '../../../images/camera.svg'; +import DetailsIcon from '../../../images/details.svg'; +import TrackIcon from '../../../images/Track.svg'; +import {defineMessages, useIntl} from 'react-intl'; +import {Text} from '../../../sharedComponents/Text'; +import {TrackDescriptionField} from './saveTrack/TrackDescriptionField'; +import {useBottomSheetModal} from '../../../sharedComponents/BottomSheetModal'; +import DiscardIcon from '../../../images/delete.svg'; +import ErrorIcon from '../../../images/Error.svg'; +import {TabName} from '../../../Navigation/types.ts'; +import {useCurrentTrackStore} from '../../../hooks/tracks/useCurrentTrackStore.ts'; +import {useNavigationFromHomeTabs} from '../../../hooks/useNavigationWithTypes.ts'; +import {useFocusEffect} from '@react-navigation/native'; +import {SaveTrackButton} from './saveTrack/SaveTrackButton.tsx'; +import Close from '../../../images/close.svg'; + +export const SaveTrackScreen = () => { + const navigation = useNavigationFromHomeTabs(); + const clearCurrentTrack = useCurrentTrackStore( + state => state.clearCurrentTrack, + ); + const {formatMessage: t} = useIntl(); + const [description, setDescription] = useState(''); + const {sheetRef, isOpen, openSheet, closeSheet} = useBottomSheetModal({ + openOnMount: false, + }); + + const handleCameraPress = React.useCallback(() => { + navigation.navigate('AddPhoto'); + }, [navigation]); + + const bottomSheetItems = [ + { + icon: , + label: t(m.photoButton), + onPress: handleCameraPress, + }, + { + icon: , + label: t(m.detailsButton), + onPress: () => {}, + }, + ]; + + const handleDiscard = () => { + closeSheet(); + navigation.navigate(TabName.Map); + clearCurrentTrack(); + }; + + useFocusEffect( + useCallback(() => { + navigation.setOptions({ + headerLeft: () => ( + + + + ), + headerRight: () => , + }); + }, [description, navigation, openSheet]), + ); + + // disables back button + useFocusEffect( + useCallback(() => { + const disableBack = () => { + openSheet(); + }; + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + disableBack(); + return true; + }, + ); + + return () => subscription.remove(); + }, [openSheet]), + ); + + return ( + + + + + {t(m.newTitle)} + + + , + }, + { + onPress: closeSheet, + text: t(m.discardTrackDefaultButton), + variation: 'outlined', + }, + ]} + title={t(m.discardTrackTitle)} + description={t(m.discardTrackDescription)} + icon={} + /> + + + + ); +}; + +const styles = StyleSheet.create({ + icon: {width: 30, height: 30}, + image: {marginBottom: 15}, + titleText: {fontSize: 20, fontWeight: '700'}, + container: { + flex: 1, + flexDirection: 'column', + alignContent: 'stretch', + }, + titleWrapper: { + padding: 10, + marginTop: 20, + borderRadius: 6, + borderWidth: 1, + borderColor: '#EDEDED', + flexDirection: 'row', + alignItems: 'center', + }, + scrollViewContent: { + paddingHorizontal: 20, + flexDirection: 'column', + alignContent: 'stretch', + }, +}); + +export const m = defineMessages({ + trackEditScreenTitle: { + id: 'screens.SaveTrack.TrackEditView.title', + defaultMessage: 'New Track', + description: 'Title for new track screen', + }, + newTitle: { + id: 'screens.SaveTrack.track', + defaultMessage: 'Track', + description: 'Category title for new track screen', + }, + detailsButton: { + id: 'screens.SaveTrack.TrackEditView.saveTrackDetails', + defaultMessage: 'Details', + description: 'Button label for check details', + }, + photoButton: { + id: 'screens.SaveTrack.TrackEditView.saveTrackCamera', + defaultMessage: 'Camera', + description: 'Button label for adding photo', + }, + discardTrackTitle: { + id: 'Modal.DiscardTrack.title', + defaultMessage: 'Discard Track?', + }, + discardTrackDescription: { + id: 'Modal.DiscardTrack.description', + defaultMessage: 'Your Track will not be saved.\n This cannot be undone.', + }, + discardTrackDiscardButton: { + id: 'Modal.GPSDisable.discardButton', + defaultMessage: 'Discard Track', + }, + discardTrackDefaultButton: { + id: 'Modal.GPSDisable.defaultButton', + defaultMessage: 'Continue Editing', + }, +}); diff --git a/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx b/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx index da9650783..a0c3b213b 100644 --- a/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx +++ b/src/frontend/screens/MapScreen/track/TrackPathLayer.tsx @@ -24,10 +24,7 @@ export const TrackPathLayer = () => { return ( locationHistory.length > 1 && isTracking && ( - console.log('display bottom sheet')} - id="routeSource" - shape={toRoute(finalLocationHistory)}> + = ({description}) => { + const saveTrack = useCreateTrack(); + const navigation = useNavigationFromHomeTabs(); + const currentTrack = useCurrentTrackStore(); + + const handleSaveClick = () => { + saveTrack.mutate( + { + schemaName: 'track', + attachments: [], + refs: currentTrack.observations.map(observationId => ({ + id: observationId, + type: 'observation', + })), + tags: { + notes: description, + }, + locations: currentTrack.locationHistory.map(loc => { + return { + coords: { + latitude: loc.latitude, + longitude: loc.longitude, + }, + mocked: false, + timestamp: DateTime.fromMillis(loc.timestamp).toISO()!, + }; + }), + }, + { + onSuccess: () => { + navigation.navigate(TabName.Map); + currentTrack.clearCurrentTrack(); + }, + }, + ); + }; + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + completeIcon: {width: 30, height: 30}, +}); diff --git a/src/frontend/screens/MapScreen/track/saveTrack/TrackDescriptionField.tsx b/src/frontend/screens/MapScreen/track/saveTrack/TrackDescriptionField.tsx new file mode 100644 index 000000000..cbab06d1e --- /dev/null +++ b/src/frontend/screens/MapScreen/track/saveTrack/TrackDescriptionField.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {StyleSheet, TextInput} from 'react-native'; + +const m = defineMessages({ + descriptionPlaceholder: { + id: 'screens.SaveTrack.TrackEditView.descriptionPlaceholder', + defaultMessage: 'What is happening here?', + description: 'Placeholder for description/notes field', + }, +}); + +interface DescriptionField { + description: string; + setDescription: React.Dispatch>; +} +export const TrackDescriptionField: React.FC = ({ + description, + setDescription, +}) => { + const {formatMessage: t} = useIntl(); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + textInput: { + flex: 1, + paddingVertical: 20, + minHeight: 100, + fontSize: 20, + color: 'black', + alignItems: 'flex-start', + justifyContent: 'flex-start', + textAlignVertical: 'top', + }, +}); diff --git a/src/frontend/screens/ObservationEdit/index.tsx b/src/frontend/screens/ObservationEdit/index.tsx index 19ac7faa5..6b6ee09ce 100644 --- a/src/frontend/screens/ObservationEdit/index.tsx +++ b/src/frontend/screens/ObservationEdit/index.tsx @@ -6,7 +6,7 @@ import {usePersistedDraftObservation} from '../../hooks/persistedState/usePersis import {View, ScrollView, StyleSheet} from 'react-native'; import {LocationView} from './LocationView'; import {DescriptionField} from './DescriptionField'; -import {BottomSheet} from './BottomSheet'; +import {BottomSheet} from '../../sharedComponents/BottomSheet/BottomSheet'; import {ThumbnailScrollView} from '../../sharedComponents/ThumbnailScrollView'; import {PresetView} from './PresetView'; import {useBottomSheetModal} from '../../sharedComponents/BottomSheetModal'; diff --git a/src/frontend/screens/ObservationEdit/BottomSheet.tsx b/src/frontend/sharedComponents/BottomSheet/BottomSheet.tsx similarity index 96% rename from src/frontend/screens/ObservationEdit/BottomSheet.tsx rename to src/frontend/sharedComponents/BottomSheet/BottomSheet.tsx index 41d99c28a..ecb31665f 100644 --- a/src/frontend/screens/ObservationEdit/BottomSheet.tsx +++ b/src/frontend/sharedComponents/BottomSheet/BottomSheet.tsx @@ -5,7 +5,7 @@ import { StyleSheet, TouchableNativeFeedback, } from 'react-native'; -import {Text} from '../../sharedComponents/Text'; +import {Text} from '../Text'; import {defineMessages, FormattedMessage} from 'react-intl'; const m = defineMessages({ @@ -107,7 +107,8 @@ const styles = StyleSheet.create({ }, itemContainer: { flex: 0, - height: 60, + paddingVertical: 24, + paddingHorizontal: 20, flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, @@ -117,8 +118,7 @@ const styles = StyleSheet.create({ flex: 0, alignItems: 'center', justifyContent: 'center', - paddingLeft: 30, - paddingRight: 30, + paddingRight: 10, }, itemLabel: { flex: 1, diff --git a/src/frontend/sharedComponents/BottomSheet/Content.tsx b/src/frontend/sharedComponents/BottomSheet/Content.tsx index c41837243..8400278c5 100644 --- a/src/frontend/sharedComponents/BottomSheet/Content.tsx +++ b/src/frontend/sharedComponents/BottomSheet/Content.tsx @@ -23,7 +23,7 @@ interface SecondaryActionButtonConfig extends BaseActionButtonConfig { variation: 'outlined'; } -type ActionButtonConfig = +export type ActionButtonConfig = | PrimaryActionButtonConfig | SecondaryActionButtonConfig; diff --git a/src/frontend/sharedComponents/DiscardModal.tsx b/src/frontend/sharedComponents/DiscardModal.tsx new file mode 100644 index 000000000..eae7c809d --- /dev/null +++ b/src/frontend/sharedComponents/DiscardModal.tsx @@ -0,0 +1,24 @@ +import React, {FC, ReactNode, RefObject} from 'react'; +import {BottomSheetModalMethods} from '@gorhom/bottom-sheet/lib/typescript/types'; +import {BottomSheetContent, BottomSheetModal} from './BottomSheetModal'; +import {ActionButtonConfig} from './BottomSheet/Content.tsx'; + +export interface DiscardModal { + bottomSheetRef: RefObject; + isOpen: boolean; + title: string; + description: string; + buttonConfigs: ActionButtonConfig[]; + icon?: ReactNode; + loading?: boolean; +} + +export const DiscardModal: FC = props => { + const {bottomSheetRef, isOpen} = props; + + return ( + + + + ); +};