diff --git a/code/01-starting-code/App.js b/code/01-starting-code/App.js new file mode 100644 index 00000000..3a3203b0 --- /dev/null +++ b/code/01-starting-code/App.js @@ -0,0 +1,57 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + return ( + + + + ); +} + +export default function App() { + return ( + <> + + + + + ); +} diff --git a/code/01-starting-code/app.json b/code/01-starting-code/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/01-starting-code/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/01-starting-code/assets/adaptive-icon.png b/code/01-starting-code/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/01-starting-code/assets/adaptive-icon.png differ diff --git a/code/01-starting-code/assets/favicon.png b/code/01-starting-code/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/01-starting-code/assets/favicon.png differ diff --git a/code/01-starting-code/assets/icon.png b/code/01-starting-code/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/01-starting-code/assets/icon.png differ diff --git a/code/01-starting-code/assets/splash.png b/code/01-starting-code/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/01-starting-code/assets/splash.png differ diff --git a/code/01-starting-code/babel.config.js b/code/01-starting-code/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/01-starting-code/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/01-starting-code/components/Auth/AuthContent.js b/code/01-starting-code/components/Auth/AuthContent.js new file mode 100644 index 00000000..eae3b56b --- /dev/null +++ b/code/01-starting-code/components/Auth/AuthContent.js @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + // Todo + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/01-starting-code/components/Auth/AuthForm.js b/code/01-starting-code/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/01-starting-code/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/01-starting-code/components/Auth/Input.js b/code/01-starting-code/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/01-starting-code/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/01-starting-code/components/ui/Button.js b/code/01-starting-code/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/01-starting-code/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/01-starting-code/components/ui/FlatButton.js b/code/01-starting-code/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/01-starting-code/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/01-starting-code/components/ui/IconButton.js b/code/01-starting-code/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/01-starting-code/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/01-starting-code/components/ui/LoadingOverlay.js b/code/01-starting-code/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/01-starting-code/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/01-starting-code/constants/styles.js b/code/01-starting-code/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/01-starting-code/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/01-starting-code/package.json b/code/01-starting-code/package.json new file mode 100644 index 00000000..bb3ea4d1 --- /dev/null +++ b/code/01-starting-code/package.json @@ -0,0 +1,26 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/01-starting-code/screens/LoginScreen.js b/code/01-starting-code/screens/LoginScreen.js new file mode 100644 index 00000000..9626a64a --- /dev/null +++ b/code/01-starting-code/screens/LoginScreen.js @@ -0,0 +1,7 @@ +import AuthContent from '../components/Auth/AuthContent'; + +function LoginScreen() { + return ; +} + +export default LoginScreen; diff --git a/code/01-starting-code/screens/SignupScreen.js b/code/01-starting-code/screens/SignupScreen.js new file mode 100644 index 00000000..12bdd477 --- /dev/null +++ b/code/01-starting-code/screens/SignupScreen.js @@ -0,0 +1,7 @@ +import AuthContent from '../components/Auth/AuthContent'; + +function SignupScreen() { + return ; +} + +export default SignupScreen; diff --git a/code/01-starting-code/screens/WelcomeScreen.js b/code/01-starting-code/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/01-starting-code/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/02-controlling-signup-login-screens/App.js b/code/02-controlling-signup-login-screens/App.js new file mode 100644 index 00000000..3a3203b0 --- /dev/null +++ b/code/02-controlling-signup-login-screens/App.js @@ -0,0 +1,57 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + return ( + + + + ); +} + +export default function App() { + return ( + <> + + + + + ); +} diff --git a/code/02-controlling-signup-login-screens/app.json b/code/02-controlling-signup-login-screens/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/02-controlling-signup-login-screens/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/02-controlling-signup-login-screens/assets/adaptive-icon.png b/code/02-controlling-signup-login-screens/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/02-controlling-signup-login-screens/assets/adaptive-icon.png differ diff --git a/code/02-controlling-signup-login-screens/assets/favicon.png b/code/02-controlling-signup-login-screens/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/02-controlling-signup-login-screens/assets/favicon.png differ diff --git a/code/02-controlling-signup-login-screens/assets/icon.png b/code/02-controlling-signup-login-screens/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/02-controlling-signup-login-screens/assets/icon.png differ diff --git a/code/02-controlling-signup-login-screens/assets/splash.png b/code/02-controlling-signup-login-screens/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/02-controlling-signup-login-screens/assets/splash.png differ diff --git a/code/02-controlling-signup-login-screens/babel.config.js b/code/02-controlling-signup-login-screens/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/02-controlling-signup-login-screens/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/02-controlling-signup-login-screens/components/Auth/AuthContent.js b/code/02-controlling-signup-login-screens/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/02-controlling-signup-login-screens/components/Auth/AuthForm.js b/code/02-controlling-signup-login-screens/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/02-controlling-signup-login-screens/components/Auth/Input.js b/code/02-controlling-signup-login-screens/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/02-controlling-signup-login-screens/components/ui/Button.js b/code/02-controlling-signup-login-screens/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/02-controlling-signup-login-screens/components/ui/FlatButton.js b/code/02-controlling-signup-login-screens/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/02-controlling-signup-login-screens/components/ui/IconButton.js b/code/02-controlling-signup-login-screens/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/02-controlling-signup-login-screens/components/ui/LoadingOverlay.js b/code/02-controlling-signup-login-screens/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/02-controlling-signup-login-screens/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/02-controlling-signup-login-screens/constants/styles.js b/code/02-controlling-signup-login-screens/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/02-controlling-signup-login-screens/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/02-controlling-signup-login-screens/package.json b/code/02-controlling-signup-login-screens/package.json new file mode 100644 index 00000000..bb3ea4d1 --- /dev/null +++ b/code/02-controlling-signup-login-screens/package.json @@ -0,0 +1,26 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/02-controlling-signup-login-screens/screens/LoginScreen.js b/code/02-controlling-signup-login-screens/screens/LoginScreen.js new file mode 100644 index 00000000..9626a64a --- /dev/null +++ b/code/02-controlling-signup-login-screens/screens/LoginScreen.js @@ -0,0 +1,7 @@ +import AuthContent from '../components/Auth/AuthContent'; + +function LoginScreen() { + return ; +} + +export default LoginScreen; diff --git a/code/02-controlling-signup-login-screens/screens/SignupScreen.js b/code/02-controlling-signup-login-screens/screens/SignupScreen.js new file mode 100644 index 00000000..12bdd477 --- /dev/null +++ b/code/02-controlling-signup-login-screens/screens/SignupScreen.js @@ -0,0 +1,7 @@ +import AuthContent from '../components/Auth/AuthContent'; + +function SignupScreen() { + return ; +} + +export default SignupScreen; diff --git a/code/02-controlling-signup-login-screens/screens/WelcomeScreen.js b/code/02-controlling-signup-login-screens/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/02-controlling-signup-login-screens/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/03-creating-new-users/App.js b/code/03-creating-new-users/App.js new file mode 100644 index 00000000..3a3203b0 --- /dev/null +++ b/code/03-creating-new-users/App.js @@ -0,0 +1,57 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + return ( + + + + ); +} + +export default function App() { + return ( + <> + + + + + ); +} diff --git a/code/03-creating-new-users/app.json b/code/03-creating-new-users/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/03-creating-new-users/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/03-creating-new-users/assets/adaptive-icon.png b/code/03-creating-new-users/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/03-creating-new-users/assets/adaptive-icon.png differ diff --git a/code/03-creating-new-users/assets/favicon.png b/code/03-creating-new-users/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/03-creating-new-users/assets/favicon.png differ diff --git a/code/03-creating-new-users/assets/icon.png b/code/03-creating-new-users/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/03-creating-new-users/assets/icon.png differ diff --git a/code/03-creating-new-users/assets/splash.png b/code/03-creating-new-users/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/03-creating-new-users/assets/splash.png differ diff --git a/code/03-creating-new-users/babel.config.js b/code/03-creating-new-users/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/03-creating-new-users/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/03-creating-new-users/components/Auth/AuthContent.js b/code/03-creating-new-users/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/03-creating-new-users/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/03-creating-new-users/components/Auth/AuthForm.js b/code/03-creating-new-users/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/03-creating-new-users/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/03-creating-new-users/components/Auth/Input.js b/code/03-creating-new-users/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/03-creating-new-users/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/03-creating-new-users/components/ui/Button.js b/code/03-creating-new-users/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/03-creating-new-users/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/03-creating-new-users/components/ui/FlatButton.js b/code/03-creating-new-users/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/03-creating-new-users/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/03-creating-new-users/components/ui/IconButton.js b/code/03-creating-new-users/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/03-creating-new-users/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/03-creating-new-users/components/ui/LoadingOverlay.js b/code/03-creating-new-users/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/03-creating-new-users/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/03-creating-new-users/constants/styles.js b/code/03-creating-new-users/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/03-creating-new-users/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/03-creating-new-users/package.json b/code/03-creating-new-users/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/03-creating-new-users/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/03-creating-new-users/screens/LoginScreen.js b/code/03-creating-new-users/screens/LoginScreen.js new file mode 100644 index 00000000..9626a64a --- /dev/null +++ b/code/03-creating-new-users/screens/LoginScreen.js @@ -0,0 +1,7 @@ +import AuthContent from '../components/Auth/AuthContent'; + +function LoginScreen() { + return ; +} + +export default LoginScreen; diff --git a/code/03-creating-new-users/screens/SignupScreen.js b/code/03-creating-new-users/screens/SignupScreen.js new file mode 100644 index 00000000..15d1e542 --- /dev/null +++ b/code/03-creating-new-users/screens/SignupScreen.js @@ -0,0 +1,23 @@ +import { useState } from 'react'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + await createUser(email, password); + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/03-creating-new-users/screens/WelcomeScreen.js b/code/03-creating-new-users/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/03-creating-new-users/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/03-creating-new-users/util/auth.js b/code/03-creating-new-users/util/auth.js new file mode 100644 index 00000000..41374e69 --- /dev/null +++ b/code/03-creating-new-users/util/auth.js @@ -0,0 +1,14 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E' + +export async function createUser(email, password) { + const response = await axios.post( + 'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=' + API_KEY, + { + email: email, + password: password, + returnSecureToken: true + } + ); +} diff --git a/code/04-logging-users-in/App.js b/code/04-logging-users-in/App.js new file mode 100644 index 00000000..3a3203b0 --- /dev/null +++ b/code/04-logging-users-in/App.js @@ -0,0 +1,57 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + return ( + + + + ); +} + +export default function App() { + return ( + <> + + + + + ); +} diff --git a/code/04-logging-users-in/app.json b/code/04-logging-users-in/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/04-logging-users-in/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/04-logging-users-in/assets/adaptive-icon.png b/code/04-logging-users-in/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/04-logging-users-in/assets/adaptive-icon.png differ diff --git a/code/04-logging-users-in/assets/favicon.png b/code/04-logging-users-in/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/04-logging-users-in/assets/favicon.png differ diff --git a/code/04-logging-users-in/assets/icon.png b/code/04-logging-users-in/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/04-logging-users-in/assets/icon.png differ diff --git a/code/04-logging-users-in/assets/splash.png b/code/04-logging-users-in/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/04-logging-users-in/assets/splash.png differ diff --git a/code/04-logging-users-in/babel.config.js b/code/04-logging-users-in/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/04-logging-users-in/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/04-logging-users-in/components/Auth/AuthContent.js b/code/04-logging-users-in/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/04-logging-users-in/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/04-logging-users-in/components/Auth/AuthForm.js b/code/04-logging-users-in/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/04-logging-users-in/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/04-logging-users-in/components/Auth/Input.js b/code/04-logging-users-in/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/04-logging-users-in/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/04-logging-users-in/components/ui/Button.js b/code/04-logging-users-in/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/04-logging-users-in/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/04-logging-users-in/components/ui/FlatButton.js b/code/04-logging-users-in/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/04-logging-users-in/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/04-logging-users-in/components/ui/IconButton.js b/code/04-logging-users-in/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/04-logging-users-in/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/04-logging-users-in/components/ui/LoadingOverlay.js b/code/04-logging-users-in/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/04-logging-users-in/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/04-logging-users-in/constants/styles.js b/code/04-logging-users-in/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/04-logging-users-in/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/04-logging-users-in/package.json b/code/04-logging-users-in/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/04-logging-users-in/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/04-logging-users-in/screens/LoginScreen.js b/code/04-logging-users-in/screens/LoginScreen.js new file mode 100644 index 00000000..aecd267c --- /dev/null +++ b/code/04-logging-users-in/screens/LoginScreen.js @@ -0,0 +1,23 @@ +import { useState } from 'react'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + await login(email, password); + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/04-logging-users-in/screens/SignupScreen.js b/code/04-logging-users-in/screens/SignupScreen.js new file mode 100644 index 00000000..15d1e542 --- /dev/null +++ b/code/04-logging-users-in/screens/SignupScreen.js @@ -0,0 +1,23 @@ +import { useState } from 'react'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + await createUser(email, password); + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/04-logging-users-in/screens/WelcomeScreen.js b/code/04-logging-users-in/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/04-logging-users-in/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/04-logging-users-in/util/auth.js b/code/04-logging-users-in/util/auth.js new file mode 100644 index 00000000..eec60719 --- /dev/null +++ b/code/04-logging-users-in/util/auth.js @@ -0,0 +1,23 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + console.log(response.data); +} + +export async function createUser(email, password) { + await authenticate('signUp', email, password); +} + +export async function login(email, password) { + await authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/code/05-auth-error-handling/App.js b/code/05-auth-error-handling/App.js new file mode 100644 index 00000000..3a3203b0 --- /dev/null +++ b/code/05-auth-error-handling/App.js @@ -0,0 +1,57 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + return ( + + + + ); +} + +export default function App() { + return ( + <> + + + + + ); +} diff --git a/code/05-auth-error-handling/app.json b/code/05-auth-error-handling/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/05-auth-error-handling/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/05-auth-error-handling/assets/adaptive-icon.png b/code/05-auth-error-handling/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/05-auth-error-handling/assets/adaptive-icon.png differ diff --git a/code/05-auth-error-handling/assets/favicon.png b/code/05-auth-error-handling/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/05-auth-error-handling/assets/favicon.png differ diff --git a/code/05-auth-error-handling/assets/icon.png b/code/05-auth-error-handling/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/05-auth-error-handling/assets/icon.png differ diff --git a/code/05-auth-error-handling/assets/splash.png b/code/05-auth-error-handling/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/05-auth-error-handling/assets/splash.png differ diff --git a/code/05-auth-error-handling/babel.config.js b/code/05-auth-error-handling/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/05-auth-error-handling/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/05-auth-error-handling/components/Auth/AuthContent.js b/code/05-auth-error-handling/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/05-auth-error-handling/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/05-auth-error-handling/components/Auth/AuthForm.js b/code/05-auth-error-handling/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/05-auth-error-handling/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/05-auth-error-handling/components/Auth/Input.js b/code/05-auth-error-handling/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/05-auth-error-handling/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/05-auth-error-handling/components/ui/Button.js b/code/05-auth-error-handling/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/05-auth-error-handling/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/05-auth-error-handling/components/ui/FlatButton.js b/code/05-auth-error-handling/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/05-auth-error-handling/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/05-auth-error-handling/components/ui/IconButton.js b/code/05-auth-error-handling/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/05-auth-error-handling/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/05-auth-error-handling/components/ui/LoadingOverlay.js b/code/05-auth-error-handling/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/05-auth-error-handling/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/05-auth-error-handling/constants/styles.js b/code/05-auth-error-handling/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/05-auth-error-handling/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/05-auth-error-handling/package.json b/code/05-auth-error-handling/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/05-auth-error-handling/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/05-auth-error-handling/screens/LoginScreen.js b/code/05-auth-error-handling/screens/LoginScreen.js new file mode 100644 index 00000000..d6723da9 --- /dev/null +++ b/code/05-auth-error-handling/screens/LoginScreen.js @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + try { + await login(email, password); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + } + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/05-auth-error-handling/screens/SignupScreen.js b/code/05-auth-error-handling/screens/SignupScreen.js new file mode 100644 index 00000000..e10d16aa --- /dev/null +++ b/code/05-auth-error-handling/screens/SignupScreen.js @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + try { + await createUser(email, password); + } catch (error) { + Alert.alert( + 'Authentication failed', + 'Could not create user, please check your input and try again later.' + ); + } + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/05-auth-error-handling/screens/WelcomeScreen.js b/code/05-auth-error-handling/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/05-auth-error-handling/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/05-auth-error-handling/util/auth.js b/code/05-auth-error-handling/util/auth.js new file mode 100644 index 00000000..eec60719 --- /dev/null +++ b/code/05-auth-error-handling/util/auth.js @@ -0,0 +1,23 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + console.log(response.data); +} + +export async function createUser(email, password) { + await authenticate('signUp', email, password); +} + +export async function login(email, password) { + await authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/code/06-storing-managing-the-user-auth-state/App.js b/code/06-storing-managing-the-user-auth-state/App.js new file mode 100644 index 00000000..c0198b03 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/App.js @@ -0,0 +1,60 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; +import AuthContextProvider from './store/auth-context'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + return ( + + + + + + ); +} + +export default function App() { + return ( + <> + + + + + ); +} diff --git a/code/06-storing-managing-the-user-auth-state/app.json b/code/06-storing-managing-the-user-auth-state/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/06-storing-managing-the-user-auth-state/assets/adaptive-icon.png b/code/06-storing-managing-the-user-auth-state/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/06-storing-managing-the-user-auth-state/assets/adaptive-icon.png differ diff --git a/code/06-storing-managing-the-user-auth-state/assets/favicon.png b/code/06-storing-managing-the-user-auth-state/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/06-storing-managing-the-user-auth-state/assets/favicon.png differ diff --git a/code/06-storing-managing-the-user-auth-state/assets/icon.png b/code/06-storing-managing-the-user-auth-state/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/06-storing-managing-the-user-auth-state/assets/icon.png differ diff --git a/code/06-storing-managing-the-user-auth-state/assets/splash.png b/code/06-storing-managing-the-user-auth-state/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/06-storing-managing-the-user-auth-state/assets/splash.png differ diff --git a/code/06-storing-managing-the-user-auth-state/babel.config.js b/code/06-storing-managing-the-user-auth-state/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/06-storing-managing-the-user-auth-state/components/Auth/AuthContent.js b/code/06-storing-managing-the-user-auth-state/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/components/Auth/AuthForm.js b/code/06-storing-managing-the-user-auth-state/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/components/Auth/Input.js b/code/06-storing-managing-the-user-auth-state/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/components/ui/Button.js b/code/06-storing-managing-the-user-auth-state/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/components/ui/FlatButton.js b/code/06-storing-managing-the-user-auth-state/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/components/ui/IconButton.js b/code/06-storing-managing-the-user-auth-state/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/components/ui/LoadingOverlay.js b/code/06-storing-managing-the-user-auth-state/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/constants/styles.js b/code/06-storing-managing-the-user-auth-state/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/06-storing-managing-the-user-auth-state/package.json b/code/06-storing-managing-the-user-auth-state/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/06-storing-managing-the-user-auth-state/screens/LoginScreen.js b/code/06-storing-managing-the-user-auth-state/screens/LoginScreen.js new file mode 100644 index 00000000..d6723da9 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/screens/LoginScreen.js @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + try { + await login(email, password); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + } + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/06-storing-managing-the-user-auth-state/screens/SignupScreen.js b/code/06-storing-managing-the-user-auth-state/screens/SignupScreen.js new file mode 100644 index 00000000..e10d16aa --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/screens/SignupScreen.js @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + try { + await createUser(email, password); + } catch (error) { + Alert.alert( + 'Authentication failed', + 'Could not create user, please check your input and try again later.' + ); + } + setIsAuthenticating(false); + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/06-storing-managing-the-user-auth-state/screens/WelcomeScreen.js b/code/06-storing-managing-the-user-auth-state/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/06-storing-managing-the-user-auth-state/store/auth-context.js b/code/06-storing-managing-the-user-auth-state/store/auth-context.js new file mode 100644 index 00000000..8105cb78 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/store/auth-context.js @@ -0,0 +1,31 @@ +import { createContext, useState } from 'react'; + +export const AuthContext = createContext({ + token: '', + isAuthenticated: false, + authenticate: (token) => {}, + logout: () => {}, +}); + +function AuthContextProvider({ children }) { + const [authToken, setAuthToken] = useState(); + + function authenticate(token) { + setAuthToken(token); + } + + function logout() { + setAuthToken(null); + } + + const value = { + token: authToken, + isAuthenticated: !!authToken, + authenticate: authenticate, + logout: logout, + }; + + return {children}; +} + +export default AuthContextProvider; diff --git a/code/06-storing-managing-the-user-auth-state/util/auth.js b/code/06-storing-managing-the-user-auth-state/util/auth.js new file mode 100644 index 00000000..eec60719 --- /dev/null +++ b/code/06-storing-managing-the-user-auth-state/util/auth.js @@ -0,0 +1,23 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + console.log(response.data); +} + +export async function createUser(email, password) { + await authenticate('signUp', email, password); +} + +export async function login(email, password) { + await authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/code/07-protecting-screens/App.js b/code/07-protecting-screens/App.js new file mode 100644 index 00000000..b9de2217 --- /dev/null +++ b/code/07-protecting-screens/App.js @@ -0,0 +1,63 @@ +import { useContext } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; +import AuthContextProvider, { AuthContext } from './store/auth-context'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + return ( + + + + ); +} + +function Navigation() { + const authCtx = useContext(AuthContext); + + return ( + + {!authCtx.isAuthenticated && } + {authCtx.isAuthenticated && } + + ); +} + +export default function App() { + return ( + <> + + + + + + ); +} diff --git a/code/07-protecting-screens/app.json b/code/07-protecting-screens/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/07-protecting-screens/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/07-protecting-screens/assets/adaptive-icon.png b/code/07-protecting-screens/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/07-protecting-screens/assets/adaptive-icon.png differ diff --git a/code/07-protecting-screens/assets/favicon.png b/code/07-protecting-screens/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/07-protecting-screens/assets/favicon.png differ diff --git a/code/07-protecting-screens/assets/icon.png b/code/07-protecting-screens/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/07-protecting-screens/assets/icon.png differ diff --git a/code/07-protecting-screens/assets/splash.png b/code/07-protecting-screens/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/07-protecting-screens/assets/splash.png differ diff --git a/code/07-protecting-screens/babel.config.js b/code/07-protecting-screens/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/07-protecting-screens/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/07-protecting-screens/components/Auth/AuthContent.js b/code/07-protecting-screens/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/07-protecting-screens/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/07-protecting-screens/components/Auth/AuthForm.js b/code/07-protecting-screens/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/07-protecting-screens/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/07-protecting-screens/components/Auth/Input.js b/code/07-protecting-screens/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/07-protecting-screens/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/07-protecting-screens/components/ui/Button.js b/code/07-protecting-screens/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/07-protecting-screens/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/07-protecting-screens/components/ui/FlatButton.js b/code/07-protecting-screens/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/07-protecting-screens/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/07-protecting-screens/components/ui/IconButton.js b/code/07-protecting-screens/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/07-protecting-screens/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/07-protecting-screens/components/ui/LoadingOverlay.js b/code/07-protecting-screens/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/07-protecting-screens/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/07-protecting-screens/constants/styles.js b/code/07-protecting-screens/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/07-protecting-screens/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/07-protecting-screens/package.json b/code/07-protecting-screens/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/07-protecting-screens/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/07-protecting-screens/screens/LoginScreen.js b/code/07-protecting-screens/screens/LoginScreen.js new file mode 100644 index 00000000..ff128968 --- /dev/null +++ b/code/07-protecting-screens/screens/LoginScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await login(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/07-protecting-screens/screens/SignupScreen.js b/code/07-protecting-screens/screens/SignupScreen.js new file mode 100644 index 00000000..4d9e7b86 --- /dev/null +++ b/code/07-protecting-screens/screens/SignupScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await createUser(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed', + 'Could not create user, please check your input and try again later.' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/07-protecting-screens/screens/WelcomeScreen.js b/code/07-protecting-screens/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/07-protecting-screens/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/07-protecting-screens/store/auth-context.js b/code/07-protecting-screens/store/auth-context.js new file mode 100644 index 00000000..8105cb78 --- /dev/null +++ b/code/07-protecting-screens/store/auth-context.js @@ -0,0 +1,31 @@ +import { createContext, useState } from 'react'; + +export const AuthContext = createContext({ + token: '', + isAuthenticated: false, + authenticate: (token) => {}, + logout: () => {}, +}); + +function AuthContextProvider({ children }) { + const [authToken, setAuthToken] = useState(); + + function authenticate(token) { + setAuthToken(token); + } + + function logout() { + setAuthToken(null); + } + + const value = { + token: authToken, + isAuthenticated: !!authToken, + authenticate: authenticate, + logout: logout, + }; + + return {children}; +} + +export default AuthContextProvider; diff --git a/code/07-protecting-screens/util/auth.js b/code/07-protecting-screens/util/auth.js new file mode 100644 index 00000000..a21ffc4e --- /dev/null +++ b/code/07-protecting-screens/util/auth.js @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + const token = response.data.idToken; + + return token; +} + +export function createUser(email, password) { + return authenticate('signUp', email, password); +} + +export function login(email, password) { + return authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/code/08-adding-logout/App.js b/code/08-adding-logout/App.js new file mode 100644 index 00000000..72a18356 --- /dev/null +++ b/code/08-adding-logout/App.js @@ -0,0 +1,78 @@ +import { useContext } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; +import AuthContextProvider, { AuthContext } from './store/auth-context'; +import IconButton from './components/ui/IconButton'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + const authCtx = useContext(AuthContext); + return ( + + ( + + ), + }} + /> + + ); +} + +function Navigation() { + const authCtx = useContext(AuthContext); + + return ( + + {!authCtx.isAuthenticated && } + {authCtx.isAuthenticated && } + + ); +} + +export default function App() { + return ( + <> + + + + + + ); +} diff --git a/code/08-adding-logout/app.json b/code/08-adding-logout/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/08-adding-logout/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/08-adding-logout/assets/adaptive-icon.png b/code/08-adding-logout/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/08-adding-logout/assets/adaptive-icon.png differ diff --git a/code/08-adding-logout/assets/favicon.png b/code/08-adding-logout/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/08-adding-logout/assets/favicon.png differ diff --git a/code/08-adding-logout/assets/icon.png b/code/08-adding-logout/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/08-adding-logout/assets/icon.png differ diff --git a/code/08-adding-logout/assets/splash.png b/code/08-adding-logout/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/08-adding-logout/assets/splash.png differ diff --git a/code/08-adding-logout/babel.config.js b/code/08-adding-logout/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/08-adding-logout/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/08-adding-logout/components/Auth/AuthContent.js b/code/08-adding-logout/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/08-adding-logout/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/08-adding-logout/components/Auth/AuthForm.js b/code/08-adding-logout/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/08-adding-logout/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/08-adding-logout/components/Auth/Input.js b/code/08-adding-logout/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/08-adding-logout/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/08-adding-logout/components/ui/Button.js b/code/08-adding-logout/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/08-adding-logout/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/08-adding-logout/components/ui/FlatButton.js b/code/08-adding-logout/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/08-adding-logout/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/08-adding-logout/components/ui/IconButton.js b/code/08-adding-logout/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/08-adding-logout/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/08-adding-logout/components/ui/LoadingOverlay.js b/code/08-adding-logout/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/08-adding-logout/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/08-adding-logout/constants/styles.js b/code/08-adding-logout/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/08-adding-logout/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/08-adding-logout/package.json b/code/08-adding-logout/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/08-adding-logout/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/08-adding-logout/screens/LoginScreen.js b/code/08-adding-logout/screens/LoginScreen.js new file mode 100644 index 00000000..ff128968 --- /dev/null +++ b/code/08-adding-logout/screens/LoginScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await login(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/08-adding-logout/screens/SignupScreen.js b/code/08-adding-logout/screens/SignupScreen.js new file mode 100644 index 00000000..4d9e7b86 --- /dev/null +++ b/code/08-adding-logout/screens/SignupScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await createUser(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed', + 'Could not create user, please check your input and try again later.' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/08-adding-logout/screens/WelcomeScreen.js b/code/08-adding-logout/screens/WelcomeScreen.js new file mode 100644 index 00000000..b51d4283 --- /dev/null +++ b/code/08-adding-logout/screens/WelcomeScreen.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +function WelcomeScreen() { + return ( + + Welcome! + You authenticated successfully! + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/08-adding-logout/store/auth-context.js b/code/08-adding-logout/store/auth-context.js new file mode 100644 index 00000000..8105cb78 --- /dev/null +++ b/code/08-adding-logout/store/auth-context.js @@ -0,0 +1,31 @@ +import { createContext, useState } from 'react'; + +export const AuthContext = createContext({ + token: '', + isAuthenticated: false, + authenticate: (token) => {}, + logout: () => {}, +}); + +function AuthContextProvider({ children }) { + const [authToken, setAuthToken] = useState(); + + function authenticate(token) { + setAuthToken(token); + } + + function logout() { + setAuthToken(null); + } + + const value = { + token: authToken, + isAuthenticated: !!authToken, + authenticate: authenticate, + logout: logout, + }; + + return {children}; +} + +export default AuthContextProvider; diff --git a/code/08-adding-logout/util/auth.js b/code/08-adding-logout/util/auth.js new file mode 100644 index 00000000..a21ffc4e --- /dev/null +++ b/code/08-adding-logout/util/auth.js @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + const token = response.data.idToken; + + return token; +} + +export function createUser(email, password) { + return authenticate('signUp', email, password); +} + +export function login(email, password) { + return authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/code/09-accessing-protected-resources/App.js b/code/09-accessing-protected-resources/App.js new file mode 100644 index 00000000..72a18356 --- /dev/null +++ b/code/09-accessing-protected-resources/App.js @@ -0,0 +1,78 @@ +import { useContext } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; +import AuthContextProvider, { AuthContext } from './store/auth-context'; +import IconButton from './components/ui/IconButton'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + const authCtx = useContext(AuthContext); + return ( + + ( + + ), + }} + /> + + ); +} + +function Navigation() { + const authCtx = useContext(AuthContext); + + return ( + + {!authCtx.isAuthenticated && } + {authCtx.isAuthenticated && } + + ); +} + +export default function App() { + return ( + <> + + + + + + ); +} diff --git a/code/09-accessing-protected-resources/app.json b/code/09-accessing-protected-resources/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/09-accessing-protected-resources/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/09-accessing-protected-resources/assets/adaptive-icon.png b/code/09-accessing-protected-resources/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/09-accessing-protected-resources/assets/adaptive-icon.png differ diff --git a/code/09-accessing-protected-resources/assets/favicon.png b/code/09-accessing-protected-resources/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/09-accessing-protected-resources/assets/favicon.png differ diff --git a/code/09-accessing-protected-resources/assets/icon.png b/code/09-accessing-protected-resources/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/09-accessing-protected-resources/assets/icon.png differ diff --git a/code/09-accessing-protected-resources/assets/splash.png b/code/09-accessing-protected-resources/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/09-accessing-protected-resources/assets/splash.png differ diff --git a/code/09-accessing-protected-resources/babel.config.js b/code/09-accessing-protected-resources/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/09-accessing-protected-resources/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/09-accessing-protected-resources/components/Auth/AuthContent.js b/code/09-accessing-protected-resources/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/09-accessing-protected-resources/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/09-accessing-protected-resources/components/Auth/AuthForm.js b/code/09-accessing-protected-resources/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/09-accessing-protected-resources/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/09-accessing-protected-resources/components/Auth/Input.js b/code/09-accessing-protected-resources/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/09-accessing-protected-resources/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/09-accessing-protected-resources/components/ui/Button.js b/code/09-accessing-protected-resources/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/09-accessing-protected-resources/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/09-accessing-protected-resources/components/ui/FlatButton.js b/code/09-accessing-protected-resources/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/09-accessing-protected-resources/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/09-accessing-protected-resources/components/ui/IconButton.js b/code/09-accessing-protected-resources/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/09-accessing-protected-resources/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/09-accessing-protected-resources/components/ui/LoadingOverlay.js b/code/09-accessing-protected-resources/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/09-accessing-protected-resources/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/09-accessing-protected-resources/constants/styles.js b/code/09-accessing-protected-resources/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/09-accessing-protected-resources/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/09-accessing-protected-resources/package.json b/code/09-accessing-protected-resources/package.json new file mode 100644 index 00000000..85a602c5 --- /dev/null +++ b/code/09-accessing-protected-resources/package.json @@ -0,0 +1,27 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/09-accessing-protected-resources/screens/LoginScreen.js b/code/09-accessing-protected-resources/screens/LoginScreen.js new file mode 100644 index 00000000..ff128968 --- /dev/null +++ b/code/09-accessing-protected-resources/screens/LoginScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await login(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/09-accessing-protected-resources/screens/SignupScreen.js b/code/09-accessing-protected-resources/screens/SignupScreen.js new file mode 100644 index 00000000..4d9e7b86 --- /dev/null +++ b/code/09-accessing-protected-resources/screens/SignupScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await createUser(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed', + 'Could not create user, please check your input and try again later.' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/09-accessing-protected-resources/screens/WelcomeScreen.js b/code/09-accessing-protected-resources/screens/WelcomeScreen.js new file mode 100644 index 00000000..258fb35a --- /dev/null +++ b/code/09-accessing-protected-resources/screens/WelcomeScreen.js @@ -0,0 +1,47 @@ +import axios from 'axios'; +import { useContext, useEffect, useState } from 'react'; + +import { StyleSheet, Text, View } from 'react-native'; +import { AuthContext } from '../store/auth-context'; + +function WelcomeScreen() { + const [fetchedMessage, setFetchedMesssage] = useState(''); + + const authCtx = useContext(AuthContext); + const token = authCtx.token; + + useEffect(() => { + axios + .get( + 'https://react-native-course-3cceb-default-rtdb.firebaseio.com/message.json?auth=' + + token + ) + .then((response) => { + setFetchedMesssage(response.data); + }); + }, [token]); + + return ( + + Welcome! + You authenticated successfully! + {fetchedMessage} + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/09-accessing-protected-resources/store/auth-context.js b/code/09-accessing-protected-resources/store/auth-context.js new file mode 100644 index 00000000..8105cb78 --- /dev/null +++ b/code/09-accessing-protected-resources/store/auth-context.js @@ -0,0 +1,31 @@ +import { createContext, useState } from 'react'; + +export const AuthContext = createContext({ + token: '', + isAuthenticated: false, + authenticate: (token) => {}, + logout: () => {}, +}); + +function AuthContextProvider({ children }) { + const [authToken, setAuthToken] = useState(); + + function authenticate(token) { + setAuthToken(token); + } + + function logout() { + setAuthToken(null); + } + + const value = { + token: authToken, + isAuthenticated: !!authToken, + authenticate: authenticate, + logout: logout, + }; + + return {children}; +} + +export default AuthContextProvider; diff --git a/code/09-accessing-protected-resources/util/auth.js b/code/09-accessing-protected-resources/util/auth.js new file mode 100644 index 00000000..a21ffc4e --- /dev/null +++ b/code/09-accessing-protected-resources/util/auth.js @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + const token = response.data.idToken; + + return token; +} + +export function createUser(email, password) { + return authenticate('signUp', email, password); +} + +export function login(email, password) { + return authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/code/10-storing-auth-tokens-on-the-device/App.js b/code/10-storing-auth-tokens-on-the-device/App.js new file mode 100644 index 00000000..c398d132 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/App.js @@ -0,0 +1,107 @@ +import { useContext, useEffect, useState } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import AppLoading from 'expo-app-loading'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; +import AuthContextProvider, { AuthContext } from './store/auth-context'; +import IconButton from './components/ui/IconButton'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} + +function AuthenticatedStack() { + const authCtx = useContext(AuthContext); + return ( + + ( + + ), + }} + /> + + ); +} + +function Navigation() { + const authCtx = useContext(AuthContext); + + return ( + + {!authCtx.isAuthenticated && } + {authCtx.isAuthenticated && } + + ); +} + +function Root() { + const [isTryingLogin, setIsTryingLogin] = useState(true); + + const authCtx = useContext(AuthContext); + + useEffect(() => { + async function fetchToken() { + const storedToken = await AsyncStorage.getItem('token'); + + if (storedToken) { + authCtx.authenticate(storedToken); + } + + setIsTryingLogin(false); + } + + fetchToken(); + }, []); + + if (isTryingLogin) { + return ; + } + + return ; +} + +export default function App() { + + return ( + <> + + + + + + ); +} diff --git a/code/10-storing-auth-tokens-on-the-device/app.json b/code/10-storing-auth-tokens-on-the-device/app.json new file mode 100644 index 00000000..9a1223e7 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/code/10-storing-auth-tokens-on-the-device/assets/adaptive-icon.png b/code/10-storing-auth-tokens-on-the-device/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/code/10-storing-auth-tokens-on-the-device/assets/adaptive-icon.png differ diff --git a/code/10-storing-auth-tokens-on-the-device/assets/favicon.png b/code/10-storing-auth-tokens-on-the-device/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/code/10-storing-auth-tokens-on-the-device/assets/favicon.png differ diff --git a/code/10-storing-auth-tokens-on-the-device/assets/icon.png b/code/10-storing-auth-tokens-on-the-device/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/code/10-storing-auth-tokens-on-the-device/assets/icon.png differ diff --git a/code/10-storing-auth-tokens-on-the-device/assets/splash.png b/code/10-storing-auth-tokens-on-the-device/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/code/10-storing-auth-tokens-on-the-device/assets/splash.png differ diff --git a/code/10-storing-auth-tokens-on-the-device/babel.config.js b/code/10-storing-auth-tokens-on-the-device/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/code/10-storing-auth-tokens-on-the-device/components/Auth/AuthContent.js b/code/10-storing-auth-tokens-on-the-device/components/Auth/AuthContent.js new file mode 100644 index 00000000..8eb04b4f --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/Auth/AuthContent.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false, + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.replace('Signup'); + } else { + navigation.replace('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual, + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4, + }, + buttons: { + marginTop: 8, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/components/Auth/AuthForm.js b/code/10-storing-auth-tokens-on-the-device/components/Auth/AuthForm.js new file mode 100644 index 00000000..cf4a7a81 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/components/Auth/Input.js b/code/10-storing-auth-tokens-on-the-device/components/Auth/Input.js new file mode 100644 index 00000000..3aa0d5dd --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/Auth/Input.js @@ -0,0 +1,54 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid, +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8, + }, + label: { + color: 'white', + marginBottom: 4, + }, + labelInvalid: { + color: Colors.error500, + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16, + }, + inputInvalid: { + backgroundColor: Colors.error100, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/components/ui/Button.js b/code/10-storing-auth-tokens-on-the-device/components/ui/Button.js new file mode 100644 index 00000000..9b4b231d --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/components/ui/FlatButton.js b/code/10-storing-auth-tokens-on-the-device/components/ui/FlatButton.js new file mode 100644 index 00000000..1b45e9ec --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/components/ui/IconButton.js b/code/10-storing-auth-tokens-on-the-device/components/ui/IconButton.js new file mode 100644 index 00000000..7f438db4 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/components/ui/LoadingOverlay.js b/code/10-storing-auth-tokens-on-the-device/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..99ae9528 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/constants/styles.js b/code/10-storing-auth-tokens-on-the-device/constants/styles.js new file mode 100644 index 00000000..0ae92d20 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/code/10-storing-auth-tokens-on-the-device/package.json b/code/10-storing-auth-tokens-on-the-device/package.json new file mode 100644 index 00000000..40f20a47 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/package.json @@ -0,0 +1,29 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "@react-native-async-storage/async-storage": "^1.16.1", + "axios": "^0.26.0", + "expo": "~44.0.0", + "expo-status-bar": "~1.2.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-native": "0.64.3", + "react-native-web": "0.17.1", + "expo-app-loading": "~1.3.0" + }, + "devDependencies": { + "@babel/core": "^7.12.9" + }, + "private": true +} diff --git a/code/10-storing-auth-tokens-on-the-device/screens/LoginScreen.js b/code/10-storing-auth-tokens-on-the-device/screens/LoginScreen.js new file mode 100644 index 00000000..ff128968 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/screens/LoginScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function loginHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await login(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/code/10-storing-auth-tokens-on-the-device/screens/SignupScreen.js b/code/10-storing-auth-tokens-on-the-device/screens/SignupScreen.js new file mode 100644 index 00000000..4d9e7b86 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/screens/SignupScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; + +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { AuthContext } from '../store/auth-context'; +import { createUser } from '../util/auth'; + +function SignupScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + + const authCtx = useContext(AuthContext); + + async function signupHandler({ email, password }) { + setIsAuthenticating(true); + try { + const token = await createUser(email, password); + authCtx.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed', + 'Could not create user, please check your input and try again later.' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/code/10-storing-auth-tokens-on-the-device/screens/WelcomeScreen.js b/code/10-storing-auth-tokens-on-the-device/screens/WelcomeScreen.js new file mode 100644 index 00000000..258fb35a --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/screens/WelcomeScreen.js @@ -0,0 +1,47 @@ +import axios from 'axios'; +import { useContext, useEffect, useState } from 'react'; + +import { StyleSheet, Text, View } from 'react-native'; +import { AuthContext } from '../store/auth-context'; + +function WelcomeScreen() { + const [fetchedMessage, setFetchedMesssage] = useState(''); + + const authCtx = useContext(AuthContext); + const token = authCtx.token; + + useEffect(() => { + axios + .get( + 'https://react-native-course-3cceb-default-rtdb.firebaseio.com/message.json?auth=' + + token + ) + .then((response) => { + setFetchedMesssage(response.data); + }); + }, [token]); + + return ( + + Welcome! + You authenticated successfully! + {fetchedMessage} + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, +}); diff --git a/code/10-storing-auth-tokens-on-the-device/store/auth-context.js b/code/10-storing-auth-tokens-on-the-device/store/auth-context.js new file mode 100644 index 00000000..9a70df11 --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/store/auth-context.js @@ -0,0 +1,35 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { createContext, useEffect, useState } from 'react'; + +export const AuthContext = createContext({ + token: '', + isAuthenticated: false, + authenticate: (token) => {}, + logout: () => {}, +}); + +function AuthContextProvider({ children }) { + const [authToken, setAuthToken] = useState(); + + function authenticate(token) { + setAuthToken(token); + AsyncStorage.setItem('token', token); + } + + function logout() { + setAuthToken(null); + AsyncStorage.removeItem('token'); + } + + const value = { + token: authToken, + isAuthenticated: !!authToken, + authenticate: authenticate, + logout: logout, + }; + + return {children}; +} + +export default AuthContextProvider; diff --git a/code/10-storing-auth-tokens-on-the-device/util/auth.js b/code/10-storing-auth-tokens-on-the-device/util/auth.js new file mode 100644 index 00000000..a21ffc4e --- /dev/null +++ b/code/10-storing-auth-tokens-on-the-device/util/auth.js @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const API_KEY = 'AIzaSyDCYasArcOwcALFhIj2szug5aD2PgUQu1E'; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true, + }); + + const token = response.data.idToken; + + return token; +} + +export function createUser(email, password) { + return authenticate('signUp', email, password); +} + +export function login(email, password) { + return authenticate('signInWithPassword', email, password); +} \ No newline at end of file diff --git a/slides/slides.pdf b/slides/slides.pdf new file mode 100644 index 00000000..405729f3 Binary files /dev/null and b/slides/slides.pdf differ