Major improvements to upload progress.

This commit is contained in:
Patrick Fic
2025-10-31 08:18:32 -07:00
parent 8e63ef0d6d
commit ab8703a524
7 changed files with 185 additions and 61 deletions

View File

@@ -6,7 +6,7 @@
"scheme": "imex-mobile-scheme", "scheme": "imex-mobile-scheme",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"extra": { "extra": {
"expover": "1", "expover": "8",
"eas": { "eas": {
"projectId": "ffe01f3a-d507-4698-82cd-da1f1cad450b" "projectId": "ffe01f3a-d507-4698-82cd-da1f1cad450b"
} }
@@ -14,10 +14,7 @@
"runtimeVersion": "appVersion", "runtimeVersion": "appVersion",
"orientation": "default", "orientation": "default",
"icon": "./assets/ImEXlogo192noa.png", "icon": "./assets/ImEXlogo192noa.png",
"platforms": [ "platforms": ["ios", "android"],
"ios",
"android"
],
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.imex.imexmobile", "bundleIdentifier": "com.imex.imexmobile",
@@ -51,9 +48,7 @@
"fallbackToCacheTimeout": 0, "fallbackToCacheTimeout": 0,
"url": "https://u.expo.dev/ffe01f3a-d507-4698-82cd-da1f1cad450b" "url": "https://u.expo.dev/ffe01f3a-d507-4698-82cd-da1f1cad450b"
}, },
"assetBundlePatterns": [ "assetBundlePatterns": ["**/*"],
"**/*"
],
"web": { "web": {
"favicon": "./assets/ImEXlogo192noa.png", "favicon": "./assets/ImEXlogo192noa.png",
"config": { "config": {

View File

@@ -7,6 +7,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Application from "expo-application"; import * as Application from "expo-application";
import Constants from "expo-constants"; import Constants from "expo-constants";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import * as Updates from "expo-updates";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, StyleSheet, View } from "react-native"; import { Alert, ScrollView, StyleSheet, View } from "react-native";
@@ -182,6 +183,44 @@ function Tab({ bodyshop, currentUser, signOutStart }) {
number: `${Constants.expoConfig.version}(${Application.nativeBuildVersion} - ${Constants.expoConfig.extra.expover})`, number: `${Constants.expoConfig.version}(${Application.nativeBuildVersion} - ${Constants.expoConfig.extra.expover})`,
})} })}
</Text> </Text>
<Button
mode="text"
onPress={() => {
Updates.checkForUpdateAsync()
.then(async (update) => {
if (update.isAvailable) {
const reloaded = await Updates.fetchUpdateAsync();
if (reloaded.isNew) {
Alert.alert(
"Update downloaded",
"The app will now restart to apply the update.",
[
{
text: "Restart Now",
onPress: () => {
Updates.reloadAsync();
},
},
]
);
}
} else {
Alert.alert(
"No Update Available",
"You are using the latest version of the app."
);
}
})
.catch((error) => {
Alert.alert(
"Update Error",
`An error occurred while checking for updates: ${error.message}`
);
});
}}
>
Check for Update
</Button>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -1,10 +1,17 @@
import { useTheme } from "@/hooks"; import { useTheme } from "@/hooks";
import { clearUploadError } from "@/redux/photos/photos.actions"; import { cancelUploads, clearUploadError } from "@/redux/photos/photos.actions";
import { formatBytes } from "@/util/uploadUtils"; import { formatBytes } from "@/util/uploadUtils";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, StyleSheet, View } from "react-native"; import { ScrollView, StyleSheet, View } from "react-native";
import { Divider, Modal, Portal, ProgressBar, Text } from "react-native-paper"; import {
Button,
Divider,
Modal,
Portal,
ProgressBar,
Text,
} from "react-native-paper";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { import {
@@ -20,6 +27,7 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
clearError: () => dispatch(clearUploadError()), clearError: () => dispatch(clearUploadError()),
cancelUploads: () => dispatch(cancelUploads()),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(UploadProgress); export default connect(mapStateToProps, mapDispatchToProps)(UploadProgress);
@@ -29,6 +37,7 @@ export function UploadProgress({
photoUploadProgress, photoUploadProgress,
uploadError, uploadError,
clearError, clearError,
cancelUploads,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
@@ -42,6 +51,10 @@ export function UploadProgress({
return completed / total; return completed / total;
}, [photoUploadProgress]); }, [photoUploadProgress]);
const handleCancelUploads = () => {
cancelUploads();
};
return ( return (
<Portal> <Portal>
<Modal <Modal
@@ -78,10 +91,10 @@ export function UploadProgress({
</Text> </Text>
<View style={styles.progressBarContainer}> <View style={styles.progressBarContainer}>
<ProgressBar <ProgressBar
progress={(photoUploadProgress[key].progress || 0) / 100} progress={photoUploadProgress[key].progress || 0}
style={styles.progress} style={styles.progress}
color={ color={
photoUploadProgress[key].progress === 100 ? "green" : "blue" photoUploadProgress[key].progress === 1 ? "green" : "blue"
} }
/> />
<View style={styles.speedRow}> <View style={styles.speedRow}>
@@ -95,6 +108,9 @@ export function UploadProgress({
</View> </View>
</View> </View>
))} ))}
<Button onPress={handleCancelUploads}>
{t("general.actions.cancel")}
</Button>
</ScrollView> </ScrollView>
</Modal> </Modal>
</Portal> </Portal>

View File

@@ -53,3 +53,17 @@ export const clearUploadError = () => ({
type: PhotosActionTypes.CLEAR_UPLOAD_ERROR, type: PhotosActionTypes.CLEAR_UPLOAD_ERROR,
}); });
export const addUploadCancelTask = (payload) => ({
type: PhotosActionTypes.ADD_UPLOAD_CANCEL_TASK,
payload,
});
export const removeUploadCancelTask = (payload) => ({
type: PhotosActionTypes.REMOVE_UPLOAD_CANCEL_TASK,
payload,
});
export const cancelUploads = () => ({
type: PhotosActionTypes.CANCEL_UPLOADS,
});

View File

@@ -5,7 +5,9 @@ const INITIAL_STATE = {
uploadInProgress: true, uploadInProgress: true,
uploadError: null, uploadError: null,
jobid: null, jobid: null,
progress: {} progress: {},
cancelTasks: {},
cancelTriggered: false,
}; };
const photosReducer = (state = INITIAL_STATE, action) => { const photosReducer = (state = INITIAL_STATE, action) => {
@@ -17,7 +19,9 @@ const photosReducer = (state = INITIAL_STATE, action) => {
jobid: action.payload.jobid, jobid: action.payload.jobid,
uploadInProgress: true, uploadInProgress: true,
uploadError: null, uploadError: null,
progress: action.payload.progress || {} progress: action.payload.progress || {},
cancelTasks: {},
cancelTriggered: false,
}; };
case PhotosActionTypes.MEDIA_UPLOAD_FAILURE: case PhotosActionTypes.MEDIA_UPLOAD_FAILURE:
return { return {
@@ -31,9 +35,12 @@ const photosReducer = (state = INITIAL_STATE, action) => {
progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } } progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } }
}; };
case PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE: case PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE:
const { [action.payload.assetId]: _, ...remainingTasks } = state.cancelTasks;
return { return {
...state, ...state,
progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], progress: 100, status: 'completed', endTime: new Date() } } progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], progress: 100, status: 'completed', endTime: new Date() } },
cancelTasks: remainingTasks
}; };
case PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_BULK: case PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_BULK:
return { return {
@@ -46,13 +53,35 @@ const photosReducer = (state = INITIAL_STATE, action) => {
uploadInProgress: false, uploadInProgress: false,
uploadError: null, uploadError: null,
photos: [], photos: [],
progress: {} progress: {},
cancelTasks: {}
}; };
case PhotosActionTypes.CLEAR_UPLOAD_ERROR: case PhotosActionTypes.CLEAR_UPLOAD_ERROR:
return { return {
...state, ...state,
photos: [], progress: {}, photos: [],
progress: {},
uploadError: null, uploadError: null,
cancelTasks: {},
};
case PhotosActionTypes.ADD_UPLOAD_CANCEL_TASK:
return {
...state,
cancelTasks: {
...state.cancelTasks,
[action.payload.assetId]: action.payload.cancelTask,
},
};
case PhotosActionTypes.REMOVE_UPLOAD_CANCEL_TASK:
const { [action.payload.assetId]: _2, ...remainingTasks2 } = state.cancelTasks; //2 added for scoped variable conflict.
return {
...state,
cancelTasks: remainingTasks2,
};
case PhotosActionTypes.CANCEL_UPLOADS:
return {
...state,
cancelTriggered: true,
}; };
default: default:
return state; return state;

View File

@@ -1,5 +1,6 @@
import axios from "axios"; import axios from "axios";
import Constants from "expo-constants"; import Constants from "expo-constants";
import * as FileSystem from "expo-file-system/legacy";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import * as MediaLibrary from "expo-media-library"; import * as MediaLibrary from "expo-media-library";
import _ from 'lodash'; import _ from 'lodash';
@@ -15,6 +16,7 @@ import { selectDeleteAfterUpload } from "../app/app.selectors";
import { store } from "../store"; import { store } from "../store";
import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors";
import { import {
addUploadCancelTask,
deleteMediaSuccess, deleteMediaSuccess,
mediaUploadCompleted, mediaUploadCompleted,
mediaUploadFailure, mediaUploadFailure,
@@ -84,7 +86,7 @@ export function* openImagePickerAction({ payload: jobid }) {
yield put(mediaUploadStart({ photos: result.assets, jobid, progress: _.keyBy(result.assets, 'assetId') })); yield put(mediaUploadStart({ photos: result.assets, jobid, progress: _.keyBy(result.assets, 'assetId') }));
} }
} catch (error) { } catch (error) {
// console.log("Saga Error: open Picker", error); console.log("Saga Error: open Picker", error);
} }
} }
@@ -119,6 +121,9 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) {
} }
// Process each batch sequentially, but photos within batch concurrently // Process each batch sequentially, but photos within batch concurrently
for (const batch of batches) { for (const batch of batches) {
const isCancelTriggered = yield select((state) => state.photos.cancelTriggered
);
if (!isCancelTriggered) {
const uploadTasks = batch.map((photo, index) => const uploadTasks = batch.map((photo, index) =>
call(uploadSinglePhoto, photo, bodyshop, index, jobid) call(uploadSinglePhoto, photo, bodyshop, index, jobid)
); );
@@ -128,6 +133,7 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) {
yield delay(100); yield delay(100);
} }
} }
}
yield put(mediaUploadCompleted(photos)); yield put(mediaUploadCompleted(photos));
@@ -260,39 +266,53 @@ function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid)
let uploadResult let uploadResult
try { try {
uploadResult = yield new Promise((resolve, reject) => {
console.log("Starting XHR") // const s3PutResponse = yield call(cleanAxios.put,
const xhr = new XMLHttpRequest(); // preSignedUploadUrlToS3,
xhr.upload.onprogress = (e) => { // photoBlob,
console.log("Upload Progress:", e.loaded, e.total); // {
store.dispatch({ ...photo, progress: e.loaded / e.total, loaded: e.loaded }); // headers: {
put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total, loaded: e.loaded })); // "Content-Type": photoBlob.type
// },
// onUploadProgress: (e) => {
// const progress = e.loaded / e.total;
// console.log("Event Progress", e)
// put(mediaUploadProgressOne({ ...photo, progress, loaded: e.loaded }));
// },
// }
// );
const task = FileSystem.createUploadTask(
preSignedUploadUrlToS3,
photo.uri,
{
//fieldName: FIELD_NAME_OF_THE_FILE_IN_REQUEST,
httpMethod: "PUT",
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
mimeType: photoBlob.type,
headers: {},
//parameters: {...OTHER PARAMS IN REQUEST},
},
(progressData) => {
const sent = progressData.totalBytesSent;
const total = progressData.totalBytesExpectedToSend;
const progress = sent / total;
console.log(progress, sent)
store.dispatch(mediaUploadProgressOne({ ...photo, progress, loaded: sent }));
// onUpload(Number(progress.toFixed(2)) * 100);
},
);
yield put(addUploadCancelTask({ assetId: photo.assetId, cancelTask: task.cancelAsync }));
uploadResult = yield task.uploadAsync();
};
xhr.open("PUT", preSignedUploadUrlToS3);
xhr.setRequestHeader("Content-Type", photoBlob.type);
xhr.onload = () => {
if (xhr.status === 200) {
console.log("XHR Done. Resolve promise.")
resolve(true);
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
};
xhr.onerror = (req, event) => {
reject(new Error("Network error"));
};
console.log("Sending XHR")
xhr.send(photoBlob);
});
} catch (error) { } catch (error) {
console.log("Error uploading to S3", error.message, error.stack); console.log("Error uploading to S3", error.message, error.stack);
throw error; throw error;
} }
if (uploadResult) { if (uploadResult.status === 200) {
//Create doc record. //Create doc record.
const uploaded_by = yield select(selectCurrentUser); const uploaded_by = yield select(selectCurrentUser);
@@ -328,26 +348,34 @@ function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid)
}, },
})); }));
console.log("Upload and record creation successful for", photo.uri); console.log("Upload and record creation successful for", photo.uri);
} else {
console.log("Error uploading to Cloud", uploadResult);
throw new Error(`Cloud upload failed: ${uploadResult}`);
} }
} catch (error) { } catch (error) {
console.log("Error uploading to image proxy", JSON.stringify(error)); console.log("Error uploading to Cloud", JSON.stringify(error));
throw new Error(`Image proxy upload failed: ${error.message}`); throw new Error(`Cloud upload failed: ${error.message}`);
} }
} }
// Handle cancellation of uploads // Handle cancellation of uploads
function* onCancelUpload() { function* onCancelUpload() {
yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction); yield takeEvery(PhotosActionTypes.CANCEL_UPLOADS, cancelUploadAction);
} }
function* cancelUploadAction({ payload: photoId }) { function* cancelUploadAction() {
// const task = uploadTasks.get(photoId); const cancelTasks = yield select((state) => state.photos.cancelTasks);
// if (task) { try {
// yield cancel(task); const tasksToCancel = Object.values(cancelTasks);
// uploadTasks.delete(photoId); for (const cancelTask of tasksToCancel) {
// yield put(mediaUploadFailure({ photoId, error: 'Upload cancelled' })); console.log("*** ~ cancelUploadAction ~ cancelTask:", cancelTask);
// } cancelTask();
}
yield put({ type: PhotosActionTypes.CLEAR_UPLOAD_ERROR });
} catch (error) {
console.log("Error cancelling upload", error);
}
} }
function* onMediaUploadCompleted() { function* onMediaUploadCompleted() {
@@ -414,7 +442,7 @@ export function* photosSagas() {
call(onOpenImagePicker), call(onOpenImagePicker),
call(onMediaUploadStart), call(onMediaUploadStart),
call(onMediaUploadCompleted), call(onMediaUploadCompleted),
call(onMediaUploadFailure) call(onMediaUploadFailure),
//call(onCancelUpload) call(onCancelUpload)
]); ]);
} }

View File

@@ -10,5 +10,8 @@ const PhotosActionTypes = {
DELETE_MEDIA_SUCCESS: "DELETE_MEDIA_SUCCESS", DELETE_MEDIA_SUCCESS: "DELETE_MEDIA_SUCCESS",
DELETE_MEDIA_FAILURE: "DELETE_MEDIA_FAILURE", DELETE_MEDIA_FAILURE: "DELETE_MEDIA_FAILURE",
CLEAR_UPLOAD_ERROR: "CLEAR_UPLOAD_ERROR", CLEAR_UPLOAD_ERROR: "CLEAR_UPLOAD_ERROR",
ADD_UPLOAD_CANCEL_TASK: "ADD_UPLOAD_CANCEL_TASK",
REMOVE_UPLOAD_CANCEL_TASK: "REMOVE_UPLOAD_CANCEL_TASK",
CANCEL_UPLOADS: "CANCEL_UPLOADS",
}; };
export default PhotosActionTypes; export default PhotosActionTypes;