diff --git a/src/components/connection-modal/connected-step.jsx b/src/components/connection-modal/connected-step.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c02f644bc4e55d807124bd8cc41004a98cdca24b --- /dev/null +++ b/src/components/connection-modal/connected-step.jsx @@ -0,0 +1,71 @@ +import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import Box from '../box/box.jsx'; +import Dots from './dots.jsx'; +import bluetoothIcon from './icons/bluetooth-white.svg'; +import styles from './connection-modal.css'; +import classNames from 'classnames'; + +const ConnectedStep = props => ( + <Box className={styles.body}> + <Box className={styles.activityArea}> + <Box className={styles.centeredRow}> + <div className={styles.deviceActivity}> + <img + className={styles.deviceActivityIcon} + src={props.deviceImage} + /> + <img + className={styles.bluetoothConnectedIcon} + src={bluetoothIcon} + /> + </div> + </Box> + </Box> + <Box className={styles.bottomArea}> + <Box className={styles.instructions}> + <FormattedMessage + defaultMessage="Connected" + description="Message indicating that a device was connected" + id="gui.connection.connected" + /> + </Box> + <Dots + success + total={3} + /> + <div className={styles.cornerButtons}> + <button + className={classNames(styles.redButton, styles.connectionButton)} + onClick={props.onDisconnect} + > + <FormattedMessage + defaultMessage="Disconnect" + description="Button to disconnect the device" + id="gui.connection.disconnect" + /> + </button> + <button + className={styles.connectionButton} + onClick={props.onCancel} + > + <FormattedMessage + defaultMessage="Go to Editor" + description="Button to return to the editor" + id="gui.connection.go-to-editor" + /> + </button> + </div> + </Box> + </Box> +); + +ConnectedStep.propTypes = { + deviceImage: PropTypes.string.isRequired, + onCancel: PropTypes.func, + onDisconnect: PropTypes.func +}; + +export default ConnectedStep; diff --git a/src/components/connection-modal/connecting-step.jsx b/src/components/connection-modal/connecting-step.jsx new file mode 100644 index 0000000000000000000000000000000000000000..063e67a826c79f4c9f55548e321d499d8c86d6a3 --- /dev/null +++ b/src/components/connection-modal/connecting-step.jsx @@ -0,0 +1,71 @@ +import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import Box from '../box/box.jsx'; +import Dots from './dots.jsx'; + +import bluetoothIcon from './icons/bluetooth-white.svg'; +import closeIcon from '../close-button/icon--close.svg'; + +import styles from './connection-modal.css'; + +const ConnectingStep = props => ( + <Box className={styles.body}> + <Box className={styles.activityArea}> + <Box className={styles.centeredRow}> + <div className={styles.deviceActivity}> + <img + className={styles.deviceActivityIcon} + src={props.deviceImage} + /> + <img + className={styles.bluetoothConnectingIcon} + src={bluetoothIcon} + /> + </div> + </Box> + </Box> + <Box className={styles.bottomArea}> + <Box className={styles.instructions}> + <FormattedMessage + defaultMessage="Connecting" + description="" + id="gui.connection.connecting" + /> + </Box> + <Dots + counter={1} + total={3} + /> + <div className={styles.segmentedButton}> + <button + disabled + className={styles.connectionButton} + > + <FormattedMessage + defaultMessage="Connecting..." + description="Label indicating that connection is in progress" + id="gui.connection.connecting-cancelbutton" + /> + </button> + <button + className={styles.connectionButton} + onClick={props.onDisconnect} + > + <img + className={styles.abortConnectingIcon} + src={closeIcon} + /> + </button> + </div> + </Box> + </Box> +); + +ConnectingStep.propTypes = { + deviceImage: PropTypes.string.isRequired, + onDisconnect: PropTypes.func +}; + +export default ConnectingStep; diff --git a/src/components/connection-modal/connection-modal.css b/src/components/connection-modal/connection-modal.css new file mode 100644 index 0000000000000000000000000000000000000000..ee0a0aae1e6ee354f7c3a2decd302f65b000644d --- /dev/null +++ b/src/components/connection-modal/connection-modal.css @@ -0,0 +1,308 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.modal-content { + width: 480px; +} + +.header { + background-color: $pen-primary; +} + +.body { + background: $ui-white; +} + +.label { + font-weight: 500; + margin: 0 0 0.75rem; +} + +.centered-row { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.device-tile-pane { + overflow-y: auto; + width: 100%; + height: 100%; + padding: 0.5rem; +} + +.device-tile { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + background-color: $ui-white; + border-radius: 0.25rem; + padding: 10px; + width: 100%; + height: 55px; + margin-bottom: 0.5rem; +} + +.device-tile-name { + display: flex; + align-items: center; +} + +.device-tile-image { + margin-right: 0.5rem; +} + +.device-tile-name-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +} + +.device-tile-name-label { + font-weight: bold; + font-size: 0.625rem; +} + +.device-tile-name-text { + font-size: 0.875rem; +} + +.device-tile button { + padding: 0.6rem 0.75rem; + border: none; + border-radius: 0.25rem; + font-weight: 600; + font-size: 0.85rem; + background: $motion-primary; + border: $motion-primary; + color: white; + cursor: pointer; +} + +.signal-strength-meter { + display: flex; + justify-content: space-between; + align-items: flex-end; + width: 22px; + height: 16px; + margin-right: 1rem; +} + +.signal-bar { + width: 4px; + border-radius: 4px; + background-color: #DBDBDB; +} + +.signal-bar:nth-of-type(1) { height: 25%; } +.signal-bar:nth-of-type(2) { height: 50%; } +.signal-bar:nth-of-type(3) { height: 75%; } +.signal-bar:nth-of-type(4) { height: 100%; } + +.green-bar { + background-color: $pen-primary; +} + +.radar { + width: 40px; + height: 40px; + margin-right: 0.5rem; + animation: spin 4s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + + +.device-activity { + position: relative; +} + +.device-activity-icon { + /* width: 80px; + height: 80px; */ +} + +.bluetooth-connecting-icon { + position: absolute; + top: -5px; + right: -15px; + padding: 5px 5px; + background-color: $motion-primary; + border-radius: 100%; + box-shadow: 0px 0px 0px 4px $motion-transparent; + /* animation: pulse-blue-ring 1s infinite ease-in-out alternate; */ + animation: wiggle 0.5s infinite ease-in-out alternate; + +} + +@keyframes pulse-blue-ring { + 100% { + box-shadow: 0px 0px 0px 8px $motion-light-transparent; + } +} + + +.bluetooth-connected-icon { + position: absolute; + top: -5px; + right: -15px; + padding: 5px 5px; + background-color: $pen-primary; + border-radius: 100%; + box-shadow: 0px 0px 0px 4px $pen-transparent; +} + + + +@keyframes wiggle { + 0% {transform: rotate(3deg) scale(1.05);} + 25% {transform: rotate(-3deg) scale(1.05);} + 50% {transform: rotate(5deg) scale(1.05);} + 75% {transform: rotate(-2deg) scale(1.05);} + 100% {transform: rotate(0deg) scale(1.05);} +} + +.device-tile-widgets { + display: flex; + align-items: center; +} + +.activityArea { + height: 165px; + background-color: $motion-light-transparent; + display: flex; + justify-content: center; + align-items: center; +} + +.button-row { + font-weight: bolder; + text-align: center; + display: flex; +} + +.abort-connecting-icon { + width: 10px; + transform: rotate(45deg); +} + +.connection-button { + padding: 0.6rem 0.75rem; + border-radius: 0.5rem; + background: $motion-primary; + color: white; + font-weight: 600; + font-size: 0.85rem; + margin: 0.25rem; + border: none; + cursor: pointer; + display: flex; + align-items: center; +} + +.connection-button:disabled { + background: $motion-transparent; +} + +.segmented-button { + display: flex; +} + +.segmented-button .connection-button:first-of-type { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-right: 0; +} + +.segmented-button .connection-button:last-of-type { + margin-left: 1px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.button-icon-right { + margin-left: 0.5rem; +} + +.button-icon-left { + margin-right: 0.5rem; +} + +.red-button { + background: $red-primary; +} + +.corner-buttons { + display: flex; + justify-content: space-between; + width: 100%; + padding: 0 1rem; +} + +.bottom-area { + background-color: $ui-white; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 12px; +} + +.instructions { + text-align: center; + padding: 1rem; +} + +.dots-row { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding-bottom: 1rem; +} + +.dots-holder { + display: flex; + padding: 0.25rem 0.1rem; + border-radius: 1rem; + background: $motion-light-transparent; +} + +.dots-holder-success { + background: $pen-transparent; +} + +.dots-holder-error { + background: $error-transparent; +} + +.dot { + width: 0.5rem; + height: 0.5rem; + margin: 0 0.3rem; + border-radius: 100%; +} + +.inactive-step-dot { + background: $motion-transparent; +} + +.active-step-dot { + background: $motion-primary; +} + +.success-dot { + background: $pen-primary; +} + +.error-dot { + background: $error-primary; +} diff --git a/src/components/connection-modal/connection-modal.jsx b/src/components/connection-modal/connection-modal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5ddcb33e3b891aa5641cbf63d884688cc83c0183 --- /dev/null +++ b/src/components/connection-modal/connection-modal.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import keyMirror from 'keymirror'; + +import Box from '../box/box.jsx'; +import Modal from '../modal/modal.jsx'; + +import ScanningStep from '../../containers/scanning-step.jsx'; +import ConnectingStep from './connecting-step.jsx'; +import ConnectedStep from './connected-step.jsx'; +import ErrorStep from './error-step.jsx'; + +import styles from './connection-modal.css'; + +const PHASES = keyMirror({ + scanning: null, + connecting: null, + connected: null, + error: null +}); + +const ConnectionModalComponent = props => ( + <Modal + className={styles.modalContent} + contentLabel={props.name} + headerClassName={styles.header} + headerImage={props.smallDeviceImage} + onRequestClose={props.onCancel} + > + <Box className={styles.body}> + {props.phase === PHASES.scanning && <ScanningStep {...props} />} + {props.phase === PHASES.connecting && <ConnectingStep {...props} />} + {props.phase === PHASES.connected && <ConnectedStep {...props} />} + {props.phase === PHASES.error && <ErrorStep {...props} />} + </Box> + </Modal> +); + +ConnectionModalComponent.propTypes = { + name: PropTypes.node, + onCancel: PropTypes.func.isRequired, + phase: PropTypes.oneOf(Object.keys(PHASES)).isRequired, + smallDeviceImage: PropTypes.string, + title: PropTypes.string.isRequired +}; + +export { + ConnectionModalComponent as default, + PHASES +}; diff --git a/src/components/connection-modal/device-tile.jsx b/src/components/connection-modal/device-tile.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c806453aa0ff60828462376bf9c01a3a38ae550f --- /dev/null +++ b/src/components/connection-modal/device-tile.jsx @@ -0,0 +1,87 @@ +import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import Box from '../box/box.jsx'; + +import styles from './connection-modal.css'; + +class DeviceTile extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleConnecting' + ]); + } + handleConnecting () { + this.props.onConnecting(this.props.peripheralId); + } + render () { + return ( + <Box className={styles.deviceTile}> + <Box className={styles.deviceTileName}> + <img + className={styles.deviceTileImage} + src={this.props.smallDeviceImage} + /> + <Box className={styles.deviceTileNameWrapper}> + <Box className={styles.deviceTileNameLabel}> + <FormattedMessage + defaultMessage="Device name" + description="Label for field showing the device name" + id="gui.connection.device-name-label" + /> + </Box> + <Box className={styles.deviceTileNameText}> + {this.props.name} + </Box> + </Box> + </Box> + <Box className={styles.deviceTileWidgets}> + <Box className={styles.signalStrengthMeter}> + <div + className={classNames(styles.signalBar, { + [styles.greenBar]: this.props.rssi > -80 + })} + /> + <div + className={classNames(styles.signalBar, { + [styles.greenBar]: this.props.rssi > -60 + })} + /> + <div + className={classNames(styles.signalBar, { + [styles.greenBar]: this.props.rssi > -40 + })} + /> + <div + className={classNames(styles.signalBar, { + [styles.greenBar]: this.props.rssi > -20 + })} + /> + </Box> + <button + onClick={this.handleConnecting} + > + <FormattedMessage + defaultMessage="Connect" + description="Button to start connecting to a specific device" + id="gui.connection.connect" + /> + </button> + </Box> + </Box> + ); + } +} + +DeviceTile.propTypes = { + name: PropTypes.string, + onConnecting: PropTypes.func, + peripheralId: PropTypes.string, + rssi: PropTypes.number, + smallDeviceImage: PropTypes.string +}; + +export default DeviceTile; diff --git a/src/components/connection-modal/dots.jsx b/src/components/connection-modal/dots.jsx new file mode 100644 index 0000000000000000000000000000000000000000..23814eaf75ecffed01d5f964da940e8c22277448 --- /dev/null +++ b/src/components/connection-modal/dots.jsx @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; + +import Box from '../box/box.jsx'; +import styles from './connection-modal.css'; + +const Dots = props => ( + <Box className={styles.dotsRow}> + <div + className={classNames( + styles.dotsHolder, + { + [styles.dotsHolderError]: props.error, + [styles.dotsHolderSuccess]: props.success + } + )} + > + {Array(props.total).fill(0) + .map((_, i) => { + let type = 'inactive'; + if (props.counter === i) type = 'active'; + if (props.success) type = 'success'; + if (props.error) type = 'error'; + return (<Dot + key={`dot-${i}`} + type={type} + />); + })} + </div> + </Box> +); + +Dots.propTypes = { + counter: PropTypes.number, + error: PropTypes.bool, + success: PropTypes.bool, + total: PropTypes.number +}; + +const Dot = props => ( + <div + className={classNames( + styles.dot, + { + [styles.inactiveStepDot]: props.type === 'inactive', + [styles.activeStepDot]: props.type === 'active', + [styles.successDot]: props.type === 'success', + [styles.errorDot]: props.type === 'error' + } + )} + /> +); + +Dot.propTypes = { + type: PropTypes.string +}; + +export default Dots; diff --git a/src/components/connection-modal/error-step.jsx b/src/components/connection-modal/error-step.jsx new file mode 100644 index 0000000000000000000000000000000000000000..681d74b72a2b278b13ae23d39d853becf5b4116d --- /dev/null +++ b/src/components/connection-modal/error-step.jsx @@ -0,0 +1,76 @@ +import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import Box from '../box/box.jsx'; +import Dots from './dots.jsx'; +import helpIcon from './icons/help.svg'; +import backIcon from './icons/back.svg'; + +import styles from './connection-modal.css'; + +const ErrorStep = props => ( + <Box className={styles.body}> + <Box className={styles.activityArea}> + <Box className={styles.centeredRow}> + <div className={styles.deviceActivity}> + <img + className={styles.deviceActivityIcon} + src={props.deviceImage} + /> + </div> + </Box> + </Box> + <Box className={styles.bottomArea}> + <div className={styles.instructions}> + <FormattedMessage + defaultMessage="Oops, looks like something went wrong." + description="The device connection process has encountered an error." + id="gui.connection.errorMessage" + /> + </div> + <Dots + error + total={3} + /> + <Box className={styles.buttonRow}> + <button + className={styles.connectionButton} + onClick={props.onScanning} + > + <img + className={styles.buttonIconLeft} + src={backIcon} + /> + <FormattedMessage + defaultMessage="Try again" + description="Button to initiate trying the device connection again after an error" + id="gui.connection.tryagainbutton" + /> + </button> + <button + className={styles.connectionButton} + onClick={props.onHelp} + > + <img + className={styles.buttonIconLeft} + src={helpIcon} + /> + <FormattedMessage + defaultMessage="Help" + description="Button to view help content" + id="gui.connection.helpbutton" + /> + </button> + </Box> + </Box> + </Box> +); + +ErrorStep.propTypes = { + deviceImage: PropTypes.string.isRequired, + onHelp: PropTypes.func, + onScanning: PropTypes.func +}; + +export default ErrorStep; diff --git a/src/components/connection-modal/icons/back.svg b/src/components/connection-modal/icons/back.svg new file mode 100644 index 0000000000000000000000000000000000000000..42f7508f279eaeee36aac930e3f343d2bfc7c518 Binary files /dev/null and b/src/components/connection-modal/icons/back.svg differ diff --git a/src/components/connection-modal/icons/bluetooth-white.svg b/src/components/connection-modal/icons/bluetooth-white.svg new file mode 100644 index 0000000000000000000000000000000000000000..df2ae141ebce92e1af1ad8f3f6e90b57c8248225 Binary files /dev/null and b/src/components/connection-modal/icons/bluetooth-white.svg differ diff --git a/src/components/connection-modal/icons/cancel.svg b/src/components/connection-modal/icons/cancel.svg new file mode 100644 index 0000000000000000000000000000000000000000..b30decdb65a22a832208426f4258a5fa1dea00b7 Binary files /dev/null and b/src/components/connection-modal/icons/cancel.svg differ diff --git a/src/components/connection-modal/icons/close.svg b/src/components/connection-modal/icons/close.svg new file mode 100644 index 0000000000000000000000000000000000000000..a537fc800d01115552a00073103be09cf5ce9fcf Binary files /dev/null and b/src/components/connection-modal/icons/close.svg differ diff --git a/src/components/connection-modal/icons/help.svg b/src/components/connection-modal/icons/help.svg new file mode 100644 index 0000000000000000000000000000000000000000..2938e8340ebb54dd699f80eaf48794bd383a96a4 Binary files /dev/null and b/src/components/connection-modal/icons/help.svg differ diff --git a/src/components/connection-modal/icons/refresh.svg b/src/components/connection-modal/icons/refresh.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d4aebb47071a08f9214c5e4c5eafb035e2b86ea Binary files /dev/null and b/src/components/connection-modal/icons/refresh.svg differ diff --git a/src/components/connection-modal/icons/searching.png b/src/components/connection-modal/icons/searching.png new file mode 100644 index 0000000000000000000000000000000000000000..260f3227f2ef09cc1aa6fbca0fc928e2b5be0e28 Binary files /dev/null and b/src/components/connection-modal/icons/searching.png differ diff --git a/src/components/connection-modal/scanning-step.jsx b/src/components/connection-modal/scanning-step.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1728e1fd5d55df75790f0301ef00386783d95b15 --- /dev/null +++ b/src/components/connection-modal/scanning-step.jsx @@ -0,0 +1,103 @@ +import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import Box from '../box/box.jsx'; +import DeviceTile from './device-tile.jsx'; +import Dots from './dots.jsx'; + +import radarIcon from './icons/searching.png'; +import refreshIcon from './icons/refresh.svg'; + +import styles from './connection-modal.css'; + +const ScanningStep = props => ( + <Box className={styles.body}> + <Box className={styles.activityArea}> + {props.scanning ? ( + props.deviceList.length === 0 ? ( + <div className={styles.activityAreaInfo}> + <div className={styles.centeredRow}> + <img + className={styles.radar} + src={radarIcon} + /> + <FormattedMessage + defaultMessage="Looking for devices" + description="Text shown while scanning for devices" + id="gui.connection.scanning.lookingfordevices" + /> + </div> + </div> + ) : ( + <Box className={styles.deviceTilePane}> + {props.deviceList.map(device => + (<DeviceTile + key={device.peripheralId} + name={device.name} + peripheralId={device.peripheralId} + rssi={device.rssi} + smallDeviceImage={props.smallDeviceImage} + onConnecting={props.onConnecting} + />) + )} + </Box> + ) + ) : ( + <Box className={styles.instructions}> + <FormattedMessage + defaultMessage="No devices found" + description="Text shown when no devices could be found" + id="gui.connection.scanning.noDevicesFound" + /> + </Box> + )} + </Box> + <Box className={styles.bottomArea}> + <Box className={styles.instructions}> + <FormattedMessage + defaultMessage="Select your device in the list above." + description="Prompt for choosing a device to connect to" + id="gui.connection.scanning.instructions" + /> + </Box> + <Dots + counter={0} + total={3} + /> + <button + className={styles.connectionButton} + onClick={props.onRefresh} + > + <FormattedMessage + defaultMessage="Refresh" + description="Button in prompt for starting a search" + id="gui.connection.search" + /> + <img + className={styles.buttonIconRight} + src={refreshIcon} + /> + </button> + </Box> + </Box> +); + +ScanningStep.propTypes = { + deviceList: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + rssi: PropTypes.number, + peripheralId: PropTypes.string + })), + onConnecting: PropTypes.func, + onRefresh: PropTypes.func, + scanning: PropTypes.bool.isRequired, + smallDeviceImage: PropTypes.string +}; + +ScanningStep.defaultProps = { + deviceList: [], + scanning: true +}; + +export default ScanningStep; diff --git a/src/components/modal/modal.css b/src/components/modal/modal.css index 3aa21280beba80b61181349e5ae8a1bc38b52459..9a5a035a7de18edba2d6d5343fdc4fa97f213878 100644 --- a/src/components/modal/modal.css +++ b/src/components/modal/modal.css @@ -83,6 +83,10 @@ $sides: 20rem; user-select: none; } +.header-image { + margin-right: 0.5rem; +} + .header-item-filter { display: flex; flex-basis: $sides; diff --git a/src/components/modal/modal.jsx b/src/components/modal/modal.jsx index dff3ce8537b748e0c5c554d25652b8f30615efdc..940af75586f59e05291f9970b5bf972f353ccdb3 100644 --- a/src/components/modal/modal.jsx +++ b/src/components/modal/modal.jsx @@ -26,13 +26,19 @@ const ModalComponent = props => ( direction="column" grow={1} > - <div className={styles.header}> + <div className={classNames(styles.header, props.headerClassName)}> <div className={classNames( styles.headerItem, styles.headerItemTitle )} > + {props.headerImage ? ( + <img + className={styles.headerImage} + src={props.headerImage} + /> + ) : null} {props.contentLabel} </div> <div @@ -74,6 +80,8 @@ ModalComponent.propTypes = { PropTypes.object ]).isRequired, fullScreen: PropTypes.bool, + headerClassName: PropTypes.string, + headerImage: PropTypes.string, onRequestClose: PropTypes.func }; diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 40ad9234ed774d4bdbe0b3e0cc8ee78a14de89e7..20c6d482ff5c9a927d731d84200192b514fe2269 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -9,8 +9,10 @@ import VM from 'scratch-vm'; import analytics from '../lib/analytics'; import Prompt from './prompt.jsx'; +import ConnectionModal from './connection-modal.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx'; import ExtensionLibrary from './extension-library.jsx'; +import extensionData from '../lib/libraries/extensions/index.jsx'; import CustomProcedures from './custom-procedures.jsx'; import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; @@ -38,6 +40,9 @@ class Blocks extends React.Component { 'attachVM', 'detachVM', 'handleCategorySelected', + 'handleConnectionModalStart', + 'handleConnectionModalClose', + 'handleStatusButtonUpdate', 'handlePromptStart', 'handlePromptCallback', 'handlePromptClose', @@ -56,9 +61,11 @@ class Blocks extends React.Component { 'setLocale' ]); this.ScratchBlocks.prompt = this.handlePromptStart; + this.ScratchBlocks.statusButtonCallback = this.handleConnectionModalStart; this.state = { workspaceMetrics: {}, - prompt: null + prompt: null, + connectionModal: null }; this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100); this.toolboxUpdateQueue = []; @@ -94,6 +101,7 @@ class Blocks extends React.Component { shouldComponentUpdate (nextProps, nextState) { return ( this.state.prompt !== nextState.prompt || + this.state.connectionModal !== nextState.connectionModal || this.props.isVisible !== nextProps.isVisible || this.props.toolboxXML !== nextProps.toolboxXML || this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible || @@ -311,6 +319,11 @@ class Blocks extends React.Component { this.handleExtensionAdded(blocksInfo); } handleCategorySelected (categoryId) { + const extension = extensionData.find(ext => ext.extensionId === categoryId); + if (extension && extension.launchDeviceConnectionFlow) { + this.handleConnectionModalStart(categoryId); + } + this.withToolboxUpdates(() => { this.workspace.toolbox_.setSelectedCategoryById(categoryId); }); @@ -330,6 +343,23 @@ class Blocks extends React.Component { p.prompt.title !== this.ScratchBlocks.Msg.RENAME_LIST_MODAL_TITLE; this.setState(p); } + handleConnectionModalStart (extensionId) { + const extension = extensionData.find(ext => ext.extensionId === extensionId); + if (extension) { + this.setState({connectionModal: { + extensionId: extensionId, + deviceImage: extension.deviceImage, + smallDeviceImage: extension.smallDeviceImage, + name: extension.name + }}); + } + } + handleConnectionModalClose () { + this.setState({connectionModal: null}); + } + handleStatusButtonUpdate (extensionId, status) { + this.ScratchBlocks.updateStatusButton(this.workspace, extensionId, status); + } handlePromptCallback (input, optionSelection) { this.state.prompt.callback(input, optionSelection, (optionSelection === 'local') ? [] : @@ -381,6 +411,17 @@ class Blocks extends React.Component { onOk={this.handlePromptCallback} /> ) : null} + {this.state.connectionModal ? ( + <ConnectionModal + deviceImage={this.state.connectionModal.deviceImage} + extensionId={this.state.connectionModal.extensionId} + name={this.state.connectionModal.name} + smallDeviceImage={this.state.connectionModal.smallDeviceImage} + vm={vm} + onCancel={this.handleConnectionModalClose} + onStatusButtonUpdate={this.handleStatusButtonUpdate} + /> + ) : null} {extensionLibraryVisible ? ( <ExtensionLibrary vm={vm} diff --git a/src/containers/connection-modal.jsx b/src/containers/connection-modal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b256b03095835eca16d1d33520a8185fbe6b7461 --- /dev/null +++ b/src/containers/connection-modal.jsx @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import ConnectionModalComponent, {PHASES} from '../components/connection-modal/connection-modal.jsx'; +import VM from 'scratch-vm'; + +class ConnectionModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleScanning', + 'handleConnected', + 'handleConnecting', + 'handleDisconnect', + 'handleError' + ]); + this.state = { + phase: PHASES.scanning + }; + } + componentDidMount () { + this.props.vm.on('PERIPHERAL_CONNECTED', this.handleConnected); + this.props.vm.on('PERIPHERAL_ERROR', this.handleError); + + // Check if we're already connected + if (this.props.vm.getPeripheralIsConnected(this.props.extensionId)) { + this.handleConnected(); + } + + } + componentWillUnmount () { + this.props.vm.removeListener('PERIPHERAL_CONNECTED', this.handleConnected); + this.props.vm.removeListener('PERIPHERAL_ERROR', this.handleError); + } + handleScanning () { + this.setState({ + phase: PHASES.scanning + }); + } + handleConnecting (peripheralId) { + this.props.vm.connectToPeripheral(this.props.extensionId, peripheralId); + this.setState({ + phase: PHASES.connecting + }); + } + handleDisconnect () { + this.props.onStatusButtonUpdate(this.props.extensionId, 'not ready'); + this.props.vm.disconnectExtensionSession(this.props.extensionId); + this.props.onCancel(); + } + handleCancel () { + // If we're not connected to a device, close the websocket so we stop scanning. + if (!this.props.vm.getPeripheralIsConnected(this.props.extensionId)) { + this.props.vm.disconnectExtensionSession(this.props.extensionId); + } + this.props.onCancel(); + } + handleError () { + this.props.onStatusButtonUpdate(this.props.extensionId, 'not ready'); + this.setState({ + phase: PHASES.error + }); + } + handleConnected () { + this.props.onStatusButtonUpdate(this.props.extensionId, 'ready'); + this.setState({ + phase: PHASES.connected + }); + } + handleHelp () { + // @todo: implement the help button + } + render () { + return ( + <ConnectionModalComponent + deviceImage={this.props.deviceImage} + extensionId={this.props.extensionId} + name={this.props.name} + phase={this.state.phase} + smallDeviceImage={this.props.smallDeviceImage} + title={this.props.extensionId} + vm={this.props.vm} + onCancel={this.props.onCancel} + onConnected={this.handleConnected} + onConnecting={this.handleConnecting} + onDisconnect={this.handleDisconnect} + onHelp={this.handleHelp} + onScanning={this.handleScanning} + /> + ); + } +} + +ConnectionModal.propTypes = { + deviceImage: PropTypes.string.isRequired, + extensionId: PropTypes.string.isRequired, + name: PropTypes.node.isRequired, + onCancel: PropTypes.func.isRequired, + onStatusButtonUpdate: PropTypes.func.isRequired, + smallDeviceImage: PropTypes.string.isRequired, + vm: PropTypes.instanceOf(VM).isRequired +}; + +export default ConnectionModal; diff --git a/src/containers/scanning-step.jsx b/src/containers/scanning-step.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ecc19318913845154c248f716dfad6d440147c2e --- /dev/null +++ b/src/containers/scanning-step.jsx @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import ScanningStepComponent from '../components/connection-modal/scanning-step.jsx'; +import VM from 'scratch-vm'; + +class ScanningStep extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handlePeripheralListUpdate', + 'handlePeripheralScanTimeout', + 'handleRefresh' + ]); + this.state = { + scanning: true, + deviceList: [] + }; + } + componentDidMount () { + this.props.vm.startDeviceScan(this.props.extensionId); + this.props.vm.on( + 'PERIPHERAL_LIST_UPDATE', this.handlePeripheralListUpdate); + this.props.vm.on( + 'PERIPHERAL_SCAN_TIMEOUT', this.handlePeripheralScanTimeout); + } + componentWillUnmount () { + // @todo: stop the device scan here + this.props.vm.removeListener( + 'PERIPHERAL_LIST_UPDATE', this.handlePeripheralListUpdate); + this.props.vm.removeListener( + 'PERIPHERAL_SCAN_TIMEOUT', this.handlePeripheralScanTimeout); + } + handlePeripheralScanTimeout () { + this.setState({scanning: false}); + } + handlePeripheralListUpdate (newList) { + // TODO: sort peripherals by signal strength? so they don't jump around + const peripheralArray = Object.keys(newList).map(id => + newList[id] + ); + this.setState({deviceList: peripheralArray}); + } + handleRefresh () { + this.props.vm.startDeviceScan(this.props.extensionId); + this.setState({ + scanning: true, + deviceList: [] + }); + } + render () { + return ( + <ScanningStepComponent + deviceList={this.state.deviceList} + phase={this.state.phase} + smallDeviceImage={this.props.smallDeviceImage} + title={this.props.extensionId} + onConnected={this.props.onConnected} + onConnecting={this.props.onConnecting} + onRefresh={this.handleRefresh} + /> + ); + } +} + +ScanningStep.propTypes = { + extensionId: PropTypes.string.isRequired, + onConnected: PropTypes.func.isRequired, + onConnecting: PropTypes.func.isRequired, + smallDeviceImage: PropTypes.string, + vm: PropTypes.instanceOf(VM).isRequired +}; + +export default ScanningStep; diff --git a/src/css/colors.css b/src/css/colors.css index 39503771d31f4c543d8ff98bcb2d387155159bd4..37ffa62227a6f6ccb42a6170d4652db037f4f769 100644 --- a/src/css/colors.css +++ b/src/css/colors.css @@ -15,6 +15,7 @@ $text-primary-transparent: hsla(225, 15%, 40%, 0.75); $motion-primary: hsla(215, 100%, 65%, 1); /* #4C97FF */ $motion-tertiary: hsla(215, 60%, 50%, 1); /* #3373CC */ $motion-transparent: hsla(215, 100%, 65%, 0.35); /* 35% transparent version of motion-primary */ +$motion-light-transparent: hsla(215, 100%, 65%, 0.1); /* 10% transparent version of motion-primary */ $red-primary: hsla(20, 100%, 55%, 1); /* #FF661A */ $red-tertiary: hsla(20, 100%, 45%, 1); /* #E64D00 */ @@ -26,6 +27,12 @@ $control-primary: hsla(38, 100%, 55%, 1); /* #FFAB19 */ $data-primary: hsla(30, 100%, 55%, 1); /* #FF8C1A */ +$pen-primary: hsla(163, 85%, 40%, 1); /* #0FBD8C */ +$pen-transparent: hsla(163, 85%, 40%, 0.25); /* #0FBD8C */ + +$error-primary: hsla(30, 100%, 55%, 1); /* #FF8C1A */ +$error-transparent: hsla(30, 100%, 55%, 0.25); /* #FF8C1A */ + $extensions-primary: hsla(163, 85%, 40%, 1); /* #0FBD8C */ $extensions-tertiary: hsla(163, 85%, 30%, 1); /* #0B8E69 */ $extensions-transparent: hsla(163, 85%, 40%, 0.35); /* 35% transparent version of extensions-primary */ diff --git a/src/lib/libraries/extensions/device-connection/ev3/ev3-hub-illustration.svg b/src/lib/libraries/extensions/device-connection/ev3/ev3-hub-illustration.svg new file mode 100644 index 0000000000000000000000000000000000000000..7f8c8c395969e202e01d4e18c0225b5ab98c49b0 Binary files /dev/null and b/src/lib/libraries/extensions/device-connection/ev3/ev3-hub-illustration.svg differ diff --git a/src/lib/libraries/extensions/device-connection/ev3/ev3-menu-icon.svg b/src/lib/libraries/extensions/device-connection/ev3/ev3-menu-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..7c8ea12abb768d48c8d544b1984fabf621e28c42 Binary files /dev/null and b/src/lib/libraries/extensions/device-connection/ev3/ev3-menu-icon.svg differ diff --git a/src/lib/libraries/extensions/device-connection/microbit/microbit-illustration.svg b/src/lib/libraries/extensions/device-connection/microbit/microbit-illustration.svg new file mode 100644 index 0000000000000000000000000000000000000000..3ef892bcc456fd9196e72356742391fd74b67a93 Binary files /dev/null and b/src/lib/libraries/extensions/device-connection/microbit/microbit-illustration.svg differ diff --git a/src/lib/libraries/extensions/device-connection/microbit/microbit-menu-icon.svg b/src/lib/libraries/extensions/device-connection/microbit/microbit-menu-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..af97b7948e8930b49c230a7386517aef091e0a51 Binary files /dev/null and b/src/lib/libraries/extensions/device-connection/microbit/microbit-menu-icon.svg differ diff --git a/src/lib/libraries/extensions/index.jsx b/src/lib/libraries/extensions/index.jsx index 71404f8fb0e2abd8a47018569487c350ad581074..71b1f2fe9ef34d6247411fcf62dad2d97fbd7c93 100644 --- a/src/lib/libraries/extensions/index.jsx +++ b/src/lib/libraries/extensions/index.jsx @@ -11,6 +11,12 @@ import ev3Image from './ev3.png'; import boostImage from './boost.png'; import translateImage from './translate.png'; +import ev3DeviceImage from './device-connection/ev3/ev3-hub-illustration.svg'; +import ev3MenuImage from './device-connection/ev3/ev3-menu-icon.svg'; + +import microbitDeviceImage from './device-connection/microbit/microbit-illustration.svg'; +import microbitMenuImage from './device-connection/microbit/microbit-menu-icon.svg'; + export default [ { name: ( @@ -120,7 +126,10 @@ export default [ /> ), featured: true, - disabled: true + disabled: true, + launchDeviceConnectionFlow: true, + deviceImage: microbitDeviceImage, + smallDeviceImage: microbitMenuImage }, { name: 'LEGO WeDo 2.0', @@ -148,7 +157,10 @@ export default [ /> ), featured: true, - disabled: true + disabled: true, + launchDeviceConnectionFlow: true, + deviceImage: ev3DeviceImage, + smallDeviceImage: ev3MenuImage }, { name: 'LEGO Boost',