Skip to content
Merged
3 changes: 0 additions & 3 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,6 @@
"../../src/components/Form/FormProvider.tsx" "react-hooks/refs" 2
"../../src/components/Form/InputWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/Form/InputWrapper.tsx" "react-hooks/refs" 1
"../../src/components/GPSInProgressModal/index.native.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/GPSTripStateChecker/index.native.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/HeaderPageLayout.tsx" "no-restricted-imports" 2
"../../src/components/HeaderWithBackButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/Hoverable/ActiveHoverable.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
Expand Down Expand Up @@ -1050,7 +1048,6 @@
"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "no-restricted-imports" 1
"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "react-hooks/preserve-manual-memoization" 1
"../../src/pages/iou/request/step/IOURequestStepCurrencyModal.tsx" "no-restricted-imports" 1
"../../src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3
"../../src/pages/iou/request/step/IOURequestStepDistanceMap.tsx" "react-hooks/set-state-in-effect" 1
"../../src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
Expand Down
43 changes: 0 additions & 43 deletions src/components/GPSInProgressModal/index.native.tsx

This file was deleted.

8 changes: 0 additions & 8 deletions src/components/GPSInProgressModal/index.tsx

This file was deleted.

7 changes: 0 additions & 7 deletions src/components/GPSTripStateChecker/index.tsx

This file was deleted.

97 changes: 97 additions & 0 deletions src/hooks/useGPSInProgressModal/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {ModalActions} from '@components/Modal/Global/ModalContext';

import useConfirmModal from '@hooks/useConfirmModal';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';

import {closeReactNativeApp} from '@libs/actions/HybridApp';
import {setIsGPSInProgressModalOpen} from '@libs/actions/isGPSInProgressModalOpen';
import {getGpsPoints, stopGpsTrip} from '@libs/GPSDraftDetailsUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import {useEffect, useRef} from 'react';

const GPS_IN_PROGRESS_MODAL_ID = 'gpsInProgress';

/**
* Pushes the "a trip is in progress" prompt onto the global modal stack.
*
* The prompt is asked for by `closeReactNativeApp`, a non-React action that cannot call a hook, so the Onyx flag stays
* as the cross-boundary trigger and this hook is the controller that turns it into a modal. Nothing other than
* the answer below clears the flag, so the entry never has to be taken down by hand.
*/
function useGPSInProgressModal() {
const [isGPSInProgressModalOpen] = useOnyx(ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN);
const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS);
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {showConfirmModal, closeModalByID} = useConfirmModal();

// Keeps a re-render from stacking a second prompt on top of the one that is already open.
const isPromptShownRef = useRef(false);

// Set when the unmount cleanup below took the prompt down, so the handler knows the user never answered.
const hasAutoClosedPromptRef = useRef(false);
const closeModalByIDRef = useRef(closeModalByID);
useEffect(() => {
closeModalByIDRef.current = closeModalByID;
});

// The prompt lives on the app-level modal stack, which outlives AuthScreens. Take it down when the session ends
// (logout, expiry) so it does not stay over the public screens and stop the trip / switch apps once answered there.
useEffect(
() => () => {
if (!isPromptShownRef.current) {
return;
}
hasAutoClosedPromptRef.current = true;
closeModalByIDRef.current(GPS_IN_PROGRESS_MODAL_ID);
},
[],
);

// The trip keeps recording points while the prompt is open, so what gets submitted has to be read when the user
// answers rather than when the prompt is shown. Reading the render-time values inside the handler below would
// submit the trip as it stood at show time, which is a truncated trip.
const gpsDraftDetailsRef = useRef(gpsDraftDetails);
const isOfflineRef = useRef(isOffline);

useEffect(() => {
gpsDraftDetailsRef.current = gpsDraftDetails;
isOfflineRef.current = isOffline;

if (!isGPSInProgressModalOpen || isPromptShownRef.current) {
return;
}
isPromptShownRef.current = true;

showConfirmModal({
id: GPS_IN_PROGRESS_MODAL_ID,
title: translate('gps.switchToODWarningTripInProgress.title'),
prompt: translate('gps.switchToODWarningTripInProgress.prompt'),
confirmText: translate('gps.switchToODWarningTripInProgress.confirm'),
cancelText: translate('common.cancel'),
buttonVariant: CONST.BUTTON_VARIANT.DANGER,
}).then(async (result) => {
isPromptShownRef.current = false;
setIsGPSInProgressModalOpen(false);

if (hasAutoClosedPromptRef.current) {
hasAutoClosedPromptRef.current = false;
return;
}

if (result.action !== ModalActions.CONFIRM) {
return;
}

await stopGpsTrip(isOfflineRef.current, getGpsPoints(gpsDraftDetailsRef.current));
closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false, shouldIgnoreTryNewDotLoading: true});
});
}, [gpsDraftDetails, isGPSInProgressModalOpen, isOffline, showConfirmModal, translate]);
}

export default useGPSInProgressModal;
4 changes: 4 additions & 0 deletions src/hooks/useGPSInProgressModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// GPS is not available on web, so this hook is a no-op.
function useGPSInProgressModal() {}

export default useGPSInProgressModal;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ConfirmModal from '@components/ConfirmModal';
import {ModalActions} from '@components/Modal/Global/ModalContext';

import useConfirmModal from '@hooks/useConfirmModal';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
Expand All @@ -19,19 +20,27 @@ import {useSplashScreenState} from '@src/SplashScreenStateContext';

import {accountIDSelector} from '@selectors/Session';
import {hasStartedLocationUpdatesAsync, startLocationUpdatesAsync, stopLocationUpdatesAsync} from 'expo-location';
import React, {useEffect, useRef, useState} from 'react';
import {useEffect, useRef, useState} from 'react';

import useUpdateGpsNotification from './useUpdateGpsNotification';
import useUpdateGpsTripOnReconnect from './useUpdateGpsTripOnReconnect';

function GPSTripStateChecker() {
// Names this hook's entry on the global modal stack so the effect below can take that one entry down, rather
// than whatever modal happens to be on top when the trip stops being resumable.
const CONTINUE_TRIP_MODAL_ID = 'gpsContinueTrip';

/**
* Restores a GPS trip that was in progress when the app was closed, asking the user whether to continue or stop it.
*/
function useGPSTripStateChecker() {
const {translate} = useLocalize();
const [showContinueTripModal, setShowContinueTripModal] = useState(false);
const [gpsDraftDetails, gpsDraftDetailsMetadata] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS);
const [currentAccountID, currentAccountIDResult] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
const isSessionLoaded = currentAccountIDResult.status === 'loaded';
const hasHandledAppRestart = useRef(false);
const {isOffline} = useNetwork();
const {showConfirmModal, closeModalByID} = useConfirmModal();

const {splashScreenState} = useSplashScreenState();

Expand All @@ -43,6 +52,44 @@ function GPSTripStateChecker() {
// A trip started before this shipped records no accountID, so only a different one means another user.
const isTripFromDifferentUser = isSessionLoaded && !!gpsDraftDetails?.accountID && gpsDraftDetails.accountID !== currentAccountID;

// The prompt used to be rendered inline with this as its `isVisible`, so it hid itself again the moment any of
// these went false. On the global modal stack that no longer happens for free, so the effect below both shows
// the entry when this turns true and takes it down when it turns false.
const shouldShowContinueTripModal = showContinueTripModal && !!gpsDraftDetails?.isTracking && !isTripFromDifferentUser && splashScreenState === CONST.BOOT_SPLASH_STATE.HIDDEN;

// Keeps a re-render from stacking a second prompt, and tells the effect whether there is an entry left to close.
const isContinueTripModalOpenRef = useRef(false);

// Tells "the user answered" apart from "the conditions went false and we closed it ourselves". Both resolve the
// promise with CLOSE, and CLOSE means "stop the trip" here, so without this an auto-close would stop the trip.
const hasAutoClosedContinueTripModalRef = useRef(false);

// The trip keeps recording points while the prompt is open, so the handlers have to read the trip as it stands
// when the user answers. Reading the show-time values would submit a truncated trip.
const gpsDraftDetailsRef = useRef(gpsDraftDetails);
const isOfflineRef = useRef(isOffline);
const reportIDRef = useRef(reportID);
const closeModalByIDRef = useRef(closeModalByID);
useEffect(() => {
gpsDraftDetailsRef.current = gpsDraftDetails;
isOfflineRef.current = isOffline;
reportIDRef.current = reportID;
closeModalByIDRef.current = closeModalByID;
});

// The prompt lives on the app-level modal stack, which outlives AuthScreens. Take it down when the session ends
// (logout, expiry) so it does not stay over the public screens and run trip actions once answered there.
useEffect(
() => () => {
if (!isContinueTripModalOpenRef.current) {
return;
}
hasAutoClosedContinueTripModalRef.current = true;
closeModalByIDRef.current(CONTINUE_TRIP_MODAL_ID);
},
[],
);

useEffect(() => {
if (!isTripFromDifferentUser) {
return;
Expand Down Expand Up @@ -100,17 +147,17 @@ function GPSTripStateChecker() {
}, []);

const navigateToGpsScreen = () => {
Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID));
Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportIDRef.current));
};

const continueGpsTrip = async () => {
const isBackgroundTaskRunning = await hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME);

const unit = gpsDraftDetails?.unit;
const unit = gpsDraftDetailsRef.current?.unit;

if (isBackgroundTaskRunning) {
if (unit) {
startGpsTripNotification(translate, reportID, unit, gpsDraftDetails?.distanceInMeters);
startGpsTripNotification(translate, reportIDRef.current, unit, gpsDraftDetailsRef.current?.distanceInMeters);
}
return;
}
Expand All @@ -126,35 +173,66 @@ function GPSTripStateChecker() {
return;
}

startGpsTripNotification(translate, reportID, unit, gpsDraftDetails?.distanceInMeters);
startGpsTripNotification(translate, reportIDRef.current, unit, gpsDraftDetailsRef.current?.distanceInMeters);
};

const onContinueTrip = () => {
setShowContinueTripModal(false);
continueGpsTrip();
navigateToGpsScreen();
};

const onViewTrip = () => {
setShowContinueTripModal(false);
stopGpsTrip(isOffline, getGpsPoints(gpsDraftDetails));
stopGpsTrip(isOfflineRef.current, getGpsPoints(gpsDraftDetailsRef.current));
navigateToGpsScreen();
};

return (
<ConfirmModal
isVisible={showContinueTripModal && !!gpsDraftDetails?.isTracking && !isTripFromDifferentUser && splashScreenState === CONST.BOOT_SPLASH_STATE.HIDDEN}
title={translate('gps.continueGpsTripModal.title')}
prompt={translate('gps.continueGpsTripModal.prompt')}
shouldReverseStackedButtons
confirmText={translate('gps.continueGpsTripModal.confirm')}
cancelText={translate('gps.continueGpsTripModal.cancel')}
onCancel={onViewTrip}
onConfirm={onContinueTrip}
/>
);
}
useEffect(() => {
if (!shouldShowContinueTripModal) {
if (!isContinueTripModalOpenRef.current) {
return;
}

GPSTripStateChecker.displayName = 'GPSTripStateChecker';
// Closing by ID rather than with `closeModal()`, which pops whatever sits on top and would take an
// unrelated modal down if one had been opened over this one.
hasAutoClosedContinueTripModalRef.current = true;
closeModalByID(CONTINUE_TRIP_MODAL_ID);
return;
}

if (isContinueTripModalOpenRef.current) {
return;
}
isContinueTripModalOpenRef.current = true;

showConfirmModal({
id: CONTINUE_TRIP_MODAL_ID,
title: translate('gps.continueGpsTripModal.title'),
prompt: translate('gps.continueGpsTripModal.prompt'),
shouldReverseStackedButtons: true,
confirmText: translate('gps.continueGpsTripModal.confirm'),
cancelText: translate('gps.continueGpsTripModal.cancel'),
// Cancelling here stops the trip rather than dismissing the prompt, so Android hardware back must not be
// routed into it. The hook defaults this to true when it is left undefined.
shouldHandleNavigationBack: false,
}).then((result) => {
isContinueTripModalOpenRef.current = false;
setShowContinueTripModal(false);

// The conditions went false and the branch above took the entry down, so the user never answered and
// neither branch below may run.
if (hasAutoClosedContinueTripModalRef.current) {
hasAutoClosedContinueTripModalRef.current = false;
return;
}

if (result.action === ModalActions.CONFIRM) {
onContinueTrip();
return;
}

onViewTrip();
});
}, [closeModalByID, onContinueTrip, onViewTrip, shouldShowContinueTripModal, showConfirmModal, translate]);
}

export default GPSTripStateChecker;
export default useGPSTripStateChecker;
4 changes: 4 additions & 0 deletions src/hooks/useGPSTripStateChecker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// GPS is not available on web, so this hook is a no-op.
function useGPSTripStateChecker() {}

export default useGPSTripStateChecker;
Loading
Loading