This article highlights how to build a basic Local Device Assessment application in React Native with Learnosity Assessment APIs. The application will allow users to run and complete their assessments without needing an Internet connection.
This page is for developers and application architects. For a more general overview, see Using Local Device Assessment.
Requirements
- Learnosity Assessment APIs vendor ZIP file: A .ZIP file containing all of the bundled code of Learnosity Assessment APIs (Items API, Assess API and Questions API). This file can be obtained by contacting our Support team and letting them know the LTS version you want applied to the .ZIP file.
- Local Device Assessment Package: A .ZIP file containing all of the Learnosity authored activities that you want to load inside your application. This file can be generated through Data API's offlinepackage endpoint.
- Basic knowledge about React Native and JavaScript.
Tutorial
1. Setup
Note: these instructions are for Apple Mac computers only.
1.1 Installation
- Install Xcode version 12 or above.
- We will build our sample application using react-native and Expo toolkit. Let's start by installing Expo. In your terminal, run the following command to install Expo:
npm install --global expo-cli
- We also need Cocoapods version 1.8.4 (the latest version of Cocoapods is not working well with React Native's latest version).
gem install cocoapods -v 1.8.4
1.2 Setting up the project
- Let's start creating our barebones sample project LRNOfflineDemo with Expo:
expo init --template bare-minimum
- Follow the instructions to create the sample project LRNOfflineDemo.
- Access the newly created project and install the mandatory React Native libraries to run our application in offline mode:
cd LRNOfflineDemo
# Universal Web View for React Native application expo install react-native-webview # Universal library to create a static server for React Native application expo install react-native-static-server
- The followings are optional React Native libraries that we will use when putting together our application:
# React Navigation https://reactnavigation.org/docs/getting-started/ expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view # To store basic information of the logged in user expo install @react-native-async-storage/async-storage # To read local file expo install react-native-fs # To generate uuid string expo install react-native-uuid
- Once you have finished installing all the required dependencies, run the following command to install the Cocoapod dependency for iOS:
npx pod-install ios
- Create a folder named
www
, unzip the Learnosity Assessment APIs vendor ZIP file and put it inside the newly createdwww
folder. Unzip the Item bank Local Device Assessment Package ZIP file and place it inside thewww/vendor/learnosity
folder. - Create 2 empty files: index.html and lrnOffline.js inside the
www
folder. - Open Xcode application then open the ios/LRNOfflineDemo.xcworkspace.
- Drag the
www
folder into the LRNOfflineDemo project of Xcode - Tick Copy items if needed, Create folder references and make sure the LRNOfflineDemo target is selected. Click Finish.
- Your iOS application structure should be similar to the image below. Note that your www folder should have a blue color indicating the folder has been copied to into the iOS/LRNOfflineDemo project and not being linked by reference (yellow folder)
- Close Xcode.
- Test your barebones application by running it with this command:
expo start
- Select the iOS Simulator on the left hand side.
- You should see an empty application load in your iOS Simulator under the name "Expo Go".
- Once you are happy with the barebones project, close the app and terminate the current running expo process.
2. Front-end design
Our React Native application will load Learnosity Assessment APIs through a local web page. This section highlights how we can set up our front-end index.html (main web page of our web application) and the core JavaScript, lrnOffline.js (allows our web page to communicate with the React Native application).
2.1 index.html
- Load the local Learnosity Items API main endpoint by adding a script tag in the
head
element pointing tovendor/learnosity/itemsApi/[version]/primer.js
- Load the
lrnOffline.js
JavaScript file through a script tag in thehead
element. - Add an empty
<div id="learnosity-assess"></div>
to thebody
to render our Learnosity Activities into it later. - Completed code example below:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline App</title>
<script src="vendor/learnosity/itemsApi/v1.95.0/primer.js"></script>
<script src="lrnOffline.js"></script>
</head>
<body>
<div id="learnosity_assess"></div>
</body>
</html>
2.2 lrnOffline.js
This JavaScript file is responsible for bridging the communication between the React Native code with the local web page (index.html) that we are loading. This file will expose a set of public methods and events that the React Native can call or listen to.
- Expose a global window object called LRNOffline:
window.LRNOffline = (function () {
return {};
})();
- Create a utility method that we will use to send the message back to the React Native code:
const postMessageToNativeApp = (data) => {
let message = null;
try {
message = JSON.stringify(data);
} catch (e) {
console.log('not a valid json object');
message = {
type: data.type
};
}
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(message);
} else {
console.info(message);
}
};
- Create the list of message types that the React Native code can listen to.
const MESSAGE_TYPES = {
ERROR: 'error',
PRIME_OFFLINE_SUCCESS: 'primeOffline:success',
PRIME_OFFLINE_ERROR: 'primeOffline:error',
SYNC_SUCCESS: 'sync:success',
SYNC_ERROR: 'sync:error',
INIT_ITEMS_APP_SUCCESS: 'initItemsApp:success',
INIT_ITEMS_APP_ERROR: 'initItemsApp:error',
ITEMS_APP_SAVE_SUCCESS: 'itemsAppSave:success',
REQUEST_TO_CLOSE: 'requestToClose'
};
- Create the
primeOffline
public method: this method is responsible for validating whether the current logged in user should be able to load Learnosity Assessment in offline mode or not. Once a user is successfully validated with theprimeOffline
method, they can starting using Learnosity products in offline mode for a full year from the start date. Note that this method will require an Internet connection, so ideally we should call this as soon as the user logs in successfully.
const primeOffline = (security) => {
if (!window.LearnosityItems) {
postMessageToNativeApp({
type: MESSAGE_TYPES.PRIME_OFFLINE_ERROR,
error: {
message: 'LearnosityItems is not defined'
}
});
return;
}
// This process will require Internet connection
window.LearnosityItems.primeOffline(security, (msg) => {
localStorage.setItem(security.user_id, JSON.stringify({
security
}));
postMessageToNativeApp({
type: MESSAGE_TYPES.PRIME_OFFLINE_SUCCESS
});
}, (e) => {
postMessageToNativeApp({
type: MESSAGE_TYPES.PRIME_OFFLINE_ERROR,
error: {
error
}
});
});
};
- Create the public method called initItemsApp to initialize our Learnosity Items application instance in offline mode, based on the provided request. In this sample application, we will listen to the following events:
test:save:success, test:finished:submit, test:finished:save, and test:finished:discard
. Note that in order for our Items app to be initialized in offline mode, the current user needs to be validated withprimeOffline
first.
const initItemsApp = (security, request) => {
const initOptions = {
security,
request: {
...request,
type: 'offline_app',
user_id: security.user_id,
}
};
const itemsApp = window.LearnosityItems.init(initOptions, {
readyListener: () => {
postMessageToNativeApp({
type: MESSAGE_TYPES.INIT_ITEMS_APP_SUCCESS
});
},
errorListener: (error) => {
postMessageToNativeApp({
type: MESSAGE_TYPES.INIT_ITEMS_APP_ERROR,
error: {
error: error.message
}
});
}
});
// If you want to add more events to improve the communication between the native app and the webview
// add them here.
itemsApp.on('test:finished:submit test:finished:save test:finished:discard', () => {
postMessageToNativeApp({
type: MESSAGE_TYPES.REQUEST_TO_CLOSE
});
});
itemsApp.on('test:save:success', () => {
postMessageToNativeApp({
type: MESSAGE_TYPES.ITEMS_APP_SAVE_SUCCESS
});
});
return itemsApp;
};
- Completed code listing below:
window.LRNOffline = (function () {
const MESSAGE_TYPES = {
ERROR: 'error',
PRIME_OFFLINE_SUCCESS: 'primeOffline:success',
PRIME_OFFLINE_ERROR: 'primeOffline:error',
SYNC_SUCCESS: 'sync:success',
SYNC_ERROR: 'sync:error',
INIT_ITEMS_APP_SUCCESS: 'initItemsApp:success',
INIT_ITEMS_APP_ERROR: 'initItemsApp:error',
ITEMS_APP_SAVE_SUCCESS: 'itemsAppSave:success',
REQUEST_TO_CLOSE: 'requestToClose'
};
const postMessageToNativeApp = (data) => {
let message = null;
try {
message = JSON.stringify(data);
} catch (e) {
console.log('not a valid json object');
message = {
type: data.type
};
}
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(message);
} else {
console.info(message);
}
};
return {
primeOffline(security) {
if (!window.LearnosityItems) {
postMessageToNativeApp({
type: MESSAGE_TYPES.PRIME_OFFLINE_ERROR,
error: {
message: 'LearnosityItems is not defined'
}
});
return;
}
// This process will require Internet connection
window.LearnosityItems.primeOffline(security, (msg) => {
localStorage.setItem(security.user_id, JSON.stringify({
security
}));
postMessageToNativeApp({
type: MESSAGE_TYPES.PRIME_OFFLINE_SUCCESS
});
}, (e) => {
postMessageToNativeApp({
type: MESSAGE_TYPES.PRIME_OFFLINE_ERROR,
error: {
error
}
});
});
},
initItemsApp(security, request) {
const initOptions = {
security,
request: {
...request,
type: 'offline_app',
user_id: security.user_id,
}
};
const itemsApp = window.LearnosityItems.init(initOptions, {
readyListener: () => {
postMessageToNativeApp({
type: MESSAGE_TYPES.INIT_ITEMS_APP_SUCCESS
});
},
errorListener: (error) => {
postMessageToNativeApp({
type: MESSAGE_TYPES.INIT_ITEMS_APP_ERROR,
error: {
error: error.message
}
});
}
});
// If you want to add more events to improve the communication between the native app and the webview
// add them here.
itemsApp.on('test:finished:submit test:finished:save test:finished:discard', () => {
postMessageToNativeApp({
type: MESSAGE_TYPES.REQUEST_TO_CLOSE
});
});
itemsApp.on('test:save:success', () => {
postMessageToNativeApp({
type: MESSAGE_TYPES.ITEMS_APP_SAVE_SUCCESS
});
});
return itemsApp;
}
};
})();
3. React Native design
All the code under this section should be created on the same level as the main entry point App.js file of the React Native project.
3.1 Static Web Server
In order to serve our Learnosity activities locally, we need a static web server to serve the content from the www
folder.
- Create the file services/webServerManager.js with the following code. This file is responsible for spawning a localhost server serving content inside the
www
folder at port 12345.
import StaticServer from 'react-native-static-server';
import RNFS from 'react-native-fs';
// path where files will be served from (index.html here)
let path = RNFS.MainBundlePath + '/www';
let server = new StaticServer(12345, path);
let serverUrl = null;
export function startServer() {
if (!server) {
return Promise.reject('WebServerManager is undefined');
}
stopServer();
return server.start()
.then((url) => {
serverUrl = url;
console.log('Web Server started');
})
.catch(err => console.error(err));
}
export function stopServer() {
if (!server) {
return;
}
server.stop();
}
export function isServerRunning() {
if (!server) {
return Promise.reject(false);
}
return server.isRunning();
}
export function getUrl() {
return serverUrl ? 'http://localhost:12345' : null;
}
3.2 A custom web view to communicate with our localhost's index.html web page
Once our localhost:12345 is up and running, we need a web view component to talk with our web page index.html and load the Learnosity Activities.
- Create the file components/LRNWebview.js and render a basic web view that loads our localhost URL:
import React from 'react';
import WebView from 'react-native-webview';
import { getUrl } from '../services/webServerManager';
import { StyleSheet, SafeAreaView } from 'react-native';
export default class LRNWebview extends React.PureComponent {
render() {
return (
<SafeAreaView style={props.hidden ? styles.hidden : styles.default}>
<WebView
{...this.props}
ref={webView => (this.webView = webView)}
source={{uri: getUrl()}}
/>
</SafeAreaView>
)
}
}
- Create a facade so any components rendering this
LRNWebview
can use to interact with the web view's content. This facade exposes two main methods:executeJavascript
anddispatchLRNOfflineAction
export default class LRNWebview extends React.PureComponent {
constructor(props) {
super(props);
this.lrnOfflineMessageFacade = {
executeJavaScript: (jsString) => {
if (!this.webView) {
console.error('webView is not defined');
return false;
}
this.webView.injectJavaScript(
`${jsString}; true;`
);
},
dispatchLRNOfflineAction: (actionName, args) => {
if (!this.webView) {
console.error('webView is not defined');
return false;
}
if (
actionName &&
typeof actionName === 'string' &&
LRN_OFFLINE_ACTIONS[actionName]
) {
const argsString = JSON.stringify(args);
const jsString = `
try {
window.LRNOffline['${actionName}'].apply(null, ${argsString});
} catch (e) {
window.ReactNativeWebView.postMessage({
type: 'Error',
error: e.message
});
}
true;
`;
this.webView.injectJavaScript(jsString);
console.log(`Dispatch ${jsString}`);
}
}
};
}
...
- Start listening to the
onMessage
of theWebView
and dispatchprops.onLRNOfflineMessage
andprops.onMessage
if they are available:
render() {
const { props, lrnOfflineMessageFacade } = this;
return (
<SafeAreaView style={props.hidden ? styles.hidden : styles.default}>
<WebView
{...props}
style={props.hidden ? styles.hidden : styles.default}
ref={webView => (this.webView = webView)}
source={{uri: getUrl()}}
onMessage={(event) => this.onWebviewMessage(event)}
/>
</SafeAreaView>
)
}
onWebviewMessage(event) {
const { props } = this;
const { data } = event.nativeEvent;
console.log('Message received', data);
let messageData;
try {
messageData = JSON.parse(data);
} catch (e) {
console.log('Failed to parse received message', e);
messageData = data;
}
if (messageData && messageData.type && props.onLRNOfflineMessage) {
props.onLRNOfflineMessage(messageData, this.lrnOfflineMessageFacade);
}
if (props.onMessage) {
props.onMessage(event, this.webView);
}
}
- Start listening to
onLoad
,onLoadEnd
events and dispatching its corresponding props function, so that any components rendering this component can listen to those event to do further action like executing a JavaScript string or calling a window public method of the web view:
render() {
const { props, lrnOfflineMessageFacade } = this;
return (
<SafeAreaView style={props.hidden ? styles.hidden : styles.default}>
<WebView
{...props}
ref={webView => (this.webView = webView)}
source={{uri: getUrl()}}
onLoad={(event) => props.onLoad && props.onLoad(event, lrnOfflineMessageFacade)}
onLoadEnd={(event) => props.onLoadEnd && props.onLoadEnd(event, lrnOfflineMessageFacade)}
onMessage={(event) => this.onWebviewMessage(event)}
/>
</SafeAreaView>
)
}
- We often want to call certain JavaScript public methods without a visible web view, so let's add a
hidden
prop so we can hide our web view when it's set totrue
const styles = StyleSheet.create({
default: {
flex: 0,
width: '100%',
height: '100%'
},
hidden: {
display: 'none',
height: 0,
width: 0,
opacity: 0
},
});
export default class LRNWebview extends React.PureComponent {
...
render() {
const { props, lrnOfflineMessageFacade } = this;
return (
<SafeAreaView style={props.hidden ? styles.hidden : styles.default}>
<WebView
{...props}
style={props.hidden ? styles.hidden : styles.default}
ref={webView => (this.webView = webView)}
source={{uri: getUrl()}}
onLoad={(event) => props.onLoad && props.onLoad(event, lrnOfflineMessageFacade)}
onLoadEnd={(event) => props.onLoadEnd && props.onLoadEnd(event, lrnOfflineMessageFacade)}
onMessage={(event) => this.onWebviewMessage(event)}
/>
</SafeAreaView>
);
}
...
- Completed code listing:
// components/LRNWebview.js
import React from 'react';
import WebView from 'react-native-webview';
import { getUrl } from '../services/webServerManager';
import { StyleSheet, SafeAreaView } from 'react-native';
export const LRN_OFFLINE_MESSAGE_TYPES = {
ERROR: 'error',
PRIME_OFFLINE_SUCCESS: 'primeOffline:success',
PRIME_OFFLINE_ERROR: 'primeOffline:error',
SYNC_SUCCESS: 'sync:success',
SYNC_ERROR: 'sync:error',
INIT_ITEMS_APP_SUCCESS: 'initItemsApp:success',
INIT_ITEMS_APP_ERROR: 'initItemsApp:error',
ITEMS_APP_SAVE_SUCCESS: 'itemsAppSave:success',
REQUEST_TO_CLOSE: 'requestToClose'
};
export const LRN_OFFLINE_ACTIONS = {
primeOffline: 'primeOffline',
initItemsApp: 'initItemsApp',
sync: 'sync'
};
const styles = StyleSheet.create({
default: {
flex: 0,
width: '100%',
height: '100%'
},
hidden: {
display: 'none',
height: 0,
width: 0,
opacity: 0
},
});
export default class LRNWebview extends React.PureComponent {
constructor(props) {
super(props);
this.lrnOfflineMessageFacade = {
executeJavaScript: (jsString) => {
if (!this.webView) {
console.error('webView is not defined');
return false;
}
this.webView.injectJavaScript(
`${jsString}; true;`
);
},
dispatchLRNOfflineAction: (actionName, args) => {
if (!this.webView) {
console.error('webView is not defined');
return false;
}
if (
actionName &&
typeof actionName === 'string' &&
LRN_OFFLINE_ACTIONS[actionName]
) {
const argsString = JSON.stringify(args);
const jsString = `
try {
window.LRNOffline['${actionName}'].apply(null, ${argsString});
} catch (e) {
window.ReactNativeWebView.postMessage({
type: 'Error',
error: e.message
});
}
true;
`;
this.webView.injectJavaScript(jsString);
console.log(`Dispatch ${jsString}`);
}
}
};
}
render() {
const { props, lrnOfflineMessageFacade } = this;
return (
<SafeAreaView style={props.hidden ? styles.hidden : styles.default}>
<WebView
{...props}
style={props.hidden ? styles.hidden : styles.default}
ref={webView => (this.webView = webView)}
source={{uri: getUrl()}}
onLoad={(event) => props.onLoad && props.onLoad(event, lrnOfflineMessageFacade)}
onLoadEnd={(event) => props.onLoadEnd && props.onLoadEnd(event, lrnOfflineMessageFacade)}
onMessage={(event) => this.onWebviewMessage(event)}
/>
</SafeAreaView>
)
}
onWebviewMessage(event) {
const { props } = this;
const { data } = event.nativeEvent;
console.log('Message received', data);
let messageData;
try {
messageData = JSON.parse(data);
} catch (e) {
console.log('Failed to parse received message', e);
messageData = data;
}
if (messageData && messageData.type && props.onLRNOfflineMessage) {
props.onLRNOfflineMessage(messageData, this.lrnOfflineMessageFacade);
}
if (props.onMessage) {
props.onMessage(event, this.webView);
}
}
}
3.3 Login Screen
This screen is responsible for validating if the current user is valid or not. If it's a valid user, we will generate and store its Learnosity signature into our device's local storage then start calling primeOffline to validate current user with the Learnosity system.
- Create the file screens/LoginScreen.js with basic Login UI:
export default function LoginScreen({ navigation }) {
const [email, setEmail] = useState('offline_user@example.com');
const [password, setPassword] = useState('0123456789');
const [processing, setProcessing] = useState(false);
const [isValidUser, setIsValidUser] = useState(false);
return (
<View style={styles.container}>
<StatusBar style='auto' />
<View style={styles.inputView}>
<TextInput
placeholder='Email.'
defaultValue={email}
style={styles.TextInput}
editable={!processing}
onChangeText={(email) => setEmail(email)}
/>
</View>
<View style={styles.inputView}>
<TextInput
placeholder='Password.'
defaultValue={password}
secureTextEntry={true}
style={styles.TextInput}
editable={!processing}
onChangeText={(password) => setPassword(password)}
/>
</View>
<TouchableOpacity>
<Text style={styles.forgot_button}>Forgot Password?</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.loginBtn} disabled={processing} onPress={login}>
<Text style={styles.loginText}>
{processing ? 'Logging...' : 'Login'}
</Text>
</TouchableOpacity>
</View>
)
}
- Add a login function:
- The function will hit our dummy endpoint
offline-login.php
. This endpoint will not actually do any backend user validation. It will simply create a Learnosity security signature and return it back. Note that in your application, you can sign the security request in your backend or directly in your React Native application. - Save the user information into the device's local storage. Note that in a production ready application, you are encouraged to use the library that can secure the stored user's information.
- Start up our static localhost server:
- The function will hit our dummy endpoint
import { startServer } from '../services/webServerManager';
...
const login = async () => {
setProcessing(true);
const formData = new FormData();
formData.append('userid', email);
formData.append('domain', 'localhost');
const response = await fetch(`https://demos.learnosity.com/demos/other/local_device_assessment/offline-login.php`, {
method: 'POST',
body: formData
}).catch(() => setProcessing(false));
const security = await response.json();
await AsyncStorage.setItem('user', JSON.stringify({ username: email, security }));
await startServer();
setIsValidUser(true);
};
- Once we get the Learnosity security signature of the current user, we will need to validate the current user with Learnosity server by calling the web view's
primeOffline
JavaScript method. To do this, we will render a hidden LRNWebview, listen for itsonLoadEnd
andonLRNOfflineMessage
events, then dispatch the public method primeOffline. Once theprimeOffline
process is successful, we will navigate the user to the next screen.
const onLoadEnd = async (event, lrnWebviewFacade) => {
const storedUser = await AsyncStorage.getItem('user');
if (storedUser) {
const data = JSON.parse(storedUser);
console.log('storedUser', data);
lrnWebviewFacade.dispatchLRNOfflineAction(LRN_OFFLINE_ACTIONS.primeOffline, [data.security]);
}
};
const onLRNOfflineMessage = (message, lrnWebviewFacade) => {
console.log(message);
if (message.type === LRN_OFFLINE_MESSAGE_TYPES.PRIME_OFFLINE_SUCCESS) {
console.log('primeOffline current user successfully');
setProcessing(false);
navigation.replace(ROUTES.DASHBOARD);
} else if (message.type === LRN_OFFLINE_MESSAGE_TYPES.PRIME_OFFLINE_ERROR) {
console.error('Failed to primeOffline current user', message.error);
}
};
return (
<View style={styles.container}>
...
{isValidUser && (
<LRNWebview
hidden
onLoadEnd={onLoadEnd}
onLRNOfflineMessage={onLRNOfflineMessage}
/>
)}
</View>
);
- Completed code listing:
import { StatusBar } from 'expo-status-bar';
import React, { useState } from 'react';
import { Text, View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import ROUTES from '../constants/routes';
import { startServer } from '../services/webServerManager';
import LRNWebview, {
LRN_OFFLINE_ACTIONS,
LRN_OFFLINE_MESSAGE_TYPES
} from '../components/LRNWebview';
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f3f3f3',
alignItems: 'center',
justifyContent: 'center',
},
inputView: {
backgroundColor: '#fff',
borderRadius: 30,
width: '70%',
height: 45,
marginBottom: 20,
alignItems: 'center',
},
TextInput: {
width: '100%',
height: 50,
flex: 1,
padding: 10,
marginLeft: 20,
textAlign: 'center'
},
forgot_button: {
height: 30,
marginBottom: 30,
},
loginBtn: {
width: '80%',
borderRadius: 25,
height: 50,
alignItems: 'center',
justifyContent: 'center',
marginTop: 40,
backgroundColor: '#2196f3',
},
loginText: {
color: '#fff',
fontWeight: 'bold',
textTransform: 'uppercase'
}
});
export default function LoginScreen({ navigation }) {
const [email, setEmail] = useState('offline_user@example.com');
const [password, setPassword] = useState('0123456789');
const [processing, setProcessing] = useState(false);
const [isValidUser, setIsValidUser] = useState(false);
const login = async () => {
setProcessing(true);
const formData = new FormData();
formData.append('userid', email);
formData.append('domain', 'localhost');
const response = await fetch(`https://demos.learnosity.com/demos/other/local_device_assessment/offline-login.php`, {
method: 'POST',
body: formData
}).catch(() => setProcessing(false));
const security = await response.json();
await AsyncStorage.setItem('user', JSON.stringify({ username: email, security }));
await startServer();
setIsValidUser(true);
};
const onLoadEnd = async (event, lrnWebviewFacade) => {
const storedUser = await AsyncStorage.getItem('user');
if (storedUser) {
const data = JSON.parse(storedUser);
console.log('storedUser', data);
lrnWebviewFacade.dispatchLRNOfflineAction(LRN_OFFLINE_ACTIONS.primeOffline, [data.security]);
}
};
const onLRNOfflineMessage = (message, lrnWebviewFacade) => {
console.log(message);
if (message.type === LRN_OFFLINE_MESSAGE_TYPES.PRIME_OFFLINE_SUCCESS) {
console.log('primeOffline current user successfully');
setProcessing(false);
navigation.replace(ROUTES.DASHBOARD);
} else if (message.type === LRN_OFFLINE_MESSAGE_TYPES.PRIME_OFFLINE_ERROR) {
console.error('Failed to primeOffline current user', message.error);
}
};
return (
<View style={styles.container}>
<StatusBar style='auto' />
<View style={styles.inputView}>
<TextInput
placeholder='Email.'
defaultValue={email}
style={styles.TextInput}
editable={!processing}
onChangeText={(email) => setEmail(email)}
/>
</View>
<View style={styles.inputView}>
<TextInput
placeholder='Password.'
defaultValue={password}
secureTextEntry={true}
style={styles.TextInput}
editable={!processing}
onChangeText={(password) => setPassword(password)}
/>
</View>
<TouchableOpacity>
<Text style={styles.forgot_button}>Forgot Password?</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.loginBtn} disabled={processing} onPress={login}>
<Text style={styles.loginText}>
{processing ? 'Logging...' : 'Login'}
</Text>
</TouchableOpacity>
{isValidUser && (
<LRNWebview
hidden
onLoadEnd={onLoadEnd}
onLRNOfflineMessage={onLRNOfflineMessage}
/>
)}
</View>
)
}
3.4 Dashboard Screen
This screen is responsible to display the list of Assessment activities the user can take on. Once the user successfully logs into the system, this will be the default view of the application.
- Load the stored activities from
vendor/learnosity/itembank/manifest.json
and render them. Note that the way we fetch the data here is just for the purpose of this tutorial. - Add a simple
logout
function:
import RNFS from 'react-native-fs';
import React, { useEffect, useState } from 'react';
import { Alert, FlatList, SafeAreaView, Text, TouchableOpacity, View } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import ROUTES from '../constants/routes';
const getActivities = () => {
return RNFS.readFile(
RNFS.MainBundlePath + '/www/vendor/learnosity/itembank/manifest.json',
'utf8')
.then((content) => JSON.parse(content))
.then((data) => Object.keys(data.activities))
.catch(err => console.error(err));
};
export default function DashboardScreen({ navigation }) {
const [activities, setActivities] = useState([]);
const onItemSelected = (item) => handleItemSelected(item, navigation);
const logOut = () => {
Alert.alert(
'Do you want to log out', null,
[
{
text: 'Yes',
onPress: async () => {
try {
await AsyncStorage.removeItem('user');
} catch(e) {
console.log('Failed to clean up stored data', e);
}
navigation.replace(ROUTES.LOGIN);
}
},
{
text: 'Cancel'
}
]
);
};
useEffect(() => {
getActivities().then(setActivities);
}, []);
return (
<SafeAreaView style={styles.container}>
<FlatList
data={activities}
renderItem={({ item }) => (
<TouchableOpacity style={styles.item} onPress={() => onItemSelected(item)}>
<Text style={styles.title}>{item}</Text>
</TouchableOpacity>
)}
keyExtractor={item => item}
/>
<View>
<TouchableOpacity style={styles.logOutBtn} onPress={logOut}>
<Text style={styles.title}>Log out</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
)
}
- When an Activity Item is selected, we will check if it has been attempted before or not. If not, we load the next screen to start the assessment. Otherwise, we show a popup dialog to ask if the user wants to start a new assessment or resume/review the stored session. If it's a new session, we will assign a new
uuid
to therequest.session_id
. If the user wants to resume/review, we will retrieve the storedsession_id
and set it to the request object:
const handleItemSelected = async (item, navigation) => {
const navigateToAssessment = (request) => {
console.log('navigation.navigate', ROUTES.ASSESSMENT, request);
navigation.navigate(ROUTES.ASSESSMENT, request);
};
const rawStoredUser = await AsyncStorage.getItem('user');
const storedUser = JSON.parse(rawStoredUser);
const { security } = storedUser;
const rawStoredActivities = await AsyncStorage.getItem(`${security.user_id}_activities`);
const storedActivities = rawStoredActivities ? JSON.parse(rawStoredActivities) : {};
const request = {
security,
state: 'initial',
activityTemplateId: item,
};
if (storedActivities && storedActivities[item]) {
request.sessionId = storedActivities[item];
Alert.alert(
'Actions', null,
[
{
text: 'New Assessment',
onPress: () => {
request.sessionId = uuid.v4();
navigateToAssessment(request);
}
},
{
text: 'Resume',
onPress: () => {
request.state = 'resume';
navigateToAssessment(request);
}
},
{
text: 'Review',
onPress: () => {
request.state = 'review';
navigateToAssessment(request);
}
},
{
text: 'Cancel',
},
]
);
} else {
request.sessionId = uuid.v4();
navigateToAssessment(request);
}
};
- Completed code listing:
import React, { useState, useEffect } from 'react';
import { View, SafeAreaView, Text, TouchableOpacity, StyleSheet, FlatList, Alert } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import uuid from 'react-native-uuid';
import RNFS from 'react-native-fs';
import ROUTES from '../constants/routes';
const styles = StyleSheet.create({
container: {
flex: 1,
height: '100%',
backgroundColor: '#f3f3f3',
},
item: {
backgroundColor: '#2196f3',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
borderRadius: 4
},
title: {
fontSize: 16,
color: '#fff'
},
logOutBtn: {
marginLeft: '10%',
width: '80%',
borderRadius: 25,
height: 50,
alignItems: 'center',
justifyContent: 'center',
marginTop: 20,
backgroundColor: '#ff0b0b',
},
});
const getActivities = () => {
return RNFS.readFile(
RNFS.MainBundlePath + '/www/vendor/learnosity/itembank/manifest.json',
'utf8')
.then((content) => JSON.parse(content))
.then((data) => Object.keys(data.activities))
.catch(err => console.error(err));
};
const handleItemSelected = async (item, navigation) => {
const navigateToAssessment = (request) => {
console.log('navigation.navigate', ROUTES.ASSESSMENT, request);
navigation.navigate(ROUTES.ASSESSMENT, request);
};
const rawStoredUser = await AsyncStorage.getItem('user');
const storedUser = JSON.parse(rawStoredUser);
const { security } = storedUser;
const rawStoredActivities = await AsyncStorage.getItem(`${security.user_id}_activities`);
const storedActivities = rawStoredActivities ? JSON.parse(rawStoredActivities) : {};
const request = {
security,
state: 'initial',
activityTemplateId: item,
};
if (storedActivities && storedActivities[item]) {
request.sessionId = storedActivities[item];
Alert.alert(
'Actions', null,
[
{
text: 'New Assessment',
onPress: () => {
request.sessionId = uuid.v4();
navigateToAssessment(request);
}
},
{
text: 'Resume',
onPress: () => {
request.state = 'resume';
navigateToAssessment(request);
}
},
{
text: 'Review',
onPress: () => {
request.state = 'review';
navigateToAssessment(request);
}
},
{
text: 'Cancel',
},
]
);
} else {
request.sessionId = uuid.v4();
navigateToAssessment(request);
}
};
export default function DashboardScreen({ navigation }) {
const [activities, setActivities] = useState([]);
const onItemSelected = (item) => handleItemSelected(item, navigation);
const logOut = () => {
Alert.alert(
'Do you want to log out', null,
[
{
text: 'Yes',
onPress: async () => {
try {
await AsyncStorage.removeItem('user');
} catch(e) {
console.log('Failed to clean up stored data', e);
}
navigation.replace(ROUTES.LOGIN);
}
},
{
text: 'Cancel'
}
]
);
};
useEffect(() => {
getActivities().then(setActivities);
}, []);
return (
<SafeAreaView style={styles.container}>
<FlatList
data={activities}
renderItem={({ item }) => (
<TouchableOpacity style={styles.item} onPress={() => onItemSelected(item)}>
<Text style={styles.title}>{item}</Text>
</TouchableOpacity>
)}
keyExtractor={item => item}
/>
<View>
<TouchableOpacity style={styles.logOutBtn} onPress={logOut}>
<Text style={styles.title}>Log out</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
)
}
3.5 Assessment Screen
- This screen is responsible for rendering our
LRNWebview
and loading the selected Activity. We listen to theonLRNOfflineMessage
event of the web view to store the attemptedsession_id
to our local device storage so that we can resume it next time.
- Completed code listing:
import React from 'react';
import LRNWebview, { LRN_OFFLINE_ACTIONS, LRN_OFFLINE_MESSAGE_TYPES } from '../components/LRNWebview';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function AssessmentScreen({ route }) {
const { security, activityTemplateId, sessionId, state } = route.params;
const request = {
state,
session_id: sessionId,
activity_template_id: activityTemplateId,
// Replace with your own activity_id & name to identify your own activity
activity_id: activityTemplateId,
name: activityTemplateId,
};
const onLoadEnd = async (event, lrnWebviewFacade) => {
lrnWebviewFacade.dispatchLRNOfflineAction(
LRN_OFFLINE_ACTIONS.initItemsApp,
[security, request]
);
};
const onLRNOfflineMessage = async (message, lrnWebviewFacade) => {
switch (message.type) {
case LRN_OFFLINE_MESSAGE_TYPES.INIT_ITEMS_APP_SUCCESS:
console.log('Successfully initialize Items App');
break;
case LRN_OFFLINE_MESSAGE_TYPES.INIT_ITEMS_APP_ERROR:
console.log('Failed to init Items App', message.error);
break;
case LRN_OFFLINE_MESSAGE_TYPES.ITEMS_APP_SAVE_SUCCESS:
console.log('Save current activity successfully');
const key = `${security.user_id}_activities`;
const storedActivities = await AsyncStorage.getItem(key);
const storedData = storedActivities ? JSON.parse(storedActivities) : {};
storedData[activityTemplateId] = sessionId;
try {
await AsyncStorage.setItem(key, JSON.stringify(storedData));
} catch(e) {
console.error('AsyncStorage.setItem', e);
}
break;
default:
break;
}
};
return (
<LRNWebview
onLoadEnd={onLoadEnd}
onLRNOfflineMessage={onLRNOfflineMessage}
/>
)
}
3.6 App.js
Now that all of our screens have been set up, we will start glueing them together in App.js.
- Add a constant file constants/routes.js store the name of our app's routes
export default {
LOGIN: 'Login',
DASHBOARD: 'Dashboard',
ASSESSMENT: 'Assessment'
};
- Render all the screens with Stack.Navigation:
import React, { useRef, useState, useEffect } from 'react';
import { AppState } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import LoginScreen from './screens/LoginScreen';
import DashboardScreen from './screens/DashboardScreen';
import AssessmentScreen from './screens/AssessmentScreen';
const Stack = createStackNavigator();
export default function App() {
const [initialRouteName, setInitialRouteName] = useState(null);
return (
<NavigationContainer>
<Stack.Navigator initialRouteName={initialRouteName}>
<Stack.Screen name={ROUTES.LOGIN} component={LoginScreen} />
<Stack.Screen name={ROUTES.DASHBOARD} component={DashboardScreen} />
<Stack.Screen name={ROUTES.ASSESSMENT} component={AssessmentScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
- Stop our static localhost server if the app becomes inactive, and start it when the app becomes active again.
- Set up the initialRouteName:
- LoginScreen if there is no stored user in the device's local storage, or
- DashboardScreen if there is a stored user in the device's local storage.
- Completed code listing:
import React, { useRef, useState, useEffect } from 'react';
import { AppState } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import LoginScreen from './screens/LoginScreen';
import DashboardScreen from './screens/DashboardScreen';
import AssessmentScreen from './screens/AssessmentScreen';
import { startServer, stopServer, isServerRunning } from './services/webServerManager';
import ROUTES from './constants/routes.js';
import AsyncStorage from '@react-native-async-storage/async-storage';
const Stack = createStackNavigator();
let serverRunningPreviously = false;
export default function App() {
const [initialRouteName, setInitialRouteName] = useState(null);
const setupInitialRouteName = async () => {
const storedUser = await AsyncStorage.getItem('user');
if (storedUser) {
await startServer();
}
setInitialRouteName(storedUser ? ROUTES.DASHBOARD : ROUTES.LOGIN);
};
const _handleAppStateChange = (nextAppState) => {
console.log('AppState', nextAppState);
if (nextAppState === 'inactive') {
isServerRunning().then(isRunning => serverRunningPreviously = isRunning);
stopServer();
} else if (nextAppState === 'active' && serverRunningPreviously) {
startServer();
}
};
useEffect(() => {
setupInitialRouteName();
AppState.addEventListener('change', _handleAppStateChange);
return () => {
AppState.removeEventListener('change', _handleAppStateChange);
}
}, []);
if (!initialRouteName) {
console.log('Initial');
return null;
}
return (
<NavigationContainer>
<Stack.Navigator initialRouteName={initialRouteName}>
<Stack.Screen name={ROUTES.LOGIN} component={LoginScreen} />
<Stack.Screen name={ROUTES.DASHBOARD} component={DashboardScreen} />
<Stack.Screen name={ROUTES.ASSESSMENT} component={AssessmentScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
4. Test the application
- In the terminal, run the following command to start the application in the iOS Simulator:
yarn ios
5. Tutorial Complete!
If you have made it this far, you have established your basic offline application. Test it to ensure it works correctly. You can now go ahead and add in your own logic and application features as desired.