From 2946ce14655de55b979771f15aaf79c27eb36713 Mon Sep 17 00:00:00 2001 From: Andrew Cherniavskii Date: Sun, 10 Feb 2019 18:15:56 +0100 Subject: [PATCH] Split Settings screen into separate components (#37) * move Separator, SettingsRow and RowText to separate components * move touch id functionality to separate TouchId component * move Button and ProfilePic components to separate files * move Profile to separate component * fix linter error * move team to Profile component * fix linter error * fix flow errors * move usage limits to separate UsageLimits component * remove TODO comment --- src/components/elements/settings/Button.js | 9 + src/components/elements/settings/Profile.js | 278 +++++++++++ .../elements/settings/ProfilePic.js | 14 + src/components/elements/settings/RowText.js | 9 + src/components/elements/settings/Separator.js | 11 + .../elements/settings/SettingsRow.js | 11 + src/components/elements/settings/TouchId.js | 103 ++++ .../elements/settings/UsageLimits.js | 112 +++++ src/screens/Settings.js | 461 +----------------- storybook/stories/settings.js | 3 +- 10 files changed, 561 insertions(+), 450 deletions(-) create mode 100644 src/components/elements/settings/Button.js create mode 100644 src/components/elements/settings/Profile.js create mode 100644 src/components/elements/settings/ProfilePic.js create mode 100644 src/components/elements/settings/RowText.js create mode 100644 src/components/elements/settings/Separator.js create mode 100644 src/components/elements/settings/SettingsRow.js create mode 100644 src/components/elements/settings/TouchId.js create mode 100644 src/components/elements/settings/UsageLimits.js diff --git a/src/components/elements/settings/Button.js b/src/components/elements/settings/Button.js new file mode 100644 index 0000000..6ad48d2 --- /dev/null +++ b/src/components/elements/settings/Button.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const Button = styled.Text` + font-size: 18px; + font-weight: 300; + color: ${props => props.theme.settingsButton}; +`; + +export default Button; diff --git a/src/components/elements/settings/Profile.js b/src/components/elements/settings/Profile.js new file mode 100644 index 0000000..5c9b662 --- /dev/null +++ b/src/components/elements/settings/Profile.js @@ -0,0 +1,278 @@ +// @flow +import React from 'react'; +import { TouchableOpacity, Alert, Image, ActionSheetIOS } from 'react-native'; +import styled from 'styled-components'; +import { connect } from '../../../Provider'; +import { isAndroid } from '../../../lib/utils'; +import api from '../../../lib/api'; +import gradient from '../../../../assets/gradient.jpg'; +import Input from './Input'; +import Button from './Button'; +import ProfilePic from './ProfilePic'; + +type Props = { + context: Context, +}; + +type State = { + editing: boolean, + inputValue: string, +}; + + +const ProfileInfo = styled.View` + flex-direction: column; + align-items: center; + height: 56px; + width: 100%; + margin-bottom: 30px; +`; + +const ProfileMeta = styled.View` + flex-direction: row; + height: 28px; + align-items: center; +`; + +const ButtonGroup = styled.View` + flex-direction: row; + justify-content: space-between; + width: 40%; + margin-top: 15px; +`; + +const ProfileName = styled.Text` + font-size: 18px; + font-weight: 700; + letter-spacing: 0.2px; + color: ${props => props.theme.text}; + margin-right: 5px; +`; + +const Text = styled.Text` + font-size: 18px; + font-weight: 300; + letter-spacing: 0.2px; + color: ${props => props.theme.text}; +`; + +const Email = styled.Text` + font-size: 16px; + font-weight: 300; + color: ${props => props.theme.dimmedText}; + margin-top: 15px; +`; + +const DeleteText = styled.Text` + color: ${props => props.theme.deploymentErrorText}; +`; + +@connect +class Profile extends React.Component { + state = { + editing: false, + inputValue: this.props.context.user.username, + }; + + static getDerivedStateFromProps = (nextProps: Props, prevState: State) => { + const { user, team } = nextProps.context; + const { inputValue } = prevState; + + if (!team && inputValue !== user.username) { + // If user + return { + inputValue: user.username, + }; + } else if (team && inputValue !== team.name) { + // If team + return { + inputValue: team.name, + }; + } + + return null; + }; + + toggleEditing = () => { + this.setState({ editing: !this.state.editing }); + }; + + + handleInput = (inputValue: string) => { + this.setState({ inputValue }); + }; + + handleNameChange = (message: string) => { + const { refreshUserInfo, refreshTeamInfo, team } = this.props.context; + + if (message) { + // This one doesn't have an "error" field + Alert.alert('Error', message, [{ text: 'Dismiss' }]); + } else if (team) { + refreshTeamInfo(team.id); + } else { + refreshUserInfo(); + } + + this.toggleEditing(); + }; + + changeUsername = async () => { + const result = await api.user.changeUsername(this.state.inputValue); + + this.handleNameChange(result.message); + }; + + changeTeamName = async () => { + const { team } = this.props.context; + if (!team) return; + + const result = await api.teams.changeTeamName(team.id, this.state.inputValue); + + this.handleNameChange(result.message); + }; + + deleteTeam = async () => { + const message = 'Are you sure you want delete this team?'; + const { deleteTeam, team } = this.props.context; + + if (!team) return; + + if (isAndroid) { + Alert.alert( + message, + null, + [ + { text: 'Cancel', onPress: () => {} }, + { + text: 'Delete', + onPress: async () => { + await deleteTeam(team.id); + }, + }, + ], + { cancelable: false }, + ); + } else { + ActionSheetIOS.showActionSheetWithOptions( + { + title: message, + options: ['Cancel', 'Delete'], + destructiveButtonIndex: 1, + cancelButtonIndex: 0, + }, + async (buttonIndex): any => { + if (buttonIndex === 1) { + await deleteTeam(team.id); + } + }, + ); + } + }; + + render() { + const { + user, + team, + } = this.props.context; + const changeName = team ? this.changeTeamName : this.changeUsername; + + const current = team + ? { + avatar: team.avatar || null, + name: team.name, + } + : { + avatar: user.avatar || user.uid, + name: user.username, + }; + + return ( + // $FlowFixMe + + + + + + {(() => { + if (this.state.editing) { + return ( + // $FlowFixMe + + + + + + + + + + + + ); + } + // @TODO Team editing + return ( + // $FlowFixMe + + + {`${current.name}`} + {/* We can't have anything except text inside on Android, sooo */} + ( + + + + ) + + {team ? null : {user.email}} + + ); + })()} + + {(() => { + if (team) { + return ( + + DELETE TEAM + + ); + } + + return null; + })()} + + ); + } +} + +export default Profile; diff --git a/src/components/elements/settings/ProfilePic.js b/src/components/elements/settings/ProfilePic.js new file mode 100644 index 0000000..1744ac1 --- /dev/null +++ b/src/components/elements/settings/ProfilePic.js @@ -0,0 +1,14 @@ +import styled from 'styled-components'; +import { isIphoneSE } from '../../../lib/utils'; + +const ProfilePic = styled.View` + height: 128px; + width: 128px; + border-radius: 100px; + background: #e0e0e0; + overflow: hidden; + margin-bottom: 30px; + margin-top: ${isIphoneSE() ? '60px' : '120px'}; +`; + +export default ProfilePic; diff --git a/src/components/elements/settings/RowText.js b/src/components/elements/settings/RowText.js new file mode 100644 index 0000000..ad115b0 --- /dev/null +++ b/src/components/elements/settings/RowText.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const RowText = styled.Text` + font-size: 18px + font-weight: 400; + color: ${props => props.theme.text}; +`; + +export default RowText; diff --git a/src/components/elements/settings/Separator.js b/src/components/elements/settings/Separator.js new file mode 100644 index 0000000..11a9353 --- /dev/null +++ b/src/components/elements/settings/Separator.js @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +const Separator = styled.View` + height: 1px; + border-bottom-color: ${props => props.theme.border}; + border-bottom-width: 1px; + margin-vertical: 12px; + width: 80%; +`; + +export default Separator; diff --git a/src/components/elements/settings/SettingsRow.js b/src/components/elements/settings/SettingsRow.js new file mode 100644 index 0000000..406ffe3 --- /dev/null +++ b/src/components/elements/settings/SettingsRow.js @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +const SettingsRow = styled.View` + width: 80%; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-vertical: 5px; +`; + +export default SettingsRow; diff --git a/src/components/elements/settings/TouchId.js b/src/components/elements/settings/TouchId.js new file mode 100644 index 0000000..9ee513f --- /dev/null +++ b/src/components/elements/settings/TouchId.js @@ -0,0 +1,103 @@ +// @flow +import React from 'react'; +import { Switch, AsyncStorage } from 'react-native'; +import { isAndroid, themes } from '../../../lib/utils'; +import touchIdPrompt from '../../../lib/touch-id-prompt'; +import Separator from './Separator'; +import SettingsRow from './SettingsRow'; +import RowText from './RowText'; + +type Props = { + biometry?: string, + darkMode: boolean, +}; + +type State = { + touchId: boolean, +}; + + +class TouchId extends React.Component { + state = { + touchId: false, + } + + componentDidMount = () => { + this.setTouchId(); + }; + + setTouchId = async () => { + const touchIdEnabled = await AsyncStorage.getItem('@now:touchId'); + if (touchIdEnabled) { + this.setState({ touchId: true }); + } + }; + + toggleTouchId = async (active: boolean) => { + const { biometry } = this.props; + + if (active && biometry !== undefined) { + console.log('biometry active'); + // $FlowFixMe this method won't ever be called if 'biometry === undefined' + try { + await touchIdPrompt({ + biometryType: isAndroid + ? 'fingerprint' + : `${biometry.replace(/^\w/, c => c.toUpperCase())} ID`, + }); + this.setState({ touchId: true }); + } catch (e) { + console.log('ERROR SETTING TOUCH ID', e); + } + } else { + await AsyncStorage.removeItem('@now:touchId'); + this.setState({ touchId: false }); + } + }; + + render() { + const { biometry, darkMode } = this.props; + + console.log('biometry', biometry); + + if (biometry) { + return ( + // $FlowFixMe + + + + + Use{' '} + {isAndroid + ? 'fingerprint' + : `${biometry.replace(/^\w/, c => + c.toUpperCase())} ID`} + + + + + ); + } + + return null; + } +} + +export default TouchId; diff --git a/src/components/elements/settings/UsageLimits.js b/src/components/elements/settings/UsageLimits.js new file mode 100644 index 0000000..43787cb --- /dev/null +++ b/src/components/elements/settings/UsageLimits.js @@ -0,0 +1,112 @@ +// @flow +import React from 'react'; +import { AsyncStorage } from 'react-native'; +import styled from 'styled-components'; +import Separator from './Separator'; +import SettingsRow from './SettingsRow'; +import RowText from './RowText'; +import UsageLimitInput from './UsageLimitInput'; + +type Props = { + usage: any | Zeit$Usage, +}; + +type State = { + instanceLimit: string, + bandwidthLimit: string, + logsLimit: string, +}; + + +const SectionHeading = styled.Text` + font-size: 18px + font-weight: 700; + color: ${props => props.theme.text}; + width: 80%; + margin-bottom: 15px; +`; + +class UsageLimits extends React.Component { + state = { + instanceLimit: '0', + bandwidthLimit: '0', + logsLimit: '0', + }; + + componentDidMount = () => { + this.getUsageLimits(); + }; + + getUsageLimits = async () => { + const instanceLimit = + (await AsyncStorage.getItem('@now:instanceLimit')) || this.state.instanceLimit; + const bandwidthLimit = + (await AsyncStorage.getItem('@now:bandwidthLimit')) || this.state.bandwidthLimit; + const logsLimit = (await AsyncStorage.getItem('@now:logsLimit')) || this.state.logsLimit; + + this.setState({ + instanceLimit, + bandwidthLimit, + logsLimit, + }); + }; + + setLimit = async (value: string, type: 'instanceLimit' | 'bandwidthLimit' | 'logsLimit') => { + let limit = value.replace(/\D/g, ''); + if (limit.length > 0 && limit.substr(0, 1) === '0') { + limit = limit.slice(1); + } + if (limit === '') { + limit = '0'; + } + + await AsyncStorage.setItem(`@now:${type}`, limit); + this.setState({ [type]: limit }); + }; + + render() { + const { usage } = this.props; + + if (usage.mode === 'on-demand' || usage.mode === 'unlimited') { + return ( + // $FlowFixMe + + + Usage limits + + Instances + { + this.setLimit(val, 'instanceLimit'); + }} + /> + + + Bandwidth + { + this.setLimit(val, 'bandwidthLimit'); + }} + label + /> + + + Logs + { + this.setLimit(val, 'logsLimit'); + }} + label + /> + + + ); + } + return null; + } +} + +export default UsageLimits; diff --git a/src/screens/Settings.js b/src/screens/Settings.js index 7bf1d18..6c32996 100644 --- a/src/screens/Settings.js +++ b/src/screens/Settings.js @@ -1,41 +1,29 @@ -// @TODO This component is getting too huge for comfort // @flow import React from 'react'; import { SafeAreaView, - Image, TouchableOpacity, Switch, - AsyncStorage, - Alert, - ActionSheetIOS, } from 'react-native'; import styled from 'styled-components'; import * as Animatable from 'react-native-animatable'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import Header from '../components/Header'; import Dropdown from '../components/Dropdown'; -import Input from '../components/elements/settings/Input'; -import UsageLimitInput from '../components/elements/settings/UsageLimitInput'; -import api from '../lib/api'; -import touchIdPrompt from '../lib/touch-id-prompt'; +import Separator from '../components/elements/settings/Separator'; +import SettingsRow from '../components/elements/settings/SettingsRow'; +import RowText from '../components/elements/settings/RowText'; +import Button from '../components/elements/settings/Button'; +import Profile from '../components/elements/settings/Profile'; +import TouchId from '../components/elements/settings/TouchId'; +import UsageLimits from '../components/elements/settings/UsageLimits'; import { isIphoneSE, isAndroid, themes } from '../lib/utils'; import { connect } from '../Provider'; -import gradient from '../../assets/gradient.jpg'; type Props = { context: Context, }; -type State = { - editing: boolean, - touchId: boolean, - inputValue: string, - instanceLimit: string, - bandwidthLimit: string, - logsLimit: string, -}; - const Container = styled(SafeAreaView)` width: 100%; flex: 1; @@ -63,286 +51,17 @@ const Title = styled.Text` color: ${props => props.theme.text}; `; -export const ProfilePic = styled.View` - height: 128px; - width: 128px; - border-radius: 100px; - background: #e0e0e0; - overflow: hidden; - margin-bottom: 30px; - margin-top: ${isIphoneSE() ? '60px' : '120px'}; -`; - -const ProfileInfo = styled.View` - flex-direction: column; - align-items: center; - height: 56px; - width: 100%; - margin-bottom: 30px; -`; - -const ProfileMeta = styled.View` - flex-direction: row; - height: 28px; - align-items: center; -`; - -const ButtonGroup = styled.View` - flex-direction: row; - justify-content: space-between; - width: 40%; - margin-top: 15px; -`; - -const ProfileName = styled.Text` - font-size: 18px; - font-weight: 700; - letter-spacing: 0.2px; - color: ${props => props.theme.text}; - margin-right: 5px; -`; - -const Text = styled.Text` - font-size: 18px; - font-weight: 300; - letter-spacing: 0.2px; - color: ${props => props.theme.text}; -`; - -export const Button = styled.Text` - font-size: 18px; - font-weight: 300; - color: ${props => props.theme.settingsButton}; -`; - -const Email = styled.Text` - font-size: 16px; - font-weight: 300; - color: ${props => props.theme.dimmedText}; - margin-top: 15px; -`; - -const Separator = styled.View` - height: 1px; - border-bottom-color: ${props => props.theme.border}; - border-bottom-width: 1px; - margin-vertical: 12px; - width: 80%; -`; - -const SettingsRow = styled.View` - width: 80%; - flex-direction: row; - justify-content: space-between; - align-items: center; - margin-vertical: 5px; -`; - -const RowText = styled.Text` - font-size: 18px - font-weight: 400; - color: ${props => props.theme.text}; -`; - -const SectionHeading = styled.Text` - font-size: 18px - font-weight: 700; - color: ${props => props.theme.text}; - width: 80%; - margin-bottom: 15px; -`; - -const DeleteText = styled.Text` - color: ${props => props.theme.deploymentErrorText}; -`; - @connect -export default class Settings extends React.Component { - state = { - editing: false, - inputValue: this.props.context.user.username, - touchId: false, - instanceLimit: '0', - bandwidthLimit: '0', - logsLimit: '0', - }; - - static getDerivedStateFromProps = (nextProps: Props, prevState: State) => { - const { user, team } = nextProps.context; - const { inputValue } = prevState; - - if (!team && inputValue !== user.username) { - // If user - return { - inputValue: user.username, - }; - } else if (team && inputValue !== team.name) { - // If team - return { - inputValue: team.name, - }; - } - - return null; - }; - - componentDidMount = () => { - this.setTouchId(); - this.getUsageLimits(); - }; - - toggleEditing = () => { - this.setState({ editing: !this.state.editing }); - }; - - handleInput = (inputValue: string) => { - this.setState({ inputValue }); - }; - - handleNameChange = (message: string) => { - const { refreshUserInfo, refreshTeamInfo, team } = this.props.context; - - if (message) { - // This one doesn't have an "error" field - Alert.alert('Error', message, [{ text: 'Dismiss' }]); - } else if (team) { - refreshTeamInfo(team.id); - } else { - refreshUserInfo(); - } - - this.toggleEditing(); - }; - - changeUsername = async () => { - const result = await api.user.changeUsername(this.state.inputValue); - - this.handleNameChange(result.message); - }; - - changeTeamName = async () => { - const { team } = this.props.context; - if (!team) return; - - const result = await api.teams.changeTeamName(team.id, this.state.inputValue); - - this.handleNameChange(result.message); - }; - - deleteTeam = async () => { - const message = 'Are you sure you want delete this team?'; - const { deleteTeam, team } = this.props.context; - - if (!team) return; - - if (isAndroid) { - Alert.alert( - message, - null, - [ - { text: 'Cancel', onPress: () => {} }, - { - text: 'Delete', - onPress: async () => { - await deleteTeam(team.id); - }, - }, - ], - { cancelable: false }, - ); - } else { - ActionSheetIOS.showActionSheetWithOptions( - { - title: message, - options: ['Cancel', 'Delete'], - destructiveButtonIndex: 1, - cancelButtonIndex: 0, - }, - async (buttonIndex): any => { - if (buttonIndex === 1) { - await deleteTeam(team.id); - } - }, - ); - } - }; - - setTouchId = async () => { - const touchIdEnabled = await AsyncStorage.getItem('@now:touchId'); - if (touchIdEnabled) { - this.setState({ touchId: true }); - } - }; - - toggleTouchId = async (active: boolean) => { - const { biometry } = this.props.context; - - if (active && biometry !== undefined) { - // $FlowFixMe this method won't ever be called if 'biometry === undefined' - try { - await touchIdPrompt({ - biometryType: isAndroid - ? 'fingerprint' - : `${biometry.replace(/^\w/, c => c.toUpperCase())} ID`, - }); - this.setState({ touchId: true }); - } catch (e) { - console.log('ERROR SETTING TOUCH ID', e); - } - } else { - await AsyncStorage.removeItem('@now:touchId'); - this.setState({ touchId: false }); - } - }; - - getUsageLimits = async () => { - const instanceLimit = - (await AsyncStorage.getItem('@now:instanceLimit')) || this.state.instanceLimit; - const bandwidthLimit = - (await AsyncStorage.getItem('@now:bandwidthLimit')) || this.state.bandwidthLimit; - const logsLimit = (await AsyncStorage.getItem('@now:logsLimit')) || this.state.logsLimit; - - this.setState({ - instanceLimit, - bandwidthLimit, - logsLimit, - }); - }; - - setLimit = async (value: string, type: 'instanceLimit' | 'bandwidthLimit' | 'logsLimit') => { - let limit = value.replace(/\D/g, ''); - if (limit.length > 0 && limit.substr(0, 1) === '0') { - limit = limit.slice(1); - } - if (limit === '') { - limit = '0'; - } - - await AsyncStorage.setItem(`@now:${type}`, limit); - this.setState({ [type]: limit }); - }; - +export default class Settings extends React.Component { render() { const { biometry, watchIsReachable, sendTokenToWatch, usage, - user, - team, darkMode, setDarkMode, } = this.props.context; - const changeName = team ? this.changeTeamName : this.changeUsername; - const current = team - ? { - avatar: team.avatar || null, - name: team.name, - } - : { - avatar: user.avatar || user.uid, - name: user.username, - }; return ( @@ -356,86 +75,8 @@ export default class Settings extends React.Component { scrollEnabled > - - - - - {(() => { - if (this.state.editing) { - return ( - // $FlowFixMe - - - - - - - - - - - - ); - } - // @TODO Team editing - return ( - // $FlowFixMe - - - {`${current.name}`} - {/* We can't have anything except text inside on Android, sooo */} - ( - - - - ) - - {team ? null : {user.email}} - - ); - })()} - - {(() => { - if (team) { - return ( - this.deleteTeam()} - > - DELETE TEAM - - ); - } - - return null; - })()} + {/* $FlowFixMe */} + Dark Mode @@ -454,45 +95,7 @@ export default class Settings extends React.Component { onValueChange={setDarkMode} /> - {(() => { - if (biometry) { - return ( - // $FlowFixMe - - - - - Use{' '} - {isAndroid - ? 'fingerprint' - : `${biometry.replace(/^\w/, c => - c.toUpperCase())} ID`} - - - - - ); - } - - return null; - })()} + {(() => { if (watchIsReachable) { return ( @@ -514,47 +117,7 @@ export default class Settings extends React.Component { return null; })()} - {(() => { - if (usage.mode === 'on-demand' || usage.mode === 'unlimited') { - return ( - // $FlowFixMe - - - Usage limits - - Instances - { - this.setLimit(val, 'instanceLimit'); - }} - /> - - - Bandwidth - { - this.setLimit(val, 'bandwidthLimit'); - }} - label - /> - - - Logs - { - this.setLimit(val, 'logsLimit'); - }} - label - /> - - - ); - } - return null; - })()} + {/* $FlowFixMe */} diff --git a/storybook/stories/settings.js b/storybook/stories/settings.js index 76301c4..f85ff11 100644 --- a/storybook/stories/settings.js +++ b/storybook/stories/settings.js @@ -3,7 +3,8 @@ import React from 'react'; import { Image } from 'react-native'; import { storiesOf } from '@storybook/react-native'; import Input from '../../src/components/elements/settings/Input'; -import { Button, ProfilePic } from '../../src/screens/Settings'; +import Button from '../../src/components/elements/settings/Button'; +import ProfilePic from '../../src/components/elements/settings/ProfilePic'; import NowLogo from '../../assets/now-white.png'; import center from './_center';