Skip to content

dabit3/expo-amplify-full-stack-cloud-workshop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 

Repository files navigation

Full Stack Mobile App Development on AWS with Expo and Amplify

In this workshop we'll learn how to build cloud-enabled mobile applications with React Native & AWS Amplify.

The app we will be building is a basic Instagram clone that allows you to sign up, sign in, create posts, and view posts that you have created as well as others.

Example apps

  1. Message app - code here

  2. Completed full stack cloud app - code here

Debugging

Due to subtle differences between the JavaScript execution environments on the iOS Simulator on Mac and in your remote debugger, it is recommended that you enable "remote debugging" during development if you are developing using the iOS simulator on your Macbook.

Getting Started - Creating the Expo app

To get started we first need to create a new Expo project using the Expo CLI, & change into the new directory.

$ expo init expo-amplify
> Choose a template: tabs

$ cd expo-amplify
$ npm install aws-amplify aws-amplify-react-native @react-native-community/netinfo
$ expo install expo-image-picker
$ expo install expo-image-manipulator

Running the app

Next, run the app:

$ expo start

Installing the CLI & Initializing a new AWS Amplify Project

Installing the CLI

Next, we'll install the AWS Amplify CLI:

$ npm install -g @aws-amplify/cli

Now we need to configure the CLI with our credentials:

$ amplify configure

If you'd like to see a video walkthrough of this configuration process, click here.

Here we'll walk through the amplify configure setup. Once you've signed in to the AWS console, continue:

  • Specify the AWS Region: your preferred region
  • Specify the username of the new IAM user: amplify-cli-user

In the AWS Console, click Next: Permissions, Next: Tags, Next: Review, & Create User to create the new IAM user. Then, return to the command line & press Enter.

  • Enter the access key of the newly created user:
    accessKeyId: (<YOUR_ACCESS_KEY_ID>)
    secretAccessKey: (<YOUR_SECRET_ACCESS_KEY>)
  • Profile Name: amplify-cli-user

Initializing A New AWS Amplify Project

Make sure to initialize this Amplify project in the root of your new React Native application

$ amplify init

- Enter a name for the project: expoamplify
- Enter a name for the environment: dev
- Choose your default editor: (your favorite editor)
- Please choose the type of app that youre building: javascript
- What javascript framework are you using: react-native
- Source Directory Path: /
- Distribution Directory Path: /
- Build Command: npm run-script build
- Start Command: npm run-script start
- Do you want to use an AWS profile? Y
- Please choose the profile you want to use: amplify-workshop-user

Now, the AWS Amplify CLI has iniatilized a new project & you will see a couple of new files & folders: amplify & aws-exports.js. These files hold your project configuration.

Configuring the Expo app

The next thing we need to do is to configure our Expo application to be aware of our new AWS Amplify project. We can do this by referencing the auto-generated aws-exports.js file that is now in our root folder.

To configure the app, open App.js and add the following code below the last import:

// App.js
import Amplify from 'aws-amplify'
import config from './aws-exports'
Amplify.configure(config)

Now, our app is ready to start using our AWS services.

Adding Authentication and Profile view

To add authentication, we can use the following command:

$ amplify add auth

- Do you want to use default authentication and security configuration? Default configuration
- How do you want users to be able to sign in when using your Cognito User Pool? Username (keep default) 
- Do you want to configure advanced settings? No

Now, we'll run the push command and the cloud resources will be created in our AWS account.

$ amplify push

To view the AWS services any time after their creation, run the following command:

$ amplify console

Adding the authentication UI

Next, open navigation/BottomTabNavigator.js. Here, import the withAuthenticator component, and change the default export to be the withAuthenticator wrapping the main BottomTabNavigator component:

/* navigation/BottomTabNavigator.js */

/* Import the withAuthenticator component  */
import { withAuthenticator } from 'aws-amplify-react-native'

/* Remove the default export from the main component */
function BottomTabNavigator({ navigation, route }) { /* rest of component code stays */ }

/* Create new default export */
export default withAuthenticator(BottomTabNavigator)

withAuthenticator - This UI component will render an authentication flow in front of any component

Now, run the app:

$ expo start

When the app loads, you should now see an authentication flow in front of the app. You should be able to sign up, sign in, and reset your password.

Make sure that you use a real email address in order to complete the MFA required for the app sign up process.

Adding the Profile screen

Next, create a new file in the screens folder called ProfileScreen.js.

In screens/ProfileScreen.js, add the following code:

/* screens/ProfileScreen.js */
import * as React from 'react';
import { StyleSheet, Text, Button, View } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { Auth } from 'aws-amplify';

function ProfileScreen() {
  const [user, setUser] = React.useState(null)
  React.useEffect(() => {
    checkUser()
  }, [])
  async function checkUser() {
    const user = await Auth.currentAuthenticatedUser()
    setUser(user)
  }
  async function signOut() {
    await Auth.signOut()
  }
  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
      {
        user && (
          <View>
            <Text style={styles.userInfo}>Username: {user.username}</Text>
            <Text style={styles.userInfo}>Email: {user.attributes.email}</Text>
            <Text style={styles.userInfo}>Phone: {user.attributes.phone_number}</Text>
            <Text style={styles.userInfo}>User ID: {user.attributes.sub}</Text>
          </View>
        )
      }
      <Button title="Sign Out" onPress={signOut} />
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fafafa',
  },
  contentContainer: {
    paddingTop: 15,
  },
  optionIconContainer: {
    marginRight: 12,
  },
  userInfo: {
    paddingHorizontal: 15,
    paddingBottom: 10,
    fontSize: 16,
    fontWeight: 'bold'
  }
});

export default ProfileScreen

Auth - This class will allow you to call methods to handle user identity. There are over 30 methods enabling you to do things like manually sign up or sign in a user, but in our case we are using it to call Auth.signOut() to sign the user out and Auth.currentAuthenticatedUser() to check if a user is signed in and use the signed in user's metadata.

Next, open navigation/BottomTabNavigator.js and add the following:

// First, import the new ProfileScreen components
import ProfileScreen from '../screens/ProfileScreen'

// Next, add another BottomTab component to hold the profile view 
<BottomTab.Screen
  name="Profile"
  component={ProfileScreen}
  options={{
    title: 'Profile',
    tabBarIcon: ({ focused }) => <TabBarIcon focused={focused} name="md-person" />
  }}
/>

Now, run the app:

$ expo start

You should see a new Profile tab to the bottom right when you sign in. In this tab you should

Adding complex object storage

To add storage we'll use the Amplify storage category:

$ amplify add storage

? Please select from one of the below mentioned services: Content
? Please provide a friendly name for your resource that will be used to label this category in the project: images
? Please provide bucket name: <your-unique-bucket-name>
? Who should have access: Auth users only
? What kind of access do you want for Authenticated users? create, update, read, delete
? Do you want to add a Lambda Trigger for your S3 Bucket? N

Adding the API

To add the API, run the following command:

$ amplify add api

? Please select from one of the below mentioned services: GraphQL
? Provide API name: travelapi
? Choose the default authorization type for the API: Amazon Cognito User Pool
? Do you want to configure advanced settings for the GraphQL API: Y
? Configure additional auth types? N
? Configure conflict detection? Y
? Select the default resolution strategy: Auto Merge
? Do you have an annotated GraphQL schema? N
? Do you want a guided schema creation? Y
? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? Y

The schema, located at amplify/backend/api/travelapi/schema.graphql, should now be opened in your text editor.

Update the schema with the following:

type Post @model
  @auth(rules: [
    { allow: owner, operations: [create, delete, update] }
  ])
{
  id: ID!
  name: String!
  description: String
  location: String
  image: String
  owner: String
}

Save the schema and press enter.

Next, generate the models needed for DataStore:

$ amplify codegen models

You now should see a models directory located in the src directory of your project.

Deploy the API and storage service with the Amplify push command:

$ amplify push --y

Now, the API is created and we can start using it.

Adding additional screens

Next, create a couple of new files in the screens folder:

$ touch screens/CreatePostScreen.js screens/PostsScreen.js screens/MyPostsScreen.js components/PostComponent.js

Creating a new post

In this screen, you will be creating a component that will allow you to create a new post.

Below this code snippet, I will walk through the main functionality.

/* screens/CreatePostScreen.js */
import * as React from 'react';
import { StyleSheet, Text, ActivityIndicator, Button, View, Image, TextInput } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import * as ImagePicker from 'expo-image-picker';
import * as Permissions from 'expo-permissions';
import Amplify, { Storage, DataStore } from 'aws-amplify';
import * as ImageManipulator from "expo-image-manipulator";
import Constants from 'expo-constants';
import { Post } from '../src/models';

function uuid() {
  return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

const initialFormState = {
  name: '', location: '', image: ''
}

function CreatePostScreen({ navigation }) {
  const [image, setImage] = React.useState(null)
  const [formState, setFormState] = React.useState(initialFormState)
  const [saving, setSaving] = React.useState(false)
  React.useEffect(() => {
    getPermissions()
  }, [])
  async function getPermissions() {
    if (Constants.platform.ios) {
      const { status } = await Permissions.askAsync(Permissions.CAMERA_ROLL);
      if (status !== 'granted') {
        alert('Sorry, we need camera roll permissions to make this work!');
      }
    }
  };

  async function pickImage () {
    try {
      const imageId = uuid()
      let result = await ImagePicker.launchImageLibraryAsync({
        mediaTypes: ImagePicker.MediaTypeOptions.All,
        allowsEditing: true, aspect: [4, 3], quality: 1,
      });
      if (!result.cancelled) {
        const manipResult = await ImageManipulator.manipulateAsync(
          result.uri,
          [{ resize: { width: 385, height: 385 } }],
        );
        setFormState({ ...formState, image: imageId })
        setImage(manipResult.uri)
        setSaving(true)
        try {
          const response = await fetch(manipResult.uri)
          const blob = await response.blob()
          await Storage.put(imageId, blob)
          setSaving(false)
        } catch (error) {
          console.log({ error })
          setSaving(false)
        }
      }
      console.log({ result });
    } catch (error) {
      console.log({ error });
    }
  }

  async function createPost() {
    if (!image || !formState.name || !formState.location) return
    await DataStore.save(new Post(formState));
    setFormState(initialFormState)
    setImage(null)
    navigation.navigate('Posts')
  }

  function onChangeText(key, value) {
    setFormState({ ...formState, [key]: value })
  }
  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
      <TextInput
        onChangeText={val => onChangeText('name', val)}
        placeholder="Post name"
        style={styles.inputStyle}
        value={formState.name}
      />
       <TextInput
        onChangeText={val => onChangeText('location', val)}
        placeholder="Post location"
        style={styles.inputStyle}
        value={formState.location}
      />
      <Button title="Choose an image" onPress={pickImage} />
      {image && <Image source={{ uri: image }} style={{ width: 200, height: 200 }} />}
      <Button disabled={saving} title="Create Post" onPress={createPost} />
      {
        saving && (
          <View>
            <Text>Saving image... </Text><ActivityIndicator />
          </View>
        )
      }
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fafafa',
    paddingHorizontal: 15
  },
  contentContainer: {
    paddingTop: 15,
  },
  optionIconContainer: {
    marginRight: 12,
  },
  userInfo: {
    paddingHorizontal: 15,
    paddingBottom: 10,
    fontSize: 16,
    fontWeight: 'bold'
  },
  inputStyle: {
    height: 50,
    backgroundColor: '#ddd',
    marginBottom: 5,
    paddingHorizontal: 10
  }
});

export default CreatePostScreen

In this component there are 4 main functions:

getPermissions - This function prompts the user for access to the camera roll on their device.

pickImage - This function allows the user to choose an image to store as part of the post. When choosing an image, we do the following

  • Using the ImageManipulator library we first resize the image.
  • Next, we update the local state to set the image ID and the local image to show a local preview of the image
  • Finaly we use the Storage API from Amplify to upload the image to S3

createPost - This function calls the DataStore API to create a new post using the local state.

onChangeText - This function updates the formState with the user input for the post name and post location.

Creating a Post view component

To render a Post we will be creating a new component that will display the post name, location, and image.

/* components/PostComponent.js */
import React from 'react'
import {
  Text, View, Image, StyleSheet, Dimensions
} from 'react-native'

const { width } = Dimensions.get('window')

export default function PostComponent({ name, location, image }) {
  return (
    <View>
      <Text>{name}</Text>
      <Text>{location}</Text>
      <Image
        style={styles.image}
        source={{ uri: image }}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  image: {
    width: width - 30,
    height: width - 30
  }
})

Rendering a list of posts

In this component we will be fetching the list of posts and rendering them in our UI.

Below this code snippet, I will walk through the main functionality.

/* screens/PostsScreen.js */
import * as React from 'react';
import { StyleSheet, Text } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { DataStore, Storage } from 'aws-amplify'
import { Post } from '../src/models'
import PostComponent from '../components/PostComponent'

function PostsScreen() {
  const [posts, setPosts] = React.useState([]);
  let subscription;
  React.useEffect(() => {
    fetchPosts();
    subscribe();
    return () => subscription && subscription.unsubscribe();
  }, [])
  async function fetchPosts() {
    const dataStoreQuery = await DataStore.query(Post);
    const postData = await Promise.all(dataStoreQuery.map(async post => {
      post = { ...post };
      const signedImage = await Storage.get(post.image);
      post.image = signedImage;
      return post;
    }))
    setPosts(postData);
  }
  async function subscribe() {
    subscription = DataStore.observe(Post).subscribe(() => {
      fetchPosts();
    });
  }
  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
      <Text>All Posts</Text>
      {
        posts.map(post => (
          <PostComponent key={post.id} {...post} />
        ))
      }
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fafafa',
    paddingHorizontal: 15
  },
  contentContainer: {
    paddingTop: 15,
  },
  optionIconContainer: {
    marginRight: 12,
  },
  userInfo: {
    paddingHorizontal: 15,
    paddingBottom: 10,
    fontSize: 16,
    fontWeight: 'bold'
  },
  inputStyle: {
    height: 50,
    backgroundColor: '#ddd',
    marginBottom: 5,
    paddingHorizontal: 10
  }
});

export default PostsScreen

In this component there are two main functions:

fetchPosts - This function calls the DataStore API and fetches the list of posts. We then map over the list of posts and fetch a signed image for each post image, and update the image property to be the signed image.

subscribe - This function will call DataStore.observe() which will listen for new posts that are created. This will provide a real-time feed of posts created by any user of the app.

Updating the Tabs with the new components

Next, open navigation/BottomTabNavigator.js and update

/* navigation/BottomTabNavigator.js */
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import * as React from 'react';

import TabBarIcon from '../components/TabBarIcon';
import CreatePostScreen from '../screens/CreatePostScreen';
import PostsScreen from '../screens/PostsScreen'
import ProfileScreen from '../screens/ProfileScreen'
import { withAuthenticator } from 'aws-amplify-react-native'

const BottomTab = createBottomTabNavigator();
const INITIAL_ROUTE_NAME = 'Posts';

function BottomTabNavigator({ navigation, route }) {
  navigation.setOptions({ headerTitle: getHeaderTitle(route) });

  return (
    <BottomTab.Navigator initialRouteName={INITIAL_ROUTE_NAME}>
      <BottomTab.Screen
        name="Posts"
        component={PostsScreen}
        options={{
          title: 'Posts',
          tabBarIcon: ({ focused }) => <TabBarIcon focused={focused} name="md-code-working" />,
        }}
      />
      <BottomTab.Screen
        name="Create Post"
        component={CreatePostScreen}
        options={{
          title: 'Create Post',
          tabBarIcon: ({ focused }) => <TabBarIcon focused={focused} name="ios-create" />,
        }}
      />
      <BottomTab.Screen
        name="Profile"
        component={ProfileScreen}
        options={{
          title: 'Profile',
          tabBarIcon: ({ focused }) => <TabBarIcon focused={focused} name="md-person" />
        }}
      />
    </BottomTab.Navigator>
  );
}

export default withAuthenticator(BottomTabNavigator)

function getHeaderTitle(route) {
  const routeName = route.state?.routes[route.state.index]?.name ?? INITIAL_ROUTE_NAME;

  switch (routeName) {
    case 'Home':
      return 'How to get started';
    case 'Links':
      return 'Links to learn more';
  }
}

To test it out, sign out and sign up as a new user. Create a couple of new posts under the new user account.

Next, run the following command:

$ expo start

You should be able to view only your posts in the My Posts tab and see all posts in the Posts tab.

My posts

Let's add another tab that only renders the posts we've created. Since our GraphQL schema has a field that hold the information of the owner of the post, we can create a view that only shows the posts we've created ourselves.

To do so, we'll need to create a new component (MyPostsScreen.js) and update the bottom tab bar to render this components.

First, create screens/MyPostsScreen.js and add the following code:

import * as React from 'react';
import { StyleSheet, Text } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { DataStore, Storage, Auth } from 'aws-amplify'
import { Post } from '../src/models'
import PostComponent from '../components/PostComponent'

function MyPostsScreen() {
  const [posts, setPosts] = React.useState([]);
  let subscription;
  React.useEffect(() => {
    fetchPosts();
    subscribe();
    return () => subscription && subscription.unsubscribe();
  }, [])
  async function subscribe() {
    subscription = DataStore.observe(Post).subscribe(() => {
      fetchPosts();
    });
  }
  async function fetchPosts() {
    const { username } = await Auth.currentAuthenticatedUser()
    const dataStoreQuery = await DataStore.query(Post, p => p.owner('eq', username));
    let postData = await Promise.all(dataStoreQuery.map(async post => {
      post = { ...post };
      const signedImage = await Storage.get(post.image);
      post.image = signedImage;
      return post;
    }))
    setPosts(postData);
  }
  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
      <Text>All Posts</Text>
      {
        posts.map(post => <PostComponent key={post.id} {...post} />)
      }
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fafafa',
    paddingHorizontal: 15
  },
  contentContainer: {
    paddingTop: 15,
  },
  optionIconContainer: {
    marginRight: 12,
  },
  userInfo: {
    paddingHorizontal: 15,
    paddingBottom: 10,
    fontSize: 16,
    fontWeight: 'bold'
  },
  inputStyle: {
    height: 50,
    backgroundColor: '#ddd',
    marginBottom: 5,
    paddingHorizontal: 10
  }
});

export default MyPostsScreen

The main difference between this component and the PostsScreen component is that in the fetchPosts function we are filtering out the posts using the DataStore predicate of eq for equals.

const dataStoreQuery = await DataStore.query(Post, p => p.owner('eq', username));

Updating the tab bar

Finally, update the tab bar with the new tab:

/* navigation/BottomTabNavigator.js */

/* First import the new MyPostscreen component  */
import MyPostsScreen from '../screens/MyPostsScreen'

/* Next, add the new tab */ 
<BottomTab.Screen
  name="My Posts"
  component={MyPostsScreen}
  options={{
    title: 'My Posts',
    tabBarIcon: ({ focused }) => <TabBarIcon focused={focused} name="ios-list-box" />,
  }}
/>

To test it out, run the following command:

$ expo start

Custom authentication strategies

To view a final solution for a custom authentication strategy, check out the AWS Amplify React Native Auth Starter here.

This section is an overview and is considered an advanced part of the workshop. If you are not comfortable writing a custom authentication flow, I would read through this section and use it as a reference in the future.

The withAuthenticator component is a really easy way to get up and running with authentication, but in a real-world application we probably want more control over how our form looks & functions.

Let's look at how we might create our own authentication flow.

To get started, we would probably want to create input fields that would hold user input data in the state. For instance when signing up a new user, we would probably need 3 user inputs to capture the user's username, email, & password.

To do this, we could create some initial state for these values & create an event handler that we could attach to the form inputs:

// initial state
state = {
  username: '', password: '', email: ''
}

// event handler
onChangeText = (key, value) => {
  this.setState({ [key]: value })
}

// example of usage with TextInput
<TextInput
  placeholder='username'
  value={this.state.username}
  style={{ width: 300, height: 50, margin: 5, backgroundColor: "#ddd" }}
  onChangeText={v => this.onChange('username', v)}
/>

We'd also need to have a method that signed up & signed in users. We can us the Auth class to do thi. The Auth class has over 30 methods including things like signUp, signIn, confirmSignUp, confirmSignIn, & forgotPassword. Thes functions return a promise so they need to be handled asynchronously.

// import the Auth component
import { Auth } from 'aws-amplify'

// Class method to sign up a user
signUp = async() => {
  const { username, password, email } = this.state
  try {
    await Auth.signUp({ username, password, attributes: { email }})
  } catch (err) {
    console.log('error signing up user...', err)
  }
}

Removing Services

If at any time, or at the end of this workshop, you would like to delete a service from your project & your account, you can do this by running the amplify remove command:

$ amplify remove auth

$ amplify push

If you are unsure of what services you have enabled at any time, you can run the amplify status command:

$ amplify status

amplify status will give you the list of resources that are currently enabled in your app.

Deleting the project

To delete the entire project, run the delete command:

$ amplify delete

About

Full Stack Cloud App Development with Expo and AWS Amplify

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published