Skip to content

Commit f59bcc3

Browse files
authored
Merge pull request #87726 from software-mansion-labs/jakubkalinski0/Odometer_add_save_for_later_functionality
[Odometer] Add save for later functionality
2 parents 348b8b7 + 0270797 commit f59bcc3

27 files changed

Lines changed: 475 additions & 45 deletions

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9236,6 +9236,7 @@ const CONST = {
92369236
DISTANCE_MAP_NEXT_BUTTON: 'IOURequestStep-DistanceMapNextButton',
92379237
DISTANCE_MANUAL_NEXT_BUTTON: 'IOURequestStep-DistanceManualNextButton',
92389238
DISTANCE_ODOMETER_NEXT_BUTTON: 'IOURequestStep-DistanceOdometerNextButton',
9239+
DISTANCE_ODOMETER_SAVE_FOR_LATER_BUTTON: 'IOURequestStep-DistanceOdometerSaveForLaterButton',
92399240
ODOMETER_CHOOSE_FILE_BUTTON: 'IOURequestStep-OdometerChooseFileButton',
92409241
GPS_START_STOP_BUTTON: 'IOURequestStep-GPSStartStopButton',
92419242
GPS_DISCARD_BUTTON: 'IOURequestStep-GPSDiscardButton',

src/ONYXKEYS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ const ONYXKEYS = {
8787
/** GPS points stored for the GPS distance expense before they're accepted by the user */
8888
GPS_DRAFT_DETAILS: 'gpsDraftDetails',
8989

90+
/** Odometer draft stored for the Save for later flow */
91+
ODOMETER_DRAFT: 'odometerDraft',
92+
9093
/** Contains all the info for Tasks */
9194
TASK: 'task',
9295

@@ -1357,6 +1360,7 @@ type OnyxValuesMapping = {
13571360
[ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: boolean;
13581361
[ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN]: boolean;
13591362
[ONYXKEYS.GPS_DRAFT_DETAILS]: OnyxTypes.GpsDraftDetails;
1363+
[ONYXKEYS.ODOMETER_DRAFT]: OnyxTypes.OdometerDraft;
13601364
[ONYXKEYS.FULLSCREEN_VISIBILITY]: boolean;
13611365
[ONYXKEYS.NETWORK]: OnyxTypes.Network;
13621366
[ONYXKEYS.NEW_GROUP_CHAT_DRAFT]: OnyxTypes.NewGroupChatDraft;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {useEffect} from 'react';
2+
import type {OnyxEntry} from 'react-native-onyx';
3+
import type {IOURequestType} from '@userActions/IOU';
4+
import {hydrateOdometerDraftIntoTransaction} from '@userActions/OdometerTransactionUtils';
5+
import CONST from '@src/CONST';
6+
import ONYXKEYS from '@src/ONYXKEYS';
7+
import type {OdometerDraft, Transaction} from '@src/types/onyx';
8+
import useOnyx from './useOnyx';
9+
10+
type UseOdometerDraftHydratorParams = {
11+
transaction: OnyxEntry<Transaction>;
12+
transactionRequestType: IOURequestType | undefined;
13+
isLoadingTransaction?: boolean;
14+
isLoadingSelectedTab?: boolean;
15+
};
16+
17+
// Module-level so it survives host screen remounts; otherwise the mount effect re-fires and
18+
// clobbers Replace/Crop/Rotate results with the stale rehydrated draft.
19+
let lastHydratedDraft: OdometerDraft | null = null;
20+
21+
function useOdometerDraftHydrator({
22+
transaction,
23+
transactionRequestType,
24+
isLoadingTransaction = false,
25+
isLoadingSelectedTab = false,
26+
}: UseOdometerDraftHydratorParams): (newIOUType: IOURequestType) => void {
27+
const [odometerDraft] = useOnyx(ONYXKEYS.ODOMETER_DRAFT);
28+
29+
useEffect(() => {
30+
if (transactionRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) {
31+
return;
32+
}
33+
if (!odometerDraft) {
34+
return;
35+
}
36+
if (isLoadingTransaction || isLoadingSelectedTab) {
37+
return;
38+
}
39+
if (lastHydratedDraft === odometerDraft) {
40+
return;
41+
}
42+
hydrateOdometerDraftIntoTransaction(transaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, odometerDraft, transaction?.comment);
43+
lastHydratedDraft = odometerDraft;
44+
// transaction.comment intentionally excluded — it changes after our own merge and would re-fire.
45+
// eslint-disable-next-line react-hooks/exhaustive-deps
46+
}, [transactionRequestType, odometerDraft, isLoadingTransaction, isLoadingSelectedTab]);
47+
48+
return (newIOUType: IOURequestType) => {
49+
if (newIOUType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) {
50+
return;
51+
}
52+
// No guard: callers invoke this right after initMoneyRequest wipes the comment, so re-hydration is intended.
53+
hydrateOdometerDraftIntoTransaction(transaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, odometerDraft, transaction?.comment);
54+
lastHydratedDraft = odometerDraft ?? null;
55+
};
56+
}
57+
58+
export default useOdometerDraftHydrator;

src/hooks/useResetIOUType.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import CONST from '@src/CONST';
1010
import ONYXKEYS from '@src/ONYXKEYS';
1111
import type {Policy, Report, Transaction} from '@src/types/onyx';
1212
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
13+
import useOdometerDraftHydrator from './useOdometerDraftHydrator';
1314
import useOnyx from './useOnyx';
1415
import usePersonalPolicy from './usePersonalPolicy';
1516
import usePrevious from './usePrevious';
@@ -67,6 +68,13 @@ function useResetIOUType({
6768
const personalPolicy = usePersonalPolicy();
6869
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
6970

71+
const hydrateOdometerOnLanding = useOdometerDraftHydrator({
72+
transaction,
73+
transactionRequestType,
74+
isLoadingTransaction,
75+
isLoadingSelectedTab,
76+
});
77+
7078
const resetIOUTypeIfChanged = (newIOUType: IOURequestType) => {
7179
if (!(skipKeyboardDismissForPerDiem && newIOUType === CONST.IOU.REQUEST_TYPE.PER_DIEM)) {
7280
Keyboard.dismiss();
@@ -95,6 +103,10 @@ function useResetIOUType({
95103
hasOnlyPersonalPolicies: hasOnlyPersonalPolicies ?? true,
96104
draftTransactionIDs,
97105
});
106+
107+
// Layer odometer draft fields onto the freshly-rebuilt transaction. The merge queues after
108+
// initMoneyRequest's Onyx.set, so the odometer fields land on top.
109+
hydrateOdometerOnLanding(newIOUType);
98110
};
99111

100112
const tabSelectedTypeRef = useRef<IOURequestType | null>(null);

src/hooks/useRestartOnOdometerImagesFailure/index.ts

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react';
22
import type {OnyxEntry} from 'react-native-onyx';
33
import useOnyx from '@hooks/useOnyx';
44
import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt';
5-
import clearOdometerDraftTransactionState from '@libs/actions/OdometerTransactionUtils';
5+
import clearOdometerDraftTransactionState, {hydrateOdometerDraftIntoTransaction} from '@libs/actions/OdometerTransactionUtils';
66
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
77
import {getOdometerImageUri} from '@libs/OdometerImageUtils';
88
import type {IOUType} from '@src/CONST';
@@ -12,25 +12,29 @@ import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'
1212
import type {Transaction} from '@src/types/onyx';
1313
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
1414

15-
// When the component mounts, if there are odometer images or a stitched receipt, see if the files can be read from the disk.
16-
// If not, redirect the user to the starting step of the flow.
17-
// This is because until the request is saved, the image files are only stored in the browser's memory as blob:// URLs
18-
// and if the browser is refreshed, then the images cease to exist.
19-
// The best way for the user to recover from this is to start over from the start of the request process.
20-
// Returns `hasVerifiedBlobs` so callers can gate dependent side-effects (e.g. odometer image stitching)
21-
// until this check has confirmed the blobs are still readable. When there are no blob URLs to verify
22-
// (e.g. native file:// paths or remote URLs), `hasVerifiedBlobs` is `true` as soon as Onyx has loaded.
15+
type BackupHandledArgs = {
16+
shouldResetLocalState: boolean;
17+
};
18+
2319
const useRestartOnOdometerImagesFailure = (
2420
transaction: OnyxEntry<Transaction>,
2521
reportID: string,
2622
iouType: IOUType,
2723
backToReport: string | undefined,
28-
onBackupHandled?: () => void,
24+
onBackupHandled?: (args: BackupHandledArgs) => void,
2925
): {hasVerifiedBlobs: boolean} => {
3026
const [, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector});
27+
const [odometerDraft, odometerDraftStatus] = useOnyx(ONYXKEYS.ODOMETER_DRAFT);
3128
const hasCheckedRef = useRef(false);
3229
const [asyncVerificationPassed, setAsyncVerificationPassed] = useState(false);
3330

31+
// Updated via useEffect (not render-time) for React Compiler. The one-render lag is benign:
32+
// a missed bail just lets recovery fire, and recovery itself rehydrates correctly.
33+
const transactionRef = useRef(transaction);
34+
useEffect(() => {
35+
transactionRef.current = transaction;
36+
}, [transaction]);
37+
3438
const hasBlobUrls = (() => {
3539
if (!transaction) {
3640
return false;
@@ -40,22 +44,19 @@ const useRestartOnOdometerImagesFailure = (
4044
})();
4145

4246
useEffect(() => {
43-
if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata)) {
47+
if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata) || isLoadingOnyxValue(odometerDraftStatus)) {
4448
return;
4549
}
4650

47-
// Run only once after Onyx finishes loading — blob:// URLs are ephemeral and only need
48-
// to be verified on the first render after the data is available.
49-
// It has to be resolved this way in order to have a complete dependency array for the useEffect hook.
5051
if (hasCheckedRef.current) {
5152
return;
5253
}
5354
hasCheckedRef.current = true;
5455

5556
const startImage = transaction.comment?.odometerStartImage;
5657
const endImage = transaction.comment?.odometerEndImage;
57-
const stitchedUri = transaction.receipt?.source?.toString();
5858

59+
// Source images only — the stitched receipt URL is derived and OdometerReceiptStitcher regenerates it.
5960
const urlsToCheck = [
6061
{
6162
filename: typeof startImage === 'object' ? startImage?.name : undefined,
@@ -67,17 +68,10 @@ const useRestartOnOdometerImagesFailure = (
6768
path: getOdometerImageUri(endImage),
6869
type: typeof endImage === 'object' ? endImage?.type : undefined,
6970
},
70-
{
71-
filename: transaction.receipt?.filename,
72-
path: stitchedUri,
73-
type: undefined,
74-
},
7571
].filter(({path}) => !!path && path.startsWith('blob:'));
7672

77-
if (urlsToCheck.length === 0) {
78-
return;
79-
}
80-
73+
// Empty urlsToCheck flows through Promise.all -> [].every(Boolean) === true -> marks verification passed
74+
// Later blob additions (draft hydration, fresh capture) are minted in this session and stay trusted
8175
Promise.all(
8276
urlsToCheck.map(
8377
({filename, path, type}) =>
@@ -98,13 +92,29 @@ const useRestartOnOdometerImagesFailure = (
9892
return;
9993
}
10094

101-
onBackupHandled?.();
102-
clearOdometerDraftTransactionState(transaction);
95+
// Bail if another flow already replaced the images (ODOMETER_DRAFT rehydration / fresh capture).
96+
const liveStartUri = getOdometerImageUri(transactionRef.current?.comment?.odometerStartImage);
97+
const liveEndUri = getOdometerImageUri(transactionRef.current?.comment?.odometerEndImage);
98+
if (liveStartUri !== getOdometerImageUri(startImage) || liveEndUri !== getOdometerImageUri(endImage)) {
99+
setAsyncVerificationPassed(true);
100+
return;
101+
}
102+
103+
// Rehydrate over the dead URLs when a draft exists — clearing first races the destination's
104+
// auto-hydrator and ends up dropping the wrong URL.
105+
if (odometerDraft) {
106+
onBackupHandled?.({shouldResetLocalState: false});
107+
hydrateOdometerDraftIntoTransaction(transaction.transactionID, odometerDraft, transaction.comment);
108+
} else {
109+
onBackupHandled?.({shouldResetLocalState: true});
110+
clearOdometerDraftTransactionState(transaction);
111+
}
112+
103113
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
104114
});
105-
}, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled]);
115+
}, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled, odometerDraft, odometerDraftStatus]);
106116

107-
const isOnyxLoading = isLoadingOnyxValue(draftTransactionsMetadata);
117+
const isOnyxLoading = isLoadingOnyxValue(draftTransactionsMetadata, odometerDraftStatus);
108118
const hasVerifiedBlobs = !!transaction && !isOnyxLoading && (!hasBlobUrls || asyncVerificationPassed);
109119

110120
return {hasVerifiedBlobs};

src/languages/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,7 @@ const translations: TranslationDeepObject<typeof en> = {
14631463
manySplitsProvided: `Die maximale Anzahl zulässiger Aufteilungen beträgt ${CONST.IOU.SPLITS_LIMIT}.`,
14641464
dateRangeExceedsMaxDays: `Der Datumsbereich darf ${CONST.IOU.SPLITS_LIMIT} Tage nicht überschreiten.`,
14651465
stitchOdometerImagesFailed: 'Kilometerzählerbilder konnten nicht zusammengeführt werden. Bitte versuchen Sie es später noch einmal.',
1466+
failedToSaveOdometerDraft: 'Dein Kilometerzähler-Entwurf konnte nicht gespeichert werden. Bitte versuche es erneut.',
14661467
},
14671468
dismissReceiptError: 'Fehler ausblenden',
14681469
dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler schließt, wird deine hochgeladene Quittung vollständig entfernt. Bist du sicher?',

src/languages/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,7 @@ const translations = {
14841484
distanceAmountTooLargeReduceRate: 'The total amount is too large. Lower the rate.',
14851485
odometerReadingTooLarge: (formattedMax: string) => `Odometer readings cannot exceed ${formattedMax}.`,
14861486
stitchOdometerImagesFailed: 'Failed to combine odometer images. Please try again later.',
1487+
failedToSaveOdometerDraft: "Couldn't save your odometer draft. Please try again.",
14871488
invalidIntegerAmount: 'Please enter a whole dollar amount before continuing',
14881489
invalidTaxAmount: (amount: string) => `Maximum tax amount is ${amount}`,
14891490
invalidSplit: 'The sum of splits must equal the total amount',

src/languages/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,7 @@ const translations: TranslationDeepObject<typeof en> = {
13871387
distanceAmountTooLargeReduceRate: 'El importe total es demasiado alto. Disminuye la tarifa.',
13881388
odometerReadingTooLarge: (formattedMax: string) => `Las lecturas del odómetro no pueden superar ${formattedMax}.`,
13891389
stitchOdometerImagesFailed: 'No se pudieron combinar las imágenes del odómetro. Por favor, inténtalo de nuevo más tarde.',
1390+
failedToSaveOdometerDraft: 'No se pudo guardar el borrador del odómetro. Por favor, inténtalo de nuevo.',
13901391
invalidIntegerAmount: 'Por favor, introduce un importe entero en dólares antes de continuar',
13911392
invalidTaxAmount: (amount) => `El importe máximo del impuesto es ${amount}`,
13921393
invalidSplit: 'La suma de las partes debe ser igual al importe total',

src/languages/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,7 @@ const translations: TranslationDeepObject<typeof en> = {
14661466
manySplitsProvided: `Le nombre maximal de répartitions autorisées est de ${CONST.IOU.SPLITS_LIMIT}.`,
14671467
dateRangeExceedsMaxDays: `La plage de dates ne peut pas dépasser ${CONST.IOU.SPLITS_LIMIT} jours.`,
14681468
stitchOdometerImagesFailed: 'Échec de la combinaison des images de l’odomètre. Veuillez réessayer plus tard.',
1469+
failedToSaveOdometerDraft: 'Impossible d’enregistrer votre brouillon de compteur kilométrique. Veuillez réessayer.',
14691470
},
14701471
dismissReceiptError: 'Ignorer l’erreur',
14711472
dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera complètement votre reçu téléversé. Êtes-vous sûr ?',

src/languages/it.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,7 @@ const translations: TranslationDeepObject<typeof en> = {
14601460
manySplitsProvided: `Il numero massimo di suddivisioni consentite è ${CONST.IOU.SPLITS_LIMIT}.`,
14611461
dateRangeExceedsMaxDays: `L’intervallo di date non può superare ${CONST.IOU.SPLITS_LIMIT} giorni.`,
14621462
stitchOdometerImagesFailed: 'Impossibile combinare le immagini del contachilometri. Riprova più tardi.',
1463+
failedToSaveOdometerDraft: 'Impossibile salvare la tua bozza del contachilometri. Riprova.',
14631464
},
14641465
dismissReceiptError: 'Ignora errore',
14651466
dismissReceiptErrorConfirmation: 'Attenzione! Chiudere questo errore rimuoverà completamente la ricevuta che hai caricato. Sei sicuro?',

0 commit comments

Comments
 (0)