diff --git a/src/components/BarcodeCameraView.js b/src/components/BarcodeCameraView.js new file mode 100644 index 000000000..ec6c2abf6 --- /dev/null +++ b/src/components/BarcodeCameraView.js @@ -0,0 +1,144 @@ +import React, { useState, forwardRef } from 'react'; +import { Dimensions, Text } from 'react-native'; +import { View, Vibration } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera/next'; +import _ from 'lodash'; +import { Icon } from 'native-base'; +import Ionicons from 'react-native-vector-icons/Ionicons'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import FocusHandler from './FocusHandler'; + +function getBoxBoundaries(points) { + const xs = points.map(point => point.x); + const ys = points.map(point => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return { minX, maxX, minY, maxY }; +} + +function horizontalLineIntersectsBox(lineY, lineStartX, lineEndX, points) { + const { minX, maxX, minY, maxY } = getBoxBoundaries(points); + + if (lineY < minY || lineY > maxY) { + return false; + } + if (lineStartX > lineEndX) { + [lineStartX, lineEndX] = [lineEndX, lineStartX]; + } + return lineEndX >= minX && lineStartX <= maxX; +} + +function BarcodeCameraView({ disabled, ...props }, ref) { + const [hasPermission, requestPermission] = useCameraPermissions(); + + const { width: CameraWidth } = Dimensions.get('window'); + const CameraHeight = CameraWidth * 0.55; + const PaddingHorizontal = CameraWidth * 0.1618; + + const [barcode, setBarcode] = useState(null); + const [flash, setFlash] = useState(false); + + //The hide behavior is a hack to force the camera to re-render + //if another camera is opened on modal. If we don't do this, the + //camera will not render. + const [hide, setHide] = useState(disabled ?? false); + + if (!hasPermission) { + return ( + + No camera permission + + ); + } + + if (!hasPermission.granted) { + requestPermission(); + return ; + } + + const onScanned = _.throttle(result => { + const { data, cornerPoints } = result; + if ( + !horizontalLineIntersectsBox( + CameraHeight / 2, + PaddingHorizontal, + CameraWidth - PaddingHorizontal, + cornerPoints, + ) + ) { + return; + } + + if (data !== barcode) { + Vibration.vibrate(); + setBarcode(data); + props.onScanned(data); + } + }, 200); + + return ( + <> + { + if (!disabled) { + setHide(false); + } + }} + onBlur={() => { + setHide(true); + }} + /> + {!hide && ( + + + + setFlash(!flash)}> + + + + + )} + {hide && ( + + )} + + ); +} + +export default forwardRef(BarcodeCameraView); diff --git a/src/components/NotificationHandler.js b/src/components/NotificationHandler.js index 98a6bed78..6f1b91f18 100644 --- a/src/components/NotificationHandler.js +++ b/src/components/NotificationHandler.js @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { clearNotifications, + shouldNotificationBeDisplayed, startSound, stopSound, } from '../redux/App/actions'; @@ -10,6 +11,7 @@ import NotificationModal from './NotificationModal'; import { selectNotificationsToDisplay, selectNotificationsWithSound, + selectShouldNotificationBeDisplayed } from '../redux/App/selectors'; import { AppState } from 'react-native'; @@ -25,6 +27,7 @@ const NOTIFICATION_DURATION_MS = 10000; export default function NotificationHandler() { const notificationsToDisplay = useSelector(selectNotificationsToDisplay); const notificationsWithSound = useSelector(selectNotificationsWithSound); + const shouldNotificationBeDisplayed = useSelector(selectShouldNotificationBeDisplayed); const hasNotifications = useMemo( () => @@ -53,12 +56,19 @@ export default function NotificationHandler() { // but it's very limited, e.g. handlers set via setTimeout are not executed // so we do not play sound in that case, because we will not be able to stop it // use memoized value to avoid re-starting the sound when more notifications arrive - if (hasNotificationsWithSound && AppState.currentState === 'active') { + if ( + shouldNotificationBeDisplayed && + hasNotificationsWithSound && + AppState.currentState === 'active') { dispatch(startSound()); } else { dispatch(stopSound()); } - }, [hasNotificationsWithSound, dispatch]); + }, [shouldNotificationBeDisplayed, hasNotificationsWithSound, dispatch]); + + if (!shouldNotificationBeDisplayed) { + return null; + } return ( + {title} + {value ?? '-'} + + ); +} + +function BarcodePage({ + t, + httpClient, + navigation, + taskLists, + shouldNotificationBeDisplayed, +}) { + const [barcode, setBarcode] = useState(null); + const [entity, setEntity] = useState(null); + const [clientActionsQueue, setClientActionsQueue] = useState([]); + const [showNoteModal, setShowNoteModal] = useState(false); + const [noteLoading, setNoteLoading] = useState(false); + + const note = useRef(null); + + const askToUnassign = useCallback( + ({ token }) => + new Promise((resolve, reject) => { + Alert.alert( + t('BARCODE_TASK_ALREADY_ASSIGNED_TITLE'), + t('BARCODE_TASK_ALREADY_ASSIGNED_SELF_MESSAGE'), + [ + { + text: t('BARCODE_TASK_ALREADY_ASSIGNED_UNASSIGN'), + onPress: () => { + _unassignTask(httpClient, entity.id, token) + .then(resolve) + .catch(reject); + }, + }, + { + text: t('TASK_COMPLETE_ALERT_NEGATIVE'), + onPress: () => + _startTask(httpClient, entity.id).then(resolve).catch(reject), + }, + { + text: t('OK'), + onPress: resolve, + }, + ], + ); + }), + [t, httpClient, entity], + ); + + const askToAssign = useCallback( + ({ token }) => + new Promise((resolve, reject) => { + Alert.alert( + t('BARCODE_TASK_ALREADY_ASSIGNED_TITLE'), + t('BARCODE_TASK_ALREADY_ASSIGNED_ANOTHER_MESSAGE'), + [ + { + text: t('BARCODE_TASK_ALREADY_ASSIGNED_ASSIGN_TO_ME'), + onPress: () => { + _assignTask(httpClient, entity.id, token) + .then(resolve) + .catch(reject); + }, + }, + { + text: t('OK'), + onPress: resolve, + }, + ], + ); + }), + [t, httpClient, entity], + ); + + const askToStartPickup = useCallback( + ({ payload: { task_id } }) => { + return new Promise((resolve, reject) => { + Alert.alert( + t('ASK_TO_START_PICKUP_TITLE'), + t('ASK_TO_START_PICKUP_MESSAGE'), + [ + { + text: t('TASK_COMPLETE_ALERT_NEGATIVE'), + onPress: () => { + _startTask(httpClient, task_id) + .then(resolve) + .catch(reject); + }, + }, + { + text: t('OK'), + onPress: resolve, + }, + ], + ); + }); + }, + [t, httpClient], + ) + + const warningMultiplePackages = ({ count, details }) => + new Promise((resolve, _reject) => { + Alert.alert( + t('TASK_MULTIPLE_PACKAGES'), + `${t('X_PACKAGES', { count })}:\n\n${details}\n\n${t( + 'NO_NEED_TO_SCAN_OTHERS', + )}`, + [{ text: t('OK'), onPress: resolve }], + ); + }); + + const checkMultiplePackages = packages => { + if (!packages) return; + const count = packages.reduce((acc, p) => acc + p.barcodes.length, 0); + if (count > 1) { + const details = packages + .map(p => `${p.barcodes.length}x ${p.name}`) + .join('\n'); + return { + action: 'warn_multiple_packages', + fn: () => warningMultiplePackages({ count, details }), + }; + } + return null; + }; + + async function* actionGenerator(actions) { + for (const action of actions) { + yield action; + } + } + + const checkClientAction = useCallback( + ({ action, ...params }) => { + if (!entity) return; + + switch (action) { + case 'ask_to_unassign': + return askToUnassign(params); + case 'ask_to_assign': + return askToAssign(params); + case 'ask_to_start_pickup': + return askToStartPickup(params); + case 'ask_to_complete_pickup': + case 'ask_to_complete': + return new Promise((resolve, _reject) => { + const id = params?.payload?.task_id ?? entity.id; + navigation.dispatch(StackActions.pop(1)); + navigateToTask( + navigation, + null, + taskLists.find(task => task['@id'] === `/api/tasks/${id}`), + ); + resolve(); + }); + case 'warn_multiple_packages': + return params.fn(); + default: + return; + } + }, + [entity, navigation, taskLists, askToAssign, askToUnassign, askToStartPickup], + ); + + useEffect(() => { + shouldNotificationBeDisplayed(false); + return () => { + shouldNotificationBeDisplayed(true); + }; + }, [shouldNotificationBeDisplayed]); + + useEffect(() => { + async function processActions() { + if (clientActionsQueue.length === 0) return; + + const generator = actionGenerator(clientActionsQueue); + + try { + for await (const action of generator) { + await checkClientAction(action); + } + } catch (error) { + console.error('Error processing actions:', error); + } finally { + setClientActionsQueue([]); + } + } + + processActions(); + }, [clientActionsQueue, checkClientAction]); + + return ( + <> + setShowNoteModal(false)} + onBackdropPress={() => setShowNoteModal(false)}> + + {t('NOTES')} +