Improve upload progress.

This commit is contained in:
Patrick Fic
2025-10-30 16:34:10 -07:00
parent 162f599e2d
commit 8e63ef0d6d
11 changed files with 186 additions and 137 deletions

View File

@@ -1,3 +1,4 @@
import UploadProgress from "@/components/upload-progress/upload-progress";
import { checkUserSession } from "@/redux/user/user.actions"; import { checkUserSession } from "@/redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "@/redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "@/redux/user/user.selectors";
import { ApolloProvider } from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
@@ -161,6 +162,7 @@ function AppContent({ currentUser, checkUserSession, bodyshop }: any) {
if (currentUser.authorized) { if (currentUser.authorized) {
return ( return (
<ThemedLayout> <ThemedLayout>
<UploadProgress />
<AuthenticatedLayout /> <AuthenticatedLayout />
</ThemedLayout> </ThemedLayout>
); );

View File

@@ -1,4 +1,4 @@
<babeledit_project version="1.2" be_version="2.7.1"> <babeledit_project be_version="2.7.1" version="1.2">
<!-- <!--
BabelEdit project file BabelEdit project file
@@ -236,6 +236,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>upload</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>uploadprogress</name> <name>uploadprogress</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

View File

@@ -9,7 +9,6 @@ import { createStructuredSelector } from "reselect";
import { QUERY_ALL_ACTIVE_JOBS } from "../../graphql/jobs.queries"; import { QUERY_ALL_ACTIVE_JOBS } from "../../graphql/jobs.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
//import ErrorDisplay from "../error-display/error-display.component"; //import ErrorDisplay from "../error-display/error-display.component";
import UploadProgress from "../upload-progress/upload-progress";
import JobListItem from "./job-list-item"; import JobListItem from "./job-list-item";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -58,7 +57,6 @@ export function JobListComponent({ bodyshop }) {
> >
Jobs Jobs
</Text> </Text>
<UploadProgress />
<FlatList <FlatList
refreshControl={ refreshControl={
<RefreshControl refreshing={loading} onRefresh={onRefresh} /> <RefreshControl refreshing={loading} onRefresh={onRefresh} />

View File

@@ -1,8 +1,10 @@
import { useTheme } from "@/hooks";
import { clearUploadError } from "@/redux/photos/photos.actions"; import { clearUploadError } from "@/redux/photos/photos.actions";
import { formatBytes } from "@/util/uploadUtils"; import { formatBytes } from "@/util/uploadUtils";
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native"; import { ScrollView, StyleSheet, View } from "react-native";
import { ProgressBar, Text } from "react-native-paper"; import { 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 {
@@ -10,7 +12,6 @@ import {
selectUploadError, selectUploadError,
selectUploadProgress, selectUploadProgress,
} from "../../redux/photos/photos.selectors"; } from "../../redux/photos/photos.selectors";
import ErrorDisplay from "../error/error-display";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
photos: selectPhotos, photos: selectPhotos,
@@ -30,78 +31,94 @@ export function UploadProgress({
clearError, clearError,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (photos?.length === 0) return null; const theme = useTheme();
if (uploadError)
return <ErrorDisplay error={uploadError} onDismiss={clearError} />;
return (
<View style={styles.modalContainer}>
<View style={styles.modal}>
<Text variant="titleLarge" style={styles.title}>
{t("general.labels.uploadprogress")}
</Text>
{Object.keys(photoUploadProgress).map((key) => ( const completion = useMemo(() => {
<View key={key} style={styles.progressItem}> const total = Object.keys(photoUploadProgress).length;
<Text if (total === 0) return 0;
style={styles.progressText} const completed = Object.values(photoUploadProgress).filter(
numberOfLines={1} (p) => p.progress === 100
ellipsizeMode="tail" ).length;
> return completed / total;
{photoUploadProgress[key].fileName} }, [photoUploadProgress]);
</Text>
<View style={styles.progressBarContainer}> return (
<ProgressBar <Portal>
progress={photoUploadProgress[key].progress} <Modal
style={styles.progress} visible={photos?.length > 0}
color={ style={styles.modalOuter} // add
photoUploadProgress[key].progress === 1 ? "green" : "blue" contentContainerStyle={[
} styles.modalContainer,
/> { backgroundColor: theme.colors.elevation.level1 },
<View ]}
style={{ >
display: "flex", <ScrollView style={styles.modalFill}>
flexDirection: "row", <Text variant="titleLarge" style={styles.title}>
alignItems: "center", {t("general.labels.upload")}
}} </Text>
<Text variant="labelLarge">
{`${t("general.labels.uploadprogress")} ${Math.round(
completion * 100
)}%`}
</Text>
<ProgressBar
progress={completion}
style={styles.progress}
color={completion === 1 ? "green" : "blue"}
/>
<Divider style={{ marginVertical: 12 }} />
{Object.keys(photoUploadProgress).map((key) => (
<View key={key} style={styles.progressItem}>
<Text
style={styles.progressText}
numberOfLines={1}
ellipsizeMode="tail"
> >
<Text>{`${formatBytes( {photoUploadProgress[key].fileName}
photoUploadProgress[key].loaded / </Text>
(((photoUploadProgress[key].endTime || new Date()) - <View style={styles.progressBarContainer}>
photoUploadProgress[key].startTime) / <ProgressBar
1000) progress={(photoUploadProgress[key].progress || 0) / 100}
)}/sec`}</Text> style={styles.progress}
color={
photoUploadProgress[key].progress === 100 ? "green" : "blue"
}
/>
<View style={styles.speedRow}>
<Text>{`${formatBytes(
photoUploadProgress[key].loaded /
(((photoUploadProgress[key].endTime || new Date()) -
photoUploadProgress[key].startTime) /
1000)
)}/sec`}</Text>
</View>
</View> </View>
</View> </View>
</View> ))}
))} </ScrollView>
</View> </Modal>
</View> </Portal>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
modalContainer: { modalOuter: {
display: "flex", flex: 1, // ensure outer container can grow,
// flex: 1, paddingHorizontal: 24,
marginTop: 14, paddingVertical: 72,
marginBottom: 14,
justifyContent: "center",
}, },
modal: { modalContainer: {
//flex: 1, width: "100%",
display: "flex", height: "50%", // force full area (important for iOS)
marginLeft: 12, padding: 24,
marginRight: 12, justifyContent: "center",
//backgroundColor: theme.colors.elevation.level3, borderRadius: 24,
borderRadius: 20, },
paddingTop: 12, modalFill: {
shadowColor: "#000", flex: 1,
shadowOffset: { },
width: 0, speedRow: {
height: 2, flexDirection: "row",
}, alignItems: "center",
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
}, },
title: { title: {
alignSelf: "center", alignSelf: "center",
@@ -110,11 +127,6 @@ const styles = StyleSheet.create({
paddingLeft: 12, paddingLeft: 12,
paddingRight: 12, paddingRight: 12,
}, },
centeredView: {
justifyContent: "center",
alignItems: "center",
marginTop: 22,
},
progressItem: { progressItem: {
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",

View File

@@ -17,7 +17,7 @@ const photosReducer = (state = INITIAL_STATE, action) => {
jobid: action.payload.jobid, jobid: action.payload.jobid,
uploadInProgress: true, uploadInProgress: true,
uploadError: null, uploadError: null,
progress: {} progress: action.payload.progress || {}
}; };
case PhotosActionTypes.MEDIA_UPLOAD_FAILURE: case PhotosActionTypes.MEDIA_UPLOAD_FAILURE:
return { return {

View File

@@ -2,13 +2,17 @@ import axios from "axios";
import Constants from "expo-constants"; import Constants from "expo-constants";
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 moment from 'moment'; import moment from 'moment';
import { Alert, Platform } from "react-native";
import { all, call, delay, put, select, takeEvery, takeLatest } from "redux-saga/effects"; import { all, call, delay, put, select, takeEvery, takeLatest } from "redux-saga/effects";
import env from "../../env"; import env from "../../env";
import { client } from '../../graphql/client'; import { client } from '../../graphql/client';
import { GET_DOC_SIZE_TOTALS, INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries"; import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../util/CleanAxios"; import { axiosAuthInterceptorId } from "../../util/CleanAxios";
import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils';
import { selectDeleteAfterUpload } from "../app/app.selectors";
import { store } from "../store";
import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors";
import { import {
deleteMediaSuccess, deleteMediaSuccess,
@@ -19,12 +23,9 @@ import {
mediaUploadStart, mediaUploadStart,
mediaUploadSuccessOne mediaUploadSuccessOne
} from "./photos.actions"; } from "./photos.actions";
import i18n from "@/translations/i18n";
import { Platform } from "react-native";
import { selectDeleteAfterUpload } from "../app/app.selectors";
import PhotosActionTypes from "./photos.types"; import PhotosActionTypes from "./photos.types";
axios.interceptors.request.use( axios.interceptors.request.use(
function (config) { function (config) {
config.metadata = { startTime: new Date() }; config.metadata = { startTime: new Date() };
@@ -80,7 +81,7 @@ export function* openImagePickerAction({ payload: jobid }) {
exif: true, exif: true,
}); });
if (!(result.canceled)) { if (!(result.canceled)) {
yield put(mediaUploadStart({ photos: result.assets, jobid })); 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);
@@ -101,14 +102,13 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) {
else { else {
//Check to see if the job has enough space before uploading. //Check to see if the job has enough space before uploading.
// const hasEnoughSpace = yield call(checkJobSpace, jobid, photos, bodyshop);
// if (!hasEnoughSpace) {
const hasEnoughSpace = yield call(checkJobSpace, jobid, photos, bodyshop); // alert(i18n.t("mediabrowser.labels.storageexceeded"));
if (!hasEnoughSpace) { // yield put(mediaUploadFailure(i18n.t("mediabrowser.labels.storageexceeded")));
// return;
alert(i18n.t("mediabrowser.labels.storageexceeded")); // }
yield put(mediaUploadFailure(i18n.t("mediabrowser.labels.storageexceeded")));
return;
}
// Process photos in batches to avoid overwhelming the system // Process photos in batches to avoid overwhelming the system
const batchSize = 3; // Upload 3 photos concurrently const batchSize = 3; // Upload 3 photos concurrently
@@ -137,43 +137,45 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) {
} }
} }
function* checkJobSpace(jobid, photos, bodyshop) { // function* checkJobSpace(jobid, photos, bodyshop) {
try { // try {
const totalOfUploads = photos.reduce((acc, val) => { // //TODO - This function has not been validated and saw issues during testing.
//Get the size of the file based on URI. // //It was not fixed as we will not be enabling it.
if (val.fileSize) { // const totalOfUploads = photos.reduce((acc, val) => {
return acc + val.fileSize; // //Get the size of the file based on URI.
} else { // if (val.fileSize) {
alert("Asset is missing filesize. Cannot verify job space."); // return acc + val.fileSize;
return acc // } else {
} // alert("Asset is missing filesize. Cannot verify job space.");
}, 0); // return acc
// }
// }, 0);
if (jobid !== "temp") { // if (jobid !== "temp") {
const queryData = yield call(client.query, { // const queryData = yield call(client.query, {
query: GET_DOC_SIZE_TOTALS, // query: GET_DOC_SIZE_TOTALS,
fetchPolicy: "network-only", // fetchPolicy: "network-only",
variables: { // variables: {
jobId: jobid, // jobId: jobid,
}, // },
}); // });
if ( // if (
bodyshop.jobsizelimit - // bodyshop.jobsizelimit -
queryData?.data?.documents_aggregate.aggregate.sum.size <= // queryData?.data?.documents_aggregate.aggregate.sum.size <=
totalOfUploads // totalOfUploads
) { // ) {
//No more room... abandon ship. // //No more room... abandon ship.
return false; // return false;
} // }
} // }
return true; // return true;
} // }
catch (error) { // catch (error) {
console.log("Error checking job space", error, error.stack); // console.log("Error checking job space", error, error.stack);
return false; // return false;
} // }
} // }
function* uploadSinglePhoto(photo, bodyshop, index, jobid) { function* uploadSinglePhoto(photo, bodyshop, index, jobid) {
try { try {
@@ -259,29 +261,31 @@ function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid)
let uploadResult let uploadResult
try { try {
uploadResult = yield new Promise((resolve, reject) => { uploadResult = yield new Promise((resolve, reject) => {
console.log("Starting XHR")
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
console.log("Upload Progress:", e.loaded, e.total);
store.dispatch({ ...photo, progress: e.loaded / e.total, loaded: e.loaded });
put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total, loaded: e.loaded }));
};
xhr.open("PUT", preSignedUploadUrlToS3); xhr.open("PUT", preSignedUploadUrlToS3);
xhr.setRequestHeader("Content-Type", photoBlob.type); xhr.setRequestHeader("Content-Type", photoBlob.type);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total, loaded: e.loaded }));
}
};
xhr.onload = () => { xhr.onload = () => {
if (xhr.status === 200) { if (xhr.status === 200) {
console.log("XHR Done. Resolve promise.")
resolve(true); resolve(true);
} else { } else {
reject(new Error(`Upload failed: ${xhr.statusText}`)); reject(new Error(`Upload failed: ${xhr.statusText}`));
} }
}; };
xhr.onerror = (req, event) => { xhr.onerror = (req, event) => {
reject(new Error("Network error")); reject(new Error("Network error"));
}; };
console.log("Sending XHR")
xhr.send(photoBlob); 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);
@@ -395,13 +399,22 @@ function* mediaUploadCompletedAction({ payload: photos }) {
} }
} }
// Handle cancellation of uploads
function* onMediaUploadFailure() {
yield takeEvery(PhotosActionTypes.MEDIA_UPLOAD_FAILURE, mediaUploadFailureAction);
}
function* mediaUploadFailureAction({ payload: errorMessage }) {
Alert.alert("Upload Error", `An error occurred during upload: ${errorMessage}`);
}
export function* photosSagas() { export function* photosSagas() {
yield all([ yield all([
call(onOpenImagePicker), call(onOpenImagePicker),
call(onMediaUploadStart), call(onMediaUploadStart),
call(onMediaUploadCompleted) call(onMediaUploadCompleted),
call(onMediaUploadFailure)
//call(onCancelUpload) //call(onCancelUpload)
]); ]);
} }

View File

@@ -9,7 +9,7 @@ const persistConfig = {
key: "root", key: "root",
storage: AsyncStorage, storage: AsyncStorage,
// whitelist: ["photos"], // whitelist: ["photos"],
blacklist: ["user",], // Add reducers you do NOT want to persist blacklist: ["user", "photos"], // Add reducers you do NOT want to persist
}; };
const rootReducer = combineReducers({ const rootReducer = combineReducers({

View File

@@ -19,6 +19,7 @@
"labels": { "labels": {
"error": "Error", "error": "Error",
"na": "N/A", "na": "N/A",
"upload": "Upload",
"uploadprogress": "Upload Progress" "uploadprogress": "Upload Progress"
} }
}, },

View File

@@ -19,6 +19,7 @@
"labels": { "labels": {
"error": "", "error": "",
"na": "", "na": "",
"upload": "",
"uploadprogress": "" "uploadprogress": ""
} }
}, },

View File

@@ -19,6 +19,7 @@
"labels": { "labels": {
"error": "", "error": "",
"na": "", "na": "",
"upload": "",
"uploadprogress": "" "uploadprogress": ""
} }
}, },

View File

@@ -32,7 +32,7 @@ const lightTheme = {
inversePrimary: "#a5c8ff", inversePrimary: "#a5c8ff",
elevation: { elevation: {
level0: "transparent", level0: "transparent",
level1: "#f1f1f1ff", level1: "#d5d5d5ff",
level2: "#e9eff9", level2: "#e9eff9",
level3: "#e1ebf6", level3: "#e1ebf6",
level4: "#dfe9f5", level4: "#dfe9f5",
@@ -78,7 +78,7 @@ const darkTheme = {
inversePrimary: "#1890ff", inversePrimary: "#1890ff",
elevation: { elevation: {
level0: "transparent", level0: "transparent",
level1: "#1a1f2e", level1: "#575757ff",
level2: "#212837", level2: "#212837",
level3: "#293141", level3: "#293141",
level4: "#2b3344", level4: "#2b3344",