Within this tutorial, you will build a Voice Recorder service accessed via a mobile React Native application that provides users with the functionality of creating a personal account and recording a voice input associated with their account data.
While building this application, you will model your application data using Content-Types from a Strapi Admin dashboard. You will also learn how to enable and use the GraphQL API and perform file uploads using the GraphQL API.
To follow along with this tutorial, it is recommended that you satisfy the following requirements;
Before building the Strapi and react native application, open your terminal window and execute the two commands below to create a parent directory and change your working directory to it.
1 # create directory
2 mkdir strapi-voice
3
4 # change directory
5 cd strapi-voice
The two applications that will be built in this article will be stored in the parent strapi-voice directory.
In this tutorial, you will use a Postgres database to store data from your Strapi application. By default, Strapi uses an SQLite database for new applications, but you will change this to a Postgres database during the installation steps.
Before creating the Strapi application, create a new database within your running Postgres cluster for the Strapi application. This database name will be specified while creating a new Strapi application.
Execute the SQL command below using psql to create a new database, replacing the DATABASE_NAME
placeholder with your desired database name;
Note: Alternatively, you can also use your preferred GUI database tool to create the database.
1 CREATE DATABASE <DATABASE_NAME>;
The first step to working with Strapi is to create a new Strapi project using the create-strapi-app
CLI with npx. Execute the command below to launch the interactive installer;
1 npx create-strapi-app voice-app
Executing the command above would launch the interactive installer that would walk you through the process of configuring a new Strapi application in the following steps;
After providing the database credentials, wait for some minutes for the Strapi installer to verify that the credentials are valid and create the data schema in the specified database.
After a complete installation, execute the commands below to change the directory into the Strapi application and start the development server to start the Strapi server.
1 # change directory
2 cd voice-api
3
4 # start the development server
5 yarn develop
Executing the command above will start the Strapi server on port 1337. Open the admin dashboard for your Strapi application at [http://localhost:1337/admin](http://localhost:1337/admin)
to create a root admin user with an admin email and password.
At this point, you have your Strapi application up and running with a complete setup admin dashboard. The next step will be to model the data within your Strapi application using the Content Builder.
From the Strapi dashboard, navigate to the Content-builder page using the left sidebar. Click the Create new collection type text from the left-placed Collection Types section to launch the modal for creating a new collection type.
Within the opened modal, specify voice-recordings in the display name text field as the name of this collection type.
As shown above, a unique identifier for the collection type will be generated from the display name.
Next, click the Advanced Settings tab to reveal more advanced settings for the collection. Toggle off the default enabled Draft / publish system for this collection type.
The draft / publish system provides an application admin with the feature to create a draft entry and review it later before publishing. You will not need this feature within the application.
After toggling off the Draft / publish system, leave other settings at their default and click the continue button.
The next step will be to create the fields within the collection content type. Select the following fields shown within the modal;
At the right-hand side, click the default File ( from: upload ) dropdown and select the User ( from: users-permission ) option to relate the voice-recordings relation to the User collection type.
After creating the created_by relation similar to the relation shown in the image above, click the Finish button to complete the entire collection.
From the Content Builder page, the voice-recordings collection content type will be listed with all the newly added fields as shown below;
As shown in the image above, your voice-recordings collection type has five fields, each making up the voice-recording details.
Proper user management is an essential aspect of every application. In the design of this application, every User will be able to create a recording and view their past recordings.
To protect the privacy of each User's data, you will configure the role of users using the Role-Based Access Control ( RBAC ) feature provided by Strapi.
Note: This article within the Strapi blog provides an in-depth explanation of the Role-Based Access Control features of Strapi.
Using the Strapi Admin dashboard, navigate to the settings page, click the Roles item listed in the User & Permissions section within the left-placed navigation bar. This will display the default Public and Authenticated roles for each created User.
Among the two listed roles above, the Authenticated role highlighted will be of primary focus as you will expand the scope of this role in the next step.
Click the first Authenticated role to display the view for the role that allows you to edit the permissions and details.
In the Permissions card within the Authenticated role view, the voice-recording content type will be displayed with its allowed actions within the Application permission. Click the Select All checkbox to grant all users with the authenticated role to perform the enabled actions on the voice-recording content type.
Click the Save button and wait for the new change made to the Authenticated role to be effected.
At this point, the Strapi application is fully functional and ready for use. However, the design of the mobile application is to send and consume data from a GraphQL API to enjoy the benefits of GraphQL. Hence, you need to enable the GraphQL API within the Strapi application by installing the GraphQL plugin.
Stop the running Strapi application, and install the GraphQL plugin for Strapi using the command below;
1 yarn strapi install graphql
After installing the plugin above, restart the management server and navigate to the GraphiQL playground in your web browser at http://localhost:1337/graphql
to view your application data.
The authentication section of the GraphQL plugin documentation for Strapi explains how to generate and use a JWT token in your playground HTTP headers before using a GraphQL operation from the GraphiQL playground.
With the Strapi application fully set up having a Users and Voice-recordings collection type, whose data is served through a GraphQL API, you can build the React Native application connected to the GraphQL API.
Within this article, we would use the Expo CLI to bootstrap a new React Native application quickly. Expo is an open-source platform that abstracts away all the complexities of building a React application by providing tools to effortlessly build and deploy your application.
If you do not have the Expo CLI installed, execute the command below to install the expo-cli globally on your local computer using NPM.
1 npm install -g expo-cli
Next, execute the command below to launch the interactive installer within the expo-cli that will walk you through creating a new react native application with Expo.
1 expo init voice-app
When prompted to choose a template by the installer, select the blank template within the managed workflow type.
With the application created, you can launch either an android or iOS emulator based on your operating system to view local changes to the application.
Next, start the expo metro server to bundle and install your code on the running emulator; expo start
Press a to install the application on an Android emulator, or press o to install the application on an iOS emulator.
Note: You can also work with the expo application using the Metro DevTools launched in your default browser.
To use the GraphQL API from Strapi within this application, you will need to install client libraries from react-apollo alongside libraries from react-navigation to implement navigation across screens within the application;
1 npm install @apollo/client react-native-vector-icons @react-native-community/masked-view @react-navigation/native @react-navigation/stack graphql expo-av expo-file-system @react-native-async-storage/async-storage apollo-link-error
Each time a GraphQL operation is performed against the Strapi API, you must provide a valid JWT token to authenticate the request. This JWT token will be obtained from the operation response when a user either signs in to the application or creates a new account. This token will be stored in the application using the @react-native-async-storage/async-storage library that was installed above.
To use the @react-native-async-storage/async-storage
library, it is recommended that you create an abstraction over the library's API. To do this, create a storage.js
file and add the code block's content below that contains helper functions for working with the @react-native-async-storage/async-storage library.
1 // ./src/storage.js
2
3 import AsyncStorage from "@react-native-async-storage/async-storage";
4
5 export const USER_TOKEN_KEY = "@USER_TOKEN";
6 export const USER_ID_KEY = "@USER_ID";
7
8 const validateParameter = (key) => {
9 if (!key && key !== String) {
10 throw new Error("Invalid key specified");
11 }
12 };
13
14 export const setItem = async (itemKey, itemValue) => {
15 if (!key && !val && typeof key !== String && val !== String) {
16 throw new Error("Invalid key or val specified");
17 }
18 try {
19 await AsyncStorage.setItem(
20 itemKey,
21 JSON.stringify({
22 data: itemValue,
23 })
24 );
25 } catch (e) {
26 console.log(`Error setting key: ${e}`);
27 }
28 };
29
30 export const clearItem = async (key) => {
31 validateParameter(key);
32 try {
33 await AsyncStorage.removeItem(key);
34 } catch (e) {
35 console.log(`Error removing key: ${e}`);
36 }
37 };
38
39 export const getItem = async (key) => {
40 validateParameter(key);
41 try {
42 const data = await AsyncStorage.getItem(key);
43 return JSON.parse(data);
44 } catch (e) {
45 console.log(`Error removing key: ${e}`);
46 }
47 };
The code block above contains three exported asynchronous helper functions for creating, deleting, and retrieving data stored in the device local storage using the @react-native-async-storage/async-storage library. In the next step, you will use the getItem
function exported from the code block above to retrieve the stored token when creating an ApolloClient instance.
Using your preferred code editor, open the App.js
file. You will replace the boilerplate code with the content of the code block below, which instantiates the ApolloClient class and adds the navigation screens within the application.
1 import { StatusBar } from "expo-status-bar";
2 import React, { useState, useEffect } from "react";
3 import { ApolloProvider } from "@apollo/client";
4 import { createStackNavigator } from "@react-navigation/stack";
5 import {
6 ApolloClient,
7 ApolloLink,
8 HttpLink,
9 InMemoryCache,
10 } from "@apollo/client";
11 import { NavigationContainer } from "@react-navigation/native";
12
13 import Home from "./src/screens/home";
14 import CreateRecording from "./src/screens/create-recording";
15 import CreateAccount from "./src/screens/create-account";
16 import Login from "./src/screens/login";
17 import { getToken, USER_TOKEN_KEY } from "./src/utils";
18
19 const headerTitleStyle = {
20 fontSize: 17,
21 color: "#fff",
22 fontWeight: "normal",
23 };
24
25 export default function App() {
26 const [token, setToken] = useState(null);
27 const Stack = createStackNavigator();
28
29 useEffect(() => {
30 (async function () {
31 const data = await getToken(USER_TOKEN_KEY);
32 setToken(data);
33 })();
34 }, []);
35
36 const client = new ApolloClient({
37 cache: new InMemoryCache(),
38 link: ApolloLink.from([
39 new HttpLink({
40 # Replace this with an environment variable containing the URL to your deployed Strapi API
41 uri: "http://localhost::1337/graphql",
42 headers: token
43 ? {
44 Authorization: `Bearer ${token.jwt}`,
45 }
46 : null
47 }),
48 ]),
49 });
50
51 return (
52 <NavigationContainer>
53 <ApolloProvider client={client}>
54 <StatusBar style="auto" />
55
56 <Stack.Navigator>
57 <Stack.Screen
58 options={{
59 title: "Login",
60 headerShown: false,
61 headerTitleStyle,
62 headerLeftContainerStyle: {
63 color: "#fff",
64 },
65 headerStyle: {
66 backgroundColor: "#8c4bff",
67 },
68 }}
69 name="login"
70 component={Login}
71 />
72
73 <Stack.Screen
74 options={{
75 title: "CreateAccount",
76 headerTitleStyle,
77 headerShown: false,
78 headerLeftContainerStyle: {
79 color: "#fff",
80 },
81 headerStyle: {
82 backgroundColor: "#8c4bff",
83 },
84 }}
85 name="create-account"
86 component={CreateAccount}
87 />
88
89 <Stack.Screen
90 options={{
91 headerStyle: {
92 backgroundColor: "#8c4bff",
93 },
94 headerLeft: null,
95 title: "My Recordings",
96 headerTitleStyle,
97 }}
98 name="home"
99 component={Home}
100 />
101 <Stack.Screen
102 options={{
103 title: "New Recording",
104 headerTitleStyle,
105 headerLeftContainerStyle: {
106 color: "#fff",
107 },
108 headerStyle: {
109 backgroundColor: "#8c4bff",
110 },
111 }}
112 name="CreateRecording"
113 component={CreateRecording}
114 />
115 </Stack.Navigator>
116 </ApolloProvider>
117 </NavigationContainer>
118 );
119 }
The code block above contains the root component for the React Native application. When the component is mounted, a useEffect hook is first executed to retrieve any token stored locally.
Whatever token that is found is stored in the component's local state and further used in the ApolloClient headers for authenticating GraphQL operations made to the Strapi API.
The ApolloClient instance store in the client
variable is further passed as a prop to the ApolloProvider wrapper, which wraps the entire application component tree.
Navigation within this application is also implemented in the App.js
file by making use of the React-Navigation library.
The createStackNavigator hook from React-Navigation is used to create a stack of screens comprising a Login, CreateAccount, Home, and CreateRecording screen. These screens have not been created yet, but you will create them soon.
To keep the component free from unnecessary code, we would keep all unrelated code in their respective files and only important what is needed.
Create a graphql.js
file within the src
directory and add the code block content below that contains the gql template literals created using the gql template literal tag from @apollo/client.
1 // ./src/graphql.js
2 import { gql } from "@apollo/client";
3
4 export const FETCH_RECORDINGS = gql`
5 query fetchRecordings {
6 voiceRecordings {
7 description
8 recording_name
9 id
10 created_at
11 }
12 }
13 `;
14
15 export const CREATE_ACCOUNT = gql`
16 mutation createAccount(
17 $email: String!
18 $password: String!
19 $username: String!
20 ) {
21 createUser(
22 input: {
23 data: { username: $username, email: $email, password: $password }
24 }
25 ) {
26 __typename
27 }
28 }
29 `;
30
31 export const LOGIN_USER = gql`
32 mutation loginUser($email: String!, $password: String!) {
33 login(input: { identifier: $email, password: $password }) {
34 jwt
35 user {
36 id
37 }
38 }
39 }
40 `;
41
42 export const CREATE_RECORDING = gql`
43 mutation createRecording(
44 $description: String
45 $name: String
46 $userId: String
47 $fileId: String
48 ) {
49 createVoiceRecording(
50 input: {
51 data: {
52 description: $description
53 recording_name: $name
54 users_permissions_user: $userId
55 recording: $fileId
56 }
57 }
58 ) {
59 voiceRecording {
60 description
61 recording_name
62 recording {
63 id
64 }
65 }
66 }
67 }
68 `;
69
70 export const UPLOAD_FILE = gql`
71 mutation uploadFile($file: Upload!) {
72 upload(file: $file) {
73 id
74 }
75 }
76 `;
The code block above contains five exported variables, each containing the respective gql template literal tag for the Query and Mutation operations within the application.
Create a new screens
directory within the src
directory. This new directory will contain the navigation screens imported and used in the App.js
file. In the four outlined steps below, you will create a file for each of these screens and build the components within them;
Using your opened code editor, create a CreateAccount.js
file and add the code block's content below into the new CreateAccount.js
file to create a component with text fields for a user to specify a username, email, and password detail for creating a new account in the Voice recorder application.
1 // ./src/screens/CreateAccount.js
2
3 import * as React from "react";
4 import {
5 View,
6 Text,
7 TextInput,
8 ActivityIndicator,
9 StyleSheet,
10 Dimensions,
11 TouchableOpacity,
12 } from "react-native";
13 import { useMutation } from "@apollo/client";
14 import { CREATE_ACCOUNT } from "../graphql";
15
16 const { height, width } = Dimensions.get("window");
17
18 const CreateAccount = (props) => {
19 const [Email, setEmail] = React.useState("");
20 const [username, setUsername] = React.useState("");
21 const [Password, setPassword] = React.useState("");
22 const [confirmPassword, setConfirmPassword] = React.useState("");
23 const [error, setError] = React.useState(null);
24 const [isLoading, setLoading] = React.useState(false);
25
26 const [createAccount, { data }] = useMutation(CREATE_ACCOUNT, {
27 variables: {
28 username,
29 email: Email,
30 password: Password,
31 },
32 });
33
34 const handleCreateAccount = async () => {
35 setLoading(true);
36 try {
37 await createAccount();
38 if (data) {
39 props.navigation.navigate("login");
40 }
41 } catch (e) {
42 console.log(`error creating account : ${e}`);
43 setError(e);
44 } finally {
45 setLoading(false);
46 }
47 };
48
49 return (
50 <View style={styles.body}>
51 <View>
52 <Text style={[styles.title, styles.alignCenter]}>
53 Strapi Voice Recorder
54 </Text>
55 <View style={{ marginVertical: 5 }} />
56 <Text style={{ textAlign: "center", fontSize: 15 }}>
57 {`Voice recorder application powered \n by Strapi CMS API`}{" "}
58 </Text>
59 <View style={{ marginVertical: 15 }} />
60 {error && (
61 <Text style={{ textAlign: "center", fontSize: 14, color: "red" }}>
62 {error.message}{" "}
63 </Text>
64 )}
65 <View style={styles.input}>
66 <TextInput
67 keyboardType="text"
68 value={username}
69 placeholder="Your username. e.g Johnny"
70 onChangeText={(val) => setUsername(val)}
71 />
72 </View>
73 <View style={{ marginVertical: 10 }} />
74 <View style={styles.input}>
75 <TextInput
76 keyboardType="email-address"
77 value={Email}
78 placeholder="Your email. John@mail.com"
79 onChangeText={(val) => setEmail(val)}
80 />
81 </View>
82 <View style={{ marginVertical: 10 }} />
83 <View style={styles.input}>
84 <TextInput
85 secureTextEntry={true}
86 value={Password}
87 placeholder="Your Password"
88 onChangeText={(val) => setPassword(val)}
89 />
90 </View>
91 <View style={{ marginVertical: 10 }} />
92 <View style={styles.input}>
93 <TextInput
94 secureTextEntry={true}
95 value={confirmPassword}
96 placeholder="Confirm Your Password"
97 onChangeText={(val) => setConfirmPassword(val)}
98 />
99 </View>
100 <View style={{ marginVertical: 10 }} />
101 <View style={styles.alignCenter}>
102 <TouchableOpacity
103 disabled={Password !== confirmPassword}
104 onPress={() => handleCreateAccount()}
105 style={[styles.button, styles.alignCenter]}
106 >
107 {!isLoading ? (
108 <Text style={{ color: "#fff" }}> Create New Account </Text>
109 ) : (
110 <ActivityIndicator color="#282c34" />
111 )}
112 </TouchableOpacity>
113 </View>
114 <View style={{ marginVertical: 10 }} />
115 <TouchableOpacity
116 disabled={isLoading}
117 onPress={() => props.navigation.navigate("login")}
118 >
119 <View style={styles.flex}>
120 <Text style={styles.infoText}>Have An Account?</Text>
121 <Text style={[styles.infoText, { color: "black", marginLeft: 10 }]}>
122 Login Instead
123 </Text>
124 </View>
125 </TouchableOpacity>
126 </View>
127 </View>
128 );
129 };
130 const styles = StyleSheet.create({
131 flex: {
132 display: "flex",
133 flexDirection: "row",
134 justifyContent: "center",
135 },
136 title: {
137 fontSize: 22,
138 textAlign: "center",
139 fontWeight: "500",
140 },
141 infoText: {
142 textAlign: "center",
143 fontSize: 14,
144 color: "grey",
145 },
146 body: {
147 backgroundColor: "#fff",
148 height,
149 display: "flex",
150 justifyContent: "center",
151 alignItems: "center",
152 },
153 input: {
154 backgroundColor: "#fff",
155 paddingHorizontal: 10,
156 borderWidth: 1,
157 borderRadius: 5,
158 borderColor: "#c0c0c0",
159 height: 45,
160 width: width - 30,
161 },
162 alignCenter: {
163 display: "flex",
164 justifyContent: "center",
165 alignItems: "center",
166 },
167 button: {
168 height: 40,
169 borderWidth: 1,
170 borderColor: "#28BFFD",
171 backgroundColor: "#28BFFD",
172 color: "#fff",
173 width: width - 30,
174 fontSize: 16,
175 borderRadius: 3,
176 },
177 });
178
179 export default CreateAccount;
The component within the code block above displays input fields and stores the values typed in the local component state, after which the input values are used in a GraphQL mutation as variables to create a new user.
Of peculiar interest is the handleCreateAccount
function within the CreateAccount
component. This function is executed at the top of the Create New Account button. It executes the createAccount GraphQL mutation destructured from the GraphQL literal using the useMutation hook from @apollo/client.
After the GraphQL mutation is executed without any error, the User is navigated to the Login screen to login using the same credentials used in creating the account. This flow adheres to the Strapi API structure and allows the application to retrieve the User's JWT token to authenticate future requests.
An example of the Create-account screen in the voice recorder application running within an Android emulator is shown in the image below;
As shown in the image above, a user can also navigate to the Login screen to login to an existing account by tapping the Login Instead text. In the next step, you will create the Login screen.
Using your opened code editor, create a login.js
file in the screens
directory and add the code block's content below into the login.js
file to create a component that accepts a user's email and password specified when an account was created.
1 // ./src/screens/login.js
2
3 import * as React from "react";
4 import { useMutation } from "@apollo/client";
5 import {
6 View,
7 Text,
8 TextInput,
9 StyleSheet,
10 Dimensions,
11 ActivityIndicator,
12 TouchableOpacity,
13 } from "react-native";
14 import { setItem, getItem, USER_TOKEN_KEY } from "../storage";
15 import { LOGIN_USER } from "../graphql";
16 const { height, width } = Dimensions.get("window");
17
18 const Login = (props) => {
19 const [Email, setEmail] = React.useState("");
20 const [Password, setPassword] = React.useState("");
21 const [isLoading, setLoading] = React.useState(false);
22 const [error, setLoginError] = React.useState(null);
23 const [loginUser, { data }] = useMutation(LOGIN_USER);
24
25 React.useEffect(() => {
26 (async function () {
27 const token = await getItem(USER_TOKEN_KEY);
28 if (token) {
29 props.navigation.navigate("home");
30 }
31 })();
32 }, []);
33
34 const handleLogin = async () => {
35 setLoading(true);
36 try {
37 await loginUser({
38 variables: {
39 email: Email,
40 password: Password,
41 },
42 });
43 if (data) {
44 await setItem(data.login.jwt);
45 await setItem(data.login.user.id)
46 props.navigation.navigate("home");
47 }
48 } catch (e) {
49 console.log(e);
50 setLoginError(e);
51 } finally {
52 setLoading(false);
53 }
54 };
55
56 return (
57 <View style={styles.body}>
58 <View>
59 <Text style={[styles.title, styles.alignCenter]}>
60 {" "}
61 Strapi Voice Recorder
62 </Text>
63 <View style={{ marginVertical: 5 }} />
64 <Text style={{ textAlign: "center", fontSize: 15 }}>
65 {" "}
66 {`Voice recorder application powered \n by Strapi CMS API`}{" "}
67 </Text>
68 <View style={{ marginVertical: 15 }} />
69 {error && (
70 <Text style={{ textAlign: "center", fontSize: 14, color: "red" }}>
71 {error.message}
72 </Text>
73 )}
74 <View style={styles.input}>
75 <TextInput
76 value={Email}
77 placeholder="Enter your email address"
78 onChangeText={(value) => setEmail(value)}
79 />
80 </View>
81 <View style={{ marginVertical: 10 }} />
82 <View style={styles.input}>
83 <TextInput
84 value={Password}
85 secureTextEntry={true}
86 placeholder="Enter your Password"
87 onChangeText={(value) => setPassword(value)}
88 />
89 </View>
90 <View style={{ marginVertical: 10 }} />
91 <View style={styles.alignCenter}>
92 <TouchableOpacity
93 onPress={() => handleLogin()}
94 disabled={isLoading}
95 style={[styles.button, styles.alignCenter]}
96 >
97 {!isLoading ? (
98 <Text style={{ color: "#fff", fontSize: 15 }}> Sign In </Text>
99 ) : (
100 <ActivityIndicator color="#fff" />
101 )}
102 </TouchableOpacity>
103 </View>
104 <View style={{ marginVertical: 10 }} />
105 <TouchableOpacity
106 onPress={() => props.navigation.navigate("create-account")}
107 >
108 <View style={styles.flex}>
109 <Text style={styles.infoText}>Don't Have An Account?</Text>
110 <Text style={[styles.infoText, { color: "black", marginLeft: 10 }]}>
111 Create Account
112 </Text>
113 </View>
114 </TouchableOpacity>
115 </View>
116 </View>
117 );
118 };
119
120 const styles = StyleSheet.create({
121 flex: {
122 display: "flex",
123 flexDirection: "row",
124 justifyContent: "center",
125 },
126 title: {
127 fontSize: 22,
128 textAlign: "center",
129 fontWeight: "500",
130 },
131 infoText: {
132 textAlign: "center",
133 fontSize: 14,
134 color: "grey",
135 },
136 body: {
137 backgroundColor: "#fff",
138 height,
139 display: "flex",
140 justifyContent: "center",
141 alignItems: "center",
142 },
143 input: {
144 backgroundColor: "#fff",
145 paddingHorizontal: 10,
146 borderWidth: 1,
147 borderRadius: 5,
148 borderColor: "#c0c0c0",
149 height: 45,
150 width: width - 30,
151 },
152 alignCenter: {
153 display: "flex",
154 justifyContent: "center",
155 alignItems: "center",
156 },
157 button: {
158 height: 40,
159 borderWidth: 1,
160 borderColor: "#28BFFD",
161 backgroundColor: "#28BFFD",
162 color: "#fff",
163 width: width - 30,
164 fontSize: 16,
165 borderRadius: 3,
166 },
167 });
168
169 export default Login;
The component above uses a useEffect hook to check if a JWT token for the User is present in the device. If the JWT token is found, the User is navigated to the Home screen.
This check serves as a simple means to detect if the User is already authenticated, and it is performed here because the Login screen is the first screen in the Navigation Stack, and it will be the first screen shown when the application is opened.
Note: This authentication can be better performed by writing a custom middleware with React Navigation.
The component also contains two input fields for collecting a user email and password input values and stores them in the local state created using the useState hooks. These input values are further passed as variables into a GraphQL mutation to authenticate the User.
The handleLogin
function is executed at the tap of the Sign In button, and it executes the GraphQL mutation to submit the User's email and password to the Strapi GraphQL API and returns an object containing the JWT token and the User's ID.
The token and user id values are further stored in the device local storage using the setItem
helper function, which uses the @react-native-async-storage/async-storage library underneath.
The JWT token will be used to authenticate GraphQL operations to retrieve and create data to the Strapi API. The user ID will be used to identify the User when creating a new recording later on.
The image below shows the Login screen within the voice recorder application running on an Android emulator.
The home screen within this application will serve as the default screen for all authenticated users, displaying a list of created recordings.
The Home screen has been broken down into a Home parent component to follow React composition design principle. A record card child component displays a card with details about a user's recording gotten from the parent component as a prop.
We would begin by building the RecordCard component. Create a new components
directory within the src
directory and create a RecordCard.js
file within the component directory. Add the content of the code block below into the RecordCard.js
file.
1 // ./src/components/RecordCard.js
2
3 import * as React from "react";
4 import { View, Text } from "react-native";
5 import Icon from 'react-native-vector-icons/AntDesign';
6
7 import { HomeStyles as styles } from "../styles"
8
9 const RecordingCard = ({data, onPlay}) => (
10 <View style={[styles.alignCenter]}>
11 <View style={styles.post}>
12 <View
13 onClick={() => onPlay()}
14 style={[styles.play, styles.alignCenter
15 , {
16 flexDirection: "column"
17 }]}>
18 <Icon size={30} name={"playcircleo"}/>
19 <Text style={{fontSize: 14}}> Play </Text>
20 </View>
21
22 <View style={[styles.alignCenter, {flexDirection: 'column'}]}>
23 <Text style={styles.title}> {data.recording_name}</Text>
24 <Text> {data.created_at} </Text>
25 </View>
26
27 <View />
28 </View>
29 </View>
30 )
31
32 export default RecordingCard;
The RecordingCard component above receives a data object containing the details of a user's recording and an onPlay
function that is executed when the play icon within the card is clicked. This function plays the recorded audio for the User to listen to.
Next, create a Home.js
file in the screens directory and add the code block content below to import and use the RecordingCard component in a Flat list component.
1 // ./src/screens/Home.js
2
3 import React, { useEffect } from "react";
4 import {
5 View,
6 Text,
7 TouchableOpacity,
8 StyleSheet,
9 FlatList,
10 Dimensions,
11 ActivityIndicator,
12 } from "react-native";
13 import { useQuery } from "@apollo/client";
14 import Icon from "react-native-vector-icons/Ionicons";
15 import MaterialIcons from "react-native-vector-icons/MaterialIcons";
16 import RecordingCard from "../components/recordingCard";
17
18 import { clearToken, getToken, USER_TOKEN_KEY } from "../utils";
19 import { FETCH_RECORDINGS } from "../graphql";
20
21 const { width, height } = Dimensions.get("screen");
22
23 const handleLogout = async (navigation) => {
24 try {
25 await clearToken(USER_TOKEN_KEY);
26
27 navigation.navigate("login");
28 } catch (e) {
29 console.log(e);
30 }
31 };
32
33 const Home = ({ navigation }) => {
34 useEffect(() => {
35 (async function () {
36 const token = await getToken(USER_TOKEN_KEY);
37 if (!token) {
38 navigation.navigate("login");
39 }
40
41 navigation.setOptions({
42 headerRight: () => {
43 return (
44 <View style={{ paddingRight: 15 }}>
45 <TouchableOpacity
46 style={{ flexDirection: "row" }}
47 onPress={() => handleLogout(navigation)}
48 >
49 <MaterialIcons name={"logout"} color={"#fff"} size={20} />
50 </TouchableOpacity>
51 </View>
52 );
53 },
54 });
55 })();
56 }, []);
57
58 const { data, error, loading } = useQuery(FETCH_RECORDINGS);
59
60 if (loading) {
61 return (
62 <View style={styles.alignCenter}>
63 <ActivityIndicator color="#8c4bff" />
64 </View>
65 );
66 }
67
68 if (error) {
69 return (
70 <View style={[styles.alignCenter, { paddingTop: 15 }]}>
71 <Text> An error occurred while loading your recordings... </Text>
72 </View>
73 );
74 }
75
76 return (
77 <View style={{ flex: 1, backgroundColor: "#fff" }}>
78 <FlatList
79 data={data.voiceRecordings}
80 keyExtractor={(item) => item.id}
81 renderItem={({ item }) => (
82 <RecordingCard onPlay={() => playAudio()} data={item} />
83 )}
84 />
85
86 <View style={styles.alignCenter}>
87 <TouchableOpacity
88 onPress={() => navigation.navigate("CreateRecording")}
89 style={styles.button}
90 >
91 <Icon name={"ios-add"} color={"#fff"} size={20} />
92 <Text style={{ color: "#fff" }}> Create New Recording </Text>
93 </TouchableOpacity>
94 </View>
95 </View>
96 );
97 };
98
99 const styles = StyleSheet.create({
100 alignCenter: {
101 display: "flex",
102 justifyContent: "center",
103 alignItems: "center",
104 },
105 button: {
106 display: "flex",
107 justifyContent: "center",
108 alignItems: "center",
109 flexDirection: "row",
110 borderColor: "#8c4bff",
111 backgroundColor: "#8c4bff",
112 height: 47,
113 width: width - 25,
114 borderWidth: 1,
115 color: "#fff",
116 fontSize: 16,
117 borderRadius: 5,
118 marginBottom: 10,
119 },
120 });
121
122 export default Home;
In the Home component above, a GraphQL Query operation was made to retrieve the created recordings through the useQuery hook from the @apollo/client library. The data gotten from the GraphQL Query is passed into a Flat list component to render a performant list of recordings using the previously created RecordingCard component.
The Home component also contains a useEffect hook, and the logic to check if a user is authenticated from the Login screen is also performed within the useEffect hook. If no token is found, the User is navigated to the Login screen.
The image below shows the Home screen within the application running on an Android emulator;
As displayed in the image above, an empty array was returned from the GraphQL Query because a recording within the voice-recording content type hasn't been created yet.
At the top of the Create New Recording button at the bottom of the page, a user will be navigated to the CreateRecording screen. This screen doesn't exist yet. Hence you will create it in the next step.
As the name implies, the CreateRecording screen has a record button for a user to record a voice input and provide a name and description using the two input fields on the page.
Create a createRecording.js
file within the screens
directory and add the code block content below to build the component within the CreateRecording screen.
1 // ./src/screens/createRecording.js
2
3 import * as React from "react";
4 import {
5 View,
6 Text,
7 TextInput,
8 StyleSheet,
9 Dimensions,
10 TouchableOpacity,
11 } from "react-native";
12 import Icon from "react-native-vector-icons/Ionicons";
13 import { Audio } from "expo-av";
14 import * as FileSystem from "expo-file-system";
15 import { useMutation } from "@apollo/client";
16 import { CREATE_RECORDING, UPLOAD_FILE } from "../graphql";
17 import { getItem, USER_ID_KEY } from "../utils";
18
19 const { width, height } = Dimensions.get("screen");
20
21 const CreateRecording = ({ navigation }) => {
22 const [name, setName] = React.useState("");
23 const [description, setDescription] = React.useState("");
24 const [canRecord, setRecordStatus] = React.useState(false);
25 const [record, setRecord] = React.useState(null);
26 const [uploadFile, { data }] = useMutation(UPLOAD_FILE);
27 const [createRecording, { error }] = useMutation(CREATE_RECORDING);
28
29 const startRecording = async () => {
30 setRecordStatus(!canRecord);
31 try {
32 await Audio.requestPermissionsAsync();
33 await Audio.setAudioModeAsync({
34 allowsRecordingIOS: true,
35 playsInSilentModeIOS: true,
36 });
37 console.log("Starting recording...");
38 const recording = new Audio.Recording();
39 await recording.prepareToRecordAsync(
40 Audio.RECORDING_OPTIONS_PRESET_HIGH_QUALITY
41 );
42 await recording.startAsync();
43 setRecord(recording);
44 } catch (err) {
45 console.error("Failed to start recording", err);
46 }
47 };
48
49 const submitRecording = async () => {
50
51 await record.stopAndUnloadAsync();
52 const uri = record.getURI();
53 const Recording = await FileSystem.readAsStringAsync(uri, {
54 encoding: FileSystem.EncodingType.UTF8,
55 });
56
57 try {
58 await uploadFile({
59 variables: {
60 file: Recording,
61 },
62 });
63 const userId = await getItem(USER_ID_KEY);
64 await createRecording({
65 variables: {
66 name,
67 fileId: data.id,
68 description,
69 userId,
70 },
71 });
72 navigation.navigate("home");
73 } catch (e) {
74 console.log(e);
75 } finally {
76 setRecordStatus(!canRecord);
77 }
78 };
79
80 return (
81 <View style={styles.root}>
82 <View style={styles.alignCenter}>
83 <Text> {error} </Text>
84 <View style={styles.inputContainer}>
85 <Text style={styles.title}> Recording Name </Text>
86 <View style={styles.input}>
87 <TextInput
88 value={name}
89 placeholder="A name for the recording"
90 onChangeText={(value) => setName(value)}
91 />
92 </View>
93 </View>
94 <View style={styles.inputContainer}>
95 <Text style={styles.title}> Recording Description </Text>
96 <View style={styles.input}>
97 <TextInput
98 value={description}
99 placeholder="A description of your recording"
100 onChangeText={(value) => setDescription(value)}
101 />
102 </View>
103 </View>
104 <View style={{ marginVertical: 10 }} />
105 <TouchableOpacity
106 disabled={!name.length > 2 && description.length > 2}
107 onPress={() => {
108 if (!canRecord) {
109 startRecording();
110 } else {
111 submitRecording();
112 }
113 }}
114 style={[
115 styles.button,
116 styles.alignCenter,
117 {
118 backgroundColor: canRecord ? "red" : "#8c4bff",
119 borderColor: canRecord ? "red" : "#8c4bff",
120 },
121 ]}
122 >
123 {!canRecord ? (
124 <Text style={{ color: "#fff", fontSize: 15 }}>
125 Save and Start Recording
126 </Text>
127 ) : (
128 <Text style={{ color: "#fff", fontSize: 15 }}>Stop Recording</Text>
129 )}
130 </TouchableOpacity>
131 <View style={[styles.iconContainer, styles.alignCenter]}>
132 {canRecord ? (
133 <View>
134 <Icon name={"ios-mic-outline"} size={85} />
135 </View>
136 ) : (
137 <Icon
138 name={"md-mic-off-circle-outline"}
139 color={"#c0c0c0"}
140 size={85}
141 />
142 )}
143 </View>
144 </View>
145 </View>
146 );
147 };
148
149 const styles = StyleSheet.create({
150 title: {
151 fontSize: 15,
152 paddingBottom: 8,
153 },
154 root: {
155 backgroundColor: "#fff",
156 height,
157 },
158 input: {
159 backgroundColor: "#fff",
160 paddingLeft: 10,
161 borderWidth: 0.7,
162 borderColor: "#c0c0c0",
163 height: 50,
164 borderRadius: 4,
165 marginBottom: 5,
166 width: width - 25,
167 },
168 inputContainer: {
169 marginTop: 10,
170 width: width - 25,
171 },
172 alignCenter: {
173 display: "flex",
174 justifyContent: "center",
175 alignItems: "center",
176 },
177 button: {
178 borderColor: "#8c4bff",
179 backgroundColor: "#8c4bff",
180 height: 47,
181 width: width - 25,
182 borderWidth: 1,
183 color: "#fff",
184 fontSize: 16,
185 borderRadius: 5,
186 },
187 iconContainer: {
188 height: 350,
189 },
190 });
191
192 export default CreateRecording;
The CreateRecording
component in the code block above provides a user with the functionality to record a voice input, provide a name and description detail, then submit the recording to the Strapi GraphQL API.
This is made possible through the use of three functions within the component that is explained below;
This function is executed at the tap of the Save and Start Recording button, and it uses the expo-av library in a try/catch block to record a user's voice input.
The requestPermissionAsync
method is invoked to request the User's permission to use the device audio API. After which, a recording session is started and stored in the component's local state.
This function is executed at the tap of the Stop Recording button, which is shown when the recording session is active. This function stops the active recording session and uploads the recording to the Strapi GraphQL API.
First, the stopAndUnloadAsync method in the record class is invoked to stop the active recording and save it to the device's local storage in a WAV file. The file path to the recording file is gotten using the getURI
method and stored in the uri
variable for later use.
Next, the recording file is retrieved in a UTF8 encoding format and stored in a variable. The User's recording stored in the recordingFile
variable is then uploaded to the Strapi API in a GraphQL mutation containing the file.
After the file has been uploaded, another GraphQL mutation containing the recording name, description, userId, and fileId is executed. The fileId
used in this mutation is gotten from the response object returned after the uploadFile
mutation was executed successfully. It relates the voice-recording file within Strapi to the recording details created by a user.
The image below shows the Home screen within the application running on an Android emulator;
After a recording has been taken and two GraphQL mutations within the CreateRecording component have been executed successfully, the User is programmatically navigated to the Home screen where the recording created is listed out.
The image below shows the recording created in the previous image listed out;
With the application fully functional, you can create more recordings and show them on the Home screen.
Going through the Strapi web admin dashboard, you will find the recordings in the voice-recording collection type and the uploaded files in the Media-library.
Huge congrats to you.
By going through the various steps within this article, you have built a fully functional mobile application that allows users to create an account and record their voice input.
First, we began the article by creating a new Strapi application. Then we built the data model for the application using the Content builder from the new application's admin dashboard. After that, you enabled GraphQL support for the API.
Next, we bootstrapped a React Native application using the Expo CLI then we connected the mobile application to the Strapi GraphQL API. Lastly, we ended the article by building the application screens and making GraphQL queries and mutations from the components.
The source code for the React Native application has been pushed to this GitHub repository. Feel free to clone it and use it as a boilerplate when building your mobile application using Strapi.
Victory works as a Frontend Engineer and also as an advocate for Cloud Engineering through written articles on Cloud Services as a Technical Author.