Project Notes by James Priest
Live Demo | GitHub Repo |
This site contains code notes for how I built project 3 of my Udacity React Nanodegree Program.
The assignment is to build a mobile flashcard app from scratch using React Native.
There is no starter code and below is the link to the rubric.
Here are the high-level requirements for this app.
The application should have five views. The Quiz View should change based on the data.
Here are the views needed for this app.
We’ll use Expo’s AsyncStorage
to store our decks and flashcards. Redux is optional for this project but we’ll use it nonetheless.
Using AsyncStorage we’ll manage an object whose shape is similar to this:
{
React: {
title: 'React',
questions: [
{
question: 'What is React?',
answer: 'A library for managing user interfaces'
},
{
question: 'Where do you make Ajax requests in React?',
answer: 'The componentDidMount lifecycle event'
}
]
},
JavaScript: {
title: 'JavaScript',
questions: [
{
question: 'What is a closure?',
answer: 'The combination of a function and the lexical environment
within which that function was declared.'
}
]
}
}
Effects of app on the data.
title
and a questions
key.title
is the title for the specific deckquestions
is an array of questions and answers for that deck.To manage your AsyncStorage database, you’ll want to create four different helper methods.
getDecks
: return all of the decks along with their titles, questions, and answers.getDeck
: take in a single id argument and return the deck associated with that id.saveDeckTitle
: take in a single title argument and add it to the decks.addCardToDeck
: take in two arguments, title and card, and will add the card to the list of questions for the deck with the associated title.I created the API methods and a UI to test with.
The methods are located in ‘utils/api.js’
import { AsyncStorage } from 'react-native';
import { decks } from './_DATA';
const DECKS_STORAGE_KEY = 'MobileFlashcards:decks';
export async function getDecks() {
try {
const storeResults = await AsyncStorage.getItem(DECKS_STORAGE_KEY);
if (storeResults === null) {
AsyncStorage.setItem(DECKS_STORAGE_KEY, JSON.stringify(decks));
}
return storeResults === null ? decks : JSON.parse(storeResults);
} catch (err) {
console.log(err);
}
}
export async function getDeck(id) {
try {
const storeResults = await AsyncStorage.getItem(DECKS_STORAGE_KEY);
return JSON.parse(storeResults)[id];
} catch (err) {
console.log(err);
}
}
export async function saveDeckTitle(title) {
try {
await AsyncStorage.mergeItem(
DECKS_STORAGE_KEY,
JSON.stringify({
[title]: {
title,
questions: []
}
})
);
} catch (err) {
console.log(err);
}
}
export async function removeDeck(key) {
try {
const results = await AsyncStorage.getItem(DECKS_STORAGE_KEY);
const data = JSON.parse(results);
data[key] = undefined;
delete data[key];
AsyncStorage.setItem(DECKS_STORAGE_KEY, JSON.stringify(data));
} catch (err) {
console.log(err);
}
}
export async function addCardToDeck(title, card) {
try {
const deck = await getDeck(title);
await AsyncStorage.mergeItem(
DECKS_STORAGE_KEY,
JSON.stringify({
[title]: {
questions: [...deck.questions].concat(card)
}
})
);
} catch (err) {
console.log(err);
}
}
export async function resetDecks() {
try {
await AsyncStorage.removeItem(DECKS_STORAGE_KEY);
} catch (err) {
console.log(err);
}
}
The methods can then be tested by clicking each button.
Here are screenshots of the initial views.
This view contains multiple instances of the Deck component.
Stack Navigator - Quiz Question
Stack Navigator - Quiz Passing
Stack Navigator - Quiz Failing
The code is split into 3 parts.
// MainTabNavigator.js
import React from 'react';
import PropTypes from 'prop-types';
import { Platform } from 'react-native';
import { Icon } from 'expo';
import {
createBottomTabNavigator,
createStackNavigator
} from 'react-navigation';
import DeckList from '../components/DeckList';
import AddDeck from '../components/AddDeck';
import DeckDetail from '../components/DeckDetail';
import AddCard from '../components/AddCard';
import Quiz from '../components/Quiz';
import Settings from '../components/Settings';
import { darkGray, white, green, lightGreen } from '../utils/colors';
const isIOS = Platform.OS === 'ios' ? true : false;
const routeConfigs = {
Decks: {
screen: DeckList,
navigationOptions: {
tabBarLabel: 'Decks',
tabBarIcon: ({ tintColor }) => (
<Icon.Ionicons
name={isIOS ? 'ios-bookmarks' : 'md-bookmarks'}
size={30}
color={tintColor}
/>
)
}
},
AddDeck: {
screen: AddDeck,
navigationOptions: {
tabBarLabel: 'Add Deck',
tabBarIcon: ({ tintColor }) => (
<Icon.FontAwesome name="plus-square" size={30} color={tintColor} />
)
}
},
Settings: {
screen: Settings,
navigationOptions: {
tabBarLabel: 'Settings',
tabBarIcon: ({ tintColor }) => (
<Icon.FontAwesome name="sliders" size={30} color={tintColor} />
)
}
}
};
routeConfigs.Decks.navigationOptions.tabBarIcon.propTypes = {
tintColor: PropTypes.string.isRequired
};
routeConfigs.AddDeck.navigationOptions.tabBarIcon.propTypes = {
tintColor: PropTypes.string.isRequired
};
routeConfigs.Settings.navigationOptions.tabBarIcon.propTypes = {
tintColor: PropTypes.string.isRequired
};
const tabNavigatorConfig = {
navigationOptions: {
header: null
},
defaultNavigationOptions: {
bounces: true
},
tabBarOptions: {
activeTintColor: green,
style: {
height: 60,
backgroundColor: white,
shadowColor: 'rgba(0,0,0, 0.24)',
shadowOffset: {
width: 0,
height: 3
},
shadowRadius: 6,
shadowOpacity: 1,
borderTopWidth: 1,
borderTopColor: darkGray
},
labelStyle: {
fontSize: 12,
fontWeight: 'bold'
},
tabStyle: {
marginTop: 5,
marginBottom: 3
},
showIcon: true
}
};
const Tabs = createBottomTabNavigator(routeConfigs, tabNavigatorConfig);
const MainNavigator = createStackNavigator(
{
Home: {
screen: Tabs
},
DeckDetail: {
screen: DeckDetail,
navigationOptions: {
headerTintColor: green,
headerStyle: {
backgroundColor: lightGreen
},
title: 'Deck Details'
}
},
AddCard: {
screen: AddCard,
navigationOptions: {
headerTintColor: green,
headerStyle: {
backgroundColor: lightGreen
},
headerTitleStyle: {
textAlign: 'center',
justifyContent: 'center',
textAlign: 'center'
},
title: 'Add Card'
}
},
Quiz: {
screen: Quiz,
navigationOptions: {
headerTintColor: green,
headerStyle: {
backgroundColor: lightGreen
}
}
}
},
{ headerLayoutPreset: 'center' }
);
export default MainNavigator;
// AppNavigator.js
import React from 'react';
import { createAppContainer } from 'react-navigation';
import MainTabNavigator from './MainTabNavigator';
export default createAppContainer(MainTabNavigator);
// App.js
import React from 'react';
import PropTypes from 'prop-types';
import { StyleSheet, View, StatusBar } from 'react-native';
import Constants from 'expo-constants';
import AppNavigator from './navigation/AppNavigator';
function FlashcardStatusBar({ backgroundColor, ...props }) {
return (
<View style={{ backgroundColor, height: Constants.statusBarHeight }}>
<StatusBar translucent backgroundColor={backgroundColor} {...props} />
</View>
);
}
FlashcardStatusBar.propTypes = {
backgroundColor: PropTypes.string.isRequired
};
export default class App extends React.Component {
render() {
return (
<View style={styles.container}>
<FlashcardStatusBar
backgroundColor="green"
barStyle="light-content"
/>
<AppNavigator />
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#dde'
}
});
The next step was to add in all the redux pieces.
The actions is in ‘./actions/index.js’.
// index.js
import { getDecks } from '../utils/api';
export const RECEIVE_DECKS = 'RECEIVE_DECKS';
export const ADD_DECK = 'ADD_DECK';
export const REMOVE_DECK = 'REMOVE_DECK';
export const ADD_CARD = 'ADD_CARD';
export function receiveDecks(decks) {
return {
type: RECEIVE_DECKS,
decks
};
}
export function addDeck(title) {
return {
type: ADD_DECK,
title
};
}
export function removeDeck(id) {
return {
type: REMOVE_DECK,
id
};
}
export function addCardToDeck(id, card) {
return {
type: ADD_CARD,
deckId,
card
};
}
export function handleInitialData() {
return dispatch => {
return getDecks().then(decks => {
dispatch(receiveDecks(decks));
});
};
}
This is in ‘./reducers/index.js’.
// index.js
import {
RECEIVE_DECKS,
ADD_DECK,
REMOVE_DECK,
ADD_CARD
} from '../actions/index';
// import { decks as INITIAL_STATE } from '../utils/_DATA';
export default function decks(state = {}, action) {
switch (action.type) {
case RECEIVE_DECKS:
return {
...state,
...action.decks
};
case ADD_DECK:
const { title } = action;
return {
...state,
[title]: {
title,
questions: []
}
};
case REMOVE_DECK:
const { id } = action;
// return ({ [id]: value, ...remainingDecks } = state);
const { [id]: value, ...remainingDecks } = state;
console.log(remainingDecks);
return remainingDecks;
case ADD_CARD:
const { deckId, card } = action;
return {
...state,
[deckId]: {
...state[deckId],
questions: [...state[deckId].questions].concat(card)
}
};
default:
return state;
}
}
The next step was to add the Provider code to ‘./App.js’.
// App.js
import React from 'react';
import PropTypes from 'prop-types';
import { StyleSheet, View, StatusBar } from 'react-native';
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import { Provider } from 'react-redux';
import reducer from './reducers/index';
import Constants from 'expo-constants';
import AppNavigator from './navigation/AppNavigator';
const store = createStore(
reducer,
applyMiddleware(thunk, logger)
);
function FlashcardStatusBar({ backgroundColor, ...props }) {
return (
<View style={{ backgroundColor, height: Constants.statusBarHeight }}>
<StatusBar translucent backgroundColor={backgroundColor} {...props} />
</View>
);
}
FlashcardStatusBar.propTypes = {
backgroundColor: PropTypes.string.isRequired
};
export default class App extends React.Component {
render() {
return (
<Provider store={store}>
<View style={styles.container}>
<FlashcardStatusBar
backgroundColor="green"
barStyle="light-content"
/>
<AppNavigator />
</View>
</Provider>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#dde'
}
});
Now we can connect Redux up to our initial component. This is in ‘./components/DeckList.js’.
// DeckList.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
ScrollView,
View,
Text,
StyleSheet,
TouchableOpacity
} from 'react-native';
import { connect } from 'react-redux';
import Deck from './Deck';
import { gray, green } from '../utils/colors';
import { handleInitialData } from '../actions/index';
export class DeckList extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
handleInitialData: PropTypes.func.isRequired,
decks: PropTypes.object.isRequired
};
componentDidMount() {
this.props.handleInitialData();
}
render() {
const { decks, navigation } = this.props;
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>Mobile Flashcards</Text>
{Object.values(decks).map(deck => {
return (
<TouchableOpacity
key={deck.title}
onPress={() =>
navigation.navigate('DeckDetail', { title: deck.title })
}
>
<Deck id={deck.title} />
</TouchableOpacity>
);
})}
<View style={{ marginBottom: 30 }} />
</ScrollView>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray
},
title: {
fontSize: 40,
textAlign: 'center',
marginBottom: 16,
color: green
}
});
const mapStateToProps = state => ({ decks: state });
export default connect(
mapStateToProps,
{ handleInitialData }
)(DeckList);
The Decks component is located in ‘./components/Deck.js’. It looks like this.
// Deck.js
import React from 'react';
import PropTypes from 'prop-types';
import { View, Text, StyleSheet } from 'react-native';
import { white, textGray } from '../utils/colors';
import { connect } from 'react-redux';
const Deck = props => {
const { deck } = props;
if (deck === undefined) {
return <View style={styles.deckContainer} />;
}
return (
<View style={styles.deckContainer}>
<View>
<Text style={styles.deckText}>{deck.title}</Text>
</View>
<View>
<Text style={styles.cardText}>{deck.questions.length} cards</Text>
</View>
</View>
);
};
Deck.propTypes = {
deck: PropTypes.object
};
const styles = StyleSheet.create({
deckContainer: {
alignItems: 'center',
justifyContent: 'center',
flexBasis: 120,
minHeight: 120,
borderWidth: 1,
borderColor: '#aaa',
backgroundColor: white,
borderRadius: 5,
marginBottom: 10
},
deckText: {
fontSize: 28
},
cardText: {
fontSize: 18,
color: textGray
}
});
const mapStateToProps = (state, { id }) => {
const deck = state[id];
return {
deck
};
};
export default connect(mapStateToProps)(Deck);
// AddDeck.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Text, View, StyleSheet, TextInput } from 'react-native';
import TouchButton from './TouchButton';
import { gray, green, white, textGray } from '../utils/colors';
import { connect } from 'react-redux';
import { addDeck } from '../actions/index';
export class AddDeck extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
addDeck: PropTypes.func.isRequired
};
state = {
text: ''
};
handleChange = text => {
this.setState({ text });
};
handleSubmit = () => {
const { addDeck, navigation } = this.props;
addDeck(this.state.text);
this.setState(() => ({ text: '' }));
navigation.goBack();
};
render() {
return (
<View style={styles.container}>
<View style={{ height: 60 }} />
<View style={styles.block}>
<Text style={styles.title}>What is the title of your new deck?</Text>
</View>
<View style={[styles.block]}>
<TextInput
style={styles.input}
value={this.state.text}
onChangeText={this.handleChange}
/>
</View>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: white }}
onPress={this.handleSubmit}
disabled={this.state.text === ''}
>
Create Deck
</TouchButton>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray
},
block: {
marginBottom: 20
},
title: {
textAlign: 'center',
fontSize: 32
},
input: {
borderWidth: 1,
borderColor: textGray,
backgroundColor: white,
paddingLeft: 10,
paddingRight: 10,
borderRadius: 5,
fontSize: 20,
height: 40,
marginBottom: 20
}
});
export default connect(
null,
{ addDeck }
)(AddDeck);
// DeckDetails.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { View, StyleSheet } from 'react-native';
import Deck from './Deck';
import TouchButton from './TouchButton';
import TextButton from './TextButton';
import { gray, textGray, green, white, red } from '../utils/colors';
import { connect } from 'react-redux';
import { removeDeck } from '../actions/index';
export class DeckDetail extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
removeDeck: PropTypes.func.isRequired,
deck: PropTypes.object
};
shouldComponentUpdate(nextProps) {
return nextProps.deck !== undefined;
}
handleDelete = id => {
this.props.removeDeck(id);
this.props.navigation.goBack();
};
render() {
const { deck } = this.props;
return (
<View style={styles.container}>
<Deck id={deck.title} />
<View>
<TouchButton
btnStyle={{ backgroundColor: white, borderColor: textGray }}
txtStyle={{ color: textGray }}
onPress={() =>
this.props.navigation.navigate('AddCard', { title: deck.title })
}
>
Add Card
</TouchButton>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: white }}
txtStyle={{ color: white }}
onPress={() =>
this.props.navigation.navigate('Quiz', { title: deck.title })
}
>
Start Quiz
</TouchButton>
</View>
<TextButton
txtStyle={{ color: red }}
onPress={() => this.handleDelete(deck.title)}
>
Delete Deck
</TextButton>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-around',
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray
}
});
const mapStateToProps = (state, { navigation }) => {
const title = navigation.getParam('title', 'undefined');
const deck = state[title];
return {
deck
};
};
export default connect(
mapStateToProps,
{ removeDeck }
)(DeckDetail);
// Card.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Text, View, TextInput, StyleSheet } from 'react-native';
import TouchButton from './TouchButton';
import { gray, green } from '../utils/colors';
import { connect } from 'react-redux';
import { addCardToDeck } from '../actions/index';
export class AddCard extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
addCardToDeck: PropTypes.func.isRequired
};
state = {
question: '',
answer: ''
};
handleQuestionChange = question => {
this.setState({ question });
};
handleAnswerChange = answer => {
this.setState({ answer });
};
handleSubmit = () => {
const { addCardToDeck, title, navigation } = this.props;
const card = {
question: this.state.question,
answer: this.state.answer
};
addCardToDeck(title, card);
this.setState({ question: '', answer: '' });
navigation.goBack();
};
render() {
return (
<View style={styles.container}>
<View>
<View style={styles.block}>
<Text style={styles.title}>Add a question</Text>
</View>
<View style={[styles.block]}>
<TextInput
style={styles.input}
value={this.state.question}
onChangeText={this.handleQuestionChange}
placeholder="Question"
/>
</View>
<View style={[styles.block]}>
<TextInput
style={styles.input}
value={this.state.answer}
onChangeText={this.handleAnswerChange}
placeholder="Answer"
/>
</View>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: '#fff' }}
onPress={this.handleSubmit}
disabled={this.state.question === '' || this.state.answer === ''}
>
Submit
</TouchButton>
</View>
<View style={{ height: '30%' }} />
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray,
justifyContent: 'space-around'
},
block: {
marginBottom: 20
},
title: {
textAlign: 'center',
fontSize: 32
},
input: {
borderWidth: 1,
borderColor: 'gray',
backgroundColor: '#fff',
paddingLeft: 10,
paddingRight: 10,
borderRadius: 5,
fontSize: 20,
height: 40
}
});
const mapStateToProps = (state, { navigation }) => {
const title = navigation.getParam('title', 'undefined');
return {
title
};
};
export default connect(
mapStateToProps,
{ addCardToDeck }
)(AddCard);
// Quiz.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { View, Text, StyleSheet, ViewPagerAndroid } from 'react-native';
import TextButton from './TextButton';
import TouchButton from './TouchButton';
import { gray, green, red, textGray, darkGray, white } from '../utils/colors';
import { connect } from 'react-redux';
const screen = {
QUESTION: 'question',
ANSWER: 'answer',
RESULT: 'result'
};
const answer = {
CORRECT: 'correct',
INCORRECT: 'incorrect'
};
export class Quiz extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
deck: PropTypes.object.isRequired
};
state = {
show: screen.QUESTION,
correct: 0,
incorrect: 0,
questionCount: this.props.deck.questions.length,
answered: Array(this.props.deck.questions.length).fill(0)
};
handlePageChange = evt => {
this.setState({
show: screen.QUESTION
});
};
handleAnswer = (response, page) => {
if (response === answer.CORRECT) {
this.setState(prevState => ({ correct: prevState.correct + 1 }));
} else {
this.setState(prevState => ({ incorrect: prevState.incorrect + 1 }));
}
this.setState(
prevState => ({
answered: prevState.answered.map((val, idx) => (page === idx ? 1 : val))
}),
() => {
const { correct, incorrect, questionCount } = this.state;
if (questionCount === correct + incorrect) {
this.setState({ show: screen.RESULT });
} else {
this.viewPager.setPage(page + 1);
this.setState(prevState => ({
show: screen.QUESTION
}));
}
}
);
};
handleReset = () => {
this.setState(prevState => ({
show: screen.QUESTION,
correct: 0,
incorrect: 0,
answered: Array(prevState.questionCount).fill(0)
}));
};
render() {
const { questions } = this.props.deck;
const { show } = this.state;
if (questions.length === 0) {
return (
<View style={styles.pageStyle}>
<View style={styles.block}>
<Text style={[styles.count, { textAlign: 'center' }]}>
You cannot take a quiz because there are no cards in the deck.
</Text>
<Text style={[styles.count, { textAlign: 'center' }]}>
Please add some cards and try again.
</Text>
</View>
</View>
);
}
if (this.state.show === screen.RESULT) {
const { correct, questionCount } = this.state;
const percent = ((correct / questionCount) * 100).toFixed(0);
const resultStyle =
percent >= 70 ? styles.resultTextGood : styles.resultTextBad;
return (
<View style={styles.pageStyle}>
<View style={styles.block}>
<Text style={styles.count}>Done</Text>
</View>
<View style={styles.block}>
<Text style={[styles.count, { textAlign: 'center' }]}>
Quiz Complete!
</Text>
<Text style={resultStyle}>
{correct} / {questionCount} correct
</Text>
</View>
<View style={styles.block}>
<Text style={[styles.count, { textAlign: 'center' }]}>
Percentage correct
</Text>
<Text style={resultStyle}>{percent}%</Text>
</View>
<View>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: white }}
onPress={this.handleReset}
>
Restart Quiz
</TouchButton>
<TouchButton
btnStyle={{ backgroundColor: gray, borderColor: textGray }}
txtStyle={{ color: textGray }}
onPress={() => {
this.handleReset();
this.props.navigation.navigate('Home');
}}
>
Home
</TouchButton>
</View>
</View>
);
}
return (
<ViewPagerAndroid
style={styles.container}
scrollEnabled={true}
onPageSelected={this.handlePageChange}
ref={viewPager => {
this.viewPager = viewPager;
}}
>
{questions.map((question, idx) => (
<View style={styles.pageStyle} key={idx}>
<View style={styles.block}>
<Text style={styles.count}>
{idx + 1} / {questions.length}
</Text>
</View>
<View style={[styles.block, styles.questionContainer]}>
<Text style={styles.questionText}>
{show === screen.QUESTION ? 'Question' : 'Answer'}
</Text>
<View style={styles.questionWrapper}>
<Text style={styles.title}>
{show === screen.QUESTION
? question.question
: question.answer}
</Text>
</View>
</View>
{show === screen.QUESTION ? (
<TextButton
txtStyle={{ color: red }}
onPress={() => this.setState({ show: screen.ANSWER })}
>
Answer
</TextButton>
) : (
<TextButton
txtStyle={{ color: red }}
onPress={() => this.setState({ show: screen.QUESTION })}
>
Question
</TextButton>
)}
<View>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: white }}
onPress={() => this.handleAnswer(answer.CORRECT, idx)}
disabled={this.state.answered[idx] === 1}
>
Correct
</TouchButton>
<TouchButton
btnStyle={{ backgroundColor: red, borderColor: white }}
onPress={() => this.handleAnswer(answer.INCORRECT, idx)}
disabled={this.state.answered[idx] === 1}
>
Incorrect
</TouchButton>
</View>
</View>
))}
</ViewPagerAndroid>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
pageStyle: {
flex: 1,
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray,
justifyContent: 'space-around'
},
block: {
marginBottom: 20
},
count: {
fontSize: 24
},
title: {
fontSize: 32,
textAlign: 'center'
},
questionContainer: {
borderWidth: 1,
borderColor: darkGray,
backgroundColor: white,
borderRadius: 5,
paddingTop: 20,
paddingBottom: 20,
paddingLeft: 16,
paddingRight: 16,
flexGrow: 1
},
questionWrapper: {
flex: 1,
justifyContent: 'center'
},
questionText: {
textDecorationLine: 'underline',
textAlign: 'center',
fontSize: 20
},
resultTextGood: {
color: green,
fontSize: 46,
textAlign: 'center'
},
resultTextBad: {
color: red,
fontSize: 46,
textAlign: 'center'
}
});
const mapStateToProps = (state, { title }) => {
const deck = state[title];
return {
deck
};
};
export default withNavigation(connect(mapStateToProps)(Quiz_Android));
// Quiz_iOS.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { View, Text, StyleSheet, ScrollView, Dimensions } from 'react-native';
import TextButton from './TextButton';
import TouchButton from './TouchButton';
import { gray, green, red, textGray, darkGray, white } from '../utils/colors';
import { connect } from 'react-redux';
import { withNavigation } from 'react-navigation';
const screen = {
QUESTION: 'question',
ANSWER: 'answer',
RESULT: 'result'
};
const answer = {
CORRECT: 'correct',
INCORRECT: 'incorrect'
};
const SCREEN_WIDTH = Dimensions.get('window').width;
class Quiz_iOS extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
deck: PropTypes.object.isRequired
};
state = {
show: screen.QUESTION,
correct: 0,
incorrect: 0,
questionCount: this.props.deck.questions.length,
answered: Array(this.props.deck.questions.length).fill(0)
};
handleScroll = () => {
this.setState({
show: screen.QUESTION
});
};
handleAnswer = (response, page) => {
if (response === answer.CORRECT) {
this.setState(prevState => ({ correct: prevState.correct + 1 }));
} else {
this.setState(prevState => ({ incorrect: prevState.incorrect + 1 }));
}
this.setState(
prevState => ({
answered: prevState.answered.map((val, idx) => (page === idx ? 1 : val))
}),
() => {
const { correct, incorrect, questionCount } = this.state;
if (questionCount === correct + incorrect) {
this.setState({ show: screen.RESULT });
} else {
this.scrollView.scrollTo({ x: (page + 1) * SCREEN_WIDTH });
this.setState(prevState => ({
show: screen.QUESTION
}));
}
}
);
};
handleReset = () => {
this.setState(prevState => ({
show: screen.QUESTION,
correct: 0,
incorrect: 0,
answered: Array(prevState.questionCount).fill(0)
}));
};
render() {
const { questions } = this.props.deck;
const { show } = this.state;
if (questions.length === 0) {
return (
<View style={styles.pageStyle}>
<View style={styles.block}>
<Text style={[styles.count, { textAlign: 'center' }]}>
You cannot take a quiz because there are no cards in the deck.
</Text>
<Text style={[styles.count, { textAlign: 'center' }]}>
Please add some cards and try again.
</Text>
</View>
</View>
);
}
if (this.state.show === screen.RESULT) {
const { correct, questionCount } = this.state;
const percent = ((correct / questionCount) * 100).toFixed(0);
const resultStyle =
percent >= 70 ? styles.resultTextGood : styles.resultTextBad;
return (
<View style={styles.pageStyle}>
<View style={styles.block}>
<Text style={styles.count}>Done</Text>
</View>
<View style={styles.block}>
<Text style={[styles.count, { textAlign: 'center' }]}>
Quiz Complete!
</Text>
<Text style={resultStyle}>
{correct} / {questionCount} correct
</Text>
</View>
<View style={styles.block}>
<Text style={[styles.count, { textAlign: 'center' }]}>
Percentage correct
</Text>
<Text style={resultStyle}>{percent}%</Text>
</View>
<View>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: white }}
onPress={this.handleReset}
>
Restart Quiz
</TouchButton>
<TouchButton
btnStyle={{ backgroundColor: gray, borderColor: textGray }}
txtStyle={{ color: textGray }}
onPress={() => {
this.handleReset();
this.props.navigation.navigate('Home');
}}
>
Home
</TouchButton>
</View>
</View>
);
}
return (
<ScrollView
style={styles.container}
pagingEnabled={true}
horizontal={true}
onMomentumScrollBegin={this.handleScroll}
ref={scrollView => {
this.scrollView = scrollView;
}}
>
{questions.map((question, idx) => (
<View style={styles.pageStyle} key={idx}>
<View style={styles.block}>
<Text style={styles.count}>
{idx + 1} / {questions.length}
</Text>
</View>
<View style={[styles.block, styles.questionContainer]}>
<Text style={styles.questionText}>
{show === screen.QUESTION ? 'Question' : 'Answer'}
</Text>
<View style={styles.questionWrapper}>
<Text style={styles.title}>
{show === screen.QUESTION
? question.question
: question.answer}
</Text>
</View>
</View>
{show === screen.QUESTION ? (
<TextButton
txtStyle={{ color: red }}
onPress={() => this.setState({ show: screen.ANSWER })}
>
Answer
</TextButton>
) : (
<TextButton
txtStyle={{ color: red }}
onPress={() => this.setState({ show: screen.QUESTION })}
>
Question
</TextButton>
)}
<View>
<TouchButton
btnStyle={{ backgroundColor: green, borderColor: white }}
onPress={() => this.handleAnswer(answer.CORRECT, idx)}
disabled={this.state.answered[idx] === 1}
>
Correct
</TouchButton>
<TouchButton
btnStyle={{ backgroundColor: red, borderColor: white }}
onPress={() => this.handleAnswer(answer.INCORRECT, idx)}
disabled={this.state.answered[idx] === 1}
>
Incorrect
</TouchButton>
</View>
</View>
))}
</ScrollView>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
pageStyle: {
flex: 1,
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray,
justifyContent: 'space-around',
width: SCREEN_WIDTH
},
block: {
marginBottom: 20
},
count: {
fontSize: 24
},
title: {
fontSize: 32,
textAlign: 'center'
},
questionContainer: {
borderWidth: 1,
borderColor: darkGray,
backgroundColor: white,
borderRadius: 5,
paddingTop: 20,
paddingBottom: 20,
paddingLeft: 16,
paddingRight: 16,
flexGrow: 1
},
questionWrapper: {
flex: 1,
justifyContent: 'center'
},
questionText: {
textDecorationLine: 'underline',
textAlign: 'center',
fontSize: 20
},
resultTextGood: {
color: green,
fontSize: 46,
textAlign: 'center'
},
resultTextBad: {
color: red,
fontSize: 46,
textAlign: 'center'
}
});
const mapStateToProps = (state, { title }) => {
const deck = state[title];
return {
deck
};
};
export default withNavigation(connect(mapStateToProps)(Quiz_iOS));
// Quiz.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Constants from 'expo-constants';
import Quiz_Android from './Quiz_Android';
import Quiz_iOS from './Quiz_iOS';
export class Quiz extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired
};
static navigationOptions = ({ navigation }) => {
const title = navigation.getParam('title', '');
return {
title: `${title} Quiz`
};
};
render() {
const { navigation } = this.props;
const title = navigation.getParam('title', '');
if (Constants.platform.android) {
return <Quiz_Android title={title} />;
}
return <Quiz_iOS title={title} />;
}
}
export default Quiz;
Next I added AsyncStorage to each of my store updates in order to persist data between sessions.
// AddDeck.js
import { saveDeckTitleAS } from '../utils/api';
export class AddDeck extends Component {
...
handleSubmit = () => {
const { addDeck, navigation } = this.props;
const { text } = this.state;
addDeck(text);
saveDeckTitleAS(text);
this.setState(() => ({ text: '' }));
navigation.goBack();
};
...
}
// api.js
export async function saveDeckTitleAS(title) {
try {
await AsyncStorage.mergeItem(
DECKS_STORAGE_KEY,
JSON.stringify({
[title]: {
title,
questions: []
}
})
);
} catch (err) {
console.log(err);
}
}
// AddCard.js
import { addCardToDeckAS } from '../utils/api';
export class AddCard extends Component {
...
handleSubmit = () => {
const { addCardToDeck, title, navigation } = this.props;
const card = {
question: this.state.question,
answer: this.state.answer
};
addCardToDeck(title, card);
addCardToDeckAS(title, card);
this.setState({ question: '', answer: '' });
navigation.goBack();
};
...
}
// api.js
export async function addCardToDeckAS(title, card) {
try {
const deck = await getDeck(title);
await AsyncStorage.mergeItem(
DECKS_STORAGE_KEY,
JSON.stringify({
[title]: {
questions: [...deck.questions].concat(card)
}
})
);
} catch (err) {
console.log(err);
}
}
// DeleteDeck.js
import { removeDeckAS } from '../utils/api';
export class DeckDetail extends Component {
...
handleDelete = id => {
const { removeDeck, navigation } = this.props;
removeDeck(id);
removeDeckAS(id);
navigation.goBack();
};
...
}
// api.js
export async function removeDeckAS(key) {
try {
const results = await AsyncStorage.getItem(DECKS_STORAGE_KEY);
const data = JSON.parse(results);
data[key] = undefined;
delete data[key];
AsyncStorage.setItem(DECKS_STORAGE_KEY, JSON.stringify(data));
} catch (err) {
console.log(err);
}
}
A settings tab has been added that allows AsyncStorage to be reset back to the original data set.
// Settings.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Text, View, StyleSheet } from 'react-native';
import { gray, white, red, textGray, green } from '../utils/colors';
import TouchButton from './TouchButton';
import { resetDecks } from '../utils/api.js';
import { connect } from 'react-redux';
import { resetStore } from '../actions/index';
export class Settings extends Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
resetStore: PropTypes.func.isRequired
};
handleResetDecks = () => {
const { resetStore, navigation } = this.props;
resetStore();
resetDecks();
navigation.goBack();
};
render() {
return (
<View style={styles.container}>
<Text style={styles.title}> Settings </Text>
<View style={styles.block}>
<View style={styles.blockContainer}>
<Text style={styles.blockText}>
This will reset the data back to the original data set.
</Text>
<View style={{ height: 20 }} />
<TouchButton
btnStyle={{ backgroundColor: red, borderColor: white }}
onPress={this.handleResetDecks}
>
Reset Data
</TouchButton>
</View>
</View>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
backgroundColor: gray
},
title: {
fontSize: 40,
textAlign: 'center',
marginBottom: 16,
color: green
},
block: {
marginBottom: 20
},
blockContainer: {
borderWidth: 1,
borderColor: '#aaa',
backgroundColor: white,
borderRadius: 5,
paddingTop: 20,
paddingRight: 20,
paddingLeft: 20
},
blockText: {
fontSize: 18,
color: textGray
}
});
export default connect(
null,
{ resetStore }
)(Settings);
// api.js
export async function resetDecks() {
try {
await AsyncStorage.setItem(DECKS_STORAGE_KEY, JSON.stringify(decks));
} catch (err) {
console.log(err);
}
}
Next I add in the daily notification as a reminder to study.
// helpers.js
import React from 'react';
import { AsyncStorage } from 'react-native';
import { Notifications } from 'expo';
import * as Permissions from 'expo-permissions';
const NOTIFICATION_KEY = 'MobileFlashcard:notifications';
const CHANNEL_ID = 'DailyReminder';
export function clearLocalNotification() {
return AsyncStorage.removeItem(NOTIFICATION_KEY).then(
Notifications.cancelAllScheduledNotificationsAsync
);
}
function createNotification() {
return {
title: 'Mobile Flashcards Reminder',
body: "👋 Don't forget to study for today!",
ios: {
sound: true
},
android: {
channelId: CHANNEL_ID,
sticky: false,
color: 'red'
}
};
}
function createChannel() {
return {
name: 'Daily Reminder',
description: 'This is a daily reminder for you to study your flashcards.',
sound: true,
priority: 'high'
};
}
export function setLocalNotification() {
AsyncStorage.getItem(NOTIFICATION_KEY)
.then(JSON.parse)
.then(data => {
if (data === null) {
Permissions.askAsync(Permissions.NOTIFICATIONS).then(({ status }) => {
if (status === 'granted') {
Notifications.createChannelAndroidAsync(CHANNEL_ID, createChannel())
.then(val => console.log('channel return:', val))
.then(() => {
Notifications.cancelAllScheduledNotificationsAsync();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(20);
tomorrow.setMinutes(0);
Notifications.scheduleLocalNotificationAsync(
createNotification(),
{
time: tomorrow,
repeat: 'day'
}
);
AsyncStorage.setItem(NOTIFICATION_KEY, JSON.stringify(true));
})
.catch(err => {
console.log('err', err);
});
}
});
}
});
}
The notification is first set in App.js
// App.js
import { setLocalNotification } from './utils/helpers';
export default class App extends React.Component {
componentDidMount() {
setLocalNotification();
}
...
}
The notification is then set again for the next day at 8pm each time a quiz is taken.
// Quiz.js
import { setLocalNotification, clearLocalNotification } from '../utils/helpers';
export class Quiz extends Component {
componentDidMount() {
clearLocalNotification().then(setLocalNotification);
}
...
}
{
"expo": {
"name": "Mobile Flashcards",
"description": "This app allows you to create flashcard decks to test yourself with.",
"slug": "mobile-flashcards",
"privacy": "public",
"sdkVersion": "33.0.0",
"platforms": [
"ios",
"android"
],
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"notification": {
"icon": "./assets/icon-48.png"
},
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "io.jamespriest.mobileflashcards"
},
"android": {
"package": "io.jamespriest.mobileflashcards"
}
}
}