diff --git a/.env.dist b/.env.dist index 4f2e514c9..1f2ec0812 100644 --- a/.env.dist +++ b/.env.dist @@ -11,3 +11,4 @@ GOOGLE_SIGN_IN_CLIENT_ID= DEFAULT_SERVER= PRIMARY_COLOR= SMARTLOOK_PROJECT_KEY= +DEBUG_REDUX_LOGGER_LEVEL=log diff --git a/.github/workflows/build_android.yml b/.github/workflows/build_android.yml index 41714599a..40b42142b 100644 --- a/.github/workflows/build_android.yml +++ b/.github/workflows/build_android.yml @@ -1,23 +1,37 @@ name: Build Android +run-name: > + ${{ inputs.deploy_google_play && format('Release to {0} track', inputs.google_play_track) || 'Build' }} + ${{ inputs.tag }} + ${{ inputs.build_official && '; CoopCycle' || '' }} + ${{ inputs.build_official_beta && '; CoopCycle (Beta)' || '' }} + ${{ inputs.build_naofood && '; Naofood' || '' }} + ${{ inputs.build_kooglof && '; Kooglof' || '' }} + ${{ inputs.build_robinfood && '; RobinFood' || '' }} + ${{ inputs.build_coursiers_rennais && '; Les Coursiers Rennais' || '' }} + ${{ inputs.build_eraman && '; Eraman' || '' }} on: workflow_dispatch: inputs: + tag: + type: string + description: Build a specific git tag + required: true deploy_google_play: - description: 'Deploy to Google Play' + description: 'Upload to Google Play' required: true type: boolean - default: true + default: false google_play_track: description: 'Google Play track' required: true type: string - default: 'production' + default: 'internal' build_official: - description: 'Build official app' + description: 'Build CoopCycle production app' required: true type: boolean build_official_beta: - description: 'Build official beta app' + description: 'Build CoopCycle beta app' required: true type: boolean build_naofood: @@ -41,19 +55,21 @@ on: required: true type: boolean jobs: - default: + coopcycle: if: ${{ inputs.build_official }} - name: Build default app + name: Build CoopCycle production app uses: ./.github/workflows/fastlane_android.yml secrets: inherit with: + tag: ${{ inputs.tag }} google_play_track: ${{ inputs.google_play_track }} deploy_google_play: ${{ inputs.deploy_google_play }} - default_beta: + coopcycle_beta: if: ${{ inputs.build_official_beta }} - name: Build default beta app + name: Build CoopCycle beta app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: beta app_name: CoopCycle (Beta) package_name: fr.coopcycle.beta @@ -67,6 +83,7 @@ jobs: name: Build Naofood app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: naofood instance_url: https://naofood.coopcycle.org app_name: Naofood @@ -82,6 +99,7 @@ jobs: name: Build Zampate app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: zampate instance_url: https://zampate.coopcycle.org app_name: Zampate @@ -97,6 +115,7 @@ jobs: name: Build Kooglof app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: kooglof instance_url: https://kooglof.coopcycle.org app_name: Kooglof @@ -112,6 +131,7 @@ jobs: name: Build RobinFood app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: robinfood instance_url: https://robinfood.coopcycle.org app_name: Robin Food @@ -127,6 +147,7 @@ jobs: name: Build Coursiers MTP app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: coursiersmontpellier instance_url: https://coursiersmontpellier.coopcycle.org app_name: Coursiers MTP @@ -142,6 +163,7 @@ jobs: name: Build LCR app uses: ./.github/workflows/fastlane_android.yml with: + tag: ${{ inputs.tag }} instance: lcr instance_url: https://lcr.coopcycle.org app_name: Les Coursiers Rennais diff --git a/.github/workflows/build_ios.yml b/.github/workflows/build_ios.yml index c1a8c0923..7095be2e5 100644 --- a/.github/workflows/build_ios.yml +++ b/.github/workflows/build_ios.yml @@ -1,9 +1,24 @@ name: Build iOS +run-name: > + Upload to App Store ${{ inputs.tag }} + ${{ inputs.build_official && '; CoopCycle' || '' }} + ${{ inputs.build_official_beta && '; CoopCycle (Beta)' || '' }} + ${{ inputs.build_naofood && '; Naofood' || '' }} + ${{ inputs.build_kooglof && '; Kooglof' || '' }} + ${{ inputs.build_robinfood && '; RobinFood' || '' }} on: workflow_dispatch: inputs: + tag: + type: string + description: Build a specific git tag + required: true build_official: - description: 'Build official app' + description: 'Build CoopCycle production app' + required: true + type: boolean + build_official_beta: + description: 'Build CoopCycle beta app (TODO: finish setup)' required: true type: boolean build_naofood: @@ -23,18 +38,31 @@ on: required: true type: boolean jobs: - default: + coopcycle: if: ${{ inputs.build_official }} - name: Build default app + name: Build CoopCycle production app uses: ./.github/workflows/fastlane_ios.yml with: + tag: ${{ inputs.tag }} google_service_info_plist_base64: GOOGLE_SERVICE_INFO_PLIST_BASE64 secrets: inherit + coopcycle_beta: + if: ${{ inputs.build_official_beta }} + name: Build CoopCycle beta app + uses: ./.github/workflows/fastlane_ios.yml + with: + tag: ${{ inputs.tag }} + instance: beta + app_name: CoopCycle (Beta) + app_id: org.coopcycle.CoopCycleBeta + google_service_info_plist_base64: GOOGLE_SERVICE_INFO_PLIST_BASE64_BETA + secrets: inherit naofood: if: ${{ inputs.build_naofood }} name: Build Naofood app uses: ./.github/workflows/fastlane_ios.yml with: + tag: ${{ inputs.tag }} instance: naofood instance_url: https://naofood.coopcycle.org app_name: Naofood @@ -47,6 +75,7 @@ jobs: name: Build Kooglof app uses: ./.github/workflows/fastlane_ios.yml with: + tag: ${{ inputs.tag }} instance: kooglof instance_url: https://kooglof.coopcycle.org app_name: Kooglof @@ -59,6 +88,7 @@ jobs: name: Build RobinFood app uses: ./.github/workflows/fastlane_ios.yml with: + tag: ${{ inputs.tag }} instance: robinfood instance_url: https://robinfood.coopcycle.org app_name: Robin Food diff --git a/.github/workflows/fastlane_android.yml b/.github/workflows/fastlane_android.yml index befb15947..336c143d5 100644 --- a/.github/workflows/fastlane_android.yml +++ b/.github/workflows/fastlane_android.yml @@ -2,6 +2,10 @@ name: Fastlane Android on: workflow_call: inputs: + tag: + type: string + description: Build a specific git tag + required: true instance: type: string required: false @@ -32,11 +36,7 @@ on: google_play_track: type: string required: false - default: "production" - branch: - type: string - required: false - default: "master" + default: "internal" deploy_google_play: type: boolean required: false @@ -48,7 +48,7 @@ jobs: steps: - uses: actions/checkout@v3 with: - ref: ${{ inputs.branch }} + ref: ${{ inputs.tag }} - uses: actions/setup-node@v4 with: node-version: '20.x' @@ -202,7 +202,7 @@ jobs: steps: - uses: actions/checkout@v3 with: - ref: ${{ inputs.branch }} + ref: ${{ inputs.tag }} - name: Download apk artifact uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/fastlane_ios.yml b/.github/workflows/fastlane_ios.yml index 1df1eb173..6a664049c 100644 --- a/.github/workflows/fastlane_ios.yml +++ b/.github/workflows/fastlane_ios.yml @@ -2,6 +2,10 @@ name: Fastlane iOS on: workflow_call: inputs: + tag: + type: string + description: Build a specific git tag + required: true instance: type: string required: false @@ -29,6 +33,8 @@ jobs: runs-on: macOS-14 steps: - uses: actions/checkout@v3 + with: + ref: ${{ inputs.tag }} - uses: actions/setup-node@v4 with: node-version: '20.x' diff --git a/ios/fastlane/metadata-beta/app_icon.png b/ios/fastlane/metadata-beta/app_icon.png new file mode 100644 index 000000000..4f220ced0 Binary files /dev/null and b/ios/fastlane/metadata-beta/app_icon.png differ diff --git a/package.json b/package.json index e9d899d52..48b9137bc 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "react-navigation-header-buttons": "^11.2.1", "react-query": "^3.39.3", "react-redux": "^7.2.8", + "recursive-diff": "^1.0.9", "reduce-reducers": "^1.0.4", "redux": "^4.2.1", "redux-actions": "^2.6.5", diff --git a/src/components/NotificationHandler.js b/src/components/NotificationHandler.js index 2960b69d4..743870987 100644 --- a/src/components/NotificationHandler.js +++ b/src/components/NotificationHandler.js @@ -1,383 +1,64 @@ -import { CommonActions } from '@react-navigation/native'; -import moment from 'moment'; -import { Icon, Text } from 'native-base'; -import React, { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { FlatList, StyleSheet, TouchableOpacity, View } from 'react-native'; -import Modal from 'react-native-modal'; -import Sound from 'react-native-sound'; -import FontAwesome from 'react-native-vector-icons/FontAwesome'; -import Ionicons from 'react-native-vector-icons/Ionicons'; -import { connect } from 'react-redux'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; -import NavigationHolder from '../NavigationHolder'; -import PushNotification from '../notifications'; - -import analyticsEvent from '../analytics/Event'; -import tracker from '../analytics/Tracker'; import { clearNotifications, - pushNotification, - registerPushNotificationToken, + startSound, + stopSound, } from '../redux/App/actions'; -import { loadTasks, selectTasksChangedAlertSound } from '../redux/Courier'; +import NotificationModal from './NotificationModal'; import { - loadOrder, - loadOrderAndNavigate, - loadOrderAndPushNotification, -} from '../redux/Restaurant/actions'; -import { message as wsMessage } from '../redux/middlewares/CentrifugoMiddleware/actions'; - -import ModalContent from './ModalContent'; + selectNotificationsToDisplay, + selectNotificationsWithSound, +} from '../redux/App/selectors'; +import { AppState } from 'react-native'; -// Make sure sound will play even when device is in silent mode -Sound.setCategory('Playback'); +const NOTIFICATION_DURATION_MS = 10000; /** * This component is used - * 1/ To configure push notifications (see componentDidMount) - * 2/ To show notifications when the app is in foreground. + * 1/ To show notifications when the app is in the foreground (using NotificationModal) + * 2/ To play a sound when a notification is received (via SoundMiddleware) + * + * Push notifications are configured and received in PushNotificationMiddleware */ -class NotificationHandler extends Component { - constructor(props) { - super(props); - - this.state = { - sound: null, - isSoundReady: false, - }; - } - - _onTasksChanged(date) { - if (this.props.currentRoute !== 'CourierTaskList') { - NavigationHolder.navigate('CourierTaskList', {}); +export default function NotificationHandler() { + const notificationsToDisplay = useSelector(selectNotificationsToDisplay); + const notificationsWithSound = useSelector(selectNotificationsWithSound); + + const dispatch = useDispatch(); + + useEffect(() => { + if ( + notificationsToDisplay.length > 0 || + notificationsWithSound.length > 0 + ) { + setTimeout(() => { + dispatch(clearNotifications()); + }, NOTIFICATION_DURATION_MS); } - - this.props.loadTasks(moment(date)); - } - - _loadSound() { - const bell = new Sound( - 'misstickle__indian_bell_chime.wav', - Sound.MAIN_BUNDLE, - error => { - if (error) { - return; - } - - bell.setNumberOfLoops(-1); - - this.setState({ - sound: bell, - isSoundReady: true, - }); - }, - ); - } - - _startSound() { - const { sound, isSoundReady } = this.state; - if (isSoundReady) { - sound.play(success => { - if (!success) { - sound.reset(); - } - }); - // Clear notifications after 10 seconds - setTimeout(() => this.props.clearNotifications(), 10000); - } - } - - _stopSound() { - const { sound, isSoundReady } = this.state; - if (isSoundReady) { - sound.stop(() => {}); + }, [notificationsToDisplay, notificationsWithSound, dispatch]); + + useEffect(() => { + // on Android, when notification is received, OS let us execute some code + // 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 + if ( + notificationsWithSound.length > 0 && + AppState.currentState === 'active' + ) { + dispatch(startSound()); + } else { + dispatch(stopSound()); } - } - - includesNotification(notifications, predicate) { - return notifications.findIndex(predicate) !== -1; - } - - componentDidUpdate(prevProps) { - if (this.props.notifications !== prevProps.notifications) { - if (this.props.notifications.length > 0) { - if ( - this.includesNotification( - this.props.notifications, - n => n.event === 'order:created', - ) - ) { - this._startSound(); - } else if ( - this.includesNotification( - this.props.notifications, - n => n.event === 'tasks:changed', - ) - ) { - if (this.props.tasksChangedAlertSound) { - this._startSound(); - } - } - } else { - this._stopSound(); - } - } - } - - componentDidMount() { - this._loadSound(); - - PushNotification.configure({ - onRegister: token => this.props.registerPushNotificationToken(token), - onNotification: message => { - const { event } = message.data; - - if (event && event.name === 'order:created') { - tracker.logEvent( - analyticsEvent.restaurant._category, - analyticsEvent.restaurant.orderCreatedMessage, - message.foreground ? 'in_app' : 'notification_center', - ); - - const { order } = event.data; - - // Here in any case, we navigate to the order that was tapped, - // it should have been loaded via WebSocket already. - this.props.loadOrderAndNavigate(order); - } - - if (event && event.name === 'tasks:changed') { - tracker.logEvent( - analyticsEvent.courier._category, - analyticsEvent.courier.tasksChangedMessage, - message.foreground ? 'in_app' : 'notification_center', - ); - - if (message.foreground) { - this.props.pushNotification('tasks:changed', { - date: event.data.date, - }); - } else { - // user clicked on a notification in the notification center - this._onTasksChanged(event.data.date); - } - } - }, - onBackgroundMessage: message => { - const { event } = message.data; - if (event && event.name === 'order:created') { - this.props.loadOrder(event.data.order, order => { - if (order) { - // Simulate a WebSocket message - this.props.message({ - name: 'order:created', - data: { order }, - }); - } - }); - } - }, - }); - } - - componentWillUnmount() { - PushNotification.removeListeners(); - } - - _keyExtractor(item, index) { - switch (item.event) { - case 'order:created': - return `order:created:${item.params.order.id}`; - case 'tasks:changed': - return `tasks:changed:${moment()}`; - } - } - - renderItem(notification) { - switch (notification.event) { - case 'order:created': - return this.renderOrderCreated(notification.params.order); - case 'tasks:changed': - return this.renderTasksChanged( - notification.params.date, - notification.params.added, - notification.params.removed, - ); - } - } - - _navigateToOrder(order) { - this._stopSound(); - this.props.clearNotifications(); - - NavigationHolder.dispatch( - CommonActions.navigate({ - name: 'RestaurantNav', - params: { - screen: 'Main', - params: { - restaurant: order.restaurant, - // We don't want to load orders again when navigating - loadOrders: false, - screen: 'RestaurantOrder', - params: { - order, - }, - }, - }, - }), - ); - } - - _navigateToTasks(date) { - this._stopSound(); - this.props.clearNotifications(); - - NavigationHolder.dispatch( - CommonActions.navigate({ - name: 'CourierNav', - params: { - screen: 'CourierHome', - params: { - screen: 'CourierTaskList', - }, - }, - }), - ); - - this.props.loadTasks(moment(date)); - } - - renderOrderCreated(order) { - return ( - this._navigateToOrder(order)}> - {this.props.t('NOTIFICATION_ORDER_CREATED_TITLE')} - - - ); - } - - renderTasksChanged(date, added, removed) { - return ( - this._navigateToTasks(date)}> - - - {this.props.t('NOTIFICATION_TASKS_CHANGED_TITLE')} - - {added && Array.isArray(added) && added.length > 0 && ( - - {this.props.t('NOTIFICATION_TASKS_ADDED', { - count: added.length, - })} - - )} - {removed && Array.isArray(removed) && removed.length > 0 && ( - - {this.props.t('NOTIFICATION_TASKS_REMOVED', { - count: removed.length, - })} - - )} - - - - ); - } - - renderModalContent() { - return ( - - - - - - {this.props.t('NEW_NOTIFICATION')} - - - - this.renderItem(item)} - /> - this.props.clearNotifications()}> - {this.props.t('CLOSE')} - - - ); - } - - render() { - return ( - - {this.renderModalContent()} - - ); - } -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 25, - backgroundColor: '#39CCCC', - }, - footer: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 25, - }, - item: { - paddingVertical: 25, - paddingHorizontal: 20, - borderBottomColor: '#f7f7f7', - borderBottomWidth: StyleSheet.hairlineWidth, - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, -}); - -function mapStateToProps(state) { - return { - currentRoute: state.app.currentRoute, - isModalVisible: state.app.notifications.length > 0, - notifications: state.app.notifications, - tasksChangedAlertSound: selectTasksChangedAlertSound(state), - }; + }, [notificationsWithSound, dispatch]); + + return ( + { + dispatch(clearNotifications()); + }} + /> + ); } - -function mapDispatchToProps(dispatch) { - return { - loadOrder: (order, cb) => dispatch(loadOrder(order, cb)), - loadOrderAndNavigate: order => dispatch(loadOrderAndNavigate(order)), - loadOrderAndPushNotification: order => - dispatch(loadOrderAndPushNotification(order)), - loadTasks: date => dispatch(loadTasks(date)), - registerPushNotificationToken: token => - dispatch(registerPushNotificationToken(token)), - clearNotifications: () => dispatch(clearNotifications()), - pushNotification: (event, params) => - dispatch(pushNotification(event, params)), - message: payload => dispatch(wsMessage(payload)), - }; -} - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withTranslation()(NotificationHandler)); diff --git a/src/components/NotificationModal.js b/src/components/NotificationModal.js new file mode 100644 index 000000000..42bf44dcc --- /dev/null +++ b/src/components/NotificationModal.js @@ -0,0 +1,175 @@ +import Modal from 'react-native-modal'; +import ModalContent from './ModalContent'; +import { FlatList, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Icon, Text } from 'native-base'; +import Ionicons from 'react-native-vector-icons/Ionicons'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment/moment'; +import FontAwesome from 'react-native-vector-icons/FontAwesome'; +import NavigationHolder from '../NavigationHolder'; +import { CommonActions } from '@react-navigation/native'; +import { useDispatch } from 'react-redux'; +import { loadTasks } from '../redux/Courier'; +import { EVENT as EVENT_ORDER } from '../domain/Order'; +import { EVENT as EVENT_TASK_COLLECTION } from '../domain/TaskCollection'; + +const styles = StyleSheet.create({ + heading: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 25, + backgroundColor: '#39CCCC', + }, + footer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 25, + }, + item: { + paddingVertical: 25, + paddingHorizontal: 20, + borderBottomColor: '#f7f7f7', + borderBottomWidth: StyleSheet.hairlineWidth, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, +}); + +export default function NotificationModal({ notifications, onDismiss }) { + const isModalVisible = notifications.length > 0; + + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + const _keyExtractor = (item, index) => { + switch (item.event) { + case EVENT_ORDER.CREATED: + return `order:created:${item.params.order.id}`; + case EVENT_TASK_COLLECTION.CHANGED: + return `tasks:changed:${moment()}`; + } + }; + + const _navigateToOrder = order => { + onDismiss(); + + NavigationHolder.dispatch( + CommonActions.navigate({ + name: 'RestaurantNav', + params: { + screen: 'Main', + params: { + restaurant: order.restaurant, + // We don't want to load orders again when navigating + loadOrders: false, + screen: 'RestaurantOrder', + params: { + order, + }, + }, + }, + }), + ); + }; + + const _navigateToTasks = date => { + onDismiss(); + + NavigationHolder.dispatch( + CommonActions.navigate({ + name: 'CourierNav', + params: { + screen: 'CourierHome', + params: { + screen: 'CourierTaskList', + }, + }, + }), + ); + + dispatch(loadTasks(moment(date))); + }; + + const renderOrderCreated = order => { + return ( + _navigateToOrder(order)}> + {t('NOTIFICATION_ORDER_CREATED_TITLE')} + + + ); + }; + + const renderTasksChanged = (date, added, removed) => { + return ( + _navigateToTasks(date)}> + + + {t('NOTIFICATION_TASKS_CHANGED_TITLE')} + + {added && Array.isArray(added) && added.length > 0 && ( + + {t('NOTIFICATION_TASKS_ADDED', { + count: added.length, + })} + + )} + {removed && Array.isArray(removed) && removed.length > 0 && ( + + {t('NOTIFICATION_TASKS_REMOVED', { + count: removed.length, + })} + + )} + + + + ); + }; + + const renderItem = notification => { + switch (notification.event) { + case EVENT_ORDER.CREATED: + return renderOrderCreated(notification.params.order); + case EVENT_TASK_COLLECTION.CHANGED: + return renderTasksChanged( + notification.params.date, + notification.params.added, + notification.params.removed, + ); + } + }; + + return ( + + + + + + {t('NEW_NOTIFICATION')} + + + renderItem(item)} + /> + onDismiss()}> + {t('CLOSE')} + + + + ); +} diff --git a/src/domain/Order.js b/src/domain/Order.js new file mode 100644 index 000000000..401d4759c --- /dev/null +++ b/src/domain/Order.js @@ -0,0 +1,16 @@ +export const STATE = { + NEW: 'new', + ACCEPTED: 'accepted', + REFUSED: 'refused', + STARTED: 'started', + READY: 'ready', + FULFILLED: 'fulfilled', + CANCELLED: 'cancelled', +}; + +export const EVENT = { + STATE_CHANGED: 'order:state_changed', + CREATED: 'order:created', + ACCEPTED: 'order:accepted', + PICKED: 'order:picked', +}; diff --git a/src/domain/TaskCollection.js b/src/domain/TaskCollection.js new file mode 100644 index 000000000..5de512f2a --- /dev/null +++ b/src/domain/TaskCollection.js @@ -0,0 +1,3 @@ +export const EVENT = { + CHANGED: 'tasks:changed', +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index de30e33fb..7012dc3b2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -147,9 +147,11 @@ "TASKS_CHANGED_ALERT_SOUND": "Tasks changed alert sound", "RESTAURANT_LIST_CLICK_BELOW": "Click on a restaurant in the list below", "RESTAURANT_ORDER_LIST_NEW_ORDERS": "New orders ({{count}})", - "RESTAURANT_ORDER_LIST_ACCEPTED_ORDERS": "Accepted orders ({{count}})", - "RESTAURANT_ORDER_LIST_CANCELLED_ORDERS": "Cancelled orders ({{count}})", - "RESTAURANT_ORDER_LIST_FULFILLED_ORDERS": "Fulfilled orders ({{count}})", + "RESTAURANT_ORDER_LIST_ACCEPTED_ORDERS": "Accepted ({{count}})", + "RESTAURANT_ORDER_LIST_STARTED_ORDERS": "In preparation ({{count}})", + "RESTAURANT_ORDER_LIST_READY_ORDERS": "Ready ({{count}})", + "RESTAURANT_ORDER_LIST_CANCELLED_ORDERS": "Cancelled ({{count}})", + "RESTAURANT_ORDER_LIST_FULFILLED_ORDERS": "Delivered ({{count}})", "RESTAURANT_ORDER_BUTTON_ACCEPT": "Accept", "RESTAURANT_ORDER_BUTTON_REFUSE": "Refuse", "RESTAURANT_ORDER_BUTTON_CANCEL": "Cancel", @@ -319,8 +321,11 @@ "TOUCH_TO_RELOAD": "Touch to reload", "OFFLINE": "You are offline", "SCAN_FOR_PRINTERS": "Tap to scan", + "AUTO_ACCEPT_ORDERS_PRINT_NUMBER_OF_COPIES": "Number of copies", "SEARCH_WITH_ADDRESS": "Search « {{address}} »", "RESTAURANT_ORDER_CONNECT_PRINTER": "Connect a printer", + "RESTAURANT_ORDER_PRINTING": "Printing order", + "RESTAURANT_ORDER_FAILED_TO_PRINT": "Failed to print", "SWIPE_TO_ACCEPT_REFUSE": "Slide to the right to accept, to the left to refuse", "ADD_COUPON": "Add a voucher code", "VOUCHER_CODE": "Voucher code", diff --git a/src/navigation/components/DrawerContent.js b/src/navigation/components/DrawerContent.js index 67d9a6773..17dad7476 100644 --- a/src/navigation/components/DrawerContent.js +++ b/src/navigation/components/DrawerContent.js @@ -252,7 +252,7 @@ class DrawerContent extends Component { - {VersionNumber.appVersion} + {`${VersionNumber.appVersion} (${VersionNumber.buildVersion})`} diff --git a/src/navigation/restaurant/Dashboard.js b/src/navigation/restaurant/Dashboard.js index a62560b62..49d34053a 100644 --- a/src/navigation/restaurant/Dashboard.js +++ b/src/navigation/restaurant/Dashboard.js @@ -1,10 +1,10 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Alert, NativeModules } from 'react-native'; +import { Center, VStack } from 'native-base'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; import moment from 'moment'; -import { Center, VStack } from 'native-base'; -import React, { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { Alert, InteractionManager, NativeModules } from 'react-native'; -import { connect } from 'react-redux'; import DangerAlert from '../../components/DangerAlert'; import Offline from '../../components/Offline'; @@ -13,7 +13,6 @@ import DatePickerHeader from './components/DatePickerHeader'; import OrderList from './components/OrderList'; import WebSocketIndicator from './components/WebSocketIndicator'; -import PushNotification from '../../notifications'; import { selectIsCentrifugoConnected, selectIsLoading, @@ -26,38 +25,103 @@ import { loadOrders, } from '../../redux/Restaurant/actions'; import { - selectAcceptedOrders, - selectCancelledOrders, - selectFulfilledOrders, - selectNewOrders, - selectPickedOrders, + selectDate, + selectRestaurant, selectSpecialOpeningHoursSpecificationForToday, } from '../../redux/Restaurant/selectors'; +import PushNotification from '../../notifications'; +import OrdersToPrintQueue from './components/OrdersToPrintQueue'; import { connect as connectCentrifugo } from '../../redux/middlewares/CentrifugoMiddleware/actions'; const RNSound = NativeModules.RNSound; -class DashboardPage extends Component { - constructor(props) { - super(props); +export default function DashboardPage({ navigation, route }) { + const restaurant = useSelector(selectRestaurant); + const date = useSelector(selectDate); + const specialOpeningHoursSpecification = useSelector( + selectSpecialOpeningHoursSpecificationForToday, + ); + + const isInternetReachable = useSelector( + state => state.app.isInternetReachable, + ); + const isLoading = useSelector(selectIsLoading); + const isCentrifugoConnected = useSelector(selectIsCentrifugoConnected); + + const { navigate } = navigation; + + const [wasAlertShown, setWasAlertShown] = useState(false); + + const { t } = useTranslation(); - this.state = { - wasAlertShown: false, + const dispatch = useDispatch(); + + useEffect(() => { + activateKeepAwakeAsync(); + + return () => { + deactivateKeepAwake(); }; - } + }, []); - _checkSystemVolume() { + useEffect(() => { + if (!isCentrifugoConnected) { + dispatch(connectCentrifugo()); + } + }, [dispatch, isCentrifugoConnected]); + + useEffect(() => { + if (route.params?.loadOrders ?? true) { + dispatch( + loadOrders(restaurant, date.format('YYYY-MM-DD'), () => { + // If getInitialNotification returns something, + // it means the app was opened from a quit state. + // + // We handle this here, and *NOT* in NotificationHandler, + // because when the app opens from a quit state, + // NotificationHandler.componentDidMount is called too early. + // + // It tries to call loadOrderAndNavigate, and it fails + // because Redux is not completely ready. + // + // It's not a big issue to handle this here, + // because as the app was opened from a quit state, + // the home screen will be this one (for restaurants). + // + // @see https://rnfirebase.io/messaging/notifications#handling-interaction + PushNotification.getInitialNotification().then(remoteMessage => { + if (remoteMessage) { + const { event } = remoteMessage.data; + if (event && event.name === 'order:created') { + dispatch(loadOrderAndNavigate(event.data.order)); + } + } + }); + }), + ); + } + }, [restaurant, date, dispatch, route.params?.loadOrders]); + + // This is needed to display the title + useEffect(() => { + // WARNING Make sure to call navigation.setParams() only when needed to avoid infinite loop + const navRestaurant = route.params?.restaurant; + if (!navRestaurant || navRestaurant !== restaurant) { + navigation.setParams({ restaurant: restaurant }); + } + }, [restaurant, navigation, route.params?.restaurant]); + + const _checkSystemVolume = useCallback(() => { RNSound.getSystemVolume(volume => { if (volume < 0.4) { + setWasAlertShown(true); Alert.alert( - this.props.t('RESTAURANT_SOUND_ALERT_TITLE'), - this.props.t('RESTAURANT_SOUND_ALERT_MESSAGE'), + t('RESTAURANT_SOUND_ALERT_TITLE'), + t('RESTAURANT_SOUND_ALERT_MESSAGE'), [ { - text: this.props.t('RESTAURANT_SOUND_ALERT_CONFIRM'), + text: t('RESTAURANT_SOUND_ALERT_CONFIRM'), onPress: () => { - this.setState({ wasAlertShown: true }); - // If would be cool to open the device settings directly, // but it is not (yet) possible to sent an Intent with extra flags // https://stackoverflow.com/questions/57514207/open-settings-using-linking-sendintent @@ -68,188 +132,61 @@ class DashboardPage extends Component { }, }, { - text: this.props.t('CANCEL'), + text: t('CANCEL'), style: 'cancel', - onPress: () => this.setState({ wasAlertShown: true }), }, ], ); } }); - } - - componentDidMount() { - activateKeepAwakeAsync(); - - if (!this.props.isCentrifugoConnected) { - this.props.connectCent(); - } - - InteractionManager.runAfterInteractions(() => { - if (this.props.route.params?.loadOrders || true) { - this.props.loadOrders( - this.props.restaurant, - this.props.date.format('YYYY-MM-DD'), - () => { - // If getInitialNotification returns something, - // it means the app was opened from a quit state. - // - // We handle this here, and *NOT* in NotificationHandler, - // because when the app opens from a quit state, - // NotificationHandler.componentDidMount is called too early. - // - // It tries to call loadOrderAndNavigate, and it fails - // because Redux is not completely ready. - // - // It's not a big issue to handle this here, - // because as the app was opened from a quit state, - // the home screen will be this one (for restaurants). - // - // @see https://rnfirebase.io/messaging/notifications#handling-interaction - PushNotification.getInitialNotification().then(remoteMessage => { - if (remoteMessage) { - const { event } = remoteMessage.data; - if (event && event.name === 'order:created') { - this.props.loadOrderAndNavigate(event.data.order); - } - } - }); - }, - ); - } - // setTimeout(() => this._checkSystemVolume(), 1500) - }); - } - - componentWillUnmount() { - deactivateKeepAwake(); - } - - componentDidUpdate(prevProps) { - const hasRestaurantChanged = - this.props.restaurant !== prevProps.restaurant && - this.props.restaurant['@id'] !== prevProps.restaurant['@id']; - - const hasChanged = - this.props.date !== prevProps.date || hasRestaurantChanged; - - if (hasChanged) { - this.props.loadOrders( - this.props.restaurant, - this.props.date.format('YYYY-MM-DD'), - ); - } - - // This is needed to display the title - // WARNING Make sure to call navigation.setParams() only when needed to avoid infinite loop - const navRestaurant = this.props.route.params?.restaurant; - if (!navRestaurant || navRestaurant !== this.props.restaurant) { - this.props.navigation.setParams({ restaurant: this.props.restaurant }); - } + }, [t]); + useEffect(() => { // Make sure to show Alert once loading has finished, // or it will be closed on iOS // https://github.com/facebook/react-native/issues/10471 - if ( - !this.state.wasAlertShown && - !this.props.isLoading && - prevProps.isLoading - ) { - this._checkSystemVolume(); - } - } - - renderDashboard() { - const { navigate } = this.props.navigation; - const { date, restaurant, specialOpeningHoursSpecification } = this.props; - - return ( - - {restaurant.state === 'rush' && ( - - this.props.changeStatus(this.props.restaurant, 'normal') - } - /> - )} - {specialOpeningHoursSpecification && ( - - this.props.deleteOpeningHoursSpecification( - specialOpeningHoursSpecification, - ) - } - /> - )} - - navigate('RestaurantDate')} - onTodayClick={() => this.props.changeDate(moment())} - /> - navigate('RestaurantOrder', { order })} - /> - - ); - } - - render() { - if (this.props.isInternetReachable) { - return this.renderDashboard(); + if (!wasAlertShown && !isLoading) { + _checkSystemVolume(); + // setTimeout(() => _checkSystemVolume(), 1500) } + }, [isLoading, wasAlertShown, _checkSystemVolume]); + if (!isInternetReachable) { return (
); } -} - -function mapStateToProps(state) { - return { - httpClient: state.app.httpClient, - baseURL: state.app.baseURL, - orders: state.restaurant.orders, - newOrders: selectNewOrders(state), - acceptedOrders: selectAcceptedOrders(state), - pickedOrders: selectPickedOrders(state), - cancelledOrders: selectCancelledOrders(state), - fulfilledOrders: selectFulfilledOrders(state), - date: state.restaurant.date, - restaurant: state.restaurant.restaurant, - specialOpeningHoursSpecification: - selectSpecialOpeningHoursSpecificationForToday(state), - isInternetReachable: state.app.isInternetReachable, - isLoading: selectIsLoading(state), - isCentrifugoConnected: selectIsCentrifugoConnected(state), - }; -} -function mapDispatchToProps(dispatch) { - return { - loadOrders: (restaurant, date, cb) => - dispatch(loadOrders(restaurant, date, cb)), - loadOrderAndNavigate: order => dispatch(loadOrderAndNavigate(order)), - changeDate: date => dispatch(changeDate(date)), - changeStatus: (restaurant, state) => - dispatch(changeStatus(restaurant, state)), - deleteOpeningHoursSpecification: openingHoursSpecification => - dispatch(deleteOpeningHoursSpecification(openingHoursSpecification)), - connectCent: () => dispatch(connectCentrifugo()), - }; + return ( + + {restaurant.state === 'rush' && ( + dispatch(changeStatus(restaurant, 'normal'))} + /> + )} + {specialOpeningHoursSpecification && ( + + dispatch( + deleteOpeningHoursSpecification(specialOpeningHoursSpecification), + ) + } + /> + )} + + + navigate('RestaurantDate')} + onTodayClick={() => dispatch(changeDate(moment()))} + /> + navigate('RestaurantOrder', { order })} + /> + + ); } - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withTranslation()(DashboardPage)); diff --git a/src/navigation/restaurant/Order.js b/src/navigation/restaurant/Order.js index 7a7b22367..7fea23185 100644 --- a/src/navigation/restaurant/Order.js +++ b/src/navigation/restaurant/Order.js @@ -16,6 +16,10 @@ import { printOrder, } from '../../redux/Restaurant/actions'; import { isMultiVendor } from '../../utils/order'; +import { + selectIsPrinterConnected, + selectPrinter, +} from '../../redux/Restaurant/selectors'; const OrderNotes = ({ order }) => { if (order.notes) { @@ -83,7 +87,7 @@ class OrderScreen extends Component { } /> )} - {canEdit && order.state === 'accepted' && ( + {canEdit && (order.state === 'accepted' || order.state === 'started' || order.state === 'ready') && ( @@ -103,9 +107,8 @@ class OrderScreen extends Component { function mapStateToProps(state, ownProps) { return { order: ownProps.route.params?.order, - isPrinterConnected: - !!state.restaurant.printer || state.restaurant.isSunmiPrinter, - printer: state.restaurant.printer, + isPrinterConnected: selectIsPrinterConnected(state), + printer: selectPrinter(state), }; } diff --git a/src/navigation/restaurant/Printer.js b/src/navigation/restaurant/Printer.js index 2e0b084f8..33639ee07 100644 --- a/src/navigation/restaurant/Printer.js +++ b/src/navigation/restaurant/Printer.js @@ -1,7 +1,8 @@ import _ from 'lodash'; -import { Center, Icon, Text } from 'native-base'; +import { Center, Icon, Text, View } from 'native-base'; import React, { Component } from 'react'; -import { withTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation, withTranslation } from 'react-i18next'; import { ActivityIndicator, FlatList, @@ -14,18 +15,131 @@ import { } from 'react-native'; import FontAwesome from 'react-native-vector-icons/FontAwesome'; import { connect } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; import ItemSeparator from '../../components/ItemSeparator'; import { bluetoothStartScan, connectPrinter, disconnectPrinter, + setPrintNumberOfCopies, } from '../../redux/Restaurant/actions'; +import { + selectAutoAcceptOrdersEnabled, + selectAutoAcceptOrdersPrintNumberOfCopies, + selectPrinter, +} from '../../redux/Restaurant/selectors'; +import { useBackgroundContainerColor } from '../../styles/theme'; import { getMissingAndroidPermissions } from '../../utils/bluetooth'; +import Range from '../checkout/ProductDetails/Range'; const BleManagerModule = NativeModules.BleManager; const bleManagerEmitter = new NativeEventEmitter(BleManagerModule); +function Item({ item }) { + const dispatch = useDispatch(); + const navigation = useNavigation(); + + const _connect = device => { + dispatch( + connectPrinter(device, () => navigation.navigate('RestaurantSettings')), + ); + }; + + const _disconnect = device => { + dispatch( + disconnectPrinter(device, () => + navigation.navigate('RestaurantSettings'), + ), + ); + }; + + return ( + (item.isConnected ? _disconnect(item) : _connect(item))}> + + {item.name || + (item.advertising && item.advertising.localName) || + item.id} + + + + ); +} + +function PrinterComponent({ devices, isScanning, _onPressScan }) { + const printer = useSelector(selectPrinter); + const autoAcceptOrdersEnabled = useSelector(selectAutoAcceptOrdersEnabled); + const printNumberOfCopies = useSelector( + selectAutoAcceptOrdersPrintNumberOfCopies, + ); + + const backgroundColor = useBackgroundContainerColor(); + + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + let items = []; + if (printer) { + items.push({ ...printer, isConnected: true }); + } else { + items = devices.map(device => ({ ...device, isConnected: false })); + } + + const hasItems = !isScanning && items.length > 0; + + if (!hasItems) { + return ( +
+ + + {t('SCAN_FOR_PRINTERS')} + {isScanning && ( + + )} + +
+ ); + } + + return ( + + item.id} + renderItem={({ item }) => } + ItemSeparatorComponent={ItemSeparator} + /> + {autoAcceptOrdersEnabled ? ( + + {t('AUTO_ACCEPT_ORDERS_PRINT_NUMBER_OF_COPIES')}: + + dispatch(setPrintNumberOfCopies(printNumberOfCopies - 1)) + } + quantity={printNumberOfCopies} + onPressIncrement={() => + dispatch(setPrintNumberOfCopies(printNumberOfCopies + 1)) + } + /> + + ) : null} + + ); +} + class Printer extends Component { constructor(props) { super(props); @@ -51,18 +165,6 @@ class Printer extends Component { this.discoverPeripheral.remove(); } - _connect(device) { - this.props.connectPrinter(device, () => - this.props.navigation.navigate('RestaurantSettings'), - ); - } - - _disconnect(device) { - this.props.disconnectPrinter(device, () => - this.props.navigation.navigate('RestaurantSettings'), - ); - } - async _onPressScan() { if (this.props.isScanning) { return; @@ -89,65 +191,13 @@ class Printer extends Component { } } - renderItem(item) { - return ( - - item.isConnected ? this._disconnect(item) : this._connect(item) - }> - - {item.name || - (item.advertising && item.advertising.localName) || - item.id} - - - - ); - } - + //FIXME; fully migrate to a functional component render() { - const { devices } = this.state; - const { isScanning, printer } = this.props; - - let items = []; - if (printer) { - items.push({ ...printer, isConnected: true }); - } else { - items = devices.map(device => ({ ...device, isConnected: false })); - } - - const hasItems = !isScanning && items.length > 0; - - if (!hasItems) { - return ( -
- this._onPressScan()} - style={{ padding: 15, alignItems: 'center' }}> - - {this.props.t('SCAN_FOR_PRINTERS')} - {isScanning && ( - - )} - -
- ); - } - return ( - item.id} - renderItem={({ item }) => this.renderItem(item)} - ItemSeparatorComponent={ItemSeparator} + this._onPressScan()} /> ); } @@ -164,19 +214,23 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-between', }, + quantityWrapper: { + margin: 20, + paddingHorizontal: 4, + paddingVertical: 20, + flexDirection: 'row', + gap: 16, + }, }); function mapStateToProps(state) { return { isScanning: state.restaurant.isScanningBluetooth, - printer: state.restaurant.printer, }; } function mapDispatchToProps(dispatch) { return { - connectPrinter: (device, cb) => dispatch(connectPrinter(device, cb)), - disconnectPrinter: (device, cb) => dispatch(disconnectPrinter(device, cb)), bluetoothStartScan: () => dispatch(bluetoothStartScan()), }; } diff --git a/src/navigation/restaurant/components/OrderList.js b/src/navigation/restaurant/components/OrderList.js index cfb5e5643..0eb4c9108 100644 --- a/src/navigation/restaurant/components/OrderList.js +++ b/src/navigation/restaurant/components/OrderList.js @@ -1,116 +1,106 @@ -import moment from 'moment'; -import { HStack, Icon, Text } from 'native-base'; -import React, { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { SectionList, StyleSheet, TouchableOpacity, View } from 'react-native'; -import FontAwesome from 'react-native-vector-icons/FontAwesome'; -import Ionicons from 'react-native-vector-icons/Ionicons'; +import React from 'react'; +import { SectionList } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { + selectAcceptedOrders, + selectAutoAcceptOrdersEnabled, + selectCancelledOrders, + selectFulfilledOrders, + selectHasReadyState, + selectHasStartedState, + selectNewOrders, + selectPickedOrders, + selectReadyOrders, + selectStartedOrders, +} from '../../../redux/Restaurant/selectors'; +import OrderListItem from './OrderListItem'; +import OrderListSectionHeader from './OrderListSectionHeader'; +import { View } from 'native-base'; -import ItemSeparatorComponent from '../../../components/ItemSeparator'; -import OrderFulfillmentMethodIcon from '../../../components/OrderFulfillmentMethodIcon'; -import OrderNumber from '../../../components/OrderNumber'; -import { PaymentMethodInfo } from '../../../components/PaymentMethodInfo'; -import { formatPrice } from '../../../utils/formatting'; +export default function OrderList({ onItemClick }) { + const autoAcceptOrdersEnabled = useSelector(selectAutoAcceptOrdersEnabled); + const hasStartedState = useSelector(selectHasStartedState); + const hasReadyState = useSelector(selectHasReadyState); -const styles = StyleSheet.create({ - item: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 15, - paddingHorizontal: 20, - }, - sectionHeader: { - paddingVertical: 10, - paddingHorizontal: 20, - }, - number: { - marginRight: 10, - }, -}); + const newOrders = useSelector(selectNewOrders); + const acceptedOrders = useSelector(selectAcceptedOrders); + const startedOrders = useSelector(selectStartedOrders); + const readyOrders = useSelector(selectReadyOrders); + const pickedOrders = useSelector(selectPickedOrders); + const cancelledOrders = useSelector(selectCancelledOrders); + const fulfilledOrders = useSelector(selectFulfilledOrders); -class OrderList extends Component { - renderItem(order) { - return ( - this.props.onItemClick(order)}> - - - - - - - {order.notes ? ( - - ) : null} - - {`${formatPrice(order.itemsTotal)}`} - {moment.parseZone(order.pickupExpectedAt).format('LT')} - - - ); - } + const { t } = useTranslation(); - render() { - const allOrders = [ - ...this.props.newOrders, - ...this.props.acceptedOrders, - ...this.props.pickedOrders, - ...this.props.cancelledOrders, - ...this.props.fulfilledOrders, - ]; - - return ( - item['@id']} - sections={[ + const sections = [ + ...(autoAcceptOrdersEnabled + ? [] + : [ { - title: this.props.t('RESTAURANT_ORDER_LIST_NEW_ORDERS', { - count: this.props.newOrders.length, + title: t('RESTAURANT_ORDER_LIST_NEW_ORDERS', { + count: newOrders.length, }), - data: this.props.newOrders, + data: newOrders, }, + ]), + { + title: t('RESTAURANT_ORDER_LIST_ACCEPTED_ORDERS', { + count: acceptedOrders.length, + }), + data: acceptedOrders, + }, + ...(hasStartedState + ? [ { - title: this.props.t('RESTAURANT_ORDER_LIST_ACCEPTED_ORDERS', { - count: this.props.acceptedOrders.length, + title: t('RESTAURANT_ORDER_LIST_STARTED_ORDERS', { + count: startedOrders.length, }), - data: this.props.acceptedOrders, + data: startedOrders, }, + ] + : []), + ...(hasReadyState + ? [ { - title: this.props.t('RESTAURANT_ORDER_LIST_PICKED_ORDERS', { - count: this.props.pickedOrders.length, + title: t('RESTAURANT_ORDER_LIST_READY_ORDERS', { + count: readyOrders.length, }), - data: this.props.pickedOrders, + data: readyOrders, }, - { - title: this.props.t('RESTAURANT_ORDER_LIST_CANCELLED_ORDERS', { - count: this.props.cancelledOrders.length, - }), - data: this.props.cancelledOrders, - }, - { - title: this.props.t('RESTAURANT_ORDER_LIST_FULFILLED_ORDERS', { - count: this.props.fulfilledOrders.length, - }), - data: this.props.fulfilledOrders, - }, - ]} - renderSectionHeader={({ section: { title } }) => ( - - {title} - - )} - renderItem={({ item }) => this.renderItem(item)} - ItemSeparatorComponent={ItemSeparatorComponent} - /> - ); - } -} + ] + : []), + { + title: t('RESTAURANT_ORDER_LIST_PICKED_ORDERS', { + count: pickedOrders.length, + }), + data: pickedOrders, + }, + { + title: t('RESTAURANT_ORDER_LIST_CANCELLED_ORDERS', { + count: cancelledOrders.length, + }), + data: cancelledOrders, + }, + { + title: t('RESTAURANT_ORDER_LIST_FULFILLED_ORDERS', { + count: fulfilledOrders.length, + }), + data: fulfilledOrders, + }, + ]; -export default withTranslation()(OrderList); + return ( + item['@id']} + sections={sections} + renderSectionHeader={({ section: { title } }) => ( + + )} + renderItem={({ item }) => ( + + )} + renderSectionFooter={() => } + /> + ); +} diff --git a/src/navigation/restaurant/components/OrderListItem.js b/src/navigation/restaurant/components/OrderListItem.js new file mode 100644 index 000000000..3e06a3040 --- /dev/null +++ b/src/navigation/restaurant/components/OrderListItem.js @@ -0,0 +1,173 @@ +import React from 'react'; +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { Icon, Row, Text } from 'native-base'; +import { useTranslation } from 'react-i18next'; +import FontAwesome from 'react-native-vector-icons/FontAwesome'; +import moment from 'moment/moment'; +import Ionicons from 'react-native-vector-icons/Ionicons'; +import { useDispatch, useSelector } from 'react-redux'; +import OrderNumber from '../../../components/OrderNumber'; +import OrderFulfillmentMethodIcon from '../../../components/OrderFulfillmentMethodIcon'; +import { PaymentMethodInfo } from '../../../components/PaymentMethodInfo'; +import { + selectAutoAcceptOrdersEnabled, + selectIsActionable, + selectOrderIdsFailedToPrint, + selectPrintingOrderId, +} from '../../../redux/Restaurant/selectors'; +import { formatPrice } from '../../../utils/formatting'; +import { + acceptOrder, + finishPreparing, + startPreparing, +} from '../../../redux/Restaurant/actions'; +import { STATE } from '../../../domain/Order'; + +const styles = StyleSheet.create({ + item: { + flex: 1, + flexDirection: 'row', + marginVertical: 6, + marginLeft: 24, + marginRight: 24, + borderColor: '#E3E3E3', + borderWidth: 1, + borderRadius: 4, + }, + content: { + padding: 12, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + number: { + marginRight: 10, + }, + moveForward: { + backgroundColor: '#5EBE68', + paddingHorizontal: 12, + justifyContent: 'center', + }, + moveForwardIcon: { + color: 'white', + }, + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 10, + paddingHorizontal: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + printing: { + backgroundColor: '#26de81', + borderBottomColor: '#1cb568', + }, + failedToPrint: { + backgroundColor: '#f7b731', + borderBottomColor: '#eca309', + }, + statusText: { + color: 'white', + textAlign: 'center', + fontSize: 14, + fontWeight: '700', + }, +}); + +function OrderPrintStatus({ order }) { + const printingOrderId = useSelector(selectPrintingOrderId); + const orderIdsFailedToPrint = useSelector(selectOrderIdsFailedToPrint); + + const isPrinting = order['@id'] === printingOrderId; + const isFailedToPrint = orderIdsFailedToPrint.indexOf(order['@id']) !== -1; + + const { t } = useTranslation(); + + if (isPrinting) { + return ( + + {t('RESTAURANT_ORDER_PRINTING')} + + + ); + } + + if (isFailedToPrint) { + return ( + + + {t('RESTAURANT_ORDER_FAILED_TO_PRINT')} + + + ); + } + + return null; +} + +export default function OrderListItem({ order, onItemClick }) { + const autoAcceptOrdersEnabled = useSelector(selectAutoAcceptOrdersEnabled); + const isActionable = useSelector(state => selectIsActionable(state, order)); + + const dispatch = useDispatch(); + + return ( + + onItemClick(order)}> + + + + + + + + {order.notes ? ( + + ) : null} + + {`${formatPrice(order.itemsTotal)}`} + {moment.parseZone(order.pickupExpectedAt).format('LT')} + + {autoAcceptOrdersEnabled && order.state === STATE.ACCEPTED ? ( + + ) : null} + + {isActionable ? ( + { + switch (order.state) { + case STATE.NEW: + dispatch(acceptOrder(order)); + break; + case STATE.ACCEPTED: + dispatch(startPreparing(order)); + break; + case STATE.STARTED: + dispatch(finishPreparing(order)); + break; + default: + break; + } + }}> + + + ) : null} + + ); +} diff --git a/src/navigation/restaurant/components/OrderListSectionHeader.js b/src/navigation/restaurant/components/OrderListSectionHeader.js new file mode 100644 index 000000000..22a4d185f --- /dev/null +++ b/src/navigation/restaurant/components/OrderListSectionHeader.js @@ -0,0 +1,19 @@ +import { Text } from 'native-base'; +import { View } from 'react-native'; +import React from 'react'; +import { useBackgroundColor } from '../../../styles/theme'; + +export default function OrderListSectionHeader({ title }) { + const backgroundColor = useBackgroundColor(); + + return ( + + {title} + + ); +} diff --git a/src/navigation/restaurant/components/OrdersToPrintQueue.js b/src/navigation/restaurant/components/OrdersToPrintQueue.js new file mode 100644 index 000000000..b5cebaa3e --- /dev/null +++ b/src/navigation/restaurant/components/OrdersToPrintQueue.js @@ -0,0 +1,92 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; +import { Text } from 'native-base'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectAutoAcceptOrdersEnabled, + selectIsPrinterConnected, + selectOrderIdsToPrint, + selectPrintingOrderId, +} from '../../../redux/Restaurant/selectors'; +import { printOrderById } from '../../../redux/Restaurant/actions'; +import { useNavigation } from '@react-navigation/native'; + +function usePrinter() { + const connected = useSelector(selectIsPrinterConnected); + + const orderIdsToPrint = useSelector(selectOrderIdsToPrint); + const printingOrderId = useSelector(selectPrintingOrderId); + + const dispatch = useDispatch(); + + useEffect(() => { + if (printingOrderId) { + return; + } + + if (orderIdsToPrint.length === 0) { + return; + } + + if (!connected) { + console.warn('Printer is not connected'); + return; + } + + const orderId = orderIdsToPrint[0]; + dispatch(printOrderById(orderId)); + }, [printingOrderId, orderIdsToPrint, connected, dispatch]); + + return { + printingOrderId, + printerConnected: connected, + }; +} + +export default function OrdersToPrintQueue() { + const autoAcceptOrdersEnabled = useSelector(selectAutoAcceptOrdersEnabled); + + const { printerConnected } = usePrinter(); + + const { t } = useTranslation(); + + const navigation = useNavigation(); + + if (autoAcceptOrdersEnabled && !printerConnected) { + return ( + { + navigation.navigate('RestaurantSettings', { + screen: 'RestaurantPrinter', + }); + }}> + {t('RESTAURANT_ORDER_CONNECT_PRINTER')} + + ); + } else { + return null; + } +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 10, + paddingHorizontal: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + disconnected: { + backgroundColor: '#f7b731', + borderBottomColor: '#eca309', + }, + text: { + color: 'white', + textAlign: 'center', + fontSize: 14, + fontWeight: '700', + }, +}); diff --git a/src/notifications/index.android.js b/src/notifications/index.android.js index 31afcdce0..5f8d353c5 100644 --- a/src/notifications/index.android.js +++ b/src/notifications/index.android.js @@ -67,7 +67,6 @@ class PushNotification { messaging() .getToken() .then(fcmToken => { - console.log(fcmToken); options.onRegister(fcmToken); }); } diff --git a/src/redux/App/actions.js b/src/redux/App/actions.js index 4d03c52be..d5de38db9 100644 --- a/src/redux/App/actions.js +++ b/src/redux/App/actions.js @@ -1,5 +1,6 @@ +import { createAction } from '@reduxjs/toolkit'; +import { createAction as createFsAction } from 'redux-actions'; import { CommonActions } from '@react-navigation/native'; -import { createAction } from 'redux-actions'; import analyticsEvent from '../../analytics/Event'; import tracker from '../../analytics/Tracker'; import userProperty from '../../analytics/UserProperty'; @@ -43,8 +44,12 @@ export const DELETE_PUSH_NOTIFICATION_TOKEN_SUCCESS = export const LOGIN = '@app/LOGIN'; export const SET_LOADING = '@app/SET_LOADING'; -export const PUSH_NOTIFICATION = '@app/PUSH_NOTIFICATION'; + +export const FOREGROUND_PUSH_NOTIFICATION = '@app/FOREGROUND_PUSH_NOTIFICATION'; + +export const ADD_NOTIFICATION = '@app/ADD_NOTIFICATION'; export const CLEAR_NOTIFICATIONS = '@app/CLEAR_NOTIFICATIONS'; + export const AUTHENTICATION_REQUEST = '@app/AUTHENTICATION_REQUEST'; export const AUTHENTICATION_SUCCESS = '@app/AUTHENTICATION_SUCCESS'; export const AUTHENTICATION_FAILURE = '@app/AUTHENTICATION_FAILURE'; @@ -109,100 +114,109 @@ export const SET_INCIDENT_ENABLED = '@app/SET_IS_INCIDENT_ENABLED'; * Action Creators */ -export const setLoading = createAction(SET_LOADING); -export const pushNotification = createAction( - PUSH_NOTIFICATION, +export const setLoading = createFsAction(SET_LOADING); + +export const foregroundPushNotification = createFsAction( + FOREGROUND_PUSH_NOTIFICATION, (event, params = {}) => ({ event, params }), ); -export const clearNotifications = createAction(CLEAR_NOTIFICATIONS); -export const _authenticationRequest = createAction(AUTHENTICATION_REQUEST); -export const _authenticationSuccess = createAction(AUTHENTICATION_SUCCESS); -const _authenticationFailure = createAction(AUTHENTICATION_FAILURE); +export const addNotification = createFsAction( + ADD_NOTIFICATION, + (event, params = {}) => ({ event, params }), +); +export const clearNotifications = createFsAction(CLEAR_NOTIFICATIONS); -export const clearAuthenticationErrors = createAction( +export const _authenticationRequest = createFsAction(AUTHENTICATION_REQUEST); +export const _authenticationSuccess = createFsAction(AUTHENTICATION_SUCCESS); +const _authenticationFailure = createFsAction(AUTHENTICATION_FAILURE); + +export const clearAuthenticationErrors = createFsAction( CLEAR_AUTHENTICATION_ERRORS, ); -const resetPasswordInit = createAction(RESET_PASSWORD_INIT); -const resetPasswordRequest = createAction(RESET_PASSWORD_REQUEST); -const resetPasswordRequestSuccess = createAction( +const resetPasswordInit = createFsAction(RESET_PASSWORD_INIT); +const resetPasswordRequest = createFsAction(RESET_PASSWORD_REQUEST); +const resetPasswordRequestSuccess = createFsAction( RESET_PASSWORD_REQUEST_SUCCESS, ); -const resetPasswordRequestFailure = createAction( +const resetPasswordRequestFailure = createFsAction( RESET_PASSWORD_REQUEST_FAILURE, ); -export const logoutRequest = createAction(LOGOUT_REQUEST); -export const _logoutSuccess = createAction(LOGOUT_SUCCESS); -export const setServers = createAction(SET_SERVERS); +export const logoutRequest = createFsAction(LOGOUT_REQUEST); +export const _logoutSuccess = createFsAction(LOGOUT_SUCCESS); +export const setServers = createFsAction(SET_SERVERS); -const setUser = createAction(SET_USER); -const _setBaseURL = createAction(SET_BASE_URL); -const _setCurrentRoute = createAction(SET_CURRENT_ROUTE); -const _setSelectServerError = createAction(SET_SELECT_SERVER_ERROR); -const _clearSelectServerError = createAction(CLEAR_SELECT_SERVER_ERROR); +const setUser = createFsAction(SET_USER); +const _setBaseURL = createFsAction(SET_BASE_URL); +const _setCurrentRoute = createFsAction(SET_CURRENT_ROUTE); +const _setSelectServerError = createFsAction(SET_SELECT_SERVER_ERROR); +const _clearSelectServerError = createFsAction(CLEAR_SELECT_SERVER_ERROR); -const _resumeCheckoutAfterActivation = createAction( +const _resumeCheckoutAfterActivation = createFsAction( RESUME_CHECKOUT_AFTER_ACTIVATION, ); -export const registerPushNotificationToken = createAction( +export const registerPushNotificationToken = createFsAction( REGISTER_PUSH_NOTIFICATION_TOKEN, ); -export const savePushNotificationTokenSuccess = createAction( +export const savePushNotificationTokenSuccess = createFsAction( SAVE_PUSH_NOTIFICATION_TOKEN_SUCCESS, ); -export const deletePushNotificationTokenSuccess = createAction( +export const deletePushNotificationTokenSuccess = createFsAction( DELETE_PUSH_NOTIFICATION_TOKEN_SUCCESS, ); -const _loadMyStoresSuccess = createAction(LOAD_MY_STORES_SUCCESS); +const _loadMyStoresSuccess = createFsAction(LOAD_MY_STORES_SUCCESS); -const loadMyRestaurantsRequest = createAction(LOAD_MY_RESTAURANTS_REQUEST); -const loadMyRestaurantsSuccess = createAction(LOAD_MY_RESTAURANTS_SUCCESS); -const loadMyRestaurantsFailure = createAction(LOAD_MY_RESTAURANTS_FAILURE); +const loadMyRestaurantsRequest = createFsAction(LOAD_MY_RESTAURANTS_REQUEST); +const loadMyRestaurantsSuccess = createFsAction(LOAD_MY_RESTAURANTS_SUCCESS); +const loadMyRestaurantsFailure = createFsAction(LOAD_MY_RESTAURANTS_FAILURE); -const setSettings = createAction(SET_SETTINGS); +const setSettings = createFsAction(SET_SETTINGS); -export const setInternetReachable = createAction(SET_INTERNET_REACHABLE); +export const setInternetReachable = createFsAction(SET_INTERNET_REACHABLE); -export const setBackgroundGeolocationEnabled = createAction( +export const setBackgroundGeolocationEnabled = createFsAction( SET_BACKGROUND_GEOLOCATION_ENABLED, ); -export const backgroundPermissionDisclosed = createAction( +export const backgroundPermissionDisclosed = createFsAction( BACKGROUND_PERMISSION_DISCLOSED, ); -export const setModal = createAction(SET_MODAL); -export const resetModal = createAction(RESET_MODAL); -export const closeModal = createAction(CLOSE_MODAL); -export const onboarded = createAction(ONBOARDED); +export const setModal = createFsAction(SET_MODAL); +export const resetModal = createFsAction(RESET_MODAL); +export const closeModal = createFsAction(CLOSE_MODAL); +export const onboarded = createFsAction(ONBOARDED); -export const acceptTermsAndConditions = createAction( +export const acceptTermsAndConditions = createFsAction( ACCEPT_TERMS_AND_CONDITIONS, ); -export const acceptPrivacyPolicy = createAction(ACCEPT_PRIVACY_POLICY); +export const acceptPrivacyPolicy = createFsAction(ACCEPT_PRIVACY_POLICY); -const loadTermsAndConditionsRequest = createAction( +const loadTermsAndConditionsRequest = createFsAction( LOAD_TERMS_AND_CONDITIONS_REQUEST, ); -const loadTermsAndConditionsSuccess = createAction( +const loadTermsAndConditionsSuccess = createFsAction( LOAD_TERMS_AND_CONDITIONS_SUCCESS, ); -const loadTermsAndConditionsFailure = createAction( +const loadTermsAndConditionsFailure = createFsAction( LOAD_TERMS_AND_CONDITIONS_FAILURE, ); -const loadPrivacyPolicyRequest = createAction(LOAD_PRIVACY_POLICY_REQUEST); -const loadPrivacyPolicySuccess = createAction(LOAD_PRIVACY_POLICY_SUCCESS); -const loadPrivacyPolicyFailure = createAction(LOAD_PRIVACY_POLICY_FAILURE); +const loadPrivacyPolicyRequest = createFsAction(LOAD_PRIVACY_POLICY_REQUEST); +const loadPrivacyPolicySuccess = createFsAction(LOAD_PRIVACY_POLICY_SUCCESS); +const loadPrivacyPolicyFailure = createFsAction(LOAD_PRIVACY_POLICY_FAILURE); + +const registrationErrors = createFsAction(REGISTRATION_ERRORS); +const loginByEmailErrors = createFsAction(LOGIN_BY_EMAIL_ERRORS); -const registrationErrors = createAction(REGISTRATION_ERRORS); -const loginByEmailErrors = createAction(LOGIN_BY_EMAIL_ERRORS); +export const setSpinnerDelayEnabled = createFsAction(SET_SPINNER_DELAY_ENABLED); +export const setIncidentEnabled = createFsAction(SET_INCIDENT_ENABLED); -export const setSpinnerDelayEnabled = createAction(SET_SPINNER_DELAY_ENABLED); -export const setIncidentEnabled = createAction(SET_INCIDENT_ENABLED); +export const startSound = createAction('START_SOUND'); +export const stopSound = createAction('STOP_SOUND'); function setBaseURL(baseURL) { return (dispatch, getState) => { diff --git a/src/redux/App/reducers.js b/src/redux/App/reducers.js index 39a4ea6f1..d4044450a 100644 --- a/src/redux/App/reducers.js +++ b/src/redux/App/reducers.js @@ -2,11 +2,19 @@ /* * App reducer, dealing with non-domain specific state */ + import Config from 'react-native-config'; -import { CONNECTED, DISCONNECTED } from '../middlewares/CentrifugoMiddleware'; + +import { + CENTRIFUGO_MESSAGE, + CONNECTED, + DISCONNECTED, +} from '../middlewares/CentrifugoMiddleware'; + import { ACCEPT_PRIVACY_POLICY, ACCEPT_TERMS_AND_CONDITIONS, + ADD_NOTIFICATION, AUTHENTICATION_FAILURE, AUTHENTICATION_REQUEST, AUTHENTICATION_SUCCESS, @@ -16,6 +24,7 @@ import { CLEAR_SELECT_SERVER_ERROR, CLOSE_MODAL, DELETE_PUSH_NOTIFICATION_TOKEN_SUCCESS, + FOREGROUND_PUSH_NOTIFICATION, LOAD_PRIVACY_POLICY_FAILURE, LOAD_PRIVACY_POLICY_REQUEST, LOAD_PRIVACY_POLICY_SUCCESS, @@ -25,7 +34,6 @@ import { LOGIN_BY_EMAIL_ERRORS, LOGOUT_SUCCESS, ONBOARDED, - PUSH_NOTIFICATION, REGISTER_PUSH_NOTIFICATION_TOKEN, REGISTRATION_ERRORS, RESET_MODAL, @@ -50,6 +58,9 @@ import { SET_USER, } from './actions'; +import { EVENT as EVENT_ORDER } from '../../domain/Order'; +import { EVENT as EVENT_TASK_COLLECTION } from '../../domain/TaskCollection'; + const initialState = { customBuild: !!Config.DEFAULT_SERVER, firstRun: true, @@ -102,6 +113,37 @@ const initialState = { isIncidentEnabled: false, }; +function updateNotifications(state, event, params) { + // we use multiple channels to receive notifications (centrifugo; push notifications), + // so the notification might be already in the store + const isAlreadyExist = + state.notifications.findIndex(notification => { + const isSameEvent = notification.event === event; + + if (isSameEvent) { + switch (notification.event) { + case EVENT_ORDER.CREATED: + return notification.params.order.id === params.order.id; + case EVENT_TASK_COLLECTION.CHANGED: + return notification.params.date === params.date; + default: + return false; + } + } else { + return false; + } + }) !== -1; + + if (isAlreadyExist) { + return state; + } else { + return { + ...state, + notifications: state.notifications.concat([{ event, params }]), + }; + } +} + export default (state = initialState, action = {}) => { switch (action.type) { case SET_BASE_URL: @@ -142,11 +184,17 @@ export default (state = initialState, action = {}) => { isCentrifugoConnected: false, }; - case PUSH_NOTIFICATION: - return { - ...state, - notifications: state.notifications.concat([action.payload]), - }; + case FOREGROUND_PUSH_NOTIFICATION: { + const { event, params } = action.payload; + + return updateNotifications(state, event, params); + } + + case ADD_NOTIFICATION: { + const { event, params } = action.payload; + + return updateNotifications(state, event, params); + } case CLEAR_NOTIFICATIONS: return { @@ -264,12 +312,19 @@ export default (state = initialState, action = {}) => { isInternetReachable: action.payload, }; - case REGISTER_PUSH_NOTIFICATION_TOKEN: - return { - ...state, - pushNotificationToken: action.payload, - pushNotificationTokenSaved: false, - }; + case REGISTER_PUSH_NOTIFICATION_TOKEN: { + const existingToken = state.pushNotificationToken; + + if (existingToken === action.payload) { + return state; + } else { + return { + ...state, + pushNotificationToken: action.payload, + pushNotificationTokenSaved: false, + }; + } + } case SAVE_PUSH_NOTIFICATION_TOKEN_SUCCESS: return { diff --git a/src/redux/App/selectors.js b/src/redux/App/selectors.js index 7f6e069c9..022d12f43 100644 --- a/src/redux/App/selectors.js +++ b/src/redux/App/selectors.js @@ -1,8 +1,14 @@ import _ from 'lodash'; import { createSelector } from 'reselect'; -import { selectIsTasksLoading } from '../Courier/taskSelectors'; +import { + selectIsTasksLoading, + selectTasksChangedAlertSound, +} from '../Courier/taskSelectors'; import { selectIsDispatchFetching } from '../Dispatch/selectors'; +import { EVENT as EVENT_ORDER } from '../../domain/Order'; +import { EVENT as EVENT_TASK_COLLECTION } from '../../domain/TaskCollection'; +import { selectAutoAcceptOrdersEnabled } from '../Restaurant/selectors'; export const selectUser = state => state.app.user; export const selectHttpClient = state => state.app.httpClient; @@ -102,9 +108,11 @@ export const selectServersWithURL = createSelector( }, ); +export const selectBaseURL = state => state.app.baseURL; + export const selectServersInSameCity = createSelector( selectServersWithURL, - state => state.app.baseURL, + selectBaseURL, (servers, baseURL) => { if (!baseURL) { return []; @@ -148,3 +156,39 @@ export const selectIsSpinnerDelayEnabled = state => export const selectIsIncidentEnabled = state => state.app.isIncidentEnabled ?? false; + +export const selectCurrentRoute = state => state.app.currentRoute; + +export const selectNotifications = state => state.app.notifications; + +export const selectNotificationsWithSound = createSelector( + selectNotifications, + selectTasksChangedAlertSound, + (notifications, tasksChangedAlertSound) => + notifications.filter(notification => { + switch (notification.event) { + case EVENT_ORDER.CREATED: + return true; + case EVENT_TASK_COLLECTION.CHANGED: + return tasksChangedAlertSound; + default: + return false; + } + }), +); + +export const selectNotificationsToDisplay = createSelector( + selectNotifications, + selectAutoAcceptOrdersEnabled, + (notifications, autoAcceptOrdersEnabled) => + notifications.filter(notification => { + switch (notification.event) { + case EVENT_ORDER.CREATED: + return !autoAcceptOrdersEnabled; + case EVENT_TASK_COLLECTION.CHANGED: + return true; + default: + return true; + } + }), +); diff --git a/src/redux/Courier/taskActions.js b/src/redux/Courier/taskActions.js index c12f9ae82..e6e45d1f2 100644 --- a/src/redux/Courier/taskActions.js +++ b/src/redux/Courier/taskActions.js @@ -9,7 +9,7 @@ import analyticsEvent from '../../analytics/Event'; import tracker from '../../analytics/Tracker'; import i18n from '../../i18n'; import { selectPictures, selectSignatures } from './taskSelectors'; -import { selectHttpClient } from '../App/selectors'; +import { selectCurrentRoute, selectHttpClient } from '../App/selectors'; /* * Action Types @@ -162,7 +162,20 @@ function showAlertAfterBulk(messages) { * Thunk Creators */ -export function loadTasks(selectedDate, refresh = false) { +export function navigateAndLoadTasks(selectedDate) { + return function (dispatch, getState) { + const currentRoute = selectCurrentRoute(getState()); + if (currentRoute !== 'CourierTaskList') { + NavigationHolder.navigate('CourierTaskList', {}); + } + + NavigationHolder.navigate('Tasks', { selectedDate }); + + return dispatch(loadTasks(selectedDate)); + }; +} + +export function loadTasks(selectedDate, refresh = false, cb) { return function (dispatch, getState) { const { httpClient } = getState().app; @@ -192,8 +205,17 @@ export function loadTasks(selectedDate, refresh = false) { ), ); } + + if (cb && typeof cb === 'function') { + setTimeout(() => cb(), 0); + } }) - .catch(e => dispatch(loadTasksFailure(e))); + .catch(e => { + dispatch(loadTasksFailure(e)); + if (cb && typeof cb === 'function') { + setTimeout(() => cb(), 0); + } + }); }; } diff --git a/src/redux/Courier/taskEntityReducer.js b/src/redux/Courier/taskEntityReducer.js index 7433cb59d..c6057401a 100644 --- a/src/redux/Courier/taskEntityReducer.js +++ b/src/redux/Courier/taskEntityReducer.js @@ -6,7 +6,7 @@ import { BULK_ASSIGNMENT_TASKS_SUCCESS, UNASSIGN_TASK_SUCCESS, } from '../Dispatch/actions'; -import { MESSAGE } from '../middlewares/CentrifugoMiddleware'; +import { CENTRIFUGO_MESSAGE } from '../middlewares/CentrifugoMiddleware'; import { ADD_PICTURE, ADD_SIGNATURE, @@ -239,7 +239,7 @@ export const tasksEntityReducer = ( } return state; - case MESSAGE: + case CENTRIFUGO_MESSAGE: return processWsMsg(state, action); case ADD_SIGNATURE: diff --git a/src/redux/Courier/taskMiddlewares.js b/src/redux/Courier/taskMiddlewares.js index 331a2b2ad..5ae1f2e87 100644 --- a/src/redux/Courier/taskMiddlewares.js +++ b/src/redux/Courier/taskMiddlewares.js @@ -1,9 +1,10 @@ import _ from 'lodash'; import { AppState } from 'react-native'; -import { LOGOUT_SUCCESS, pushNotification } from '../App/actions'; +import { LOGOUT_SUCCESS, addNotification } from '../App/actions'; import { LOAD_TASKS_SUCCESS } from './taskActions'; import { selectTasks } from './taskSelectors'; +import { EVENT as EVENT_TASK_COLLECTION } from '../../domain/TaskCollection'; export const ringOnTaskListUpdated = ({ getState, dispatch }) => { return next => action => { @@ -54,8 +55,8 @@ export const ringOnTaskListUpdated = ({ getState, dispatch }) => { if (addedTasks.length > 0 || removedTasks.length > 0) { dispatch( - pushNotification('tasks:changed', { - date: state.date, + addNotification(EVENT_TASK_COLLECTION.CHANGED, { + date: date, added: addedTasks, removed: removedTasks, }), diff --git a/src/redux/Restaurant/__tests__/middlewares.test.js b/src/redux/Restaurant/__tests__/middlewares.test.js index adc4902dd..e6d7df7b1 100644 --- a/src/redux/Restaurant/__tests__/middlewares.test.js +++ b/src/redux/Restaurant/__tests__/middlewares.test.js @@ -4,10 +4,10 @@ import AppUser from '../../../AppUser'; import appReducer from '../../App/reducers'; import { message as wsMessage } from '../../middlewares/CentrifugoMiddleware/actions'; import { loadOrderSuccess, loadOrdersSuccess } from '../actions'; -import { ringOnNewOrderCreated } from '../middlewares'; +import { notifyOnNewOrderCreated } from '../middlewares'; import restaurantReducer from '../reducers'; -describe('ringOnNewOrderCreated', () => { +describe('notifyOnNewOrderCreated', () => { beforeEach(() => { jest.mock('react-native/Libraries/AppState/AppState', () => ({ currentState: 'active', @@ -32,7 +32,7 @@ describe('ringOnNewOrderCreated', () => { const store = createStore( reducer, preloadedState, - applyMiddleware(ringOnNewOrderCreated), + applyMiddleware(notifyOnNewOrderCreated), ); store.dispatch( @@ -76,7 +76,7 @@ describe('ringOnNewOrderCreated', () => { const store = createStore( reducer, preloadedState, - applyMiddleware(ringOnNewOrderCreated), + applyMiddleware(notifyOnNewOrderCreated), ); store.dispatch(loadOrderSuccess({ '@id': '/api/orders/1', state: 'new' })); @@ -125,7 +125,7 @@ describe('ringOnNewOrderCreated', () => { const store = createStore( reducer, preloadedState, - applyMiddleware(thunk, ringOnNewOrderCreated), + applyMiddleware(thunk, notifyOnNewOrderCreated), ); store.dispatch( @@ -172,7 +172,7 @@ describe('ringOnNewOrderCreated', () => { const store = createStore( reducer, preloadedState, - applyMiddleware(ringOnNewOrderCreated), + applyMiddleware(notifyOnNewOrderCreated), ); store.dispatch(loadOrderSuccess({ '@id': '/api/orders/1', state: 'new' })); diff --git a/src/redux/Restaurant/actions.js b/src/redux/Restaurant/actions.js index 85467b347..25c9b7d03 100644 --- a/src/redux/Restaurant/actions.js +++ b/src/redux/Restaurant/actions.js @@ -2,14 +2,14 @@ import { CommonActions } from '@react-navigation/native'; import { Buffer } from 'buffer'; import _ from 'lodash'; import BleManager from 'react-native-ble-manager'; -import { createAction } from 'redux-actions'; +import { createAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { createAction as createFsAction } from 'redux-actions'; import DropdownHolder from '../../DropdownHolder'; import NavigationHolder from '../../NavigationHolder'; import * as SunmiPrinterLibrary from '@mitsuharu/react-native-sunmi-printer-library'; import { encodeForPrinter } from '../../utils/order'; -import { pushNotification } from '../App/actions'; import i18n from '../../i18n'; @@ -19,6 +19,9 @@ import { LOAD_MY_RESTAURANTS_SUCCESS, } from '../App/actions'; +import { selectHttpClient } from '../App/selectors'; +import { selectOrderById } from './selectors'; + /* * Action Types */ @@ -114,86 +117,88 @@ export const UPDATE_LOOPEAT_FORMATS_SUCCESS = * Action Creators */ -const loadMyRestaurantsRequest = createAction(LOAD_MY_RESTAURANTS_REQUEST); -const loadMyRestaurantsSuccess = createAction(LOAD_MY_RESTAURANTS_SUCCESS); -const loadMyRestaurantsFailure = createAction(LOAD_MY_RESTAURANTS_FAILURE); +const loadMyRestaurantsRequest = createFsAction(LOAD_MY_RESTAURANTS_REQUEST); +const loadMyRestaurantsSuccess = createFsAction(LOAD_MY_RESTAURANTS_SUCCESS); +const loadMyRestaurantsFailure = createFsAction(LOAD_MY_RESTAURANTS_FAILURE); -export const loadOrdersRequest = createAction(LOAD_ORDERS_REQUEST); -export const loadOrdersSuccess = createAction(LOAD_ORDERS_SUCCESS); -export const loadOrdersFailure = createAction(LOAD_ORDERS_FAILURE); +export const loadOrdersRequest = createFsAction(LOAD_ORDERS_REQUEST); +export const loadOrdersSuccess = createFsAction(LOAD_ORDERS_SUCCESS); +export const loadOrdersFailure = createFsAction(LOAD_ORDERS_FAILURE); -export const loadOrderRequest = createAction(LOAD_ORDER_REQUEST); -export const loadOrderSuccess = createAction(LOAD_ORDER_SUCCESS); -export const loadOrderFailure = createAction(LOAD_ORDER_FAILURE); +export const loadOrderRequest = createFsAction(LOAD_ORDER_REQUEST); +export const loadOrderSuccess = createFsAction(LOAD_ORDER_SUCCESS); +export const loadOrderFailure = createFsAction(LOAD_ORDER_FAILURE); -export const loadMenusRequest = createAction(LOAD_MENUS_REQUEST); -export const loadMenusSuccess = createAction(LOAD_MENUS_SUCCESS); -export const loadMenusFailure = createAction(LOAD_MENUS_FAILURE); -export const setCurrentMenu = createAction( +export const loadMenusRequest = createFsAction(LOAD_MENUS_REQUEST); +export const loadMenusSuccess = createFsAction(LOAD_MENUS_SUCCESS); +export const loadMenusFailure = createFsAction(LOAD_MENUS_FAILURE); +export const setCurrentMenu = createFsAction( SET_CURRENT_MENU, (restaurant, menu) => ({ restaurant, menu }), ); -export const acceptOrderRequest = createAction(ACCEPT_ORDER_REQUEST); -export const acceptOrderSuccess = createAction(ACCEPT_ORDER_SUCCESS); -export const acceptOrderFailure = createAction(ACCEPT_ORDER_FAILURE); +export const acceptOrderRequest = createFsAction(ACCEPT_ORDER_REQUEST); +export const acceptOrderSuccess = createFsAction(ACCEPT_ORDER_SUCCESS); +export const acceptOrderFailure = createFsAction(ACCEPT_ORDER_FAILURE); -export const refuseOrderRequest = createAction(REFUSE_ORDER_REQUEST); -export const refuseOrderSuccess = createAction(REFUSE_ORDER_SUCCESS); -export const refuseOrderFailure = createAction(REFUSE_ORDER_FAILURE); +export const refuseOrderRequest = createFsAction(REFUSE_ORDER_REQUEST); +export const refuseOrderSuccess = createFsAction(REFUSE_ORDER_SUCCESS); +export const refuseOrderFailure = createFsAction(REFUSE_ORDER_FAILURE); -export const delayOrderRequest = createAction(DELAY_ORDER_REQUEST); -export const delayOrderSuccess = createAction(DELAY_ORDER_SUCCESS); -export const delayOrderFailure = createAction(DELAY_ORDER_FAILURE); +export const delayOrderRequest = createFsAction(DELAY_ORDER_REQUEST); +export const delayOrderSuccess = createFsAction(DELAY_ORDER_SUCCESS); +export const delayOrderFailure = createFsAction(DELAY_ORDER_FAILURE); -export const fulfillOrderRequest = createAction(FULFILL_ORDER_REQUEST); -export const fulfillOrderSuccess = createAction(FULFILL_ORDER_SUCCESS); -export const fulfillOrderFailure = createAction(FULFILL_ORDER_FAILURE); +export const fulfillOrderRequest = createFsAction(FULFILL_ORDER_REQUEST); +export const fulfillOrderSuccess = createFsAction(FULFILL_ORDER_SUCCESS); +export const fulfillOrderFailure = createFsAction(FULFILL_ORDER_FAILURE); -export const cancelOrderRequest = createAction(CANCEL_ORDER_REQUEST); -export const cancelOrderSuccess = createAction(CANCEL_ORDER_SUCCESS); -export const cancelOrderFailure = createAction(CANCEL_ORDER_FAILURE); +export const cancelOrderRequest = createFsAction(CANCEL_ORDER_REQUEST); +export const cancelOrderSuccess = createFsAction(CANCEL_ORDER_SUCCESS); +export const cancelOrderFailure = createFsAction(CANCEL_ORDER_FAILURE); -export const changeStatusRequest = createAction(CHANGE_STATUS_REQUEST); -export const changeStatusSuccess = createAction(CHANGE_STATUS_SUCCESS); -export const changeStatusFailure = createAction(CHANGE_STATUS_FAILURE); +export const changeStatusRequest = createFsAction(CHANGE_STATUS_REQUEST); +export const changeStatusSuccess = createFsAction(CHANGE_STATUS_SUCCESS); +export const changeStatusFailure = createFsAction(CHANGE_STATUS_FAILURE); -export const changeRestaurant = createAction(CHANGE_RESTAURANT); -export const changeDate = createAction(CHANGE_DATE); +export const changeRestaurant = createFsAction(CHANGE_RESTAURANT); +export const changeDate = createFsAction(CHANGE_DATE); -export const loadProductsRequest = createAction(LOAD_PRODUCTS_REQUEST); -export const loadProductsSuccess = createAction(LOAD_PRODUCTS_SUCCESS); -export const loadProductsFailure = createAction(LOAD_PRODUCTS_FAILURE); +export const loadProductsRequest = createFsAction(LOAD_PRODUCTS_REQUEST); +export const loadProductsSuccess = createFsAction(LOAD_PRODUCTS_SUCCESS); +export const loadProductsFailure = createFsAction(LOAD_PRODUCTS_FAILURE); -export const loadProductOptionsSuccess = createAction( +export const loadProductOptionsSuccess = createFsAction( LOAD_PRODUCT_OPTIONS_SUCCESS, ); -export const setNextProductsPage = createAction(SET_NEXT_PRODUCTS_PAGE); -export const loadMoreProductsSuccess = createAction(LOAD_MORE_PRODUCTS_SUCCESS); -export const setHasMoreProducts = createAction(SET_HAS_MORE_PRODUCTS); +export const setNextProductsPage = createFsAction(SET_NEXT_PRODUCTS_PAGE); +export const loadMoreProductsSuccess = createFsAction( + LOAD_MORE_PRODUCTS_SUCCESS, +); +export const setHasMoreProducts = createFsAction(SET_HAS_MORE_PRODUCTS); -export const changeProductEnabledRequest = createAction( +export const changeProductEnabledRequest = createFsAction( CHANGE_PRODUCT_ENABLED_REQUEST, (product, enabled) => ({ product, enabled }), ); -export const changeProductEnabledSuccess = createAction( +export const changeProductEnabledSuccess = createFsAction( CHANGE_PRODUCT_ENABLED_SUCCESS, ); -export const changeProductEnabledFailure = createAction( +export const changeProductEnabledFailure = createFsAction( CHANGE_PRODUCT_ENABLED_FAILURE, (error, product, enabled) => ({ error, product, enabled }), ); -export const changeProductOptionValueEnabledRequest = createAction( +export const changeProductOptionValueEnabledRequest = createFsAction( CHANGE_PRODUCT_OPTION_VALUE_ENABLED_REQUEST, (productOptionValue, enabled) => ({ productOptionValue, enabled }), ); -export const changeProductOptionValueEnabledSuccess = createAction( +export const changeProductOptionValueEnabledSuccess = createFsAction( CHANGE_PRODUCT_OPTION_VALUE_ENABLED_SUCCESS, (productOptionValue, enabled) => ({ productOptionValue, enabled }), ); -export const changeProductOptionValueEnabledFailure = createAction( +export const changeProductOptionValueEnabledFailure = createFsAction( CHANGE_PRODUCT_OPTION_VALUE_ENABLED_FAILURE, (error, productOptionValue, enabled) => ({ error, @@ -202,39 +207,47 @@ export const changeProductOptionValueEnabledFailure = createAction( }), ); -export const closeRestaurantRequest = createAction(CLOSE_RESTAURANT_REQUEST); -export const closeRestaurantSuccess = createAction(CLOSE_RESTAURANT_SUCCESS); -export const closeRestaurantFailure = createAction(CLOSE_RESTAURANT_FAILURE); +export const closeRestaurantRequest = createFsAction(CLOSE_RESTAURANT_REQUEST); +export const closeRestaurantSuccess = createFsAction(CLOSE_RESTAURANT_SUCCESS); +export const closeRestaurantFailure = createFsAction(CLOSE_RESTAURANT_FAILURE); -export const deleteOpeningHoursSpecificationRequest = createAction( +export const deleteOpeningHoursSpecificationRequest = createFsAction( DELETE_OPENING_HOURS_SPECIFICATION_REQUEST, ); -export const deleteOpeningHoursSpecificationSuccess = createAction( +export const deleteOpeningHoursSpecificationSuccess = createFsAction( DELETE_OPENING_HOURS_SPECIFICATION_SUCCESS, ); -export const deleteOpeningHoursSpecificationFailure = createAction( +export const deleteOpeningHoursSpecificationFailure = createFsAction( DELETE_OPENING_HOURS_SPECIFICATION_FAILURE, ); -export const printerConnected = createAction(PRINTER_CONNECTED); -export const printerDisconnected = createAction(PRINTER_DISCONNECTED); +export const printerConnected = createFsAction(PRINTER_CONNECTED); +export const printerDisconnected = createFsAction(PRINTER_DISCONNECTED); -export const bluetoothEnabled = createAction(BLUETOOTH_ENABLED); -export const bluetoothDisabled = createAction(BLUETOOTH_DISABLED); -const _bluetoothStartScan = createAction(BLUETOOTH_START_SCAN); -export const bluetoothStopScan = createAction(BLUETOOTH_STOP_SCAN); -export const bluetoothStarted = createAction(BLUETOOTH_STARTED); +export const bluetoothEnabled = createFsAction(BLUETOOTH_ENABLED); +export const bluetoothDisabled = createFsAction(BLUETOOTH_DISABLED); +const _bluetoothStartScan = createFsAction(BLUETOOTH_START_SCAN); +export const bluetoothStopScan = createFsAction(BLUETOOTH_STOP_SCAN); +export const bluetoothStarted = createFsAction(BLUETOOTH_STARTED); -export const sunmiPrinterDetected = createAction(SUNMI_PRINTER_DETECTED); +export const sunmiPrinterDetected = createFsAction(SUNMI_PRINTER_DETECTED); -export const setLoopeatFormats = createAction( +export const setLoopeatFormats = createFsAction( SET_LOOPEAT_FORMATS, (order, loopeatFormats) => ({ order, loopeatFormats }), ); -export const updateLoopeatFormatsSuccess = createAction( +export const updateLoopeatFormatsSuccess = createFsAction( UPDATE_LOOPEAT_FORMATS_SUCCESS, ); +export const printPending = createAction('PRINT_PENDING'); +export const printFulfilled = createAction('PRINT_FULFILLED'); +export const printRejected = createAction('PRINT_REJECTED'); + +export const setPrintNumberOfCopies = createAction( + 'SET_PRINT_NUMBER_OF_COPIES', +); + /* * Thunk Creators */ @@ -388,23 +401,6 @@ export function loadOrderAndNavigate(order, cb) { }; } -export function loadOrderAndPushNotification(order) { - return function (dispatch, getState) { - const { app } = getState(); - const { httpClient } = app; - - dispatch(loadOrderRequest()); - - return httpClient - .get(order) - .then(res => { - dispatch(loadOrderSuccess(res)); - dispatch(pushNotification('order:created', { order: res })); - }) - .catch(e => dispatch(loadOrderFailure(e))); - }; -} - export function acceptOrder(order, cb) { return function (dispatch, getState) { const { app } = getState(); @@ -432,6 +428,28 @@ export function acceptOrder(order, cb) { }; } +export const startPreparing = createAsyncThunk( + 'order/startPreparing', + async (order, thunkAPI) => { + const { getState } = thunkAPI; + + const httpClient = selectHttpClient(getState()); + + return await httpClient.put(order['@id'] + '/start_preparing'); + }, +); + +export const finishPreparing = createAsyncThunk( + 'order/finishPreparing', + async (order, thunkAPI) => { + const { getState } = thunkAPI; + + const httpClient = selectHttpClient(getState()); + + return await httpClient.put(order['@id'] + '/finish_preparing'); + }, +); + export function refuseOrder(order, reason, cb) { return function (dispatch, getState) { const { app } = getState(); @@ -637,8 +655,23 @@ function bluetoothErrorToString(e) { : e; } +export function printOrderById(orderId) { + return async (dispatch, getState) => { + const order = selectOrderById(getState(), orderId); + + if (!order) { + console.warn('Order not found', orderId); + return; + } + + await dispatch(printOrder(order)); + }; +} + export function printOrder(order) { return async (dispatch, getState) => { + dispatch(printPending(order)); + const { printer, isSunmiPrinter } = getState().restaurant; try { @@ -647,13 +680,16 @@ export function printOrder(order) { await SunmiPrinterLibrary.sendRAWData( Buffer.from(encodeForPrinter(order, true)).toString('base64'), ); + dispatch(printFulfilled(order)); return; } } catch (e) { - console.log(e); + console.warn('printOrder with SunmiPrinter failed', e); } if (!printer) { + console.warn('No printer selected'); + dispatch(printRejected(order)); return; } @@ -669,6 +705,7 @@ export function printOrder(order) { await BleManager.connect(printer.id); } catch (e) { dispatch(printerDisconnected()); + dispatch(printRejected(order)); DropdownHolder.getDropdown().alertWithType( 'error', i18n.t('RESTAURANT_PRINTER_CONNECT_ERROR_TITLE'), @@ -742,14 +779,16 @@ export function printOrder(order) { writableCharacteristic.characteristic, Array.from(encoded), ); - - break; + dispatch(printFulfilled(order)); } catch (e) { - console.log('Write failed', e); + console.warn('printOrder | Write failed', e); + dispatch(printRejected(order)); } } } } catch (e) { + console.warn('printOrder | Error', e); + dispatch(printRejected(order)); DropdownHolder.getDropdown().alertWithType( 'error', i18n.t('RESTAURANT_PRINTER_CONNECT_ERROR_TITLE'), @@ -760,7 +799,7 @@ export function printOrder(order) { } export function connectPrinter(device, cb) { - return function (dispatch, getState) { + return function (dispatch) { BleManager.connect(device.id) .then(() => { dispatch(printerConnected(device)); @@ -786,12 +825,12 @@ export function connectPrinter(device, cb) { } export function disconnectPrinter(device, cb) { - return function (dispatch, getState) { + return function (dispatch) { BleManager.disconnect(device.id) - // We use Promose.finally because if the state + // We use Promise.finally because if the state // contains a saved printer which is not connected anymore, // BleManager.disconnect will return an error - .catch(e => console.log(e)) + .catch(e => console.warn('disconnectPrinter', e)) .finally(() => { dispatch(printerDisconnected()); diff --git a/src/redux/Restaurant/middlewares.js b/src/redux/Restaurant/middlewares.js index cdc0c06af..a1cc42749 100644 --- a/src/redux/Restaurant/middlewares.js +++ b/src/redux/Restaurant/middlewares.js @@ -1,11 +1,12 @@ import _ from 'lodash'; import { AppState } from 'react-native'; -import { pushNotification } from '../App/actions'; +import { addNotification } from '../App/actions'; import { selectUser } from '../App/selectors'; import { LOAD_ORDERS_SUCCESS } from './actions'; +import { EVENT as EVENT_ORDER, STATE } from '../../domain/Order'; -export const ringOnNewOrderCreated = ({ getState, dispatch }) => { +export const notifyOnNewOrderCreated = ({ getState, dispatch }) => { return next => action => { if (AppState.currentState !== 'active') { return next(action); @@ -40,8 +41,8 @@ export const ringOnNewOrderCreated = ({ getState, dispatch }) => { (a, b) => a['@id'] + ':' + a.state === b['@id'] + ':' + b.state, ); orders.forEach(o => { - if (o.state === 'new') { - dispatch(pushNotification('order:created', { order: o })); + if (o.state === STATE.NEW) { + dispatch(addNotification(EVENT_ORDER.CREATED, { order: o })); } }); } diff --git a/src/redux/Restaurant/reducers.js b/src/redux/Restaurant/reducers.js index ebef9db0b..056909344 100644 --- a/src/redux/Restaurant/reducers.js +++ b/src/redux/Restaurant/reducers.js @@ -1,3 +1,6 @@ +import moment from 'moment'; +import _ from 'lodash'; + import { ACCEPT_ORDER_FAILURE, ACCEPT_ORDER_REQUEST, @@ -58,6 +61,12 @@ import { SET_NEXT_PRODUCTS_PAGE, SUNMI_PRINTER_DETECTED, UPDATE_LOOPEAT_FORMATS_SUCCESS, + finishPreparing, + printFulfilled, + printPending, + printRejected, + setPrintNumberOfCopies, + startPreparing, } from './actions'; import { @@ -66,10 +75,9 @@ import { LOAD_MY_RESTAURANTS_SUCCESS, } from '../App/actions'; -import { MESSAGE } from '../middlewares/CentrifugoMiddleware/actions'; +import { CENTRIFUGO_MESSAGE } from '../middlewares/CentrifugoMiddleware/actions'; -import _ from 'lodash'; -import moment from 'moment'; +import { EVENT as EVENT_ORDER, STATE } from '../../domain/Order'; const initialState = { fetchError: null, // Error object describing the error @@ -85,11 +93,30 @@ const initialState = { menus: [], bluetoothEnabled: false, isScanningBluetooth: false, + /** + * Peripheral (react-native-ble-manager) + */ printer: null, productOptions: [], isSunmiPrinter: false, bluetoothStarted: false, loopeatFormats: {}, + /** + * { + * [orderId]: { + * copiesToPrint: number, + * failedAttempts: number, + * } + * } + */ + ordersToPrint: {}, + printingOrderId: null, + preferences: { + autoAcceptOrders: { + printNumberOfCopies: 1, + printMaxFailedAttempts: 3, + }, + }, }; const spliceOrders = (state, payload) => { @@ -170,6 +197,34 @@ const spliceProductOptions = (state, payload) => { return state; }; +function updateOrdersToPrint(state, orderId) { + if (state.restaurant.autoAcceptOrdersEnabled) { + if (state.ordersToPrint[orderId]) { + return state; + } + + const numberOfCopies = + state.preferences.autoAcceptOrders.printNumberOfCopies; + + if (numberOfCopies === 0) { + return state; + } + + return { + ...state, + ordersToPrint: { + ...state.ordersToPrint, + [orderId]: { + copiesToPrint: numberOfCopies, + failedAttempts: 0, + }, + }, + }; + } else { + return state; + } +} + export default (state = initialState, action = {}) => { let newState; @@ -182,6 +237,8 @@ export default (state = initialState, action = {}) => { case DELAY_ORDER_REQUEST: case FULFILL_ORDER_REQUEST: case CANCEL_ORDER_REQUEST: + case startPreparing.pending.type: + case finishPreparing.pending.type: case CHANGE_STATUS_REQUEST: case LOAD_PRODUCTS_REQUEST: case CLOSE_RESTAURANT_REQUEST: @@ -201,6 +258,8 @@ export default (state = initialState, action = {}) => { case DELAY_ORDER_FAILURE: case FULFILL_ORDER_FAILURE: case CANCEL_ORDER_FAILURE: + case startPreparing.rejected.type: + case finishPreparing.rejected.type: case CHANGE_STATUS_FAILURE: case LOAD_PRODUCTS_FAILURE: case CLOSE_RESTAURANT_FAILURE: @@ -278,6 +337,8 @@ export default (state = initialState, action = {}) => { case DELAY_ORDER_SUCCESS: case FULFILL_ORDER_SUCCESS: case CANCEL_ORDER_SUCCESS: + case startPreparing.fulfilled.type: + case finishPreparing.fulfilled.type: return { ...state, orders: spliceOrders(state, action.payload), @@ -346,7 +407,7 @@ export default (state = initialState, action = {}) => { restaurant: action.payload, }; - case DELETE_OPENING_HOURS_SPECIFICATION_SUCCESS: + case DELETE_OPENING_HOURS_SPECIFICATION_SUCCESS: { const { specialOpeningHoursSpecification } = state; return { @@ -362,6 +423,7 @@ export default (state = initialState, action = {}) => { ), }, }; + } case CHANGE_STATUS_SUCCESS: return { @@ -463,35 +525,30 @@ export default (state = initialState, action = {}) => { isSunmiPrinter: true, }; - case MESSAGE: + case CENTRIFUGO_MESSAGE: if (action.payload.name && action.payload.data) { const { name, data } = action.payload; switch (name) { - case 'order:created': - case 'order:accepted': + case EVENT_ORDER.CREATED: case 'order:picked': - case 'order:cancelled': - // FIXME - // Fix this on API side - let newOrder = { ...data.order }; - if (name === 'order:cancelled' && newOrder.state !== 'cancelled') { - newOrder = { - ...newOrder, - state: 'cancelled', - }; - } - if (name === 'order:accepted' && newOrder.state !== 'accepted') { - newOrder = { - ...newOrder, - state: 'accepted', - }; - } - return { ...state, - orders: addOrReplace(state, newOrder), + orders: addOrReplace(state, data.order), }; + case EVENT_ORDER.STATE_CHANGED: { + const updatedOrdersState = { + ...state, + orders: addOrReplace(state, data.order), + }; + + if (data.order.state === STATE.ACCEPTED) { + return updateOrdersToPrint(updatedOrdersState, data.order['@id']); + } else { + return updatedOrdersState; + } + } + default: break; } @@ -499,6 +556,83 @@ export default (state = initialState, action = {}) => { return state; + case printPending.type: + return { + ...state, + printingOrderId: action.payload['@id'], + }; + + case printFulfilled.type: { + const orderId = action.payload['@id']; + const printTask = state.ordersToPrint[orderId]; + + if (!printTask) { + return state; + } + + if (printTask.copiesToPrint > 1) { + // We have more copies to print + return { + ...state, + printingOrderId: null, + ordersToPrint: { + ...state.ordersToPrint, + [orderId]: { + ...printTask, + copiesToPrint: printTask.copiesToPrint - 1, + failedAttempts: 0, + }, + }, + }; + } else { + // We have printed all needed copies + + const ordersToPrint = { ...state.ordersToPrint }; + delete ordersToPrint[orderId]; + + return { + ...state, + printingOrderId: null, + ordersToPrint: ordersToPrint, + }; + } + } + + case printRejected.type: { + const orderId = action.payload['@id']; + const printTask = state.ordersToPrint[orderId]; + + if (!printTask) { + return state; + } + + return { + ...state, + printingOrderId: null, + ordersToPrint: { + ...state.ordersToPrint, + [orderId]: { + ...printTask, + failedAttempts: printTask.failedAttempts + 1, + }, + }, + }; + } + + case setPrintNumberOfCopies.type: { + const numberOfCopies = action.payload; + return { + ...state, + preferences: { + ...state.preferences, + autoAcceptOrders: { + ...state.preferences.autoAcceptOrders, + printNumberOfCopies: numberOfCopies, + }, + }, + }; + } + case BLUETOOTH_STARTED: return { ...state, diff --git a/src/redux/Restaurant/selectors.js b/src/redux/Restaurant/selectors.js index e84e99797..00205ea3f 100644 --- a/src/redux/Restaurant/selectors.js +++ b/src/redux/Restaurant/selectors.js @@ -1,14 +1,18 @@ -import _, { find } from 'lodash'; -import moment from 'moment'; import { createSelector } from 'reselect'; +import { find } from 'lodash'; +import moment from 'moment'; +import _ from 'lodash'; import { matchesDate } from '../../utils/order'; +import { EVENT, STATE } from '../../domain/Order'; + +export const selectRestaurant = state => state.restaurant.restaurant; +export const selectDate = state => state.restaurant.date; -const _selectDate = state => state.restaurant.date; const _selectOrders = state => state.restaurant.orders; export const selectSpecialOpeningHoursSpecificationForToday = createSelector( - state => state.restaurant.restaurant, - _selectDate, + selectRestaurant, + selectDate, (restaurant, date) => { if (!Array.isArray(restaurant?.specialOpeningHoursSpecification)) { return null; @@ -27,7 +31,7 @@ export const selectSpecialOpeningHoursSpecificationForToday = createSelector( ); export const selectSpecialOpeningHoursSpecification = createSelector( - state => state.restaurant.restaurant, + selectRestaurant, restaurant => { if (restaurant) { return restaurant.specialOpeningHoursSpecification; @@ -37,44 +41,105 @@ export const selectSpecialOpeningHoursSpecification = createSelector( }, ); +export const selectAutoAcceptOrdersEnabled = createSelector( + selectRestaurant, + restaurant => restaurant?.autoAcceptOrdersEnabled ?? false, +); + +// Temporarily display new sections only for restaurants with auto accept orders enabled +// Later on: decide when to display these sections to other restaurants +export const selectHasStartedState = createSelector( + selectAutoAcceptOrdersEnabled, + autoAcceptOrdersEnabled => autoAcceptOrdersEnabled, +); + +// Temporarily display new sections only for restaurants with auto accept orders enabled +// Later on: decide when to display these sections to other restaurants +export const selectHasReadyState = createSelector( + selectAutoAcceptOrdersEnabled, + autoAcceptOrdersEnabled => autoAcceptOrdersEnabled, +); + export const selectNewOrders = createSelector( - _selectDate, + selectDate, _selectOrders, (date, orders) => _.sortBy( - _.filter(orders, o => matchesDate(o, date) && o.state === 'new'), + _.filter(orders, o => matchesDate(o, date) && o.state === STATE.NEW), [o => moment.parseZone(o.pickupExpectedAt)], ), ); +function isOrderPicked(order) { + return order.events.findIndex(ev => ev.type === EVENT.PICKED) !== -1; +} + export const selectAcceptedOrders = createSelector( - _selectDate, + selectDate, + _selectOrders, + (date, orders) => + _.sortBy( + _.filter( + orders, + o => + matchesDate(o, date) && + o.state === STATE.ACCEPTED && + !isOrderPicked(o), + ), + [o => moment.parseZone(o.pickupExpectedAt)], + ), +); + +export const selectStartedOrders = createSelector( + selectDate, + _selectOrders, + (date, orders) => + _.sortBy( + _.filter( + orders, + o => + matchesDate(o, date) && + o.state === STATE.STARTED && + !isOrderPicked(o), + ), + [o => moment.parseZone(o.pickupExpectedAt)], + ), +); + +export const selectReadyOrders = createSelector( + selectDate, _selectOrders, (date, orders) => _.sortBy( _.filter( orders, - o => matchesDate(o, date) && o.state === 'accepted' && !o.assignedTo, + o => + matchesDate(o, date) && o.state === STATE.READY && !isOrderPicked(o), ), [o => moment.parseZone(o.pickupExpectedAt)], ), ); export const selectPickedOrders = createSelector( - _selectDate, + selectDate, _selectOrders, (date, orders) => _.sortBy( _.filter( orders, - o => matchesDate(o, date) && o.state === 'accepted' && !!o.assignedTo, + o => + matchesDate(o, date) && + (o.state === STATE.ACCEPTED || + o.state === STATE.STARTED || + o.state === STATE.READY) && + isOrderPicked(o), ), [o => moment.parseZone(o.pickupExpectedAt)], ), ); export const selectCancelledOrders = createSelector( - _selectDate, + selectDate, _selectOrders, (date, orders) => _.sortBy( @@ -82,15 +147,100 @@ export const selectCancelledOrders = createSelector( orders, o => matchesDate(o, date) && - (o.state === 'refused' || o.state === 'cancelled'), + (o.state === STATE.REFUSED || o.state === STATE.CANCELLED), ), [o => moment.parseZone(o.pickupExpectedAt)], ), ); export const selectFulfilledOrders = createSelector( - _selectDate, + selectDate, _selectOrders, (date, orders) => - _.filter(orders, o => matchesDate(o, date) && o.state === 'fulfilled'), + _.filter(orders, o => matchesDate(o, date) && o.state === STATE.FULFILLED), +); + +export const selectPrinter = state => state.restaurant.printer; + +const selectIsSunmiPrinter = state => state.restaurant.isSunmiPrinter; + +export const selectIsPrinterConnected = createSelector( + selectPrinter, + selectIsSunmiPrinter, + (printer, isSunmiPrinter) => Boolean(printer) || isSunmiPrinter, +); + +const selectOrdersToPrint = state => state.restaurant.ordersToPrint; + +export const selectAutoAcceptOrdersPrintNumberOfCopies = state => + state.restaurant.preferences.autoAcceptOrders.printNumberOfCopies; + +const selectAutoAcceptOrdersPrintMaxFailedAttempts = state => + state.restaurant.preferences.autoAcceptOrders.printMaxFailedAttempts; + +export const selectOrderIdsToPrint = createSelector( + selectOrdersToPrint, + selectAutoAcceptOrdersPrintMaxFailedAttempts, + (ordersToPrint, printMaxFailedAttempts) => { + const orderIdsToPrint = []; + + Object.keys(ordersToPrint).forEach(orderId => { + const printTask = ordersToPrint[orderId]; + if (printTask.failedAttempts <= printMaxFailedAttempts) { + orderIdsToPrint.push(orderId); + } + }); + + return orderIdsToPrint; + }, +); + +export const selectOrderIdsFailedToPrint = createSelector( + selectOrdersToPrint, + selectAutoAcceptOrdersPrintMaxFailedAttempts, + (ordersToPrint, printMaxFailedAttempts) => { + const orderIdsFailedToPrint = []; + + Object.keys(ordersToPrint).forEach(orderId => { + const printTask = ordersToPrint[orderId]; + if (printTask.failedAttempts > printMaxFailedAttempts) { + orderIdsFailedToPrint.push(orderId); + } + }); + + return orderIdsFailedToPrint; + }, +); + +export const selectPrintingOrderId = state => state.restaurant.printingOrderId; + +const selectOrderId = (state, id) => id; + +export const selectOrderById = createSelector( + _selectOrders, + selectOrderId, + (orders, id) => { + if (id) { + return orders.find(order => order['@id'] === id); + } else { + return undefined; + } + }, +); + +const selectOrder = (state, order) => order; + +export const selectIsActionable = createSelector( + selectHasStartedState, + selectHasReadyState, + selectOrder, + (hasStartedState, hasReadyState, order) => { + return ( + [ + STATE.NEW, + ...(hasStartedState ? [STATE.ACCEPTED] : []), + ...(hasReadyState ? [STATE.STARTED] : []), + ].includes(order.state) && !isOrderPicked(order) + ); + }, ); diff --git a/src/redux/middlewares/CentrifugoMiddleware/actions.js b/src/redux/middlewares/CentrifugoMiddleware/actions.js index ae760151d..eb2f55259 100644 --- a/src/redux/middlewares/CentrifugoMiddleware/actions.js +++ b/src/redux/middlewares/CentrifugoMiddleware/actions.js @@ -3,7 +3,7 @@ import { createAction } from 'redux-actions'; import { updateTask } from '../../Dispatch/actions'; export const CONNECT = '@centrifugo/CONNECT'; -export const MESSAGE = '@centrifugo/MESSAGE'; +export const CENTRIFUGO_MESSAGE = '@centrifugo/MESSAGE'; export const CONNECTED = '@centrifugo/CONNECTED'; export const DISCONNECTED = '@centrifugo/DISCONNECTED'; @@ -12,7 +12,7 @@ export const connect = createAction(CONNECT); export const connected = createAction(CONNECTED); export const disconnected = createAction(DISCONNECTED); -export const _message = createAction(MESSAGE); +export const _message = createAction(CENTRIFUGO_MESSAGE); export function message(payload) { return function (dispatch, getState) { diff --git a/src/redux/middlewares/CentrifugoMiddleware/index.js b/src/redux/middlewares/CentrifugoMiddleware/index.js index e0b6f3e6d..f02cd3a23 100644 --- a/src/redux/middlewares/CentrifugoMiddleware/index.js +++ b/src/redux/middlewares/CentrifugoMiddleware/index.js @@ -2,27 +2,40 @@ import Centrifuge from 'centrifuge'; import parseUrl from 'url-parse'; import { + CENTRIFUGO_MESSAGE, CONNECT, CONNECTED, DISCONNECTED, - MESSAGE, connected, disconnected, message, } from './actions'; import { + selectBaseURL, selectHttpClient, selectHttpClientHasCredentials, selectIsAuthenticated, selectUser, } from '../../App/selectors'; +import { LOGOUT_SUCCESS } from '../../App/actions'; const isCentrifugoAction = ({ type }) => [CONNECT].some(x => x === type); export default ({ getState, dispatch }) => { + let centrifuge = null; + return next => action => { - // TODO Run if connected + if (action.type === LOGOUT_SUCCESS) { + const result = next(action); + + if (centrifuge && centrifuge.isConnected()) { + centrifuge.disconnect(); + centrifuge = null; + } + + return result; + } if (!isCentrifugoAction(action)) { return next(action); @@ -38,14 +51,14 @@ export default ({ getState, dispatch }) => { } const httpClient = selectHttpClient(state); - const baseURL = state.app.baseURL; + const baseURL = selectBaseURL(state); const user = selectUser(state); httpClient.get('/api/centrifugo/token').then(tokenResponse => { const url = parseUrl(baseURL); const protocol = url.protocol === 'https:' ? 'wss' : 'ws'; - const centrifuge = new Centrifuge( + centrifuge = new Centrifuge( `${protocol}://${url.hostname}/centrifugo/connection/websocket`, { debug: __DEV__, @@ -80,4 +93,4 @@ export default ({ getState, dispatch }) => { }; }; -export { CONNECTED, DISCONNECTED, MESSAGE, connected, disconnected }; +export { CENTRIFUGO_MESSAGE, CONNECTED, DISCONNECTED, connected, disconnected }; diff --git a/src/redux/middlewares/PushNotificationMiddleware/index.js b/src/redux/middlewares/PushNotificationMiddleware/index.js index d0847278d..db945bcc5 100644 --- a/src/redux/middlewares/PushNotificationMiddleware/index.js +++ b/src/redux/middlewares/PushNotificationMiddleware/index.js @@ -1,7 +1,10 @@ import { Platform } from 'react-native'; +import moment from 'moment'; import { LOGOUT_REQUEST, deletePushNotificationTokenSuccess, + foregroundPushNotification, + registerPushNotificationToken, savePushNotificationTokenSuccess, } from '../../App/actions'; import { @@ -9,14 +12,119 @@ import { selectHttpClientHasCredentials, selectIsAuthenticated, } from '../../App/selectors'; - -let isFetching = false; +import PushNotification from '../../../notifications'; +import { EVENT as EVENT_ORDER } from '../../../domain/Order'; +import tracker from '../../../analytics/Tracker'; +import analyticsEvent from '../../../analytics/Event'; +import { loadOrder, loadOrderAndNavigate } from '../../Restaurant/actions'; +import { EVENT as EVENT_TASK_COLLECTION } from '../../../domain/TaskCollection'; +import { loadTasks, navigateAndLoadTasks } from '../../Courier/taskActions'; // As remote push notifications are configured very early, // most of the time the user won't be authenticated // (for example, when app is launched for the first time) // We store the token for later, when the user authenticates export default ({ getState, dispatch }) => { + const onRegister = token => { + console.log('onRegister token:', token); + dispatch(registerPushNotificationToken(token)); + }; + + /** + * called when a user taps on a notification in the notification center + * android: notification is only shown when the app is in the background + * ios: notification is shown both in the foreground and background + */ + const onNotification = message => { + console.log('onNotification message:', message); + + const { event } = message.data; + + if (event && event.name === EVENT_ORDER.CREATED) { + tracker.logEvent( + analyticsEvent.restaurant._category, + analyticsEvent.restaurant.orderCreatedMessage, + message.foreground ? 'in_app' : 'notification_center', + ); + + const { order } = event.data; + + // Here in any case, we navigate to the order that was tapped, + // it should have been loaded via WebSocket already. + dispatch(loadOrderAndNavigate(order)); + } + + if (event && event.name === EVENT_TASK_COLLECTION.CHANGED) { + tracker.logEvent( + analyticsEvent.courier._category, + analyticsEvent.courier.tasksChangedMessage, + message.foreground ? 'in_app' : 'notification_center', + ); + + if (message.foreground) { + dispatch( + foregroundPushNotification(event.name, { + date: event.data.date, + }), + ); + } else { + dispatch(navigateAndLoadTasks(moment(event.data.date))); + } + } + }; + + /** + * called when a push notification is received while the app is in the foreground + * android only! + */ + const onBackgroundMessage = message => { + console.log('onBackgroundMessage message:', message.data); + + const { event } = message.data; + + if (event) { + switch (event.name) { + case EVENT_ORDER.CREATED: + dispatch( + loadOrder(event.data.order, order => { + if (order) { + dispatch( + foregroundPushNotification(event.name, { + order: order, + }), + ); + } + }), + ); + break; + case EVENT_TASK_COLLECTION.CHANGED: { + const dateStr = event.data.date; + + dispatch( + loadTasks(moment(dateStr), true, () => { + dispatch( + foregroundPushNotification(event.name, { + date: dateStr, + }), + ); + }), + ); + break; + } + default: + break; + } + } + }; + + PushNotification.configure({ + onRegister: token => onRegister(token), + onNotification: message => onNotification(message), + onBackgroundMessage: message => onBackgroundMessage(message), + }); + + let isFetching = false; + return next => action => { const result = next(action); const state = getState(); @@ -60,7 +168,6 @@ export default ({ getState, dispatch }) => { isFetching = false; }); } - return result; }; }; diff --git a/src/redux/middlewares/SoundMiddleware/index.js b/src/redux/middlewares/SoundMiddleware/index.js new file mode 100644 index 000000000..0cd5584f4 --- /dev/null +++ b/src/redux/middlewares/SoundMiddleware/index.js @@ -0,0 +1,50 @@ +import Sound from 'react-native-sound'; +import { startSound, stopSound } from '../../App/actions'; + +// Make sure sound will play even when device is in silent mode +Sound.setCategory('Playback'); + +export default ({ getState, dispatch }) => { + let isSoundReady = false; + let isSoundPlaying = false; + const bell = new Sound( + 'misstickle__indian_bell_chime.wav', + Sound.MAIN_BUNDLE, + error => { + if (error) { + return; + } + + bell.setNumberOfLoops(-1); + isSoundReady = true; + }, + ); + + return next => action => { + const result = next(action); + + if (action.type === startSound.type) { + if (isSoundReady && !isSoundPlaying) { + isSoundPlaying = true; + bell.play(success => { + if (!success) { + bell.reset(); + } + }); + } + + return result; + } + + if (action.type === stopSound.type) { + if (isSoundPlaying) { + bell.stop(() => {}); + isSoundPlaying = false; + } + + return result; + } + + return result; + }; +}; diff --git a/src/redux/middlewares/devSetup.js b/src/redux/middlewares/devSetup.js new file mode 100644 index 000000000..00babaacc --- /dev/null +++ b/src/redux/middlewares/devSetup.js @@ -0,0 +1,75 @@ +import { applyMiddleware } from 'redux'; +import { composeWithDevTools } from 'redux-devtools-extension'; +import { createLogger } from 'redux-logger'; +import reactotron from 'reactotron-react-native'; +import rdiff from 'recursive-diff'; + +import Config from 'react-native-config'; + +import ReactotronConfig from '../../../ReactotronConfig'; + +function logger(storeAPI) { + return function wrapDispatch(next) { + return function handleAction(action) { + const stateBefore = storeAPI.getState(); + const result = next(action); + const stateAfter = storeAPI.getState(); + + const diff = rdiff.getDiff(stateBefore, stateAfter); + + const opToLabel = { + add: 'ADDED', + delete: 'DELETED', + update: 'UPDATED', + }; + + const unflattenDiff = diff.reduce((acc, { op, path, val }) => { + const lastKey = path.pop(); + const lastObj = path.reduce((acc, key) => { + if (!acc[key]) { + acc[key] = {}; + } + return acc[key]; + }, acc); + lastObj[`${lastKey} (${opToLabel[op]})`] = val; + return acc; + }, {}); + + if (diff.length > 0) { + console.log('REDUX STATE changed:', unflattenDiff); + } + + reactotron.display({ + name: 'REDUX STATE', + value: { + diff: unflattenDiff, + after: stateAfter, + before: stateBefore, + }, + preview: + diff.length > 0 ? `number of changes: ${diff.length}` : 'no changes', + important: false, + }); + + return result; + }; + }; +} + +export default function configureForDevelopment(middlewaresList) { + const middlewares = [...middlewaresList]; + + middlewares.push( + createLogger({ + level: Config.DEBUG_REDUX_LOGGER_LEVEL ?? 'log', + collapsed: true, + }), + ); + + middlewares.push(logger); + + return composeWithDevTools( + applyMiddleware(...middlewares), + ReactotronConfig.createEnhancer(), + ); +} diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 003aa1c6d..a6a71e99a 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -49,7 +49,7 @@ const taskEntitiesPersistConfig = { const restaurantPersistConfig = { key: 'restaurant', storage: AsyncStorage, - whitelist: ['myRestaurants', 'restaurant', 'printer'], + whitelist: ['myRestaurants', 'restaurant', 'printer', 'preferences'], }; const tasksUiPersistConfig = { diff --git a/src/redux/store.js b/src/redux/store.js index 87f9db7dc..12bb7962b 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -1,23 +1,23 @@ import { applyMiddleware, createStore } from 'redux'; -import ReduxAsyncQueue from 'redux-async-queue'; -import { composeWithDevTools } from 'redux-devtools-extension'; -import { createLogger } from 'redux-logger'; import thunk from 'redux-thunk'; +import ReduxAsyncQueue from 'redux-async-queue'; import { persistStore } from 'redux-persist'; import Config from 'react-native-config'; -import { filterExpiredCarts } from './Checkout/middlewares'; -import { ringOnTaskListUpdated } from './Courier/taskMiddlewares'; -import { ringOnNewOrderCreated } from './Restaurant/middlewares'; -import BluetoothMiddleware from './middlewares/BluetoothMiddleware'; -import CentrifugoMiddleware from './middlewares/CentrifugoMiddleware'; + +import reducers from './reducers'; import GeolocationMiddleware from './middlewares/GeolocationMiddleware'; +import BluetoothMiddleware from './middlewares/BluetoothMiddleware'; import HttpMiddleware from './middlewares/HttpMiddleware'; import NetInfoMiddleware from './middlewares/NetInfoMiddleware'; import PushNotificationMiddleware from './middlewares/PushNotificationMiddleware'; import SentryMiddleware from './middlewares/SentryMiddleware'; -import reducers from './reducers'; +import { ringOnTaskListUpdated } from './Courier/taskMiddlewares'; +import CentrifugoMiddleware from './middlewares/CentrifugoMiddleware'; +import { filterExpiredCarts } from './Checkout/middlewares'; +import SoundMiddleware from './middlewares/SoundMiddleware'; +import { notifyOnNewOrderCreated } from './Restaurant/middlewares'; const middlewares = [ thunk, @@ -28,6 +28,7 @@ const middlewares = [ CentrifugoMiddleware, SentryMiddleware, filterExpiredCarts, + SoundMiddleware, ]; if (!Config.DEFAULT_SERVER) { @@ -35,22 +36,15 @@ if (!Config.DEFAULT_SERVER) { ...[ GeolocationMiddleware, BluetoothMiddleware, - ringOnNewOrderCreated, + notifyOnNewOrderCreated, ringOnTaskListUpdated, ], ); } -if (__DEV__) { - middlewares.push(createLogger({ collapsed: true })); -} - const middlewaresProxy = middlewaresList => { if (__DEV__) { - return composeWithDevTools( - applyMiddleware(...middlewaresList), - require('../../ReactotronConfig').default.createEnhancer(), - ); + return require('./middlewares/devSetup').default(middlewaresList); } else { return applyMiddleware(...middlewaresList); } diff --git a/yarn.lock b/yarn.lock index 12a9ade20..dcb9e53cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13379,6 +13379,11 @@ recast@^0.21.0: source-map "~0.6.1" tslib "^2.0.1" +recursive-diff@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/recursive-diff/-/recursive-diff-1.0.9.tgz#e617cbfcf125d4d73954c06997289c2d3321d5f7" + integrity sha512-5mqpskzvXDo5Vy29Vj8tH30a0+XBmY11aqWGoN/uB94UHRwndX2EuPvH+WtbqOYkrwAF718/lDo6U4CB1qSSqQ== + recyclerlistview@^4.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/recyclerlistview/-/recyclerlistview-4.2.0.tgz#a140149aaa470c9787a1426452651934240d69ef"