Merge branch 'feature/sdk44'

* feature/sdk44: (29 commits)
  1.3.5-6 - Prod Build - Add back filename.
  1.3.7-5 - Production - Update logic and numbers. Remove package.
  1.3.7-4 - Prod Build - Updated sentry logging.
  1.3.7-3 - Test Build - Update Photo Viewer.
  1.3.7-2 - Test Build - Added progress for fetching file size.
  Improve cloudinary media upload experience.
  Changed image viewer and added mobile feature check.
  1.3.6-8 - Production Build - Resolve deletion.
  1.3.6-7 Production Build - Resolve errors on local upload and deleting.
  1.3.6-6 Production Build - Version updates for submission.
  1.3.6-5 Resolve uploads for cloudinary.
  1.3.6-4 Test Build - Add average upload for local upload.
  1.3.6-3 Test Build - Refactor local uploads to go in bulk.
  1.3.6-2 Test Build - Individual file uploads & cloudariny fixes.
  1.3.6-1 Release - Toast for successful push.
  Remove IPA file.
  1.3.6(1) Resolved local upload issues due to form type.
  Resolve video upload issues.
  Added local build configuration.
  Add IMS Token.
  ...
This commit is contained in:
Patrick Fic
2022-08-17 14:58:16 -07:00
32 changed files with 6603 additions and 5396 deletions

4
.gitignore vendored
View File

@@ -12,3 +12,7 @@ yarn-error.log
# macOS
.DS_Store
*.ipa
*.aab

38
App.js
View File

@@ -8,12 +8,23 @@ import ScreenMainComponent from "./components/screen-main/screen-main.component"
import { logImEXEvent } from "./firebase/firebase.analytics";
import { client } from "./graphql/client";
import { persistor, store } from "./redux/store";
import "intl";
import "intl/locale-data/jsonp/en";
import "./translations/i18n";
import "expo-asset";
import Toast from "react-native-toast-message";
import { SafeAreaProvider } from "react-native-safe-area-context";
Sentry.init({
dsn: "https://8d6c3de1940a4e4f8b81cf4d2150bdea@o492140.ingest.sentry.io/5558869",
enableInExpoDevelopment: true,
// debug: true, // Sentry will try to print out useful debugging information if something goes wrong with sending an event. Set this to `false` in production.
tracesSampleRate: 0.2,
integrations: [
new Sentry.Native.ReactNativeTracing({
tracingOrigins: ["localhost", "imex.online", "cloudinary.com", /^\//],
// ... other options
}),
],
debug: true, // Sentry will try to print out useful debugging information if something goes wrong with sending an event. Set this to `false` in production.
});
Sentry.Native.nativeCrash();
@@ -22,7 +33,7 @@ const theme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: "dodgerblue",
primary: "#1890ff",
accent: "tomato",
},
};
@@ -34,15 +45,18 @@ export default class App extends React.Component {
render() {
return (
<Provider store={store}>
<PersistGate persistor={persistor}>
<ApolloProvider client={client}>
<PaperProvider theme={theme}>
<ScreenMainComponent />
</PaperProvider>
</ApolloProvider>
</PersistGate>
</Provider>
<SafeAreaProvider>
<Provider store={store}>
<PersistGate persistor={persistor}>
<ApolloProvider client={client}>
<PaperProvider theme={theme}>
<ScreenMainComponent />
<Toast />
</PaperProvider>
</ApolloProvider>
</PersistGate>
</Provider>
</SafeAreaProvider>
);
}
}

View File

@@ -2,24 +2,25 @@
"expo": {
"name": "ImEX Mobile",
"slug": "imexmobile",
"version": "1.2.3",
"extra": { "expover": "1" },
"version": "1.3.7",
"extra": {
"expover": "6"
},
"orientation": "default",
"icon": "./assets/logo192noa.png",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.imex.imexmobile",
"buildNumber": "1.2.3",
"buildNumber": "6",
"googleServicesFile": "./GoogleService-Info.plist"
},
"android": {
"package": "com.imex.imexmobile",
"versionCode": 1020300,
"versionCode": 1100019,
"googleServicesFile": "./google-services.json"
},
"splash": {
"image": "./assets/Splash.png",
"backgroundColor": "#efefef"
},
"notification": {
@@ -29,7 +30,6 @@
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": ["**/*"],
"web": {
"favicon": "./assets/logo192noa.png",
"config": {
@@ -57,6 +57,17 @@
}
}
]
}
},
"plugins": [
"sentry-expo",
[
"expo-media-library",
{
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
"isAccessMediaLocationEnabled": "true"
}
]
]
}
}

View File

@@ -24,6 +24,27 @@
<folder_node>
<name>app</name>
<children>
<concept_node>
<name>nomobileaccess</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>
<name>title</name>
<definition_loaded>false</definition_loaded>
@@ -1216,6 +1237,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>search</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>
</children>
</folder_node>
<folder_node>
@@ -1341,6 +1383,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>localserver</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>
<name>nomedia</name>
<definition_loaded>false</definition_loaded>

View File

@@ -20,14 +20,24 @@ export default function JobDocumentsComponent({ job, loading, refetch }) {
const fullphotos = useMemo(
() =>
job.documents.map((doc) => {
job.documents.map((doc, idx) => {
return {
id: idx,
videoUrl:
DetermineFileType(doc.type) === "video" && GenerateSrcUrl(doc),
source:
DetermineFileType(doc.type) === "video"
? { uri: GenerateThumbUrl(doc) }
: { uri: GenerateSrcUrl(doc) },
url:
DetermineFileType(doc.type) === "video"
? GenerateThumbUrl(doc)
: GenerateSrcUrl(doc),
uri:
DetermineFileType(doc.type) === "video"
? GenerateThumbUrl(doc)
: GenerateSrcUrl(doc),
thumbUrl: GenerateThumbUrl(doc),
};
}),
[job.documents]

View File

@@ -1,7 +1,8 @@
import { useQuery } from "@apollo/client";
import React from "react";
import { RefreshControl, View, Text } from "react-native";
import { FlatList } from "react-native-gesture-handler";
import { useTranslation } from "react-i18next";
import { FlatList, RefreshControl, Text, View } from "react-native";
import { Button, Searchbar, Title } from "react-native-paper";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_ALL_ACTIVE_JOBS } from "../../graphql/jobs.queries";
@@ -9,8 +10,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import ErrorDisplay from "../error-display/error-display.component";
import JobListItem from "../job-list-item/job-list-item.component";
import LoadingDisplay from "../loading-display/loading-display.component";
import { Title, Button, Searchbar } from "react-native-paper";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
@@ -23,6 +23,7 @@ export function JobListComponent({ bodyshop }) {
statuses: bodyshop.md_ro_statuses.active_statuses || ["Open", "Open*"],
},
skip: !bodyshop,
notifyOnNetworkStatusChange: true,
});
const onRefresh = async () => {
@@ -32,7 +33,6 @@ export function JobListComponent({ bodyshop }) {
if (loading) return <LoadingDisplay />;
if (error) return <ErrorDisplay errorMessage={error.message} />;
if (data && data.jobs && data.jobs.length === 0)
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
@@ -74,7 +74,11 @@ export function JobListComponent({ bodyshop }) {
return (
<View style={{ flex: 1 }}>
<Searchbar onChangeText={onChangeSearch} value={searchQuery} />
<Searchbar
onChangeText={onChangeSearch}
value={searchQuery}
placeholder={t("joblist.labels.search")}
/>
<FlatList
refreshControl={
<RefreshControl refreshing={loading} onRefresh={onRefresh} />

View File

@@ -0,0 +1,192 @@
import * as MediaLibrary from "expo-media-library";
import React, { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Modal,
StyleSheet,
Text,
View,
} from "react-native";
import { ProgressBar } from "react-native-paper";
import Toast from "react-native-toast-message";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.analytics";
import {
selectCurrentCameraJobId,
selectDeleteAfterUpload,
} from "../../redux/app/app.selectors";
import * as Sentry from "sentry-expo";
import { formatBytes } from "../../util/document-upload.utility";
import { handleLocalUpload } from "../../util/local-document-upload.utility";
const mapStateToProps = createStructuredSelector({
selectedCameraJobId: selectCurrentCameraJobId,
deleteAfterUpload: selectDeleteAfterUpload,
});
export default connect(mapStateToProps, null)(UploadProgress);
export function UploadProgress({
selectedCameraJobId,
deleteAfterUpload,
uploads,
setUploads,
forceRerender,
}) {
const [progress, setProgress] = useState({
loading: false,
uploadInProgress: false,
speed: 0,
});
useEffect(() => {
//Set the state of uploads to do.
if (uploads) {
beginUploads(uploads);
setUploads(null);
}
}, [uploads]);
async function handleOnSuccess({ duration, data }) {
//If it's not in production, show a toast with the time.
Toast.show({
type: "success",
text1: ` Upload completed in ${duration}.`,
//
// text2: duration,
});
if (deleteAfterUpload) {
try {
await MediaLibrary.deleteAssetsAsync(data);
} catch (error) {
console.log("Unable to delete picture.", error);
Sentry.Native.captureException(error);
}
}
logImEXEvent("imexmobile_successful_upload");
forceRerender();
setProgress({ ...progress, speed: 0, percent: 1, uploadInProgress: false });
}
function handleOnProgress({ percent, loaded }) {
setProgress((progress) => ({
...progress,
speed: loaded - progress.loaded,
loaded: loaded,
percent,
}));
}
function handleOnError({ assetid, error }) {
logImEXEvent("imexmobile_upload_documents_error");
Toast.show({
type: "error",
text1: "Unable to upload documents.",
text2: error,
autoHide: false,
});
setProgress({
speed: 0,
percent: 1,
uploadInProgress: false,
});
}
const beginUploads = async (data) => {
//Validate to make sure the totals for the file sizes do not exceed the total on the job.
setProgress({
percent: 0,
loaded: 0,
uploadInProgress: true,
start: new Date(),
average: 0,
});
await handleLocalUpload({
files: data,
onError: ({ assetid, error }) => handleOnError({ assetid, error }),
onProgress: ({ percent, loaded }) =>
handleOnProgress({ percent, loaded }),
onSuccess: ({ duration }) => handleOnSuccess({ duration, data }),
context: {
jobid:
selectedCameraJobId !== "temp" ? selectedCameraJobId : "temporary",
},
});
};
return (
<Modal
visible={progress.uploadInProgress}
animationType="slide"
transparent={true}
onRequestClose={() => {
Alert.alert("Cancel?", "Do you want to abort the upload?", [
{
text: "Yes",
onPress: () => {
setUploads(null);
setProgress(null);
},
},
{ text: "No" },
]);
}}
>
<View style={styles.modalContainer}>
<View style={styles.modal}>
<ActivityIndicator style={{ alignSelf: "center", marginTop: 16 }} />
<ProgressBar
progress={progress.percent}
style={{ alignSelf: "center", marginTop: 16 }}
color={progress.percent === 1 ? "green" : "blue"}
/>
<Text style={{ alignSelf: "center", marginTop: 16 }}>{`${formatBytes(
progress.speed
)}/sec`}</Text>
<Text
style={{ alignSelf: "center", marginTop: 16 }}
>{`Avg. ${formatBytes(
progress.loaded / ((new Date() - progress.start) / 1000)
)}/sec`}</Text>
<Text
style={{ alignSelf: "center", marginTop: 16 }}
>{`Total Uploaded ${formatBytes(progress.loaded)}`}</Text>
<Text style={{ alignSelf: "center", marginTop: 16 }}>{`Duration ${(
(new Date() - progress.start) /
1000
).toFixed(1)} sec`}</Text>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modalContainer: {
display: "flex",
flex: 1,
justifyContent: "center",
},
modal: {
// flex: 1,
display: "flex",
marginLeft: 20,
marginRight: 20,
backgroundColor: "white",
borderRadius: 20,
padding: 18,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
});

View File

@@ -1,13 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { Video } from "expo-av";
import React, { useState } from "react";
import {
Dimensions,
Modal,
SafeAreaView,
TouchableOpacity,
} from "react-native";
import Gallery from "react-native-image-gallery";
import { SafeAreaView } from "react-native";
import React from "react";
import ImageView from "react-native-image-viewing";
export default function MediaCacheOverlay({
photos,
@@ -16,61 +10,73 @@ export default function MediaCacheOverlay({
imgIndex,
setImgIndex,
}) {
const [currentIndex, setcurrentIndex] = useState(0);
const [dragging, setDragging] = useState(false);
const videoRef = React.useRef(null);
//const videoRef = React.useRef(null);
return (
<Modal
onDismiss={() => setPreviewVisible(false)}
onRequestClose={() => setPreviewVisible(false)}
visible={previewVisible}
transparent={false}
>
<SafeAreaView style={{ flex: 1, backgroundColor: "black" }}>
<Gallery
initialPage={imgIndex}
images={photos}
onPageScroll={({ position }) => setcurrentIndex(position)}
onPageScrollStateChanged={(state) =>
state === "idle" ? setDragging(false) : setDragging(true)
}
/>
<TouchableOpacity
style={{ position: "absolute" }}
onPress={() => setPreviewVisible(false)}
>
<Ionicons
name="ios-close"
size={64}
color="dodgerblue"
style={{ margin: 20 }}
/>
</TouchableOpacity>
{!dragging && photos[currentIndex] && photos[currentIndex].videoUrl && (
<TouchableOpacity
style={{
position: "absolute",
left: Dimensions.get("window").width / 2 - 32,
top: Dimensions.get("window").height / 2 - 32,
justifyContent: "center",
alignItems: "center",
}}
onPress={async () => {
await videoRef.current.loadAsync(
{ uri: photos[currentIndex].videoUrl },
{},
false
);
videoRef.current.presentFullscreenPlayer();
}}
>
<Ionicons name="play" size={64} color="white" />
</TouchableOpacity>
)}
</SafeAreaView>
<Video ref={videoRef} useNativeControls />
</Modal>
<SafeAreaView>
<ImageView
onRequestClose={() => setPreviewVisible(false)}
visible={previewVisible}
images={photos}
imageIndex={imgIndex}
onImageIndexChange={(...props) => {
console.log(props);
}}
/>
</SafeAreaView>
);
// return (
// <Modal
// onDismiss={() => setPreviewVisible(false)}
// onRequestClose={() => setPreviewVisible(false)}
// visible={previewVisible}
// transparent={false}
// >
// <SafeAreaView style={{ flex: 1, backgroundColor: "black" }}>
// <Gallery
// initialPage={imgIndex}
// images={photos}
// onPageScroll={({ position }) => setcurrentIndex(position)}
// onPageScrollStateChanged={(state) =>
// state === "idle" ? setDragging(false) : setDragging(true)
// }
// />
// <TouchableOpacity
// style={{ position: "absolute" }}
// onPress={() => setPreviewVisible(false)}
// >
// <Ionicons
// name="ios-close"
// size={64}
// color="dodgerblue"
// style={{ margin: 20 }}
// />
// </TouchableOpacity>
// {!dragging && photos[currentIndex] && photos[currentIndex].videoUrl && (
// <TouchableOpacity
// style={{
// position: "absolute",
// left: Dimensions.get("window").width / 2 - 32,
// top: Dimensions.get("window").height / 2 - 32,
// justifyContent: "center",
// alignItems: "center",
// }}
// onPress={async () => {
// await videoRef.current.loadAsync(
// { uri: photos[currentIndex].videoUrl },
// {},
// false
// );
// videoRef.current.presentFullscreenPlayer();
// }}
// >
// <Ionicons name="play" size={64} color="white" />
// </TouchableOpacity>
// )}
// </SafeAreaView>
// <Video ref={videoRef} useNativeControls />
// </Modal>
// );
}

View File

@@ -10,8 +10,18 @@ import JobLines from "../job-lines/job-lines.component";
import JobNotes from "../job-notes/job-notes.component";
import JobTombstone from "../job-tombstone/job-tombstone.component";
import LoadingDisplay from "../loading-display/loading-display.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ScreenJobDetail);
export default function ScreenJobDetail({ route }) {
export function ScreenJobDetail({ bodyshop, route }) {
const {
params: { jobId },
} = route;
@@ -45,12 +55,16 @@ export default function ScreenJobDetail({ route }) {
loading: loading,
refetch: refetch,
}),
documents: () =>
JobDocuments({
job: data.jobs_by_pk,
loading: loading,
refetch: refetch,
}),
...(bodyshop.uselocalmediaserver
? {}
: {
documents: () =>
JobDocuments({
job: data.jobs_by_pk,
loading: loading,
refetch: refetch,
}),
}),
notes: () =>
JobNotes({
job: data.jobs_by_pk,
@@ -63,7 +77,9 @@ export default function ScreenJobDetail({ route }) {
const [routes] = React.useState([
{ key: "job", title: t("jobdetail.labels.job") },
{ key: "lines", title: t("jobdetail.labels.lines") },
{ key: "documents", title: t("jobdetail.labels.documents") },
...(bodyshop.uselocalmediaserver
? []
: [{ key: "documents", title: t("jobdetail.labels.documents") }]),
{ key: "notes", title: t("jobdetail.labels.notes") },
]);

View File

@@ -3,9 +3,9 @@ import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import i18n from "i18next";
import moment from "moment";
import React, { useEffect } from "react";
import { Button } from "react-native-paper";
import { SafeAreaView } from "react-native-safe-area-context";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.analytics";
@@ -107,6 +107,15 @@ const MoreStackNavigator = () => (
const BottomTabsNavigator = () => (
<BottomTabs.Navigator
screenOptions={({ route }) => ({
// tabBarActiveTintColor: "dodgerblue",
// tabBarInactiveTintColor: "slategrey",
// tabBarStyle: [
// {
// display: "flex",
// },
// null,
// ],
// eslint-disable-next-line react/display-name
tabBarIcon: ({ color, size }) => {
let iconName;
@@ -123,24 +132,27 @@ const BottomTabsNavigator = () => (
return <Ionicons name={iconName} size={size} color={color} />;
},
})}
tabBarOptions={{
activeTintColor: "dodgerblue",
inactiveTintColor: "slategrey",
}}
>
<BottomTabs.Screen
name="JobTab"
options={{ title: i18n.t("joblist.titles.jobtab") }}
options={{
title: i18n.t("joblist.titles.jobtab"),
headerShown: false,
}}
component={JobStackNavigator}
/>
<BottomTabs.Screen
name="MediaBrowserTab"
options={{ title: i18n.t("mediabrowser.titles.mediabrowsertab") }}
options={{
title: i18n.t("mediabrowser.titles.mediabrowsertab"),
headerShown: false,
}}
component={MediaBrowserStackNavigator}
/>
<BottomTabs.Screen
name="MoreTab"
options={{ title: i18n.t("more.titles.moretab") }}
options={{ title: i18n.t("more.titles.moretab"), headerShown: false }}
component={MoreStackNavigator}
/>
</BottomTabs.Navigator>
@@ -156,24 +168,33 @@ export function ScreenMainComponent({
}, [checkUserSession]);
return (
<SafeAreaView style={{ flex: 1 }}>
<NavigationContainer>
{currentUser.authorized === null ? (
<ScreenSplash />
) : currentUser.authorized ? (
bodyshop ? (
<NavigationContainer>
{currentUser.authorized === null ? (
<ScreenSplash />
) : currentUser.authorized ? (
bodyshop ? (
HasAccess(bodyshop) ? (
<BottomTabsNavigator />
) : (
<ScreenSplash />
<ScreenSplash noAccess />
)
) : (
<ScreenSignIn />
)}
</NavigationContainer>
</SafeAreaView>
<ScreenSplash />
)
) : (
<ScreenSignIn />
)}
</NavigationContainer>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ScreenMainComponent);
function HasAccess({ features }) {
if (features.mobile === undefined) return true;
if (features.mobile === false) return false;
const d = moment(moment(features.mobile));
if (d.isValid()) return d.isAfter(moment());
}

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { AssetsSelector } from "expo-images-picker";
import React, { useCallback, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, Text, View } from "react-native";
import { connect } from "react-redux";
@@ -11,12 +11,16 @@ import CameraSelectJob from "../camera-select-job/camera-select-job.component";
import JobSpaceAvailable from "../job-space-available/job-space-available.component";
import UploadDeleteSwitch from "../upload-delete-switch/upload-delete-switch.component";
import UploadProgress from "../upload-progress/upload-progress.component";
import { MediaType } from "expo-media-library";
import { selectBodyshop } from "../../redux/user/user.selectors";
import LocalUploadProgress from "../local-upload-progress/local-upload-progress.component";
const mapStateToProps = createStructuredSelector({
selectedCameraJobId: selectCurrentCameraJobId,
bodyshop: selectBodyshop,
});
export function ImageBrowserScreen({ selectedCameraJobId }) {
export function ImageBrowserScreen({ bodyshop, selectedCameraJobId }) {
const { t } = useTranslation();
const [uploads, setUploads] = useState(null);
const [tick, setTick] = useState(0);
@@ -29,10 +33,105 @@ export function ImageBrowserScreen({ selectedCameraJobId }) {
if (data.length !== 0) setUploads(data);
};
const widgetErrors = useMemo(
() => ({
errorTextColor: "black",
errorMessages: {
hasErrorWithPermissions: "Please Allow media gallery permissions.",
hasErrorWithLoading: "There was an error while loading images.",
hasErrorWithResizing: "There was an error while loading images.",
hasNoAssets: "No images found.",
},
}),
[]
);
const widgetSettings = useMemo(
() => ({
getImageMetaData: false, // true might perform slower results but gives meta data and absolute path for ios users
initialLoad: 100,
assetsType: [MediaType.photo, MediaType.video],
minSelection: 1,
// maxSelection: 3,
portraitCols: 4,
landscapeCols: 4,
}),
[]
);
const widgetResize = useMemo(
() => ({
width: 50,
compress: 0.7,
base64: false,
saveTo: "jpeg",
}),
[]
);
const _textStyle = {
color: "white",
};
const _buttonStyle = {
backgroundColor: "orange",
borderRadius: 5,
};
const widgetNavigator = useMemo(
() => ({
Texts: {
finish: t("mediabrowser.actions.upload"),
back: t("mediabrowser.actions.refresh"),
selected: "selected",
},
midTextColor: "black",
minSelection: 1,
buttonTextStyle: styles.textStyle,
buttonStyle: styles.buttonStyle,
onBack: () => {
forceRerender();
},
onSuccess: onDone,
}),
[]
);
const widgetStyles = useMemo(
() => ({
margin: 2,
bgColor: "white",
spinnerColor: "blue",
widgetWidth: 99,
videoIcon: {
Component: Ionicons,
iconName: "ios-videocam",
color: "white",
size: 20,
},
selectedIcon: {
Component: Ionicons,
iconName: "ios-checkmark-circle-outline",
color: "white",
bg: "rgba(35,35,35, 0.75)",
size: 32,
},
}),
[]
);
return (
<View style={[styles.flex, styles.container]}>
<CameraSelectJob />
<JobSpaceAvailable jobid={selectedCameraJobId} key={`${tick}-space`} />
{bodyshop.uselocalmediaserver ? (
<Text style={{ margin: 10 }}>
{t("mediabrowser.labels.localserver", {
url: bodyshop.localmediaserverhttp,
})}
</Text>
) : (
<JobSpaceAvailable jobid={selectedCameraJobId} key={`${tick}-space`} />
)}
<UploadDeleteSwitch />
{!selectedCameraJobId && (
<View
@@ -49,65 +148,25 @@ export function ImageBrowserScreen({ selectedCameraJobId }) {
<AssetsSelector
style={{ flex: 1 }}
key={tick}
options={{
assetsType: ["photo", "video"],
margin: 3,
portraitCols: 4,
landscapeCols: 6,
widgetWidth: 100,
widgetBgColor: "white",
selectedBgColor: "#adadad",
spinnerColor: "#c8c8c8",
videoIcon: {
Component: Ionicons,
iconName: "ios-videocam",
color: "white",
size: 20,
},
selectedIcon: {
Component: Ionicons,
iconName: "ios-checkmark-circle-outline",
color: "white",
bg: "rgba(35,35,35, 0.75)",
size: 32,
},
defaultTopNavigator: {
continueText: t("mediabrowser.actions.upload"),
goBackText: t("mediabrowser.actions.refresh"),
buttonStyle: styles.buttonStyle,
textStyle: styles.textStyle,
backFunction: () => {
forceRerender();
},
doneFunction: onDone,
},
noAssets: {
Component: function NoAsset() {
return (
<View
style={{
display: "flex",
flex: 1,
height: 200,
marginHorizontal: 20,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="ios-camera" size={72} />
<Text style={{ textAlign: "center", marginTop: 10 }}>
{t("mediabrowser.labels.nomedia")}
</Text>
</View>
);
},
},
}}
Settings={widgetSettings}
Errors={widgetErrors}
Styles={widgetStyles}
Navigator={widgetNavigator}
/>
)}
{bodyshop.uselocalmediaserver ? (
<LocalUploadProgress
uploads={uploads}
setUploads={setUploads}
forceRerender={forceRerender}
/>
) : (
<UploadProgress
uploads={uploads}
setUploads={setUploads}
forceRerender={forceRerender}
/>
)}
<UploadProgress uploads={uploads} forceRerender={forceRerender} />
</View>
);
}
@@ -130,3 +189,60 @@ const styles = StyleSheet.create({
});
export default connect(mapStateToProps, null)(ImageBrowserScreen);
// options={{
// assetsType: ["photo", "video"],
// margin: 3,
// portraitCols: 4,
// landscapeCols: 6,
// widgetWidth: 100,
// widgetBgColor: "white",
// selectedBgColor: "#adadad",
// spinnerColor: "#c8c8c8",
// videoIcon: {
// Component: Ionicons,
// iconName: "ios-videocam",
// color: "white",
// size: 20,
// },
// selectedIcon: {
// Component: Ionicons,
// iconName: "ios-checkmark-circle-outline",
// color: "white",
// bg: "rgba(35,35,35, 0.75)",
// size: 32,
// },
// defaultTopNavigator: {
// continueText: t("mediabrowser.actions.upload"),
// goBackText: t("mediabrowser.actions.refresh"),
// buttonStyle: styles.buttonStyle,
// textStyle: styles.textStyle,
// backFunction: () => {
// forceRerender();
// },
// doneFunction: onDone,
// },
// noAssets: {
// Component: function NoAsset() {
// return (
// <View
// style={{
// display: "flex",
// flex: 1,
// height: 200,
// marginHorizontal: 20,
// alignItems: "center",
// justifyContent: "center",
// }}
// >
// <Ionicons name="ios-camera" size={72} />
// <Text style={{ textAlign: "center", marginTop: 10 }}>
// {t("mediabrowser.labels.nomedia")}
// </Text>
// </View>
// );
// },
// },
// }}

View File

@@ -1,8 +1,8 @@
import Constants from "expo-constants";
import React from "react";
import { useTranslation } from "react-i18next";
import { Button, View, Text } from "react-native";
import { Title } from "react-native-paper";
import { View, Text } from "react-native";
import { Title, Button } from "react-native-paper";
import { purgeStoredState } from "redux-persist";
import SignOutButton from "../sign-out-button/sign-out-button.component";
import * as Updates from "expo-updates";
@@ -25,9 +25,9 @@ export default function ScreenSettingsComponent() {
})}
</Title>
<Text>{Updates.releaseChannel}</Text>
<Text>Release Channel {Updates.releaseChannel}</Text>
<SignOutButton />
<Button title="Purge State" onPress={() => purgeStoredState()} />
{/* <Button title="Purge State" onPress={() => purgeStoredState()} /> */}
</View>
);
}

View File

@@ -38,7 +38,7 @@ export function SignIn({ emailSignInStart, signingIn }) {
<View
style={{
display: "flex",
marginTop: 80,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-evenly",
@@ -47,43 +47,52 @@ export function SignIn({ emailSignInStart, signingIn }) {
<Image style={localStyles.logo} source={Logo} />
<Title>{t("app.title")}</Title>
</View>
<Formik initialValues={{ email: "", password: "" }} onSubmit={formSubmit}>
{({ handleChange, handleBlur, handleSubmit, values }) => (
<View>
<TextInput
label={t("signin.fields.email")}
mode="outlined"
autoCapitalize="none"
keyboardType="email-address"
onChangeText={handleChange("email")}
onBlur={handleBlur("email")}
value={values.email}
style={[localStyles.input]}
/>
<TextInput
label={t("signin.fields.password")}
mode="outlined"
secureTextEntry={true}
onChangeText={handleChange("password")}
onBlur={handleBlur("password")}
value={values.password}
style={[localStyles.input]}
/>
<View style={{ flex: 1 }}>
<Formik
initialValues={{ email: "", password: "" }}
onSubmit={formSubmit}
>
{({ handleChange, handleBlur, handleSubmit, values }) => (
<View>
<TextInput
label={t("signin.fields.email")}
mode="outlined"
autoCapitalize="none"
keyboardType="email-address"
onChangeText={handleChange("email")}
onBlur={handleBlur("email")}
value={values.email}
style={[localStyles.input]}
/>
<SignInErrorAlertComponent />
<Button mode="outlined" loading={signingIn} onPress={handleSubmit}>
<Text>{t("signin.actions.signin")}</Text>
</Button>
<Text>
{t("settings.labels.version", {
number: Constants.manifest.version,
})}
{`${process.env.NODE_ENV || ""} ${Updates.releaseChannel || ""}`}
</Text>
</View>
)}
</Formik>
<TextInput
label={t("signin.fields.password")}
mode="outlined"
secureTextEntry={true}
onChangeText={handleChange("password")}
onBlur={handleBlur("password")}
value={values.password}
style={[localStyles.input]}
/>
<SignInErrorAlertComponent />
<Button
mode="outlined"
loading={signingIn}
onPress={handleSubmit}
>
<Text>{t("signin.actions.signin")}</Text>
</Button>
</View>
)}
</Formik>
</View>
<Text style={{ padding: 10, alignSelf: "center" }}>
{t("settings.labels.version", {
number: Constants.manifest.version,
})}
</Text>
</View>
);
}

View File

@@ -1,10 +1,11 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Image, StyleSheet, View } from "react-native";
import { Title } from "react-native-paper";
import { Title, Subheading, Divider } from "react-native-paper";
import Logo from "../../assets/logo192.png";
import SignOutButton from "../sign-out-button/sign-out-button.component";
export default function ScreenSplash() {
export default function ScreenSplash({ noAccess }) {
const { t } = useTranslation();
return (
<View style={[localStyles.container]}>
@@ -13,7 +14,17 @@ export default function ScreenSplash() {
<Title>{t("app.title")}</Title>
</View>
<ActivityIndicator color="dodgerblue" size="large" />
{noAccess ? (
<View style={[localStyles.logoContainer]}>
<Subheading style={{ textAlign: "center" }}>
{t("app.nomobileaccess")}
</Subheading>
<Divider />
<SignOutButton />
</View>
) : (
<ActivityIndicator color="dodgerblue" size="large" />
)}
</View>
);
}
@@ -28,7 +39,7 @@ const localStyles = StyleSheet.create({
logoContainer: {
display: "flex",
flexDirection: "column",
margin: 10,
alignItems: "center",
},
logo: { width: 175, height: 175, margin: 20 },

View File

@@ -16,6 +16,7 @@ export function SignOutButton({ signOutStart }) {
const { t } = useTranslation();
return (
<Button
style={{ margin: 8 }}
onPress={() => signOutStart()}
title={t("general.actions.signout")}
/>

View File

@@ -1,19 +1,18 @@
import { useApolloClient } from "@apollo/client";
import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library";
import _ from "lodash";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
Modal,
ScrollView,
Platform,
StyleSheet,
Text,
View,
} from "react-native";
import { ProgressBar } from "react-native-paper";
import { Divider, ProgressBar } from "react-native-paper";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.analytics";
@@ -27,6 +26,8 @@ import {
selectCurrentUser,
} from "../../redux/user/user.selectors";
import { formatBytes, handleUpload } from "../../util/document-upload.utility";
import Toast from "react-native-toast-message";
import * as Sentry from "sentry-expo";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -43,12 +44,17 @@ export function UploadProgress({
selectedCameraJobId,
deleteAfterUpload,
uploads,
setUploads,
forceRerender,
}) {
const [progress, setProgress] = useState({
loading: false,
uploadInProgress: false,
speed: 0,
totalToUpload: 0,
totalUploaded: 0,
startTime: null,
totalFiles: 0,
totalFilesCompleted: 0,
currentFile: null,
files: {}, //uri is the key, value is progress
});
@@ -58,63 +64,79 @@ export function UploadProgress({
const { t } = useTranslation();
useEffect(() => {
//Set the state of uploads to do.
if (uploads) onDone(uploads);
if (uploads) {
onDone(uploads);
setUploads(null);
}
}, [uploads]);
//if (!uploads) return null;
function handleOnSuccess(id) {
logImEXEvent("imexmobile_successful_upload");
filesToDelete.push(id);
function handleOnSuccess(asset) {
//NEEDS REDO.
filesToDelete.push(asset);
setProgress((progress) => ({
...progress,
action: t("mediabrowser.labels.converting"),
// totalUploaded: progress.totalToUpload + asset.size,
totalFilesCompleted: progress.totalFilesCompleted + 1,
files: {
...progress.files,
[id]: {
...progress.files[id],
action: t("mediabrowser.labels.converting"),
},
},
// });
}));
}
function handleOnProgress(uri, percent, loaded) {
setProgress((progress) => ({
...progress,
speed: loaded - progress.files[uri].loaded,
action:
percent === 1
? t("mediabrowser.labels.converting")
: t("mediabrowser.labels.uploading"),
files: {
...progress.files,
[uri]: {
...progress.files[uri],
percent,
action:
percent === 1
? t("mediabrowser.labels.converting")
: t("mediabrowser.labels.uploading"),
loaded: loaded,
[asset.uri]: {
...progress.files[asset.uri],
uploadEnd: new Date(),
},
},
}));
}
function handleOnError(...props) {
logImEXEvent("imexmobile_upload_documents_error", { props });
}
const onDone = async (data) => {
//Validate to make sure the totals for the file sizes do not exceed the total on the job.
setProgress({
files: _.keyBy(data, "id"),
loading: true,
uploadInProgress: true,
function handleOnProgress({ uri, filename }, percent, loaded) {
//NEED REDO
setProgress((progress) => {
return {
...progress,
totalUploaded:
progress.totalUploaded +
(loaded - (progress.files[uri]?.loaded || 0)),
files: {
...progress.files,
[uri]: {
...progress.files[uri],
percent,
filename,
speed: loaded - (progress.files[uri]?.loaded || 0),
loaded: loaded,
uploadStart: progress.files[uri]?.uploadStart || new Date(),
},
},
};
});
}
function handleOnError(error) {
logImEXEvent("imexmobile_upload_documents_error", { error });
Toast.show({
type: "error",
text1: "Unable to upload document.",
text2: error,
autoHide: false,
});
}
const onDone = async (selectedFiles) => {
setProgress((progress) => {
return {
...progress,
uploadInProgress: true,
statusText: "Preparing upload...",
};
});
//Validate to make sure the totals for the file sizes do not exceed the total on the job.
const data = [];
const totalOfUploads = await selectedFiles.reduce(async (acc, val) => {
//Get the size of the file based on URI.
const info = await FileSystem.getInfoAsync(val.uri, { size: true });
data.push({ ...info, ...val }); //Add in the size.
val.albumId && MediaLibrary.migrateAlbumIfNeededAsync(val.albumId);
return (await acc) + info.size;
}, 0);
if (selectedCameraJobId !== "temp") {
const queryData = await client.query({
@@ -124,11 +146,6 @@ export function UploadProgress({
jobId: selectedCameraJobId,
},
});
const totalOfUploads = await data.reduce(async (acc, val) => {
//Get the size of the file based on URI.
const info = await FileSystem.getInfoAsync(val.uri, { size: true });
return (await acc) + info.size;
}, 0);
if (
bodyshop.jobsizelimit -
@@ -140,7 +157,7 @@ export function UploadProgress({
...progress,
speed: 0,
action: null,
loading: false,
statusText: null,
uploadInProgress: false,
}));
Alert.alert(
@@ -150,10 +167,22 @@ export function UploadProgress({
return;
}
}
//Sequentially await the proms.
//We made it this far. We have enough space, so let's start uploading.
setProgress((progress) => ({
...progress,
totalToUpload: totalOfUploads,
totalUploaded: 0,
totalFilesCompleted: 0,
startTime: new Date(),
totalFiles: data.length,
currentFile: null,
statusText: null,
files: {}, //uri is the key, value is progress
}));
for (var i = 0; i < data.length + 4; i = i + 4) {
//Reset the files.
setProgress((progress) => ({ ...progress, files: {} }));
let proms = [];
if (data[i]) {
proms.push(CreateUploadProm(data[i]));
@@ -171,38 +200,57 @@ export function UploadProgress({
await Promise.all(proms);
}
//Everything is uploaded, delete the succesful ones.
if (deleteAfterUpload) {
try {
await MediaLibrary.deleteAssetsAsync(filesToDelete);
console.log("Trying to Delete", filesToDelete);
if (Platform.OS === "android") {
await Promise.all(
filesToDelete.map(async (f) =>
MediaLibrary.removeAssetsFromAlbumAsync(f, f.albumId)
)
);
}
console.log(
"Delete Result",
await MediaLibrary.deleteAssetsAsync(filesToDelete.map((f) => f.id))
);
} catch (error) {
console.log("Unable to delete picture.", error);
Sentry.Native.captureException(error);
}
}
filesToDelete = [];
setProgress({
loading: false,
speed: 0,
action: null,
Toast.show({
type: "success",
text1: ` Upload completed.`,
//
// text2: duration,
});
//Reset state.
setProgress({
uploadInProgress: false,
files: {}, //uri is the key, value is progress
totalToUpload: 0,
totalUploaded: 0,
totalFilesCompleted: 0,
startTime: null,
totalFiles: 0,
currentFile: null,
files: {},
});
forceRerender();
};
const CreateUploadProm = async (p) => {
let filename;
filename = p.filename || p.uri.split("/").pop();
await handleUpload(
return handleUpload(
{
filename,
mediaId: p.id,
onError: handleOnError,
onProgress: ({ percent, loaded }) =>
handleOnProgress(p.id, percent, loaded),
onSuccess: () => handleOnSuccess(p.id),
handleOnProgress(p, percent, loaded),
onSuccess: () => handleOnSuccess(p),
},
{
bodyshop: bodyshop,
@@ -211,20 +259,6 @@ export function UploadProgress({
photo: p,
}
);
//Set the state to mark that it's done.
setProgress((progress) => ({
...progress,
action: null,
speed: 0,
files: {
...progress.files,
[p.id]: {
...progress.files[p.id],
action: null,
},
},
}));
};
return (
@@ -233,20 +267,31 @@ export function UploadProgress({
animationType="slide"
transparent={true}
onRequestClose={() => {
Alert.alert("Modal has been closed.");
Alert.alert("Cancel?", "Do you want to abort the upload?", [
{
text: "Yes",
onPress: () => {
setUploads(null);
setProgress({
uploadInProgress: false,
totalToUpload: 0,
totalUploaded: 0,
totalFilesCompleted: 0,
startTime: null,
totalFiles: 0,
currentFile: null,
files: {},
});
},
},
{ text: "No" },
]);
}}
>
<View style={styles.modal}>
{progress.loading && <ActivityIndicator />}
{progress.action && (
<Text>{`${progress.action} ${
(progress.speed !== 0 || !progress.speed) &&
`- ${formatBytes(progress.speed)}/sec`
}`}</Text>
)}
<ScrollView contentContainerStyle={styles.centeredView}>
<View style={styles.modalContainer}>
<View style={styles.modal}>
{Object.keys(progress.files).map((key) => (
<View key={progress.files[key].id} style={styles.progressItem}>
<View key={key} style={styles.progressItem}>
<Text style={styles.progressText}>
{progress.files[key].filename}
</Text>
@@ -256,19 +301,59 @@ export function UploadProgress({
style={styles.progress}
color={progress.files[key].percent === 1 ? "green" : "blue"}
/>
<View
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
}}
>
<Text>{`${formatBytes(
progress.files[key].loaded /
(((progress.files[key].uploadEnd || new Date()) -
progress.files[key].uploadStart) /
1000)
)}/sec`}</Text>
{progress.files[key].percent === 1 && (
<>
<ActivityIndicator style={{ marginLeft: 12 }} />
<Text style={{ marginLeft: 4 }}>Processing...</Text>
</>
)}
</View>
</View>
</View>
))}
</ScrollView>
<View style={styles.centeredView}>
{progress.statusText ? (
<>
<ActivityIndicator style={{ marginLeft: 12 }} />
<Text style={{ marginLeft: 4 }}>{progress.statusText}</Text>
<Divider />
</>
) : (
<>
<Text>{`${progress.totalFilesCompleted} of ${progress.totalFiles} uploaded.`}</Text>
<Text>{`${formatBytes(progress.totalUploaded)} of ${formatBytes(
progress.totalToUpload
)} uploaded.`}</Text>
</>
)}
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modal: {
modalContainer: {
display: "flex",
flex: 1,
marginTop: 50,
marginBottom: 60,
justifyContent: "center",
},
modal: {
//flex: 1,
display: "flex",
marginLeft: 20,
marginRight: 20,
backgroundColor: "white",
@@ -284,9 +369,8 @@ const styles = StyleSheet.create({
elevation: 5,
},
centeredView: {
flex: 1,
// justifyContent: "center",
// alignItems: "center",
justifyContent: "center",
alignItems: "center",
marginTop: 22,
},
progressItem: {

22
eas.json Normal file
View File

@@ -0,0 +1,22 @@
{
"cli": {
"version": ">= 0.52.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"test": {
"releaseChannel": "test",
"env": { "ANDROID_SDK_ROOT": "/Users/pfic/Library/Android/sdk" }
},
"production": {
"releaseChannel": "production",
"env": { "ANDROID_SDK_ROOT": "/Users/pfic/Library/Android/sdk" }
}
},
"submit": {
"production": {}
}
}

View File

@@ -2,11 +2,17 @@ import firebase from "firebase/app";
import "firebase/auth";
import env from "../env";
if (!firebase.apps.length) {
firebase.initializeApp(env.firebase);
}
import { initializeApp } from "firebase/app";
import { getAuth, initializeAuth } from "firebase/auth";
import { getReactNativePersistence } from "firebase/auth/react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
const defaultApp = initializeApp(env.firebase);
initializeAuth(defaultApp, {
persistence: getReactNativePersistence(AsyncStorage),
});
export const auth = getAuth();
export const auth = firebase.auth();
//export const analytics = firebase.analytics();
export default firebase;
@@ -19,14 +25,3 @@ export const getCurrentUser = () => {
}, reject);
});
};
export const updateCurrentUser = (userDetails) => {
return new Promise((resolve, reject) => {
const unsubscribe = auth.onAuthStateChanged((userAuth) => {
userAuth.updateProfile(userDetails).then((r) => {
unsubscribe();
resolve(userAuth);
});
}, reject);
});
};

View File

@@ -44,4 +44,4 @@
}
],
"configuration_version": "1"
}
}

View File

@@ -6,13 +6,11 @@ export const QUERY_BODYSHOP = gql`
id
jobsizelimit
md_ro_statuses
md_order_statuses
uselocalmediaserver
localmediaserverhttp
shopname
messagingservicesid
md_referral_sources
md_messaging_presets
md_parts_locations
md_notes_presets
features
localmediatoken
}
}
`;

View File

@@ -71,9 +71,8 @@ const link = split(
// "##Intercepted GQL Transaction : " +
// definition.operation +
// "|" +
// definition.name.value +
// "##",
// query
// // definition.name.value +
// "##"
// );
return (
definition.kind === "OperationDefinition" &&
@@ -128,11 +127,16 @@ export const client = new ApolloClient({
//link: from([apolloLogger, errorLink, authLink, link]),
link: from([authLink, link]),
cache,
notifyOnNetworkStatusChange: true,
// connectToDevTools: process.env.NODE_ENV !== "production",
defaultOptions: {
watchQuery: {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
},
query: {
fetchPolicy: "network-only",
},
},
});

View File

@@ -183,7 +183,7 @@ export const GET_JOB_BY_PK = gql`
date_exported
status
owner_owing
joblines {
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
id
unq_seq
line_ind

6
metro.config.js Normal file
View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("metro-config");
const { resolver: defaultResolver } = getDefaultConfig.getDefaultValues();
exports.resolver = {
...defaultResolver,
sourceExts: [...defaultResolver.sourceExts, "cjs", "jsx"],
};

View File

@@ -8,69 +8,86 @@
"eject": "expo eject",
"release:test": "expo publish --release-channel test",
"release:production": "expo publish --release-channel production",
"build:ios:production": "expo build:ios --release-channel production",
"build:ios:test": "expo build:ios --release-channel test",
"build:android:production": "expo build:android --release-channel production",
"build:android:test": "expo build:android --release-channel test"
"build:production": "eas build --profile production",
"build:test": "eas build --profile test",
"build:test:local:ios": "eas build --profile test --platform ios --local",
"build:test:local:android": "eas build --profile test --platform android --local",
"build:production:local:ios": "eas build --profile production --platform ios --local",
"build:production:local:android": "eas build --profile production --platform android --local"
},
"dependencies": {
"@apollo/client": "^3.3.19",
"@expo/vector-icons": "^12.0.0",
"@react-native-async-storage/async-storage": "^1.13.0",
"@apollo/client": "^3.7.0-alpha.3",
"@expo/vector-icons": "^13.0.0",
"@react-native-async-storage/async-storage": "~1.17.6",
"@react-native-community/art": "^1.2.0",
"@react-native-community/masked-view": "0.1.10",
"@react-navigation/bottom-tabs": "^5.11.11",
"@react-navigation/drawer": "^5.12.5",
"@react-navigation/native": "^5.9.4",
"@react-navigation/stack": "^5.14.5",
"axios": "^0.21.0",
"cloudinary-core": "^2.11.4",
"dinero.js": "^1.8.1",
"expo": "^41.0.0",
"expo-app-loading": "^1.0.3",
"expo-av": "~9.1.2",
"expo-camera": "~11.0.2",
"expo-file-system": "~11.0.2",
"expo-firebase-analytics": "~4.0.2",
"expo-font": "~9.1.0",
"expo-images-picker": "git+https://github.com/snaptsoft/expo-images-picker/",
"expo-localization": "~10.1.0",
"expo-media-library": "~12.0.2",
"expo-permissions": "~12.0.1",
"expo-status-bar": "~1.0.4",
"expo-video-thumbnails": "~5.1.0",
"firebase": "8.2.3",
"formik": "^2.2.8",
"@react-native-community/cli-debugger-ui": "^7.0.3",
"@react-native-community/masked-view": "^0.1.11",
"@react-navigation/bottom-tabs": "^6.2.0",
"@react-navigation/drawer": "^6.3.1",
"@react-navigation/native": "^6.0.8",
"@react-navigation/stack": "^6.1.1",
"axios": "^0.27.2",
"cloudinary-core": "^2.12.3",
"dinero.js": "^1.9.1",
"expo": "^45.0.5",
"expo-app-loading": "~2.0.0",
"expo-application": "~4.1.0",
"expo-av": "~11.2.3",
"expo-camera": "~12.2.0",
"expo-constants": "~13.1.1",
"expo-dev-client": "~1.0.0",
"expo-device": "~4.2.0",
"expo-file-system": "~14.0.0",
"expo-firebase-analytics": "~7.0.0",
"expo-font": "~10.1.0",
"expo-image-manipulator": "~10.3.1",
"expo-images-picker": "^2.4.1",
"expo-localization": "~13.0.0",
"expo-media-library": "~14.1.0",
"expo-permissions": "~13.2.0",
"expo-status-bar": "~1.3.0",
"expo-system-ui": "~1.2.0",
"expo-updates": "~0.13.2",
"expo-video-thumbnails": "~6.3.0",
"firebase": "^9.8.3",
"formik": "^2.2.9",
"graphql": "^15.4.0",
"i18next": "^20.3.1",
"i18next": "^21.8.10",
"intl": "^1.2.5",
"lodash": "^4.17.20",
"luxon": "^1.27.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-i18next": "^11.10.0",
"react-native": "https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz",
"react-native-gesture-handler": "~1.10.2",
"react-native-image-gallery": "archriss/react-native-image-gallery#152/head",
"luxon": "^2.3.1",
"mime": "^3.0.0",
"moment": "^2.29.1",
"normalize-url": "^7.0.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-i18next": "^11.17.2",
"react-native": "0.68.2",
"react-native-gesture-handler": "~2.2.1",
"react-native-image-gallery": "^2.1.5",
"react-native-image-viewing": "^0.2.2",
"react-native-indicators": "^0.17.0",
"react-native-pager-view": "5.0.12",
"react-native-paper": "^4.9.1",
"react-native-progress": "^4.1.2",
"react-native-reanimated": "~2.1.0",
"react-native-screens": "~3.0.0",
"react-native-tab-view": "3.0.1",
"react-native-web": "~0.13.12",
"react-redux": "^7.2.4",
"redux": "^4.1.0",
"react-native-pager-view": "5.4.15",
"react-native-paper": "^4.11.2",
"react-native-progress": "^5.0.0",
"react-native-reanimated": "~2.8.0",
"react-native-safe-area-context": "4.2.4",
"react-native-screens": "~3.11.1",
"react-native-tab-view": "3.1.1",
"react-native-toast-message": "^2.1.5",
"react-native-web": "0.17.7",
"react-redux": "^7.2.6",
"redux": "^4.1.2",
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"redux-saga": "^1.1.3",
"reselect": "^4.0.0",
"sentry-expo": "^3.1.0",
"reselect": "^4.1.6",
"sentry-expo": "^4.2.0",
"subscriptions-transport-ws": "^0.9.18"
},
"devDependencies": {
"@babel/core": "~7.9.0",
"babel-preset-expo": "8.3.0",
"@babel/core": "^7.12.9",
"babel-preset-expo": "~9.1.0",
"eslint": "^7.27.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-native": "^3.11.0"

1
readme.md Normal file
View File

@@ -0,0 +1 @@
USE VERSION 16 of NODE LTS.

View File

@@ -1,11 +1,13 @@
import * as Analytics from "expo-firebase-analytics";
import { signInWithEmailAndPassword, signOut } from "firebase/auth";
import { all, call, put, takeLatest } from "redux-saga/effects";
import * as Sentry from "sentry-expo";
import { logImEXEvent } from "../../firebase/firebase.analytics";
import {
auth,
getCurrentUser,
updateCurrentUser,
} from "../../firebase/firebase.utils";
import { logImEXEvent } from "../../firebase/firebase.analytics";
import { QUERY_BODYSHOP } from "../../graphql/bodyshop.queries";
import { client } from "../../graphql/client";
import {
@@ -29,7 +31,7 @@ export function* onEmailSignInStart() {
export function* signInWithEmail({ payload: { email, password } }) {
try {
logImEXEvent("imexmobile_sign_in_attempt", { user: email });
const { user } = yield auth.signInWithEmailAndPassword(email, password);
const { user } = yield signInWithEmailAndPassword(auth, email, password);
yield put(
signInSuccess({
uid: user.uid,
@@ -41,6 +43,7 @@ export function* signInWithEmail({ payload: { email, password } }) {
);
} catch (error) {
yield put(signInFailure(error));
//logImEXEvent("redux_sign_in_failure", { user: email, error });
}
}
@@ -78,7 +81,7 @@ export function* signOutStart() {
try {
logImEXEvent("imexmobile_sign_out");
yield auth.signOut();
yield signOut(auth);
yield put(signOutSuccess());
} catch (error) {
yield put(signOutFailure(error.message));
@@ -105,12 +108,21 @@ export function* onSignInSuccess() {
export function* signInSuccessSaga({ payload }) {
try {
Analytics.setUserId(payload.email);
Sentry.Native.setUser({ email: payload.email });
const shop = yield client.query({ query: QUERY_BODYSHOP });
logImEXEvent("imexmobile_sign_in_success", payload);
yield put(setBodyshop(shop.data.bodyshops[0]));
// yield put(
// setBodyshop({
// ...shop.data.bodyshops[0],
// uselocalmediaserver: true,
// localmediaserverhttp: `http://192.168.1.235:8000`,
// })
// );
} catch (error) {
console.log("UH-OH. Couldn't get shop details.", error);
Sentry.Native.captureException(error);
}
}

View File

@@ -1,6 +1,7 @@
{
"translation": {
"app": {
"nomobileaccess": "Your shop does not currently have access to ImEX Mobile. ",
"title": "ImEX Mobile"
},
"camera": {
@@ -77,7 +78,8 @@
"labels": {
"activejobs": "Jobs",
"detail": "Job Detail",
"nojobs": "There are no active jobs."
"nojobs": "There are no active jobs.",
"search": "Search..."
},
"titles": {
"jobtab": "Jobs"
@@ -91,6 +93,7 @@
"labels": {
"converting": "Converting",
"deleteafterupload": "Delete After Upload",
"localserver": "Local Server URL: {{url}}",
"nomedia": "Look's like there's no media on your device. Take some photos or videos and they will appear here.",
"selectjob": "--- Select a job ---",
"selectjobassetselector": "Please select a job to upload media. ",

View File

@@ -1,6 +1,7 @@
{
"translation": {
"app": {
"nomobileaccess": "",
"title": ""
},
"camera": {
@@ -77,7 +78,8 @@
"labels": {
"activejobs": "",
"detail": "",
"nojobs": ""
"nojobs": "",
"search": ""
},
"titles": {
"jobtab": ""
@@ -91,6 +93,7 @@
"labels": {
"converting": "",
"deleteafterupload": "",
"localserver": "",
"nomedia": "",
"selectjob": "",
"selectjobassetselector": "",

View File

@@ -1,6 +1,7 @@
{
"translation": {
"app": {
"nomobileaccess": "",
"title": ""
},
"camera": {
@@ -77,7 +78,8 @@
"labels": {
"activejobs": "",
"detail": "",
"nojobs": ""
"nojobs": "",
"search": ""
},
"titles": {
"jobtab": ""
@@ -91,6 +93,7 @@
"labels": {
"converting": "",
"deleteafterupload": "",
"localserver": "",
"nomedia": "",
"selectjob": "",
"selectjobassetselector": "",

View File

@@ -5,6 +5,7 @@ import { INSERT_NEW_DOCUMENT } from "../graphql/documents.queries";
import { axiosAuthInterceptorId } from "./CleanAxios";
import * as MediaLibrary from "expo-media-library";
import { gql } from "@apollo/client";
import * as Sentry from "sentry-expo";
//Context: currentUserEmail, bodyshop, jobid, invoiceid
@@ -13,24 +14,25 @@ var cleanAxios = axios.create();
cleanAxios.interceptors.request.eject(axiosAuthInterceptorId);
export const handleUpload = async (ev, context) => {
const { filename, mediaId, onError, onSuccess, onProgress } = ev;
const { mediaId, onError, onSuccess, onProgress } = ev;
const { bodyshop, jobId } = context;
const imageData = await MediaLibrary.getAssetInfoAsync(mediaId);
const newFile = await (await fetch(imageData.localUri)).blob();
const newFile = await (
await fetch(imageData.localUri || imageData.uri)
).blob();
let extension = imageData.localUri.split(".").pop();
let key = `${bodyshop.id}/${jobId}/${(filename || newFile.data.name).replace(
/\.[^/.]+$/,
""
)}-${new Date().getTime()}`;
let key = `${bodyshop.id}/${jobId}/${(
imageData.filename || imageData.uri.split("/").pop()
).replace(/\.[^/.]+$/, "")}-${new Date().getTime()}`;
const res = await uploadToCloudinary(
key,
mediaId,
imageData,
extension,
newFile.type,
newFile,
newFile.type, //Filetype
newFile, //File
onError,
onSuccess,
onProgress,
@@ -51,41 +53,33 @@ export const uploadToCloudinary = async (
onProgress,
context
) => {
const { bodyshop, jobId, billId, uploaded_by, callback, tagsArray, photo } =
context;
const { bodyshop, jobId, uploaded_by } = context;
//Set variables for getting the signed URL.
let timestamp = Math.floor(Date.now() / 1000);
let public_id = key;
let tags = `${bodyshop.textid},${
tagsArray ? tagsArray.map((tag) => `${tag},`) : ""
}`;
// let eager = process.env.REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS;
const upload_preset = fileType.startsWith("video")
? "incoming_upload_video"
: "incoming_upload";
//Get the signed url.
let signedURLResponse;
try {
signedURLResponse = await axios.post(`${env.API_URL}/media/sign`, {
public_id: public_id,
tags: tags,
timestamp: timestamp,
upload_preset: upload_preset,
});
} catch (error) {
console.log("ERROR GETTING SIGNED URL", error);
Sentry.Native.captureException(error);
return { success: false, error: error };
}
if (signedURLResponse.status !== 200) {
console.log("Error Getting Signed URL", signedURLResponse.statusText);
if (onError) onError(signedURLResponse.statusText);
return { success: false, error: signedURLResponse.statusText };
}
@@ -93,7 +87,10 @@ export const uploadToCloudinary = async (
var signature = signedURLResponse.data;
var options = {
headers: { "X-Requested-With": "XMLHttpRequest" },
headers: {
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "multipart/form-data",
},
onUploadProgress: (e) => {
if (onProgress)
onProgress({ percent: e.loaded / e.total, loaded: e.loaded });
@@ -109,7 +106,6 @@ export const uploadToCloudinary = async (
formData.append("upload_preset", upload_preset);
formData.append("api_key", env.REACT_APP_CLOUDINARY_API_KEY);
formData.append("public_id", public_id);
formData.append("tags", tags);
formData.append("timestamp", timestamp);
formData.append("signature", signature);
@@ -121,13 +117,13 @@ export const uploadToCloudinary = async (
fileType
)}/upload`,
formData,
{
...options,
}
options
);
// console.log("Cloudinary Upload Response", cloudinaryUploadResponse.data);
} catch (error) {
console.log("CLOUDINARY error", error.response, cloudinaryUploadResponse);
Sentry.Native.captureException(error);
if (onError) onError(error.message);
return { success: false, error: error };
}
@@ -144,35 +140,10 @@ export const uploadToCloudinary = async (
//Insert the document with the matching key.
const documentInsert = await client.mutate({
mutation: INSERT_NEW_DOCUMENT,
update: (cache, { data }) => {
cache.modify({
fields: {
documents: (existingDocs = []) => {
const newDocRef = cache.writeFragment({
data: data.insert_documents.returning[0],
fragment: gql`
fragment newDoc on documents {
id
name
key
type
takenat
extension
jobid
}
`,
});
return [...existingDocs, newDocRef];
},
},
});
},
variables: {
docInput: [
{
...(jobId ? { jobid: jobId } : {}),
...(billId ? { billid: billId } : {}),
uploaded_by: uploaded_by,
key: key,
type: fileType,
@@ -194,19 +165,8 @@ export const uploadToCloudinary = async (
status: "done",
key: documentInsert.data.insert_documents.returning[0].key,
});
// notification["success"]({
// message: i18n.t("documents.successes.insert"),
// });
if (callback) {
callback();
}
} else {
if (onError) onError(JSON.stringify(documentInsert.errors));
// notification["error"]({
// message: i18n.t("documents.errors.insert", {
// message: JSON.stringify(JSON.stringify(documentInsert.errors)),
// }),
// });
return {
success: false,
error: JSON.stringify(documentInsert.errors),
@@ -231,6 +191,7 @@ export function formatBytes(a, b = 2) {
if (0 === a || !a) return "0 Bytes";
const c = 0 > b ? 0 : b,
d = Math.floor(Math.log(a) / Math.log(1024));
return (
parseFloat((a / Math.pow(1024, d)).toFixed(c)) +
" " +

View File

@@ -0,0 +1,109 @@
import axios from "axios";
import { store } from "../redux/store";
import mime from "mime";
import * as MediaLibrary from "expo-media-library";
import * as Sentry from "sentry-expo";
axios.interceptors.request.use(
function (config) {
config.metadata = { startTime: new Date() };
return config;
},
function (error) {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
function (response) {
response.config.metadata.endTime = new Date();
response.duration =
response.config.metadata.endTime - response.config.metadata.startTime;
return response;
},
function (error) {
error.config.metadata.endTime = new Date();
error.duration =
error.config.metadata.endTime - error.config.metadata.startTime;
return Promise.reject(error);
}
);
export const handleLocalUpload = async ({
files,
onError,
onSuccess,
onProgress,
context,
}) => {
const { jobid } = context;
const bodyshop = store.getState().user.bodyshop;
try {
var options = {
headers: {
"Content-Type": "multipart/form-data",
ims_token: bodyshop.localmediatoken,
},
onUploadProgress: (e) => {
if (onProgress)
onProgress({ percent: e.loaded / e.total, loaded: e.loaded });
},
};
const formData = new FormData();
formData.append("jobid", jobid);
const filesList = [];
for (const file of files) {
const imageData = await MediaLibrary.getAssetInfoAsync(file.id);
const mimeType = mime.getType(imageData.uri);
filesList.push({
uri: imageData.localUri || imageData.uri,
type: mimeType,
name: imageData.filename,
});
formData.append("file", {
uri: imageData.localUri || imageData.uri,
type: mimeType,
name: imageData.filename,
});
}
//formData.append("file", files);
formData.append("skip_thumbnail", true);
try {
const imexMediaServerResponse = await axios.post(
`${bodyshop.localmediaserverhttp}/jobs/upload`,
formData,
options
);
if (imexMediaServerResponse.status !== 200) {
if (onError) {
onError({
error:
imexMediaServerResponse.data ||
imexMediaServerResponse.statusText,
});
}
} else {
onSuccess &&
onSuccess({
duration: imexMediaServerResponse.headers["x-response-time"],
});
}
} catch (error) {
Sentry.Native.captureException(error);
console.log("Error uploading documents:", error.message);
onError && onError({ error: error.message });
}
} catch (error) {
console.log("Uncaught error", error);
Sentry.Native.captureException(error);
onError && onError({ error: error.message });
}
};

10367
yarn.lock

File diff suppressed because it is too large Load Diff