├── .env.example
├── .eslintrc.json
├── .github
└── workflows
│ └── linters.yml
├── .gitignore
├── .netlify
└── state.json
├── .stylelintrc.json
├── LICENSE
├── README.md
├── app_screenshot.png
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
└── src
├── assets
└── icons
│ └── speech.svg
├── components
├── App.js
├── Button.js
├── ButtonPanel.js
└── Display.js
├── index.css
├── index.js
└── logic
├── calculate.js
├── helper.js
├── operate.js
├── polly.js
└── pollyff.js
/.env.example:
--------------------------------------------------------------------------------
1 | REACT_APP_AWS_ACCESS_KEY_ID = ''
2 | REACT_APP_AWS_SECRET_ACCESS_KEY = ''
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true,
5 | "jest": true
6 | },
7 | "parserOptions": {
8 | "ecmaFeatures": {
9 | "jsx": true
10 | },
11 | "ecmaVersion": 2018,
12 | "sourceType": "module"
13 | },
14 | "extends": [
15 | "airbnb",
16 | "plugin:react/recommended"
17 | ],
18 | "plugins": [
19 | "react"
20 | ],
21 | "rules": {
22 | "react/jsx-filename-extension": [
23 | "warn",
24 | {
25 | "extensions": [
26 | ".js",
27 | ".jsx"
28 | ]
29 | }
30 | ],
31 | "import/no-unresolved": "off",
32 | "no-shadow": "off",
33 | "arrow-parens": [
34 | "error",
35 | "as-needed"
36 | ]
37 | },
38 | "ignorePatterns": [
39 | "dist/",
40 | "build/"
41 | ]
42 | }
--------------------------------------------------------------------------------
/.github/workflows/linters.yml:
--------------------------------------------------------------------------------
1 | name: Linters
2 |
3 | on: pull_request
4 |
5 | env:
6 | FORCE_COLOR: 1
7 |
8 | jobs:
9 | eslint:
10 | name: ESLint
11 | runs-on: ubuntu-18.04
12 | steps:
13 | - uses: actions/checkout@v2
14 | - uses: actions/setup-node@v1
15 | with:
16 | node-version: "12.x"
17 | - name: Setup ESLint
18 | run: |
19 | npm install --save-dev eslint@6.8.x eslint-config-airbnb@18.1.x eslint-plugin-import@2.20.x eslint-plugin-jsx-a11y@6.2.x eslint-plugin-react@7.20.x eslint-plugin-react-hooks@2.5.x
20 | [ -f .eslintrc.json ] || wget https://raw.githubusercontent.com/microverseinc/linters-config/master/react-redux/.eslintrc.json
21 | - name: ESLint Report
22 | run: npx eslint .
23 | stylelint:
24 | name: Stylelint
25 | runs-on: ubuntu-18.04
26 | steps:
27 | - uses: actions/checkout@v2
28 | - uses: actions/setup-node@v1
29 | with:
30 | node-version: "12.x"
31 | - name: Setup Stylelint
32 | run: |
33 | npm install --save-dev stylelint@13.3.x stylelint-scss@3.17.x stylelint-config-standard@20.0.x stylelint-csstree-validator
34 | [ -f .stylelintrc.json ] || wget https://raw.githubusercontent.com/microverseinc/linters-config/master/react-redux/.stylelintrc.json
35 | - name: Stylelint Report
36 | run: npx stylelint "**/*.{css,scss}"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .env
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/.netlify/state.json:
--------------------------------------------------------------------------------
1 | {
2 | "siteId": "4f9ddb8f-a239-4a0d-9426-5ea4543dd4a7"
3 | }
--------------------------------------------------------------------------------
/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "stylelint-config-standard"
4 | ],
5 | "plugins": [
6 | "stylelint-scss",
7 | "stylelint-csstree-validator"
8 | ],
9 | "rules": {
10 | "at-rule-no-unknown": null,
11 | "scss/at-rule-no-unknown": true,
12 | "csstree/validator": true
13 | },
14 | "ignoreFiles": [
15 | "build/**",
16 | "dist/**",
17 | "**/reset*.css",
18 | "**/bootstrap*.css"
19 | ]
20 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Carlos Anriquez
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://app.netlify.com/sites/anriquez-calculator/deploys)
3 |
4 | # Accesible React Calculator
5 |
6 | > Accessibility focused basic React calculator bootstrapped with create-react-app
7 |
8 | ## Live Demo on Netlify
9 | [](https://app.netlify.com/sites/anriquez-calculator/deploys)
10 |
11 | [Live Demo Link](https://anriquez-calculator.netlify.app/)
12 |
13 | 
14 |
15 | - Calculator App with a strong focus on accessibility built with React.js. Practicing React Component classes, states, props, and AWS Polly.
16 | - This project is far from perfect. I decided to practice some basic ideas about accessibility by implementing full control via keyboard/mouse.
17 | - This project is also a practice implementation for the AWS service [Amazon Polly](https://aws.amazon.com/polly/)
18 |
19 |
20 | ## How to use it.
21 |
22 | ### Normal operation
23 |
24 | - You can use the calculator by clicking on the buttons or touch screen device as a standard calculator.
25 | - You can key the numbers and operations using the keyboard.
26 | #### Special Keys:
27 | - ```Enter``` => '='
28 | - ```delete/backspace``` => 'AC'
29 | - ```_``` => '+/-'
30 | - ``` / ``` => Division symbol
31 | - ``` * or x ``` => Multiplication Symbol
32 |
33 | ### Activate Text to Speech functionality:
34 | - Touch/Click: Speech function is activated clicking/touching the button `Speech`.
35 | - Keyboard: Typing the key 's' (lower case)
36 | - On activation, you will hear a voice with an activation message en English (default language).
37 | - A Icon  will appear on display to indicate the Text to Speech activation.
38 |
39 | ### Change text to speech-language : Spanish / English
40 | - Touch/Click: on buttons `En` or `Sp` for language toggling.
41 | - Keyboard: Typing the key 'l' (lower case) for language toggling.
42 | - On Toggling, you will hear a voice on the selected language. The proper Icons will appear on Display.
43 |
44 | ### Push To Talk functionality: PTT
45 | - Touch/Click: on PTT button for PTT function activation.
46 | - Keyboard: Typing the key 'p' (lower case) for the push to talk function.
47 | - On activation, this function will read out loud the Display contents.
48 | - A similar effect can be achieved by using the '=' sign.
49 |
50 | ## Built With
51 |
52 | - React.js, Webpack, Babel
53 | - HTML5/CSS3, Javascript ES6
54 | - ESlint, Stylelint
55 | - VSCode
56 | - AWS Polly
57 |
58 |
59 | ## Getting Started
60 |
61 |
62 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
63 |
64 |
65 | To get a local copy up and running, follow these simple example steps.
66 |
67 | ### Prerequisites
68 |
69 | ### Amazon Polly Setup
70 |
71 | - Create a ```.env``` file in the root of the project and include your own AWS Amazon Polly credentials. YOu can use as reference the file included in the repo ```.env.example```
72 |
73 | ```
74 | REACT_APP_AWS_ACCESS_KEY_ID = ''
75 | REACT_APP_AWS_SECRET_ACCESS_KEY = ''
76 | ```
77 |
78 | ## Install
79 |
80 | ### `yarn start`
81 |
82 | Runs the app in the development mode.
83 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
84 |
85 | The page will reload if you make edits.
86 | You will also see any lint errors in the console.
87 |
88 | ### `yarn build`
89 |
90 | Builds the app for production to the `build` folder.
91 | It correctly bundles React in production mode and optimizes the build for the best performance.
92 |
93 | The build is minified and the filenames include the hashes.
94 | Your app is ready to be deployed!
95 |
96 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
97 |
98 |
99 | ## Authors
100 |
101 | 👤 ***Carlos Anriquez***
102 |
103 | - Github: [@canriquez](https://github.com/canriquez)
104 | - Twitter: [@cranriquez](https://twitter.com/cranriquez)
105 | - Linkedin: [linkedin](https://www.linkedin.com/in/carlosanriquez/)
106 | - Portfolio: [carlosanriquez.com](https://www.carlosanriquez.com)
107 |
108 |
109 | ## 🤝 Contributing
110 |
111 | Contributions, issues, and feature requests are welcome!
112 |
113 | Feel free to check the [issues page](https://github.com/canriquez/react-calculator/issues).
114 |
115 | ## Show your support
116 |
117 | Give a ⭐️ if you like this project!
118 |
119 | ## Acknowledgments
120 |
121 | - My Family
122 | - The Beagles!
123 | ## 📝 License
124 |
125 | This project is [MIT](./LICENSE) licensed.
126 |
--------------------------------------------------------------------------------
/app_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/canriquez/react-calculator/7c5ab5e789fa89feae086792aa18843c8cf962f5/app_screenshot.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-calculator",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "start": "react-scripts start",
7 | "build": "react-scripts build",
8 | "test": "react-scripts test",
9 | "eject": "react-scripts eject"
10 | },
11 | "eslintConfig": {
12 | "extends": "react-app"
13 | },
14 | "browserslist": {
15 | "production": [
16 | ">0.2%",
17 | "not dead",
18 | "not op_mini all"
19 | ],
20 | "development": [
21 | "last 1 chrome version",
22 | "last 1 firefox version",
23 | "last 1 safari version"
24 | ]
25 | },
26 | "devDependencies": {
27 | "eslint": "^6.8.0",
28 | "eslint-config-airbnb": "^18.1.0",
29 | "eslint-plugin-import": "^2.20.2",
30 | "eslint-plugin-jsx-a11y": "^6.2.3",
31 | "eslint-plugin-react": "^7.20.6",
32 | "eslint-plugin-react-hooks": "^2.5.1",
33 | "stylelint": "^13.3.3",
34 | "stylelint-config-standard": "^20.0.0",
35 | "stylelint-csstree-validator": "^1.8.0",
36 | "stylelint-scss": "^3.17.2"
37 | },
38 | "dependencies": {
39 | "@testing-library/jest-dom": "^4.2.4",
40 | "@testing-library/react": "^9.3.2",
41 | "@testing-library/user-event": "^7.1.2",
42 | "aws-sdk": "^2.745.0",
43 | "big.js": "^5.2.2",
44 | "prop-types": "^15.7.2",
45 | "react": "^16.13.1",
46 | "react-dom": "^16.13.1",
47 | "react-gtm-module": "^2.0.10",
48 | "react-scripts": "3.4.3"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/canriquez/react-calculator/7c5ab5e789fa89feae086792aa18843c8cf962f5/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/canriquez/react-calculator/7c5ab5e789fa89feae086792aa18843c8cf962f5/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/canriquez/react-calculator/7c5ab5e789fa89feae086792aa18843c8cf962f5/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/assets/icons/speech.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/media-has-caption */
2 | import React from 'react';
3 | import Display from './Display';
4 | import ButtonPanel from './ButtonPanel';
5 | import calculate from '../logic/calculate';
6 | import talkPolly from '../logic/polly';
7 | import {
8 | onBtn, offBtn, onIcon, offIcon, key2Click,
9 | } from '../logic/helper';
10 |
11 | class App extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.state = {
15 | total: null,
16 | next: null,
17 | operation: null,
18 | ondisplay: null,
19 | speech: false,
20 | lang: 0,
21 | };
22 |
23 | this.handleClick = this.handleClick.bind(this);
24 | this.handleKey = this.handleKey.bind(this);
25 | this.toggleSpeech = this.toggleSpeech.bind(this);
26 | this.toggleLanguage = this.toggleLanguage.bind(this);
27 | this.pttDisplay = this.pttDisplay.bind(this);
28 | }
29 |
30 | componentDidMount() {
31 | document.title = 'Accesible React Calculator';
32 | document.body.addEventListener('keydown', this.handleKey);
33 | }
34 |
35 | handleClick(buttonName) {
36 | // eslint-disable-next-line
37 | const {
38 | speech, lang,
39 | } = this.state;
40 |
41 | if (buttonName === 'Speech') {
42 | this.toggleSpeech(buttonName);
43 | return;
44 | }
45 | if ((buttonName === 'En' || buttonName === 'Sp') && speech) {
46 | this.toggleLanguage(buttonName);
47 | return;
48 | } if (buttonName === 'En' || buttonName === 'Sp') {
49 | return;
50 | }
51 |
52 | if ((buttonName === 'PTT') && speech) {
53 | this.pttDisplay(buttonName);
54 | return;
55 | } if (buttonName === 'PTT') {
56 | return;
57 | }
58 |
59 | const currentResult = calculate(this.state, buttonName);
60 | this.setState(currentResult, () => {
61 | let ttSpeech = buttonName;
62 | if (speech) {
63 | if (buttonName === '-' && lang === 0) { ttSpeech = 'minus'; }
64 | if (buttonName === 'x' && lang === 1) { ttSpeech = 'multiplicado por'; }
65 | if (buttonName === '÷' && lang === 1) { ttSpeech = 'dividido entre'; }
66 | if (buttonName === '-' && lang === 1) { ttSpeech = 'menos'; }
67 | if (buttonName === 'x' && lang === 0) { ttSpeech = 'multiplied by'; }
68 | if (buttonName === '+/-' && lang === 0) { ttSpeech = 'negative'; }
69 | if (buttonName === '+/-' && lang === 1) { ttSpeech = 'negativo'; }
70 |
71 | if (ttSpeech === '=') {
72 | this.pttDisplay('= ');
73 | } else {
74 | talkPolly(this.state, ttSpeech, buttonName);
75 | }
76 | }
77 | });
78 | }
79 |
80 | handleKey(e) {
81 | if (e.key === 'Enter') {
82 | e.preventDefault();
83 | e.stopPropagation();
84 | }
85 | const {
86 | speech, lang,
87 | } = this.state;
88 | const allowedKeys = ['s', 'l', 'p', 'Enter', 'Backspace',
89 | '%', '/', '7', '8', '9', 'x', '4', '5', '6',
90 | '-', '_', '*', '1', '2', '3', '+', '0', '.', '='];
91 |
92 | if (!allowedKeys.includes(e.key)) { return; }
93 | e.stopPropagation();
94 |
95 | let keyName = e.key;
96 | if (e.key === 's') {
97 | this.toggleSpeech(key2Click(e.key));
98 | return;
99 | }
100 |
101 | if ((keyName === 'l') && speech) {
102 | this.toggleLanguage(key2Click(e.key));
103 | return;
104 | } if (keyName === 'l') {
105 | return;
106 | }
107 |
108 | if ((keyName === 'p') && speech) {
109 | this.pttDisplay(key2Click(e.key), '');
110 | return;
111 | } if (keyName === 'p') {
112 | return;
113 | }
114 |
115 | if (e.key === '/') { keyName = '÷'; }
116 | if (e.key === 'x' || e.key === '*') { keyName = 'x'; }
117 | if (e.key === 'Backspace') { keyName = 'AC'; }
118 | if (e.key === 'Enter') { keyName = '='; }
119 | if (e.key === '_') { keyName = '+/-'; }
120 | const currentResult = calculate(this.state, keyName);
121 | this.setState(currentResult);
122 | if (speech) {
123 | if (keyName === '-' && lang === 0) { keyName = 'minus'; }
124 | if (keyName === '-' && lang === 1) { keyName = 'menos'; }
125 | if (keyName === 'x' && lang === 1) { keyName = 'multiplicado por'; }
126 | if (keyName === '÷' && lang === 1) { keyName = 'dividido entre'; }
127 | if (keyName === 'x' && lang === 0) { keyName = 'multiplied by'; }
128 | if (keyName === '+/-' && lang === 0) { keyName = 'negative'; }
129 | if (keyName === '+/-' && lang === 1) { keyName = 'negativo'; }
130 | if (keyName === 'Shift') { return; }
131 | if (keyName === '=' || keyName === 'Enter') {
132 | this.pttDisplay('= ');
133 | } else {
134 | talkPolly(this.state, keyName, key2Click(e.key));
135 | }
136 | }
137 | }
138 |
139 | toggleSpeech(buttonName) {
140 | const { speech } = this.state;
141 | if (!speech) {
142 | talkPolly(this.state, 'Speech enabled.', buttonName);
143 | this.setState(state => ({ speech: !state.speech }));
144 | onBtn('Speech');
145 | onIcon('speechico');
146 | onBtn('En');
147 | onIcon('en');
148 | // show little icon on speach top left of screen
149 | // show little icon on English language per default
150 | } else {
151 | talkPolly(this.state, 'Speech disabled.', buttonName);
152 | this.setState(state => ({ speech: !state.speech }));
153 | offBtn('Speech');
154 | offIcon('speechico');
155 | offBtn('En');
156 | offBtn('Sp');
157 | offIcon('es');
158 | offIcon('en');
159 | // hide little icon on speach top left of screen
160 | }
161 | }
162 |
163 | toggleLanguage(buttonName) {
164 | const { lang } = this.state;
165 | if (lang === 0) {
166 | this.setState({ lang: 1 }, () => {
167 | talkPolly(this.state, 'espanol activado.', buttonName);
168 | });
169 | onBtn('Sp');
170 | offBtn('En');
171 | onIcon('es');
172 | offIcon('en');
173 | // show little icon on speach top left of screen
174 | // show little icon on English language per default
175 | } else {
176 | this.setState({ lang: 0 }, () => {
177 | talkPolly(this.state, 'English activated.', buttonName);
178 | });
179 | onBtn('En');
180 | offBtn('Sp');
181 | onIcon('en');
182 | offIcon('es');
183 | // hide little icon on speach top left of screen
184 | }
185 | }
186 |
187 | pttDisplay(buttonName, txt = '') {
188 | const { total, next, operation } = this.state;
189 | let resultToRender = '';
190 | if (operation && !next) { resultToRender = total; }
191 | if (operation && next) { resultToRender = next; }
192 | if (!operation && total) { resultToRender = total; }
193 | resultToRender = txt + resultToRender;
194 | if (resultToRender === '') { resultToRender = '= 0'; }
195 |
196 | talkPolly(this.state, resultToRender, buttonName);
197 | }
198 |
199 | render() {
200 | const { total, next, operation } = this.state;
201 | let resultToRender = '';
202 | if (operation && !next) { resultToRender = total; }
203 | if (operation && next) { resultToRender = next; }
204 | if (!operation) { resultToRender = total; }
205 |
206 | return (
207 |
220 | );
221 | }
222 | }
223 |
224 | export default App;
225 |
--------------------------------------------------------------------------------
/src/components/Button.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/media-has-caption, prefer-template */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | const Button = props => {
6 | const handleClick = e => {
7 | e.stopPropagation();
8 | props.clickHandler(e.target.id);
9 | };
10 |
11 | const { buttonName, wide, color } = props;
12 | let buttonStyles = '';
13 |
14 | if (wide === 3) {
15 | buttonStyles += 'wide button';
16 | } else if (wide === 2) {
17 | buttonStyles += 'small button';
18 | } else {
19 | buttonStyles += 'top-row btop disabled';
20 | }
21 | buttonStyles += ` ${color}`;
22 |
23 | return (
24 | <>
25 |
28 |
39 | >
40 | );
41 | };
42 |
43 | Button.defaultProps = {
44 | buttonName: '',
45 | wide: false,
46 | color: 'orange',
47 | clickHandler: null,
48 | };
49 | Button.propTypes = {
50 | buttonName: PropTypes.string,
51 | wide: PropTypes.number,
52 | color: PropTypes.string,
53 | clickHandler: PropTypes.func,
54 | };
55 |
56 | export default Button;
57 |
--------------------------------------------------------------------------------
/src/components/ButtonPanel.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Button from './Button';
4 |
5 | const ButtonPanel = props => {
6 | const handleClick = buttonName => {
7 | props.clickHandler(buttonName);
8 | };
9 |
10 | const panelKeys = {
11 | 0: ['Speech', 'En', 'Sp', 'PTT'],
12 | 1: ['AC', '+/-', '%', '÷'],
13 | 2: ['7', '8', '9', 'x'],
14 | 3: ['4', '5', '6', '-'],
15 | 4: ['1', '2', '3', '+'],
16 | 5: ['0', '.', '='],
17 | };
18 |
19 | const buttonType = bttn => {
20 | if (panelKeys[0].includes(bttn)) { return 1; }
21 | if (bttn === '0') { return 3; }
22 | return 2;
23 | };
24 |
25 | const panelTag = [];
26 |
27 | Object.keys(panelKeys).forEach(key => {
28 | panelTag.push(
29 |
30 | {panelKeys[key].map((bttn, i) => (
31 |
38 | ))}
39 |
,
40 | );
41 | });
42 |
43 | return (
44 |
45 | {panelTag}
46 |
47 |
48 | );
49 | };
50 |
51 | ButtonPanel.defaultProps = {
52 | clickHandler: null,
53 | };
54 | ButtonPanel.propTypes = {
55 | clickHandler: PropTypes.func,
56 | };
57 |
58 | export default ButtonPanel;
59 |
--------------------------------------------------------------------------------
/src/components/Display.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import speech from '../assets/icons/speech.svg';
4 |
5 | const Display = props => {
6 | const { result } = props;
7 | return (
8 |
9 |
10 |

11 |
En
12 |
Sp
13 |
14 | {result || '0'}
15 |
16 |
17 | );
18 | };
19 |
20 | // Defaults and proptypes
21 | Display.propTypes = {
22 | result: PropTypes.string,
23 | };
24 |
25 | Display.defaultProps = {
26 | result: '0',
27 | };
28 |
29 | export default Display;
30 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | -webkit-box-sizing: border-box;
6 | -moz-box-sizing: border-box;
7 | transition: all 0.2s ease-in-out;
8 | }
9 |
10 | #app-container {
11 | display: flex;
12 | flex-direction: column;
13 | position: absolute;
14 | top: 1%;
15 | left: 50%;
16 | transform: translateX(-50%);
17 | width: 700px;
18 | min-width: 230px;
19 | }
20 |
21 | #display-container {
22 | background-color: rgb(133, 134, 147);
23 | display: flex;
24 | flex-direction: row;
25 | justify-content: flex-end;
26 | align-items: center;
27 | height: 100px;
28 | }
29 |
30 | #button-panel {
31 | display: flex;
32 | flex-direction: column;
33 | }
34 |
35 | #displayNumbers {
36 | color: white;
37 | padding: 10px;
38 | font-size: 4rem;
39 | font-weight: 600;
40 | }
41 |
42 | .buttonRow {
43 | display: flex;
44 | flex-direction: row;
45 | justify-content: space-between;
46 | }
47 |
48 | .button {
49 | color: black;
50 | font-size: 2rem;
51 | background: rgb(224, 224, 224);
52 | border: 2px outset rgba(209, 209, 209);
53 | }
54 |
55 | .btop {
56 | font-size: 2rem;
57 | background: darkgray !important;
58 | border: 1px outset white;
59 | }
60 |
61 | .disabled {
62 | color: white;
63 | }
64 |
65 | .enabled {
66 | color: lightgreen;
67 | }
68 |
69 | .small {
70 | width: 25%;
71 | height: 100px;
72 | }
73 |
74 | .top-row {
75 | width: 25%;
76 | height: 50px;
77 | }
78 |
79 | .wide {
80 | width: 50%;
81 | height: 100px;
82 | }
83 |
84 | .orange {
85 | background: rgb(245, 145, 62);
86 | }
87 |
88 | .gray {
89 | background: rgb(224, 224, 224);
90 | }
91 |
92 | .speech-icon {
93 | position: absolute;
94 | top: 5px;
95 | left: 10px;
96 | }
97 |
98 | .lang-icon {
99 | position: absolute;
100 | top: 10px;
101 | left: 45px;
102 | color: black;
103 | font-size: 1.1rem;
104 | }
105 |
106 | .onIcon {
107 | display: block;
108 | }
109 |
110 | .offIcon {
111 | display: none;
112 | }
113 |
114 | .brand {
115 | position: absolute;
116 | top: 80px;
117 | left: 0;
118 | }
119 |
120 | .repo {
121 | position: absolute;
122 | top: 0;
123 | right: 0;
124 | }
125 |
126 | @media screen and (max-width: 414px) {
127 | #app-container {
128 | max-width: 98%;
129 | }
130 |
131 | .small {
132 | width: 25%;
133 | height: 10vh;
134 | }
135 |
136 | .wide {
137 | width: 50%;
138 | height: 10vh;
139 | }
140 |
141 | .btop {
142 | font-size: 1.3rem;
143 | background: darkgray !important;
144 | border: 1px outset white;
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import TagManager from 'react-gtm-module';
4 | import './index.css';
5 | import App from './components/App';
6 |
7 | const tagManagerArgs = {
8 | gtmId: 'GTM-MWKTMHC',
9 | };
10 |
11 | TagManager.initialize(tagManagerArgs);
12 |
13 | ReactDOM.render(
14 | ,
15 | document.getElementById('root'),
16 | );
17 |
--------------------------------------------------------------------------------
/src/logic/calculate.js:
--------------------------------------------------------------------------------
1 | import Big from 'big.js';
2 | import operate from './operate';
3 |
4 | const calculate = (calcData, buttonName) => {
5 | // eslint-disable-next-line
6 | let { total, next, operation } = calcData;
7 | const operationTypes = ['+', '-', '÷', 'x'];
8 |
9 | const isOperation = ops => operationTypes.includes(ops);
10 |
11 | if (buttonName === '+/-') {
12 | if (operation && next !== '0') {
13 | return {
14 | total,
15 | next: (Big(next).times(-1)).toString(),
16 | operation,
17 | };
18 | }
19 | if (!total || total === '0') { return { total, next, operation }; }
20 | return {
21 | total: (Big(total).times(-1)).toString(),
22 | next,
23 | operation,
24 | };
25 | }
26 | // applies percentage calculation
27 | if (buttonName === '%') {
28 | // when operation exist, applies to the percent to total after
29 | // executing operation
30 | if (operation && (total !== '0' || next !== '0')) {
31 | const ops = operate(total, next, operation);
32 | let result;
33 | // eslint-disable-next-line
34 | ops !== 'Error' ? result = Big(ops).div(100).toString() : result = ops;
35 | return {
36 | total: result,
37 | next: null,
38 | operation: null,
39 | };
40 | }
41 | // when no operation exist, applies percent to total only
42 | if (!total || total === '0') { return { total, next, operation }; }
43 | return {
44 | total: (Big(total).div(100).toString()),
45 | next,
46 | operation,
47 | };
48 | }
49 |
50 | if (buttonName === 'AC') {
51 | // clear second number in the operation
52 | if (next && operation) {
53 | return {
54 | total,
55 | next: '0',
56 | operation,
57 | };
58 | }
59 | return {
60 | total: null,
61 | next: null,
62 | operation: null,
63 | };
64 | }
65 |
66 | // If use hits equal
67 |
68 | if (buttonName === '=') {
69 | if (operation) {
70 | // when opertion exist. executes the operation
71 | return {
72 | total: operate(total, next, operation),
73 | next: null,
74 | operation: null,
75 | };
76 | }
77 | // If no operation exist, exits with no change
78 | return {
79 | total,
80 | next,
81 | operation,
82 | };
83 | }
84 |
85 | // if user hist an operation after valid first number
86 | if (isOperation(buttonName) && total && !operation) {
87 | // store operation when no operation exists.
88 | return {
89 | total,
90 | next,
91 | operation: buttonName,
92 | };
93 | }
94 | // if user hist another operation with total and next. (solve opeartions concatenation)
95 | if (isOperation(buttonName) && total && next && operation) {
96 | // solves the current operatoin. clear next and stores new operation
97 | return {
98 | total: Big(operate(total, next, operation)).toString(),
99 | next: null,
100 | operation: buttonName,
101 | };
102 | }
103 |
104 | // if user hist another operation with total and no next. updates operation
105 | if (isOperation(buttonName) && total && !next) {
106 | // solves the current operatoin. clear next and stores new operation
107 | return {
108 | total,
109 | next,
110 | operation: buttonName,
111 | };
112 | }
113 |
114 | // if user hits a number key, we start constructing the operations input
115 |
116 | if (buttonName.match(/\d/)) {
117 | if (!operation) {
118 | // builds first number
119 | if (!total || total === '0' || total === 'Error') {
120 | total = buttonName;
121 | } else {
122 | total += buttonName;
123 | }
124 | } else if (!next || next === '0') {
125 | // builds second number.
126 | next = buttonName;
127 | } else {
128 | next += buttonName;
129 | }
130 |
131 | return {
132 | total,
133 | next,
134 | operation,
135 | };
136 | }
137 | // if user hit operations but total and next are null, we do nothing and come back
138 | if (buttonName === '.' && (total || next)) {
139 | // if total gets a valid decimal period
140 | if (!next && !total.includes('.')) {
141 | total += '.';
142 | return {
143 | total,
144 | next,
145 | operation,
146 | };
147 | } if (next && !next.includes('.')) {
148 | // if next gets a valid decimal period
149 | next += '.';
150 | return {
151 | total,
152 | next,
153 | operation,
154 | };
155 | }
156 | }
157 | return { total, next, operation };
158 | };
159 |
160 | export default calculate;
161 |
--------------------------------------------------------------------------------
/src/logic/helper.js:
--------------------------------------------------------------------------------
1 | const onBtn = btn => {
2 | const targetBtn = document.getElementById(btn);
3 | targetBtn.classList.remove('disabled');
4 | targetBtn.classList.add('enabled');
5 | };
6 |
7 | const offBtn = btn => {
8 | const targetBtn = document.getElementById(btn);
9 | targetBtn.classList.remove('enabled');
10 | targetBtn.classList.add('disabled');
11 | };
12 |
13 | const onIcon = icon => {
14 | const targetIcon = document.getElementById(icon);
15 | targetIcon.classList.remove('offIcon');
16 | targetIcon.classList.add('onIcon');
17 | };
18 |
19 | const offIcon = icon => {
20 | const targetIcon = document.getElementById(icon);
21 | targetIcon.classList.remove('onIcon');
22 | targetIcon.classList.add('offIcon');
23 | };
24 |
25 | const key2Click = key => {
26 | const allowedKeys = ['s', 'l', 'p', 'Enter', 'Backspace',
27 | '%', '/', '7', '8', '9', 'x', '4', '5', '6',
28 | '-', '_', '*', '1', '2', '3', '+', '0', '.', '='];
29 | const clickPad = ['Speech', 'En', 'PTT', '=', 'AC',
30 | '%', '÷', '7', '8', '9', 'x', '4', '5', '6',
31 | '-', '+/-', 'x', '1', '2', '3', '+', '0', '.', '='];
32 | return clickPad[allowedKeys.indexOf(key)];
33 | };
34 |
35 | export {
36 | onBtn, offBtn, onIcon, offIcon, key2Click,
37 | };
38 |
--------------------------------------------------------------------------------
/src/logic/operate.js:
--------------------------------------------------------------------------------
1 | import Big from 'big.js/big.mjs';
2 |
3 | const operate = (numberOne, numberTwo, operation) => {
4 | if (numberTwo === '0' || !numberTwo) {
5 | return 'Error';
6 | }
7 | const nOne = Big(numberOne);
8 | const nTwo = Big(numberTwo);
9 |
10 | switch (operation) {
11 | case '÷':
12 | return nOne.div(nTwo).toString();
13 |
14 | case 'x':
15 | return nOne.times(nTwo).toString();
16 | case '-':
17 | return nOne.minus(nTwo).toString();
18 | case '+':
19 | return nOne.plus(nTwo).toString();
20 | default:
21 | return 'error';
22 | }
23 | };
24 |
25 | export default operate;
26 |
--------------------------------------------------------------------------------
/src/logic/polly.js:
--------------------------------------------------------------------------------
1 | import AWS from 'aws-sdk';
2 |
3 | const talkPolly = (conf, text, buttonId) => {
4 | AWS.config.accessKeyId = process.env.REACT_APP_AWS_ACCESS_KEY_ID;
5 | AWS.config.secretAccessKey = process.env.REACT_APP_AWS_SECRET_ACCESS_KEY;
6 | AWS.config.region = 'us-east-2';
7 |
8 | const polly = new AWS.Polly();
9 |
10 | const voiceSetup = ['Joanna', 'Mia'];
11 |
12 | const voiceOptions = {
13 | Joanna: { engine: 'standard', lang: 'Joanna', sr: '22050' },
14 | Mia: { engine: 'standard', lang: 'Mia', sr: '22050' },
15 | Kevin: { engine: 'neural', lang: 'Kevin', sr: '24000' },
16 | };
17 |
18 | const params = {
19 | Engine: voiceOptions[voiceSetup[conf.lang]].engine,
20 | OutputFormat: 'mp3',
21 | Text: `${text}`,
22 | TextType: 'ssml',
23 | SampleRate: voiceOptions[voiceSetup[conf.lang]].sr,
24 | VoiceId: voiceOptions[voiceSetup[conf.lang]].lang,
25 | };
26 |
27 | polly.synthesizeSpeech(params, (err, data) => {
28 | if (err) {
29 | // an error ocurred
30 | // console.log(err, err.stack);
31 | } else {
32 | const uInt8Array = new Uint8Array(data.AudioStream);
33 | const arrayBuffer = uInt8Array.buffer;
34 | const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
35 |
36 | const audio = document.getElementById(`audio-${buttonId.charCodeAt(0) === 61 ? 'equal' : buttonId}`);
37 | const url = URL.createObjectURL(blob);
38 | audio.setAttribute('src', url);
39 | audio.play();
40 | }
41 | });
42 | };
43 |
44 | export default talkPolly;
45 |
--------------------------------------------------------------------------------
/src/logic/pollyff.js:
--------------------------------------------------------------------------------
1 | import AWS from 'aws-sdk';
2 |
3 | const TalkPolly = () => {
4 | const speakQueue = [];
5 | let isTalking = false;
6 |
7 | const pollyEnq = obj => {
8 | const { conf, text } = obj;
9 | speakQueue.push({ conf, text });
10 | };
11 |
12 | const talkPolly = (conf, text) => {
13 | AWS.config.accessKeyId = process.env.REACT_APP_AWS_ACCESS_KEY_ID;
14 | AWS.config.secretAccessKey = process.env.REACT_APP_AWS_SECRET_ACCESS_KEY;
15 | AWS.config.region = 'us-east-2';
16 |
17 | const polly = new AWS.Polly();
18 | const params = {
19 | OutputFormat: 'mp3',
20 | Text: `${text}`,
21 | TextType: 'ssml',
22 | VoiceId: conf.lang,
23 | };
24 |
25 | polly.synthesizeSpeech(params, (err, data) => {
26 | if (err) {
27 | // an error ocurred
28 | } else {
29 | const uInt8Array = new Uint8Array(data.AudioStream);
30 | const arrayBuffer = uInt8Array.buffer;
31 | const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
32 |
33 | const audio = document.getElementById('polly');
34 | const url = URL.createObjectURL(blob);
35 | /* audio[0].src = url;
36 | audio[0].play(); */
37 | audio.setAttribute('src', url);
38 | audio.play();
39 | }
40 | });
41 | };
42 |
43 | const pollyTalkNow = () => {
44 | if (isTalking) { return; }
45 | if (speakQueue.length === 0) { return; }
46 |
47 | isTalking = true;
48 | while (speakQueue.length > 0) {
49 | const speak = speakQueue.shift();
50 | if (!isTalking) {
51 | talkPolly(speak.conf, speak.text);
52 | isTalking = true;
53 | }
54 | // eslint-disable-next-line
55 | document.getElementById("polly").onended = () => { isTalking = false };
56 | }
57 | };
58 | return { pollyEnq, pollyTalkNow };
59 | };
60 |
61 | export default TalkPolly;
62 |
--------------------------------------------------------------------------------