From 0d54f6e126276c5ced0b8dd0c67b3d2524f99b96 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 15 Dec 2022 14:06:05 -0600 Subject: Split login screen into component files --- src/view/com/login/CreateAccount.tsx | 443 ++++++++++++++++++++++++ src/view/com/login/Logo.tsx | 42 +++ src/view/com/login/Signin.tsx | 262 ++++++++++++++ src/view/screens/Login.tsx | 646 +---------------------------------- 4 files changed, 752 insertions(+), 641 deletions(-) create mode 100644 src/view/com/login/CreateAccount.tsx create mode 100644 src/view/com/login/Logo.tsx create mode 100644 src/view/com/login/Signin.tsx (limited to 'src') diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx new file mode 100644 index 000000000..37dcba2fd --- /dev/null +++ b/src/view/com/login/CreateAccount.tsx @@ -0,0 +1,443 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import * as EmailValidator from 'email-validator' +import {Logo} from './Logo' +import {Picker} from '../util/Picker' +import {TextLink} from '../util/Link' +import {s, colors} from '../../lib/styles' +import { + makeValidHandle, + createFullHandle, + toNiceDomain, +} from '../../../lib/strings' +import {useStores, DEFAULT_SERVICE} from '../../../state' +import {ServiceDescription} from '../../../state/models/session' +import {ServerInputModal} from '../../../state/models/shell-ui' +import {ComAtprotoAccountCreate} from '../../../third-party/api/index' + +export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { + const store = useStores() + const [isProcessing, setIsProcessing] = useState(false) + const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) + const [error, setError] = useState('') + const [serviceDescription, setServiceDescription] = useState< + ServiceDescription | undefined + >(undefined) + const [userDomain, setUserDomain] = useState('') + const [inviteCode, setInviteCode] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [handle, setHandle] = useState('') + + useEffect(() => { + let aborted = false + setError('') + setServiceDescription(undefined) + console.log('Fetching service description', serviceUrl) + store.session.describeService(serviceUrl).then( + desc => { + if (aborted) return + setServiceDescription(desc) + setUserDomain(desc.availableUserDomains[0]) + }, + err => { + if (aborted) return + console.error(err) + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + }, + ) + return () => { + aborted = true + } + }, [serviceUrl, store.session]) + + const onPressSelectService = () => { + store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) + } + + const onPressNext = async () => { + if (!email) { + return setError('Please enter your email.') + } + if (!EmailValidator.validate(email)) { + return setError('Your email appears to be invalid.') + } + if (!password) { + return setError('Please choose your password.') + } + if (!handle) { + return setError('Please choose your username.') + } + setError('') + setIsProcessing(true) + try { + await store.session.createAccount({ + service: serviceUrl, + email, + handle: createFullHandle(handle, userDomain), + password, + inviteCode, + }) + } catch (e: any) { + let errMsg = e.toString() + if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + errMsg = + 'Invite code not accepted. Check that you input it correctly and try again.' + } + console.log(e) + setIsProcessing(false) + setError(errMsg.replace(/^Error:/, '')) + } + } + + const Policies = () => { + if (!serviceDescription) { + return + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + + + + + + This service has not provided terms of service or a privacy policy. + + + ) + } + const els = [] + if (tos) { + els.push( + , + ) + } + if (pp) { + els.push( + , + ) + } + if (els.length === 2) { + els.splice(1, 0, and ) + } + return ( + + + By creating an account you agree to the {els}. + + + ) + } + + return ( + + + + + + {error ? ( + + + + + + {error} + + + ) : undefined} + + + Create a new account + + + + + + {toNiceDomain(serviceUrl)} + + + + Change + + + + {serviceDescription ? ( + <> + {serviceDescription?.inviteCodeRequired ? ( + + + + + ) : undefined} + + + + + + + + + + ) : undefined} + + {serviceDescription ? ( + <> + + + + Choose your username + + + + + setHandle(makeValidHandle(v))} + editable={!isProcessing} + /> + + {serviceDescription.availableUserDomains.length > 1 && ( + + + ({ + label: `.${d}`, + value: d, + }))} + onChange={itemValue => setUserDomain(itemValue)} + enabled={!isProcessing} + /> + + )} + + + Your full username will be{' '} + + @{createFullHandle(handle, userDomain)} + + + + + + + ) : undefined} + + + Back + + + {serviceDescription ? ( + + {isProcessing ? ( + + ) : ( + Next + )} + + ) : undefined} + + + + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} + +const styles = StyleSheet.create({ + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + backgroundColor: colors.blue3, + }, + groupTitle: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + }, + groupContent: { + borderTopWidth: 1, + borderTopColor: colors.blue1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + color: 'white', + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + backgroundColor: colors.blue3, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + borderRadius: 10, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.blue2, + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + color: colors.white, + marginRight: 4, + }, + textBtnFakeInnerBtnLabel: { + color: colors.white, + }, + picker: { + flex: 1, + width: '100%', + backgroundColor: colors.blue3, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + borderRadius: 10, + }, + pickerLabel: { + color: colors.white, + fontSize: 18, + }, + pickerIcon: { + color: colors.white, + }, + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 20, + paddingBottom: 20, + }, + error: { + borderWidth: 1, + borderColor: colors.red5, + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorFloating: { + marginBottom: 20, + marginHorizontal: 20, + borderRadius: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + color: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx new file mode 100644 index 000000000..d1dc9c671 --- /dev/null +++ b/src/view/com/login/Logo.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Svg, {Circle, Line, Text as SvgText} from 'react-native-svg' + +export const Logo = () => { + return ( + + + + + + + + + B + + + + ) +} + +const styles = StyleSheet.create({ + logo: { + flexDirection: 'row', + justifyContent: 'center', + }, +}) diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx new file mode 100644 index 000000000..f11a4c6ca --- /dev/null +++ b/src/view/com/login/Signin.tsx @@ -0,0 +1,262 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Logo} from './Logo' +import {s, colors} from '../../lib/styles' +import {createFullHandle, toNiceDomain} from '../../../lib/strings' +import {useStores, DEFAULT_SERVICE} from '../../../state' +import {ServiceDescription} from '../../../state/models/session' +import {ServerInputModal} from '../../../state/models/shell-ui' +import {isNetworkError} from '../../../lib/errors' + +export const Signin = ({onPressBack}: {onPressBack: () => void}) => { + const store = useStores() + const [isProcessing, setIsProcessing] = useState(false) + const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) + const [serviceDescription, setServiceDescription] = useState< + ServiceDescription | undefined + >(undefined) + const [error, setError] = useState('') + const [handle, setHandle] = useState('') + const [password, setPassword] = useState('') + + useEffect(() => { + let aborted = false + setError('') + console.log('Fetching service description', serviceUrl) + store.session.describeService(serviceUrl).then( + desc => { + if (aborted) return + setServiceDescription(desc) + }, + err => { + if (aborted) return + console.error(err) + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + }, + ) + return () => { + aborted = true + } + }, [store.session, serviceUrl]) + + const onPressSelectService = () => { + store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) + } + + const onPressNext = async () => { + setError('') + setIsProcessing(true) + + // try to guess the handle if the user just gave their own username + try { + let fullHandle = handle + if ( + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullHandle.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullHandle = createFullHandle( + handle, + serviceDescription.availableUserDomains[0], + ) + } + } + + await store.session.login({ + service: serviceUrl, + handle: fullHandle, + password, + }) + } catch (e: any) { + const errMsg = e.toString() + console.log(e) + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + setError('Invalid username or password') + } else if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(errMsg.replace(/^Error:/, '')) + } + } + } + + return ( + + + + + + + + Sign in to {toNiceDomain(serviceUrl)} + + + + Change + + + + + setHandle((str || '').toLowerCase())} + editable={!isProcessing} + /> + + + + + + + {error ? ( + + + + + + {error} + + + ) : undefined} + + + Back + + + + {!serviceDescription || isProcessing ? ( + + ) : ( + Next + )} + + {!serviceDescription || isProcessing ? ( + Connecting... + ) : undefined} + + + ) +} + +const styles = StyleSheet.create({ + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + backgroundColor: colors.blue3, + }, + groupTitle: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + }, + groupContent: { + borderTopWidth: 1, + borderTopColor: colors.blue1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + color: 'white', + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + backgroundColor: colors.blue3, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + borderRadius: 10, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.blue2, + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + color: colors.white, + marginRight: 4, + }, + textBtnFakeInnerBtnLabel: { + color: colors.white, + }, + error: { + borderWidth: 1, + borderColor: colors.red5, + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + color: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx index acf7e7199..0bb672b3b 100644 --- a/src/view/screens/Login.tsx +++ b/src/view/screens/Login.tsx @@ -1,32 +1,17 @@ -import React, {useState, useEffect} from 'react' +import React, {useState} from 'react' import { - ActivityIndicator, - KeyboardAvoidingView, - ScrollView, StyleSheet, Text, - TextInput, TouchableOpacity, View, useWindowDimensions, } from 'react-native' -import Svg, {Circle, Line, Text as SvgText} from 'react-native-svg' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import * as EmailValidator from 'email-validator' +import Svg, {Line} from 'react-native-svg' import {observer} from 'mobx-react-lite' -import {Picker} from '../com/util/Picker' -import {TextLink} from '../com/util/Link' +import {Signin} from '../com/login/Signin' +import {Logo} from '../com/login/Logo' +import {CreateAccount} from '../com/login/CreateAccount' import {s, colors} from '../lib/styles' -import { - makeValidHandle, - createFullHandle, - toNiceDomain, -} from '../../lib/strings' -import {useStores, DEFAULT_SERVICE} from '../../state' -import {ServiceDescription} from '../../state/models/session' -import {ServerInputModal} from '../../state/models/shell-ui' -import {ComAtprotoAccountCreate} from '../../third-party/api/index' -import {isNetworkError} from '../../lib/errors' enum ScreenState { SigninOrCreateAccount, @@ -34,38 +19,6 @@ enum ScreenState { CreateAccount, } -const Logo = () => { - return ( - - - - - - - - - B - - - - ) -} - const SigninOrCreateAccount = ({ onPressSignin, onPressCreateAccount, @@ -115,459 +68,6 @@ const SigninOrCreateAccount = ({ ) } -const Signin = ({onPressBack}: {onPressBack: () => void}) => { - const store = useStores() - const [isProcessing, setIsProcessing] = useState(false) - const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) - const [serviceDescription, setServiceDescription] = useState< - ServiceDescription | undefined - >(undefined) - const [error, setError] = useState('') - const [handle, setHandle] = useState('') - const [password, setPassword] = useState('') - - useEffect(() => { - let aborted = false - setError('') - console.log('Fetching service description', serviceUrl) - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) return - setServiceDescription(desc) - }, - err => { - if (aborted) return - console.error(err) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true - } - }, [serviceUrl]) - - const onPressSelectService = () => { - store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) - } - - const onPressNext = async () => { - setError('') - setIsProcessing(true) - - // try to guess the handle if the user just gave their own username - try { - let fullHandle = handle - if ( - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullHandle.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullHandle = createFullHandle( - handle, - serviceDescription.availableUserDomains[0], - ) - } - } - - await store.session.login({ - service: serviceUrl, - handle: fullHandle, - password, - }) - } catch (e: any) { - const errMsg = e.toString() - console.log(e) - setIsProcessing(false) - if (errMsg.includes('Authentication Required')) { - setError('Invalid username or password') - } else if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(errMsg.replace(/^Error:/, '')) - } - } - } - - return ( - - - - - - - - Sign in to {toNiceDomain(serviceUrl)} - - - - Change - - - - - setHandle((str || '').toLowerCase())} - editable={!isProcessing} - /> - - - - - - - {error ? ( - - - - - - {error} - - - ) : undefined} - - - Back - - - - {!serviceDescription || isProcessing ? ( - - ) : ( - Next - )} - - {!serviceDescription || isProcessing ? ( - Connecting... - ) : undefined} - - - ) -} - -const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { - const store = useStores() - const [isProcessing, setIsProcessing] = useState(false) - const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) - const [error, setError] = useState('') - const [serviceDescription, setServiceDescription] = useState< - ServiceDescription | undefined - >(undefined) - const [userDomain, setUserDomain] = useState('') - const [inviteCode, setInviteCode] = useState('') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [handle, setHandle] = useState('') - - useEffect(() => { - let aborted = false - setError('') - setServiceDescription(undefined) - console.log('Fetching service description', serviceUrl) - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) return - setServiceDescription(desc) - setUserDomain(desc.availableUserDomains[0]) - }, - err => { - if (aborted) return - console.error(err) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true - } - }, [serviceUrl]) - - const onPressSelectService = () => { - store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) - } - - const onPressNext = async () => { - if (!email) { - return setError('Please enter your email.') - } - if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') - } - if (!password) { - return setError('Please choose your password.') - } - if (!handle) { - return setError('Please choose your username.') - } - setError('') - setIsProcessing(true) - try { - await store.session.createAccount({ - service: serviceUrl, - email, - handle: createFullHandle(handle, userDomain), - password, - inviteCode, - }) - } catch (e: any) { - let errMsg = e.toString() - if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { - errMsg = - 'Invite code not accepted. Check that you input it correctly and try again.' - } - console.log(e) - setIsProcessing(false) - setError(errMsg.replace(/^Error:/, '')) - } - } - - const Policies = () => { - if (!serviceDescription) { - return - } - const tos = validWebLink(serviceDescription.links?.termsOfService) - const pp = validWebLink(serviceDescription.links?.privacyPolicy) - if (!tos && !pp) { - return ( - - - - - - This service has not provided terms of service or a privacy policy. - - - ) - } - const els = [] - if (tos) { - els.push( - , - ) - } - if (pp) { - els.push( - , - ) - } - if (els.length === 2) { - els.splice(1, 0, and ) - } - return ( - - - By creating an account you agree to the {els}. - - - ) - } - - return ( - - - - - - {error ? ( - - - - - - {error} - - - ) : undefined} - - - Create a new account - - - - - - {toNiceDomain(serviceUrl)} - - - - Change - - - - {serviceDescription ? ( - <> - {serviceDescription?.inviteCodeRequired ? ( - - - - - ) : undefined} - - - - - - - - - - ) : undefined} - - {serviceDescription ? ( - <> - - - - Choose your username - - - - - setHandle(makeValidHandle(v))} - editable={!isProcessing} - /> - - {serviceDescription.availableUserDomains.length > 1 && ( - - - ({ - label: `.${d}`, - value: d, - }))} - onChange={itemValue => setUserDomain(itemValue)} - enabled={!isProcessing} - /> - - )} - - - Your full username will be{' '} - - @{createFullHandle(handle, userDomain)} - - - - - - - ) : undefined} - - - Back - - - {serviceDescription ? ( - - {isProcessing ? ( - - ) : ( - Next - )} - - ) : undefined} - - - - ) -} - export const Login = observer( (/*{navigation}: RootTabsScreenProps<'Login'>*/) => { const [screenState, setScreenState] = useState( @@ -603,12 +103,6 @@ export const Login = observer( }, ) -function validWebLink(url?: string): string | undefined { - return url && (url.startsWith('http://') || url.startsWith('https://')) - ? url - : undefined -} - const styles = StyleSheet.create({ outer: { flex: 1, @@ -617,14 +111,6 @@ const styles = StyleSheet.create({ flex: 2, justifyContent: 'center', }, - logoHero: { - paddingTop: 30, - paddingBottom: 40, - }, - logo: { - flexDirection: 'row', - justifyContent: 'center', - }, title: { textAlign: 'center', color: colors.white, @@ -663,126 +149,4 @@ const styles = StyleSheet.create({ color: colors.white, fontSize: 16, }, - group: { - borderWidth: 1, - borderColor: colors.white, - borderRadius: 10, - marginBottom: 20, - marginHorizontal: 20, - backgroundColor: colors.blue3, - }, - groupTitle: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 8, - paddingHorizontal: 12, - }, - groupTitleIcon: { - color: colors.white, - marginHorizontal: 6, - }, - groupContent: { - borderTopWidth: 1, - borderTopColor: colors.blue1, - flexDirection: 'row', - alignItems: 'center', - }, - groupContentIcon: { - color: 'white', - marginLeft: 10, - }, - textInput: { - flex: 1, - width: '100%', - backgroundColor: colors.blue3, - color: colors.white, - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 18, - borderRadius: 10, - }, - textBtn: { - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }, - textBtnLabel: { - flex: 1, - color: colors.white, - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 18, - }, - textBtnIcon: { - color: colors.white, - marginHorizontal: 12, - }, - textBtnFakeInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.blue2, - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - textBtnFakeInnerBtnIcon: { - color: colors.white, - marginRight: 4, - }, - textBtnFakeInnerBtnLabel: { - color: colors.white, - }, - picker: { - flex: 1, - width: '100%', - backgroundColor: colors.blue3, - color: colors.white, - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 18, - borderRadius: 10, - }, - pickerLabel: { - color: colors.white, - fontSize: 18, - }, - pickerIcon: { - color: colors.white, - }, - policies: { - flexDirection: 'row', - alignItems: 'flex-start', - paddingHorizontal: 20, - paddingBottom: 20, - }, - error: { - borderWidth: 1, - borderColor: colors.red5, - backgroundColor: colors.red4, - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginHorizontal: 20, - marginBottom: 15, - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 8, - }, - errorFloating: { - marginBottom: 20, - marginHorizontal: 20, - borderRadius: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - color: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, }) -- cgit 1.4.1