From a1f08988b7026ccd2b60b2a3525f7e729baf2ad9 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 17 Dec 2025 21:57:30 +0100 Subject: [PATCH 01/14] Background location permissions flow --- src/languages/en.ts | 13 ++ .../index.android.tsx | 127 ++++++++++++++++ .../index.ios.tsx | 137 ++++++++++++++++++ .../index.tsx | 8 + .../types.ts | 9 ++ .../GPSButtons/index.tsx | 45 +++++- 6 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.android.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.ios.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/types.ts diff --git a/src/languages/en.ts b/src/languages/en.ts index 62517c4cc086c..622b727e74946 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7070,6 +7070,19 @@ const translations = { title: "Can't create expense", prompt: "You can't create an expense with the same start and stop location.", }, + locationRequiredModal: { + title: 'Location access required', + prompt: 'Please allow location access in your device settings to start GPS distance tracking.', + allow: 'Allow', + }, + androidBackgroundLocationRequiredModal: { + title: 'Background location access required', + prompt: 'Please allow background location access in your device settings ("Allow all the time" option) to start GPS distance tracking.', + }, + preciseLocationRequiredModal: { + title: 'Precise location required', + prompt: 'Please enable "precise location" in your device settings to start GPS distance tracking.', + }, }, reportCardLostOrDamaged: { screenTitle: 'Report card lost or damaged', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.android.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.android.tsx new file mode 100644 index 0000000000000..c22905cde978f --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.android.tsx @@ -0,0 +1,127 @@ +import {getBackgroundPermissionsAsync, getForegroundPermissionsAsync, PermissionStatus, requestBackgroundPermissionsAsync, requestForegroundPermissionsAsync} from 'expo-location'; +import React, {useCallback, useEffect, useState} from 'react'; +import ConfirmModal from '@components/ConfirmModal'; +import {loadIllustration} from '@components/Icon/IllustrationLoader'; +import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import type BackgroundLocationPermissionsFlowProps from './types'; + +async function requestForegroundPermissions(onSuccess: () => void, onError: () => void) { + try { + const {status, android} = await requestForegroundPermissionsAsync(); + + if (status === PermissionStatus.GRANTED && android?.accuracy === 'fine') { + onSuccess(); + } + } catch (e) { + console.error('[GPS distance request] Failed to request foreground location permissions: ', e); + onError(); + } +} + +async function requestBackgroundPermissions(onSuccess: () => void, onError: () => void) { + try { + const {status} = await requestBackgroundPermissionsAsync(); + + if (status === PermissionStatus.GRANTED) { + onSuccess(); + } + } catch (e) { + console.error('[GPS distance request] Failed to request background location permissions: ', e); + onError(); + } +} + +async function checkPermissions({ + onGrant, + onDeny, + onAskForPermissions, + onError, +}: Pick & {onAskForPermissions: () => void; onError: () => void}) { + try { + const {granted, canAskAgain, android} = await getForegroundPermissionsAsync(); + + if ((!granted || android?.accuracy !== 'fine') && !canAskAgain) { + onDeny(); + return; + } + + const {granted: bgGranted, canAskAgain: bgCanAskAgain} = await getBackgroundPermissionsAsync(); + + if (!bgGranted && !bgCanAskAgain) { + onDeny(); + return; + } + + if (granted && android?.accuracy === 'fine' && bgGranted) { + onGrant(); + return; + } + + onAskForPermissions(); + } catch (e) { + console.error('[GPS distance request] Failed to get location permissions: ', e); + onError(); + } +} + +function BackgroundLocationPermissionsFlow({startPermissionsFlow, setStartPermissionsFlow, onGrant, onDeny, setShouldShowPermissionsError}: BackgroundLocationPermissionsFlowProps) { + const [showFirstAskModal, setShowFirstAskModal] = useState(false); + const [showBgPermissionsModal, setShowBgPermissionsModal] = useState(false); + const {asset: ReceiptLocationMarker} = useMemoizedLazyAsset(() => loadIllustration('ReceiptLocationMarker')); + const {translate} = useLocalize(); + + const onError = useCallback(() => setShouldShowPermissionsError(true), [setShouldShowPermissionsError]); + + useEffect(() => { + if (!startPermissionsFlow) { + return; + } + + checkPermissions({onGrant, onDeny, onError, onAskForPermissions: () => setShowFirstAskModal(true)}); + setStartPermissionsFlow(false); + }, [startPermissionsFlow, onGrant, onDeny, setStartPermissionsFlow, onError]); + + return ( + <> + setShowFirstAskModal(false)} + onConfirm={() => { + setShowFirstAskModal(false); + requestForegroundPermissions(() => setShowBgPermissionsModal(true), onError); + }} + confirmText={translate('gps.locationRequiredModal.allow')} + cancelText={translate('common.dismiss')} + prompt={translate('gps.locationRequiredModal.prompt')} + iconSource={ReceiptLocationMarker} + iconFill={false} + iconWidth={140} + iconHeight={120} + shouldCenterIcon + shouldReverseStackedButtons + /> + setShowBgPermissionsModal(false)} + onConfirm={() => { + setShowBgPermissionsModal(false); + requestBackgroundPermissions(onGrant, onError); + }} + confirmText={translate('common.settings')} + cancelText={translate('common.dismiss')} + prompt={translate('gps.androidBackgroundLocationRequiredModal.prompt')} + iconSource={ReceiptLocationMarker} + iconFill={false} + iconWidth={140} + iconHeight={120} + shouldCenterIcon + shouldReverseStackedButtons + /> + + ); +} + +export default BackgroundLocationPermissionsFlow; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.ios.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.ios.tsx new file mode 100644 index 0000000000000..20832df00db8e --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.ios.tsx @@ -0,0 +1,137 @@ +import {getBackgroundPermissionsAsync, getForegroundPermissionsAsync, PermissionStatus, requestBackgroundPermissionsAsync, requestForegroundPermissionsAsync} from 'expo-location'; +import React, {useCallback, useEffect, useState} from 'react'; +import {Linking} from 'react-native'; +import {checkLocationAccuracy} from 'react-native-permissions'; +import ConfirmModal from '@components/ConfirmModal'; +import {loadIllustration} from '@components/Icon/IllustrationLoader'; +import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import type BackgroundLocationPermissionsFlowProps from './types'; + +async function requestPermissions({onSuccess, onError, onPreciseLocationNotGranted}: {onSuccess: () => void; onPreciseLocationNotGranted: () => void; onError: () => void}) { + try { + const {status: fgStatus} = await requestForegroundPermissionsAsync(); + + if (fgStatus !== PermissionStatus.GRANTED) { + return; + } + + const {status} = await requestBackgroundPermissionsAsync(); + + if (status !== PermissionStatus.GRANTED) { + return; + } + + const accuracy = await checkLocationAccuracy(); + + if (accuracy === 'full') { + onSuccess(); + return; + } + + onPreciseLocationNotGranted(); + } catch (e) { + console.error('[GPS distance request] Failed to request location permissions: ', e); + onError(); + } +} + +async function checkPermissions({ + onGrant, + onDeny, + onAskForPermissions, + onError, +}: Pick & {onAskForPermissions: () => void; onError: () => void}) { + try { + const {granted, canAskAgain} = await getForegroundPermissionsAsync(); + + if (!canAskAgain && !granted) { + onDeny(); + return; + } + + const {granted: bgGranted, canAskAgain: bgCanAskAgain} = await getBackgroundPermissionsAsync(); + + if (!bgCanAskAgain && !bgGranted) { + onDeny(); + return; + } + + if (granted && bgGranted) { + const accuracy = await checkLocationAccuracy(); + + if (accuracy === 'full') { + onGrant(); + return; + } + } + + onAskForPermissions(); + } catch (e) { + console.error('[GPS distance request] Failed to get location permissions: ', e); + onError(); + } +} + +function BackgroundLocationPermissionsFlow({startPermissionsFlow, setStartPermissionsFlow, setShouldShowPermissionsError, onGrant, onDeny}: BackgroundLocationPermissionsFlowProps) { + const [showFirstAskModal, setShowFirstAskModal] = useState(false); + const [showPreciseLocationModal, setShowPreciseLocationModal] = useState(false); + const {asset: ReceiptLocationMarker} = useMemoizedLazyAsset(() => loadIllustration('ReceiptLocationMarker')); + const {translate} = useLocalize(); + + const onError = useCallback(() => setShouldShowPermissionsError(true), [setShouldShowPermissionsError]); + + useEffect(() => { + if (!startPermissionsFlow) { + return; + } + + checkPermissions({onGrant, onDeny, onError, onAskForPermissions: () => setShowFirstAskModal(true)}); + setStartPermissionsFlow(false); + }, [startPermissionsFlow, onDeny, onGrant, setStartPermissionsFlow, onError]); + + return ( + <> + { + setShowFirstAskModal(false); + requestPermissions({onSuccess: onGrant, onError, onPreciseLocationNotGranted: () => setShowPreciseLocationModal(true)}); + }} + onCancel={() => { + setShowFirstAskModal(false); + }} + confirmText={translate('gps.locationRequiredModal.allow')} + cancelText={translate('common.dismiss')} + prompt={translate('gps.locationRequiredModal.prompt')} + iconSource={ReceiptLocationMarker} + iconFill={false} + iconWidth={140} + iconHeight={120} + shouldCenterIcon + shouldReverseStackedButtons + /> + { + setShowPreciseLocationModal(false); + Linking.openSettings(); + }} + onCancel={() => setShowPreciseLocationModal(false)} + confirmText={translate('common.settings')} + cancelText={translate('common.dismiss')} + prompt={translate('gps.preciseLocationRequiredModal.prompt')} + iconSource={ReceiptLocationMarker} + iconFill={false} + iconWidth={140} + iconHeight={120} + shouldCenterIcon + shouldReverseStackedButtons + /> + + ); +} + +export default BackgroundLocationPermissionsFlow; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.tsx new file mode 100644 index 0000000000000..62602c022dd8a --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/index.tsx @@ -0,0 +1,8 @@ +import type BackgroundLocationPermissionsFlowProps from './types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function BackgroundLocationPermissionsFlow(props: BackgroundLocationPermissionsFlowProps) { + return null; +} + +export default BackgroundLocationPermissionsFlow; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/types.ts b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/types.ts new file mode 100644 index 0000000000000..03ec01c11966a --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow/types.ts @@ -0,0 +1,9 @@ +type BackgroundLocationPermissionsFlowProps = { + startPermissionsFlow: boolean; + setStartPermissionsFlow: React.Dispatch>; + setShouldShowPermissionsError: React.Dispatch>; + onGrant: () => void; + onDeny: () => void; +}; + +export default BackgroundLocationPermissionsFlowProps; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx index bcd2458bf1ecf..caa356fdb886e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx @@ -1,11 +1,14 @@ import React, {useState} from 'react'; -import {View} from 'react-native'; +import {Linking, View} from 'react-native'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; +import {loadIllustration} from '@components/Icon/IllustrationLoader'; +import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {addGpsPoints, initGpsDraft, resetGPSDraftDetails, setEndAddress, setIsTracking, setStartAddress} from '@libs/actions/GPSDraftDetails'; +import BackgroundLocationPermissionsFlow from '@pages/iou/request/step/IOURequestStepDistanceGPS/BackgroundLocationPermissionsFlow'; import ONYXKEYS from '@src/ONYXKEYS'; type ButtonsProps = { @@ -17,10 +20,13 @@ type ButtonsProps = { // next line will be removed in a follow-up PR where the currently unused props will be used // eslint-disable-next-line @typescript-eslint/no-unused-vars function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowPermissionsError}: ButtonsProps) { + const [startPermissionsFlow, setStartPermissionsFlow] = useState(false); const [showDiscardConfirmation, setShowDiscardConfirmation] = useState(false); const [showStopConfirmation, setShowStopConfirmation] = useState(false); const [showZeroDistanceModal, setShowZeroDistanceModal] = useState(false); + const [showLocationRequiredModal, setShowLocationRequiredModal] = useState(false); + const {asset: ReceiptLocationMarker} = useMemoizedLazyAsset(() => loadIllustration('ReceiptLocationMarker')); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, {canBeMissing: true}); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -28,8 +34,8 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP const isTripCaptured = !gpsDraftDetails?.isTracking && (gpsDraftDetails?.gpsPoints?.length ?? 0) > 0; /** - * todo: startGpsTrip, onNext and stopGpsTrip are implemented like this to show all UI components to test as of now, - * their proper implementation will be added in follow-up PRs + * todo: startGpsTrip, onNext, checkPermissions and stopGpsTrip are implemented like this to show + * all UI components to test as of now, their proper implementation will be added in follow-up PRs */ const startGpsTrip = () => { initGpsDraft(); @@ -55,6 +61,10 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP navigateToNextStep(); }; + const checkPermissions = () => { + setStartPermissionsFlow(true); + }; + return ( <> {isTripCaptured ? ( @@ -79,7 +89,7 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP ) : (