57
loading...
This website collects cookies to deliver better user experience
MyCustomRole
and from the collections dropdown, select both the exercises and workouts collection then check all the privileges. Note that this is just for testing purposes so we won't have any issues when it comes to permissions. In a production app, you have to check only the privileges your app is using:npx react-native init RNFaunaWorkout
RNFaunaWorkout
folder in your current working directory. Navigate inside that folder. That will be the root directory for all the commands and file paths that I'll be referring to in this tutorial.npm install faunadb
npm install @react-navigation/native
npm install react-native-screens react-native-safe-area-context
npm install @react-navigation/material-top-tabs react-native-tab-view
npm install react-native-pager-view
npm install react-native-paper
npm install react-native-vector-icons
react-native link react-native-vector-icons
npx pod-install
npx react-native run-android
.xcworkspace
file in the ios
directory. This will launch the iOS project in Xcode. Simply run the app from there.index.js
file. It's where we set up the React Native Paper theme:// index.js
import * as React from "react";
import { AppRegistry } from "react-native";
import { DefaultTheme } from "@react-navigation/native";
import { Provider as PaperProvider } from "react-native-paper";
import App from "./App";
import { name as appName } from "./app.json";
const theme = {
...DefaultTheme,
dark: true,
roundness: 10,
colors: {
...DefaultTheme.colors,
text: "#333",
background: "#ccc",
gray: "#858585",
white: "#fff",
default: "#f2f2f2",
},
fonts: {
...DefaultTheme.fonts,
small: 15,
regular: 16,
big: 20,
icon: 30,
},
};
export default function Main() {
return (
<PaperProvider theme={theme}>
<App />
</PaperProvider>
);
}
AppRegistry.registerComponent(appName, () => Main);
App.js
we're wrapping the app's Root component with AppContextProvider
. As you'll see later, this will provide global state that will be used throughout the app:// App.js
import React from "react";
import type { Node } from "react";
import {
SafeAreaView,
StatusBar,
useColorScheme,
StyleSheet,
} from "react-native";
import Root from "./Root";
import { AppContextProvider } from "./src/context/AppContext";
const App: () => Node = () => {
const isDarkMode = useColorScheme() === "dark";
return (
<SafeAreaView style={styles.root}>
<StatusBar barStyle={isDarkMode ? "light-content" : "dark-content"} />
<AppContextProvider>
<Root />
</AppContextProvider>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
justifyContent: "center",
},
});
export default App;
isAddingExercise
in the global app state. As you'll see later, this state value is used to determine whether to show the modal for creating a new exercise or not. On the other hand, the "add" button for the workout screen is used to navigate to the exercises screen. Because to start recording a new workout session, the user has to select an exercise first. The main purpose of having a separate tab for the workout screen is for easy access when the user has already selected an exercise:// Root.js
import React, { useContext } from "react";
import { NavigationContainer } from "@react-navigation/native";
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
import { Button, withTheme } from "react-native-paper";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import ExercisesScreen from "./src/screens/ExercisesScreen";
import WorkoutTabScreen from "./src/screens/WorkoutTabScreen";
import { AppContext } from "./src/context/AppContext";
const Tab = createBottomTabNavigator();
function getHeaderTitle(route) {
// ..
}
function Root({ theme }) {
const { colors, fonts } = theme;
const { setIsAddingExercise, setIsAddingWorkout } = useContext(AppContext);
return (
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen
name="Exercises"
component={ExercisesScreen}
options={{
tabBarLabel: "Exercises",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="dumbbell"
color={colors.gray}
size={fonts.icon}
/>
),
headerRight: () => (
<Button
icon="plus"
color={colors.text}
onPress={() => setIsAddingExercise(true)}
>
Add
</Button>
),
}}
/>
<Tab.Screen
name="Workout"
options={({ route, navigation }) => ({
tabBarLabel: "Workout",
headerTitle: getHeaderTitle(route),
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="weight-lifter"
color={colors.gray}
size={fonts.icon}
/>
),
headerRight: () => (
<Button
icon="plus"
color={colors.text}
onPress={() => navigation.navigate("Exercises")}
>
Add
</Button>
),
})}
component={WorkoutTabScreen}
/>
</Tab.Navigator>
</NavigationContainer>
);
}
export default withTheme(Root);
getHeaderTitle()
function is used for showing a different title for the workout screen based on the exercise selected by the user. You might be wondering why it's route.params.params
instead of just route.params
. That's because the data is being passed to the nested screen as you'll see later:function getHeaderTitle(route) {
if (route.params) {
const exercise_name = route.params.params.exercise.name;
return exercise_name.length > 25
? exercise_name.substr(0, 25) + ".."
: exercise_name;
}
return "Workout";
}
// src/context/AppContext.js
import React, { useState } from "react";
const AppContext = React.createContext();
const AppContextProvider = (props) => {
const [isAddingExercise, setIsAddingExercise] = useState(false); // whether to show the add exercise modal or not
const [workoutHistory, setWorkoutHistory] = useState([]);
const value = {
isAddingExercise,
setIsAddingExercise,
workoutHistory,
setWorkoutHistory,
};
return (
<AppContext.Provider value={value}>{props.children}</AppContext.Provider>
);
};
export { AppContext, AppContextProvider };
// src/config/db.js
import faunadb from "faunadb";
const client = new faunadb.Client({
secret: "YOUR FAUNA SECRET",
domain: "YOUR FAUNA DOMAIN",
});
const q = faunadb.query;
export { client, q };
secret
and the domain
where your database instance is hosted. If you selected "United States" earlier, the connection domain should be db.us.fauna.com
. If you selected anything else, check out the docs on region groups. If you scroll down near the bottom, you'll find a table showing the region group and their corresponding connection domain.// src/data/index.js
import {client, q} from '../config/db';
export const getExercises = () => {
return client
.query(q.Paginate(q.Match(q.Ref('indexes/exercises_index'))))
.then(response => {
const exercises_ref = response.data;
const getAllDataQuery = exercises_ref.map(ref => {
return q.Get(ref);
});
return client.query(getAllDataQuery).then(data => data);
})
.catch(error => console.error('Error: ', error.message));
};
faunadb
package we installed earlier provides the JavaScript API for FQL. This means that the function calls we made above basically looks similar to FQL in its raw form (eg. when you execute it via the Fauna console). If you check out the FQL API cheat sheet, you'll see the same methods we used above:client.query(q.Paginate(q.Match(q.Ref('YOUR INDEX'))))
to fetch data from the database.exercises
index:exercises_index
. Leave the defaults as it is then click SAVE:response.data
doesn't actually contain the data. All it returns is the reference to the data. That's why we have to use JavaScript's map()
function to go through the results and call q.Get()
on each to construct the query for getting the data for each row. The call to client.query(getAllDataQuery)
is what returns the actual data:return client
.query(q.Paginate(q.Match(q.Ref('indexes/exercises_index'))))
.then(response => {
const exercises_ref = response.data;
const getAllDataQuery = exercises_ref.map(ref => {
return q.Get(ref);
});
return client.query(getAllDataQuery).then(data => data);
})
.catch(error => console.error('Error: ', error.message));
q.create()
method and pass in the collection as the first argument, and an object containing a data
object which contains the data you want to save:// src/data/index.js
export const saveExercise = (name, category, primary_muscle) => {
return client
.query(
q.Create(q.Collection('exercises'), {
data: {
name,
category,
primary_muscle,
},
}),
)
.then(ret => ret)
.catch(error => console.error('Error: ', error.message));
};
CreateIndex({
name: "all_workouts_by_exercise_id",
source: Collection("workouts"),
terms: [
{ field: ["data", "exercise_id"]}
]
})
CreateIndex()
function accepts an object containing the following properties:name
- the machine-friendly name for the index. source
- the source collection.terms
- an array of term objects describing the fields that should be searchable.q.Match()
. This value will be used as the value for the term you added:// src/data/index.js
export const getWorkoutsByExercise = exercise_id => {
return client
.query(
q.Paginate(
q.Match(q.Ref('indexes/workouts_by_exercise_id_index'), exercise_id),
),
)
.then(response => {
const workouts_ref = response.data;
const getAllDataQuery = workouts_ref.map(ref => {
return q.Get(ref);
});
return client.query(getAllDataQuery).then(data => data);
})
.catch(error => console.error('Error: ', error.message));
};
workouts
collection. We also need to save the timestamp. Fauna actually saves a timestamp for each document already. But that one is attached to the database itself and is used for the temporal stuff. It also provides date and time functions but we also won't be using that. To keep things simple, we're gonna use good old new Date()
to get the unix timestamp and storing it along with the other data we need to store for each workout:// src/data/index.js
export const saveWorkout = (exercise_id, weight, reps) => {
const time_created = Math.round(new Date().getTime() / 1000);
return client
.query(
q.Create(q.Collection('workouts'), {
data: {
exercise_id,
weight,
reps,
time_created,
},
}),
)
.then(ret => console.log('created workout: ', ret))
.catch(error => console.error('Error: ', error.message));
};
q.Update()
function. Note that the data you pass in doesn't have to contain all the fields (with their updated values) that were present when you created the document. That's why we're only specifying the weight
and reps
here:// src/data/index.js
export const updateWorkout = (id, weight, reps) => {
return client
.query(
q.Update(q.Ref(q.Collection('workouts'), id), {
data: {
weight,
reps,
},
}),
)
.then(ret => console.log('updated workout: ', ret))
.catch(error => console.error('Error: ', error.message));
};
q.Ref()
. That should delete the corresponding document in the collection you specified as the first argument:// src/data/index.js
export const deleteWorkout = id => {
return client
.query(q.Delete(q.Ref(q.Collection('workouts'), id)))
.then(ret => console.log('deleted workout'))
.catch(err => console.error('Error: %s', err));
};
// src/helpers/DataFormatter.js
import groupBy from 'lodash.groupby';
import {fromUnixTime, format} from 'date-fns';
function getGroupedWorkouts(res) {
const formatted_workouts = res.map(item => {
const {exercise_id, weight, reps, time_created} = item.data;
const date = format(fromUnixTime(time_created), 'yyyy-MM-dd');
return {
id: item.ref.id,
exercise_id,
weight,
reps,
date,
time_created,
};
});
return groupBy(formatted_workouts, 'date');
}
export const groupWorkouts = res => {
return getGroupedWorkouts(res);
};
export const filterTodaysWorkout = grouped => {
const today = format(new Date(), 'yyyy-MM-dd');
return grouped[today] ? grouped[today] : [];
};
getExercises
, getWorkoutsByExercise
, and saveExercise
allows us to interact with the Fauna database. While groupWorkouts
is for formatting the data so that it can easily be presented in the UI:// src/screens/ExercisesScreen.js
import React, { useState, useEffect, useContext } from "react";
import { View, TextInput, StyleSheet } from "react-native";
import { List, withTheme } from "react-native-paper";
import AddExerciseModal from "../components/AddExerciseModal";
import { getExercises, getWorkoutsByExercise, saveExercise } from "../data";
import { AppContext } from "../context/AppContext";
import { groupWorkouts } from "../helpers/DataFormatter";
ExercisesScreen
component, we have some state for storing the exercises, filtered exercises, and the exercise being searched by the user. Filtered exercises are simply the exercises that has been filtered based on the value of searchExercise
. The filtered exercises is what's going to be displayed in the UI:function ExercisesScreen({ navigation, theme }) {
const { fonts, colors } = theme;
const [exercises, setExercises] = useState([]);
const [filteredExercises, setFilteredExercises] = useState([]);
const [searchExercise, setSearchExercise] = useState("");
}
const {
// for toggling the create exercise modal visibility
isAddingExercise,
setIsAddingExercise,
setWorkoutHistory, // for updating the state with the current workout history being viewed
} = useContext(AppContext);
useEffect(() => {
getExercises().then((res) => {
setExercises(res);
setFilteredExercises(res);
});
}, []);
useEffect(() => {
const filtered = exercises.filter((item) => {
return item.data.name.startsWith(searchExercise);
});
setFilteredExercises(filtered);
}, [searchExercise]);
createExercise
function is executed when the user clicks on the "create" button on the add exercise modal. All it does is call the saveExercise()
function for interacting with the FaunaDB database, then calls getExercises()
function to update the UI with the updated data:const createExercise = (name, category, primary_muscle) => {
saveExercise(name, category, primary_muscle).then(() => {
getExercises().then((res) => {
setExercises(res);
setFilteredExercises(res);
});
});
setIsAddingExercise(false);
};
goToWorkout
function is executed when the user clicks on any exercise on the list. This makes a request to Fauna to get the workout history for a particular exercise then updates the global state with it. Navigation works a bit differently because the workout screen is actually a tab navigator. This means that it has other screens under it. That's why aside from the name of the workout screen, we also need to pass in the name of the screen under it. In this case, it's CurrentWorkout
. Then we pass in the parameters we want to pass via the params
property. That's the reason why this specific data had to be accessed under route.params.params
as you've seen earlier in the getHeaderTitle()
function:const gotoWorkoutScreen = (item_id, item_data) => {
getWorkoutsByExercise(item_id).then((res) => {
const grouped_workouts = groupWorkouts(res);
setWorkoutHistory(grouped_workouts);
});
navigation.navigate("Workout", {
screen: "CurrentWorkout",
params: {
exercise_id: item_id,
exercise: item_data,
},
});
};
return (
<View style={styles.container}>
<View style={[styles.box, styles.searchContainer]}>
<TextInput
value={searchExercise}
placeholder="Search Exercise"
onChangeText={(text) => setSearchExercise(text)}
style={[styles.input, { backgroundColor: colors.white }]}
/>
</View>
<View style={styles.box}>
{filteredExercises.map((item) => {
return (
<List.Item
title={item.data.name}
description={item.data.muscle}
key={item.data.name}
onPress={() => gotoWorkoutScreen(item.ref.id, item.data)}
/>
);
})}
</View>
<AddExerciseModal
isAddingExercise={isAddingExercise}
setIsAddingExercise={setIsAddingExercise}
createExercise={createExercise}
/>
</View>
);
initialParams
prop on each screen so that they inherit whatever navigation params is passed to their parent:// src/screens/WorkoutTabScreen.js
import React, { useState } from "react";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import WorkoutScreen from "./WorkoutScreen";
import WorkoutHistoryScreen from "./WorkoutHistoryScreen";
const Tab = createMaterialTopTabNavigator();
function WorkoutTabScreen({ route }) {
return (
<Tab.Navigator>
<Tab.Screen
initialParams={route.params}
name="CurrentWorkout"
options={{
title: "Today",
}}
component={WorkoutScreen}
/>
<Tab.Screen
initialParams={route.params}
name="WorkoutHistory"
options={{
title: "History",
}}
component={WorkoutHistoryScreen}
/>
</Tab.Navigator>
);
}
export default WorkoutTabScreen;
// src/screens/WorkoutScreen.js
import React, { useState, useContext, useEffect } from "react";
import { View, Text, TextInput, ScrollView, StyleSheet } from "react-native";
import { Button, IconButton, withTheme } from "react-native-paper";
import { useRoute } from "@react-navigation/native";
import SetItem from "../components/SetItem";
import {
saveWorkout,
updateWorkout,
deleteWorkout,
getWorkoutsByExercise,
} from "../data";
import { groupWorkouts, filterTodaysWorkout } from "../helpers/DataFormatter";
import { AppContext } from "../context/AppContext";
workoutHistory
and setWorkoutHistory
from the global state. Because as you've seen in the exercises screen earlier, we're actually calling the setWorkoutHistory()
function to update the global state with the workout history of the exercise clicked on by the user. So we're basically just reading that here. Below that, we have a few state variables for keeping track of the currently selected set (selectedSet
), the index of the selected set (selectedIndex
), the weight (weight
), repetitions (reps
), and an array containing the workout for the current day for that specific exercise:function WorkoutScreen({ navigation, theme }) {
const route = useRoute();
const { colors, fonts } = theme;
const {
workoutHistory,
setWorkoutHistory,
} = useContext(AppContext);
const [selectedSet, setSelectedSet] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(null);
const [weight, setWeight] = useState(0);
const [reps, setReps] = useState(0);
const [todaysWorkout, setTodaysWorkout] = useState([]);
const currentAction = selectedIndex !== null ? "Update" : "Add";
const disableDelete = selectedIndex !== null ? false : true;
}
const increment = (type, value) => {
if (type === "weight") {
setWeight(weight + 1);
} else if (type === "reps") {
setReps(reps + 1);
}
};
const decrement = (type, value) => {
if (value >= 1) {
if (type === "weight") {
setWeight(value - 1);
} else if (type === "reps") {
setReps(value - 1);
}
}
};
selectedSet
and selectedIndex
to match. The weight
and reps
field also need to be updated based on the weight and reps for that set. This will then allow us to update the details for that set:const selectSet = (item, index) => {
setSelectedSet(item);
setSelectedIndex(index);
setWeight(parseInt(item.weight));
setReps(parseInt(item.reps));
};
selectedIndex
in the state. If it's present then we're updating a workout entry. Otherwise, we're creating a new entry:const saveAction = () => {
if (selectedIndex !== null) {
updateWorkout(selectedSet.id, weight, reps).then(() =>
syncWorkoutHistory()
);
} else {
if (route.params) {
saveWorkout(route.params.params.exercise_id, weight, reps).then(() =>
syncWorkoutHistory()
);
}
}
};
const syncWorkoutHistory = () => {
getWorkoutsByExercise(route.params.params.exercise_id).then((res) => {
const grouped_workouts = groupWorkouts(res);
setWorkoutHistory(grouped_workouts);
});
};
deleteSet()
function gets called when the user clicks on the "delete" button after selecting a set:const deleteSet = () => {
deleteWorkout(selectedSet.id).then(() => syncWorkoutHistory());
};
syncWorkoutHistory()
function to update the UI with the workouts for the specific exercise:useEffect(() => {
if (route.params) {
syncWorkoutHistory();
// reset the inputs
setSelectedSet(null);
setSelectedIndex(null);
setWeight(0);
setReps(0);
}
}, [route.params]);
workoutHistory
and update todaysWorkout
based on that:useEffect(() => {
if (workoutHistory) {
const todays_workout = filterTodaysWorkout(workoutHistory);
setTodaysWorkout(todays_workout);
}
}, [workoutHistory]);
return (
<ScrollView style={styles.container}>
<View style={styles.top}>
<View style={styles.field}>
<Text>WEIGHT (LB)</Text>
<View style={styles.inputContainer}>
<IconButton
icon="minus"
size={fonts.icon}
style={{ backgroundColor: colors.background }}
onPress={() => decrement("weight", weight)}
/>
<TextInput
keyboardType="number-pad"
style={[styles.input, { fontSize: fonts.big }]}
onChangeText={(text) => setWeight(text)}
value={weight.toString()}
/>
<IconButton
icon="plus"
size={fonts.icon}
style={{ backgroundColor: colors.background }}
onPress={() => increment("weight", weight)}
/>
</View>
</View>
<View style={styles.field}>
<Text>REPS</Text>
<View style={styles.inputContainer}>
<IconButton
icon="minus"
size={fonts.icon}
style={{ backgroundColor: colors.background }}
onPress={() => decrement("reps", reps)}
/>
<TextInput
keyboardType="number-pad"
style={[styles.input, { fontSize: fonts.big }]}
onChangeText={(text) => setReps(text)}
value={reps.toString()}
/>
<IconButton
icon="plus"
size={fonts.icon}
style={{ backgroundColor: colors.background }}
onPress={() => increment("reps", reps)}
/>
</View>
</View>
</View>
<View style={styles.buttonContainer}>
<Button color={colors.text} onPress={() => saveAction()}>
{currentAction}
</Button>
<Button
labelStyle={{ color: colors.text }}
disabled={disableDelete}
onPress={() => deleteSet()}
>
Delete
</Button>
</View>
<View style={styles.setContainer}>
{todaysWorkout.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<SetItem
item={item}
index={index}
key={index}
onPress={() => {
selectSet(item, index);
}}
isSelected={isSelected}
/>
);
})}
</View>
</ScrollView>
);
// src/screens/WorkoutHistory.js
import React, { useState, useContext } from "react";
import { ScrollView, View, Text, StyleSheet } from "react-native";
import { withTheme } from "react-native-paper";
import { fromUnixTime, format } from "date-fns";
import { AppContext } from "../context/AppContext";
import SetItem from "../components/SetItem";
import { getWorkoutsByExercise } from "../data";
workoutHistory
from the app context. The workout history has to be sorted from latest to oldest so we need to use Object.keys()
to get an array of the workoutHistory
's properties. In this case, the properties are the workout dates. Calling reverse()
on this resulting array will sort the workout history from latest to oldest. From there, we simply extract and format the data accordingly:function WorkoutHistoryScreen({ theme }) {
const { fonts } = theme;
const { workoutHistory } = useContext(AppContext);
return (
<ScrollView style={styles.container}>
{Object.keys(workoutHistory)
.reverse()
.map((key, date) => {
const day_workouts = workoutHistory[key];
const formatted_date = format(
fromUnixTime(day_workouts[0]["time_created"]),
"yyyy, MMMM dd"
);
return (
<View style={styles.card} key={date}>
<View style={styles.sectionHeader}>
<Text style={{ fontSize: fonts.regular }}>
{formatted_date}
</Text>
</View>
<View>
{day_workouts.map((item, index) => {
return <SetItem item={item} index={index} key={index} />;
})}
</View>
</View>
);
})}
</ScrollView>
);
}
weight x reps
to encourage the user to surpass it.