diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 388a2494..f5bc7284 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,7 +13,7 @@ - [ ] Format React code with `npm run format` - [ ] Format Python code by running `python3 -m black .` in the top-level of this repository - [ ] Thoroughly test your code's functionality, including unintended uses. -- [ ] Thoroughly test your code's responsiveness by rendering it on different devices, browsers, etc. +- [ ] Fully test the responsiveness of the feature as documented in the [Responsiveness Testing Guidelines](https://github.com/personalrobotics/feeding_web_interface/blob/main/feedingwebapp/ResponsivenessTesting.md). If you deviate from those guidelines, document above why you deviated and what you did instead. - [ ] Consider the user flow between states that this feature introduces, consider different situations that might occur for the user, and ensure that there is no way for the user to get stuck in a loop. **Before merging a pull request** diff --git a/feedingwebapp/ResponsivenessTesting.md b/feedingwebapp/ResponsivenessTesting.md new file mode 100644 index 00000000..2646a4c2 --- /dev/null +++ b/feedingwebapp/ResponsivenessTesting.md @@ -0,0 +1,32 @@ +# Methodology for Testing Responsiveness + +After developing an app feature, make sure to test it on the following setups. Total 4 devices as listed below. +- **Smartphone:** iPhone 14 Plus [Tyler]; Width: 428 & Height: 926 + - Landscape + - Portrait +- **Tablet:** iPad Pro (6th gen 12.9") [Tyler]; Width: 1024 & Height: 1366 + - Landscape + - Portrait +- **External Monitor:** Width: 4096 & Height: 2304 +- **Laptop:** Width: 2560 & Height: 1600 + - Full-screen + - Half-screen (vertical split) + - Quarter screen + +For the smartphone and laptops, test on Chrome, Firefox, Edge, and Safari. For the tablet and monitor, pick one browser to test it on. + +In total, you will run 23 tests (4 browsers * 1 smartphone * 2 orientations + 1 tablet * 2 orientations + 4 browsers * 3 laptops + 1 monitor). + +In portrait, pages should have their content appear on one screen and not require scrolling. In landscape, scrolling may be unavoidable for some screens, but try to rearrange the content horizontally in order to minimize required scrolling. + +If you don’t have the listed devices available for testing, please “Add custom device” in your browser’s responsive design mode using the width and height of the specific device’s resolution below. Please find the appropriate link from below for each browser's documentation on how to do this *except* Safari: + +1. https://www.ios-resolution.com/ +2. https://support.apple.com/kb/SP748?locale=en_US +3. Adding custom device in Chrome: https://developer.chrome.com/blog/add-a-new-custom-device-as-a-preset/ +4. Adding custom device in Edge: https://learn.microsoft.com/en-us/microsoft-edge/devtools-guide-chromium/device-mode/ +5. Adding custom device in Firefox: +https://firefox-source-docs.mozilla.org/devtools-user/responsive_design_mode/#creating-custom-devices + + + diff --git a/feedingwebapp/package-lock.json b/feedingwebapp/package-lock.json index 00a47ba5..e24cb7f9 100644 --- a/feedingwebapp/package-lock.json +++ b/feedingwebapp/package-lock.json @@ -19,6 +19,7 @@ "react-dom": "^18.1.0", "react-hook-form": "^7.43.9", "react-native-web": "^0.19.4", + "react-responsive": "^9.0.2", "react-router-dom": "^6.3.0", "react-script-tag": "^1.1.2", "react-scripts": "^5.0.1", @@ -6159,6 +6160,11 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" + }, "node_modules/css-minimizer-webpack-plugin": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", @@ -12943,6 +12949,14 @@ "tmpl": "1.0.5" } }, + "node_modules/matchmediaquery": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.3.1.tgz", + "integrity": "sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==", + "dependencies": { + "css-mediaquery": "^0.1.2" + } + }, "node_modules/mdb-react-ui-kit": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdb-react-ui-kit/-/mdb-react-ui-kit-3.0.0.tgz", @@ -14780,6 +14794,23 @@ "react": "^16.8.0 || ^17" } }, + "node_modules/react-responsive": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-9.0.2.tgz", + "integrity": "sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.2.1" + }, + "engines": { + "node": ">=0.10" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-router": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.1.tgz", @@ -16746,6 +16777,11 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", diff --git a/feedingwebapp/package.json b/feedingwebapp/package.json index a037662f..dfadddd3 100644 --- a/feedingwebapp/package.json +++ b/feedingwebapp/package.json @@ -14,6 +14,7 @@ "react-dom": "^18.1.0", "react-hook-form": "^7.43.9", "react-native-web": "^0.19.4", + "react-responsive": "^9.0.2", "react-router-dom": "^6.3.0", "react-script-tag": "^1.1.2", "react-scripts": "^5.0.1", diff --git a/feedingwebapp/src/Pages/Footer/Footer.jsx b/feedingwebapp/src/Pages/Footer/Footer.jsx index 7e881a22..ff2b6ea7 100644 --- a/feedingwebapp/src/Pages/Footer/Footer.jsx +++ b/feedingwebapp/src/Pages/Footer/Footer.jsx @@ -4,9 +4,9 @@ import { MDBFooter } from 'mdb-react-ui-kit' import Button from 'react-bootstrap/Button' import { View } from 'react-native' import Row from 'react-bootstrap/Row' +import { useMediaQuery } from 'react-responsive' // PropTypes is used to validate that the used props are in fact passed to this Component import PropTypes from 'prop-types' - // Local imports import { MOVING_STATE_ICON_DICT } from '../Constants' import { useGlobalState } from '../GlobalState' @@ -29,140 +29,162 @@ import { useGlobalState } from '../GlobalState' const Footer = (props) => { // Get the current meal state const mealState = useGlobalState((state) => state.mealState) - - // Icons and other parameters for the footer buttons + // Flag to check if the current orientation is portrait + const isPortrait = useMediaQuery({ query: '(orientation: portrait)' }) + // Icons for the footer buttons let pauseIcon = '/robot_state_imgs/pause_button_icon.svg' let backIcon = props.backMealState ? MOVING_STATE_ICON_DICT[props.backMealState] : '' let resumeIcon = MOVING_STATE_ICON_DICT[mealState] - let phantomButtonIcon = '/robot_state_imgs/phantom_view_image.svg' - // Width of Back and Resume buttons - let backResumeButtonWidth = '150px' - // Height of all Footer buttons - let footerButtonHeight = '100px' + // Sizes (width, height, fontsize) of footer buttons + let pauseButtonWidth = '98vw' + let backResumeButtonWidth = '47vw' + let pauseFontSize = '7vh' + let backResumeFontSize = isPortrait ? '3vh' : '7vh' + let footerButtonHeight = '12vh' + // Margins around footer buttons + let footerLeftRightMargin = '1.6vh' + let footerTopBottomMargin = '0.3vh' + // A single nested object with all footer buttons' properties + const buttonConfig = { + pause: { + text: 'Pause', + icon: pauseIcon, + disabled: false, + variant: 'danger', + callback: props.pauseCallback, + buttonWidth: pauseButtonWidth, + buttonHeight: footerButtonHeight, + fontSize: pauseFontSize, + iconSize: footerButtonHeight, + backgroundColor: null + }, + back: { + text: 'Back', + icon: backIcon, + disabled: false, + variant: 'warning', + callback: props.backCallback, + buttonWidth: backResumeButtonWidth, + buttonHeight: footerButtonHeight, + fontSize: backResumeFontSize, + iconSize: footerButtonHeight, + backgroundColor: null + }, + resume: { + text: 'Resume', + icon: resumeIcon, + disabled: false, + variant: 'success', + callback: props.resumeCallback, + buttonWidth: backResumeButtonWidth, + buttonHeight: footerButtonHeight, + fontSize: backResumeFontSize, + iconSize: footerButtonHeight, + backgroundColor: null + } + } /** - * Get the pause text and button to render in footer. + * Get the footer text and button to render in footer. + * + * @param {object} config - a single nested object with properties of footer buttons * - * @returns {JSX.Element} the pause text and button + * @returns {JSX.Element} the footer text and button */ - const renderPauseButton = useCallback( - (callback) => { + const renderFooterButton = useCallback( + (config) => { return ( <> - -

- Pause -

- {/* Icon to pause */} + ) }, - [pauseIcon, footerButtonHeight] - ) - - /** - * Get the back text and button to render in footer. - * - * @returns {JSX.Element} the back text and button - */ - const renderBackButton = useCallback( - (callback) => { - return ( - <> -

- Back -

- {/* Icon to move to previous state */} - - - ) - }, - [backIcon, backResumeButtonWidth, footerButtonHeight] + [footerLeftRightMargin, footerTopBottomMargin] ) - /** - * Get the resume text and button to render in footer. - * - * @returns {JSX.Element} the resume text and button - */ - const renderResumeButton = useCallback( - (callback) => { - return ( - <> -

- Resume -

- {/* Icon to resume current state */} - - - ) - }, - [resumeIcon, backResumeButtonWidth, footerButtonHeight] - ) - - /** - * Get the phantom view to render in footer. This is used as a placeholder - * when the back button or resume button are disabled. - * - * @returns {JSX.Element} the phantom view - */ - let renderPhantomButton = function () { - return ( - <> - - - ) - } - // Render the component return ( - <> - -
+ + +
{props.paused ? ( - - {props.backCallback ? renderBackButton(props.backCallback) : renderPhantomButton()} - {props.resumeCallback ? renderResumeButton(props.resumeCallback) : renderPhantomButton()} + + + {props.backCallback ? renderFooterButton(buttonConfig.back) : <>} + + + {props.resumeCallback ? renderFooterButton(buttonConfig.resume) : <>} + ) : ( - renderPauseButton(props.pauseCallback) + renderFooterButton(buttonConfig.pause) )}
- +
) } Footer.propTypes = { diff --git a/feedingwebapp/src/Pages/GlobalState.jsx b/feedingwebapp/src/Pages/GlobalState.jsx index 613f4678..b49d4079 100644 --- a/feedingwebapp/src/Pages/GlobalState.jsx +++ b/feedingwebapp/src/Pages/GlobalState.jsx @@ -94,6 +94,8 @@ export const useGlobalState = create( desiredFoodItem: null, // Whether or not the currently-executing robot motion was paused by the user paused: false, + // Flag to indicate robot motion trough teleoperation interface + teleopIsMoving: false, // Settings values stagingPosition: SETTINGS.stagingPosition[0], biteInitiation: SETTINGS.biteInitiation[0], @@ -117,6 +119,10 @@ export const useGlobalState = create( set(() => ({ paused: paused })), + setTeleopIsMoving: (teleopIsMoving) => + set(() => ({ + teleopIsMoving: teleopIsMoving + })), setStagingPosition: (stagingPosition) => set(() => ({ stagingPosition: stagingPosition diff --git a/feedingwebapp/src/Pages/Header/Header.jsx b/feedingwebapp/src/Pages/Header/Header.jsx index 813aee71..d5646404 100644 --- a/feedingwebapp/src/Pages/Header/Header.jsx +++ b/feedingwebapp/src/Pages/Header/Header.jsx @@ -3,8 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react' // The NavBar is the navigation toolbar at the top import Navbar from 'react-bootstrap/Navbar' import Nav from 'react-bootstrap/Nav' -// The Button is used for stop icon at the top -import Button from 'react-bootstrap/Button' +import { useMediaQuery } from 'react-responsive' // PropTypes is used to validate that the used props are in fact passed to this // Component import PropTypes from 'prop-types' @@ -13,7 +12,6 @@ import { ToastContainer, toast } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' // ROS imports import { useROS } from '../../ros/ros_helpers' - // Local imports import { ROS_CHECK_INTERVAL_MS, NON_MOVING_STATES } from '../Constants' import { useGlobalState, APP_PAGE, MEAL_STATE } from '../GlobalState' @@ -21,7 +19,7 @@ import LiveVideoModal from './LiveVideoModal' /** * The Header component consists of the navigation bar (which has buttons Home, - * Settings, and Video), the live video view that is toggled on and off by + * Settings, Lock and Robot Connection Icon and Video). Live video view is toggled on and off by * clicking "Video", and the ToastContainer popup that specifies when the user * cannot click Settings. */ @@ -34,6 +32,12 @@ const Header = (props) => { // useROS gives us access to functions to configure and interact with ROS. let { ros } = useROS() const [isConnected, setIsConncected] = useState(ros.isConnected) + // Flag to check if the current orientation is portrait + const isPortrait = useMediaQuery({ query: '(orientation: portrait)' }) + // Sizes of header elements (fontSize, width, height) + let textFontSize = isPortrait ? '3vh' : '6vh' + let lockImageHeight = isPortrait ? '4vh' : '8vh' + let lockImageWidth = '4vw' // Check ROS connection every ROS_CHECK_INTERVAL_MS milliseconds useEffect(() => { @@ -47,6 +51,7 @@ const Header = (props) => { const mealState = useGlobalState((state) => state.mealState) const setAppPage = useGlobalState((state) => state.setAppPage) const paused = useGlobalState((state) => state.paused) + const teleopIsMoving = useGlobalState((state) => state.teleopIsMoving) /** * When the Home button in the header is clicked, return to the Home page. @@ -75,74 +80,81 @@ const Header = (props) => { * The ToastContainer is an alert that pops up on the top of the screen * and has a timeout. */} - + {/** - * The NavBar has two elements, Home and Settings, on the left side and one - * element, Video, on the right side. An image showing the connection status - * of the robot is placed in between Settings and Video. + * The NavBar has two elements, Home and Settings, on the left side and three + * elements, Lock, Robot Connection Icon and VideoVideo, on the right side. + * An image showing the connection status + * of the robot is placed in between Lock and Video. */} - - + + - {NON_MOVING_STATES.has(mealState) || paused ? ( -
- -
+ + ) : ( <> )} {isConnected ? ( -
-

+

+ + ) : ( -
-

+

+ + )}