By the end of this lesson, you will:
Workout logging transforms casual exercise into data you can track, compare, and share. Strava users post run maps. Peloton riders compete on leaderboards. Nike Run Club celebrates personal bests.
When users log workouts, they create a fitness story. Each run, each rep, each mile becomes part of their progress narrative. Apps that make logging effortless and rewarding get opened before every workout and shared after every achievement.
Record workout data to Health Connect:
import { insertRecords } from 'react-native-health-connect';
export const logWorkout = async (workoutData) => {
const { type, startTime, endTime, distance, calories } = workoutData;
try {
// Create exercise session
const sessionRecord = {
recordType: 'ExerciseSession',
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
exerciseType: type, // 'running', 'cycling', 'swimming', etc.
title: workoutData.title || `${type} Workout`,
notes: workoutData.notes,
};
await insertRecords([sessionRecord]);
// Log associated metrics
if (distance) {
await insertRecords([
{
recordType: 'Distance',
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
distance: {
inMeters: distance,
},
},
]);
}
if (calories) {
await insertRecords([
{
recordType: 'TotalCaloriesBurned',
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
energy: {
inKilocalories: calories,
},
},
]);
}
console.log('Workout logged successfully');
return true;
} catch (error) {
console.error('Log workout error:', error);
return false;
}
};
Exercise Types:
Track workout duration in real-time:
import { useState, useRef, useEffect } from 'react';
export default function WorkoutTimer() {
const [isActive, setIsActive] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [time, setTime] = useState(0); // seconds
const [startTime, setStartTime] = useState(null);
const intervalRef = useRef(null);
useEffect(() => {
if (isActive && !isPaused) {
intervalRef.current = setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isActive, isPaused]);
const start = () => {
setIsActive(true);
setIsPaused(false);
setStartTime(new Date());
};
const pause = () => {
setIsPaused(true);
};
const resume = () => {
setIsPaused(false);
};
const stop = () => {
setIsActive(false);
setIsPaused(false);
const endTime = new Date();
return {
startTime,
endTime,
duration: time,
};
};
const reset = () => {
setIsActive(false);
setIsPaused(false);
setTime(0);
setStartTime(null);
};
const formatTime = () => {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = time % 60;
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
return {
time,
formattedTime: formatTime(),
isActive,
isPaused,
start,
pause,
resume,
stop,
reset,
};
}
Track running/cycling routes:
import * as Location from 'expo-location';
import { useState } from 'react';
export default function RouteTracker() {
const [route, setRoute] = useState([]);
const [isTracking, setIsTracking] = useState(false);
const [subscription, setSubscription] = useState(null);
const startTracking = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
console.error('Location permission denied');
return;
}
setIsTracking(true);
setRoute([]);
const sub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 5000, // Update every 5 seconds
distanceInterval: 10, // Or every 10 meters
},
(location) => {
const point = {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
timestamp: new Date(location.timestamp),
altitude: location.coords.altitude,
speed: location.coords.speed, // m/s
};
setRoute((prevRoute) => [...prevRoute, point]);
}
);
setSubscription(sub);
};
const stopTracking = () => {
if (subscription) {
subscription.remove();
setSubscription(null);
}
setIsTracking(false);
};
const calculateDistance = () => {
if (route.length < 2) return 0;
let totalDistance = 0;
for (let i = 1; i < route.length; i++) {
const prev = route[i - 1];
const curr = route[i];
// Haversine formula
const R = 6371e3; // Earth radius in meters
const φ1 = (prev.latitude * Math.PI) / 180;
const φ2 = (curr.latitude * Math.PI) / 180;
const Δφ = ((curr.latitude - prev.latitude) * Math.PI) / 180;
const Δλ = ((curr.longitude - prev.longitude) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
totalDistance += R * c;
}
return totalDistance; // meters
};
const calculateElevationGain = () => {
if (route.length < 2) return 0;
let gain = 0;
for (let i = 1; i < route.length; i++) {
const elevationDiff = route[i].altitude - route[i - 1].altitude;
if (elevationDiff > 0) {
gain += elevationDiff;
}
}
return gain; // meters
};
return {
route,
isTracking,
startTracking,
stopTracking,
distance: calculateDistance(),
elevationGain: calculateElevationGain(),
};
}
Estimate calories burned:
export const calculateCalories = (activityType, duration, weight, distance) => {
// MET values (Metabolic Equivalent of Task)
const MET_VALUES = {
walking: 3.5,
running: 9.8,
cycling: 7.5,
swimming: 8.0,
yoga: 2.5,
weightlifting: 6.0,
basketball: 8.0,
};
const met = MET_VALUES[activityType] || 5.0;
const hours = duration / 3600; // Convert seconds to hours
// Calories = MET × weight(kg) × hours
const calories = met * weight * hours;
return Math.round(calories);
};
// Enhanced calculation with distance
export const calculateRunningCalories = (duration, weight, distance) => {
// More accurate for running: ~0.75 calories per kg per km
const distanceKm = distance / 1000;
const calories = 0.75 * weight * distanceKm;
return Math.round(calories);
};
Store and retrieve past workouts:
import AsyncStorage from '@react-native-async-storage/async-storage';
const WORKOUTS_KEY = '@workout_history';
export const saveWorkout = async (workout) => {
try {
const history = await getWorkoutHistory();
const newWorkout = {
id: Date.now().toString(),
...workout,
savedAt: new Date().toISOString(),
};
history.push(newWorkout);
await AsyncStorage.setItem(WORKOUTS_KEY, JSON.stringify(history));
return newWorkout;
} catch (error) {
console.error('Save workout error:', error);
return null;
}
};
export const getWorkoutHistory = async (limit = 50) => {
try {
const data = await AsyncStorage.getItem(WORKOUTS_KEY);
const history = data ? JSON.parse(data) : [];
// Sort by date descending
return history
.sort((a, b) => new Date(b.startTime) - new Date(a.startTime))
.slice(0, limit);
} catch (error) {
console.error('Get history error:', error);
return [];
}
};
export const deleteWorkout = async (workoutId) => {
try {
const history = await getWorkoutHistory();
const filtered = history.filter((w) => w.id !== workoutId);
await AsyncStorage.setItem(WORKOUTS_KEY, JSON.stringify(filtered));
return true;
} catch (error) {
console.error('Delete workout error:', error);
return false;
}
};
Calculate aggregate stats:
export const calculateWorkoutStats = (workouts) => {
if (workouts.length === 0) {
return {
totalWorkouts: 0,
totalDuration: 0,
totalDistance: 0,
totalCalories: 0,
averageDuration: 0,
longestWorkout: null,
};
}
const totalDuration = workouts.reduce((sum, w) => sum + w.duration, 0);
const totalDistance = workouts.reduce((sum, w) => sum + (w.distance || 0), 0);
const totalCalories = workouts.reduce((sum, w) => sum + (w.calories || 0), 0);
const longestWorkout = workouts.reduce((longest, w) =>
w.duration > (longest?.duration || 0) ? w : longest
);
return {
totalWorkouts: workouts.length,
totalDuration,
totalDistance,
totalCalories,
averageDuration: totalDuration / workouts.length,
longestWorkout,
};
};
// Monthly statistics
export const getMonthlyStats = (workouts) => {
const thisMonth = new Date();
thisMonth.setDate(1);
thisMonth.setHours(0, 0, 0, 0);
const monthWorkouts = workouts.filter(
(w) => new Date(w.startTime) >= thisMonth
);
return calculateWorkoutStats(monthWorkouts);
};
Let users choose workout type:
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, FlatList } from 'react-native';
const ACTIVITY_TYPES = [
{ id: 'running', name: 'Running', icon: '🏃', color: '#FF6B6B' },
{ id: 'cycling', name: 'Cycling', icon: '🚴', color: '#4ECDC4' },
{ id: 'swimming', name: 'Swimming', icon: '🏊', color: '#45B7D1' },
{ id: 'walking', name: 'Walking', icon: '🚶', color: '#96CEB4' },
{ id: 'yoga', name: 'Yoga', icon: '🧘', color: '#DDA15E' },
{ id: 'weightlifting', name: 'Weights', icon: '🏋️', color: '#BC6C25' },
{ id: 'basketball', name: 'Basketball', icon: '🏀', color: '#F77F00' },
{ id: 'other', name: 'Other', icon: '💪', color: '#9D4EDD' },
];
export default function ActivityTypeSelector({ onSelect, selectedType }) {
const renderActivity = ({ item }) => (
<TouchableOpacity
style={[
styles.activityCard,
selectedType === item.id && {
borderColor: item.color,
backgroundColor: `${item.color}20`,
},
]}
onPress={() => onSelect(item)}
>
<Text style={styles.activityIcon}>{item.icon}</Text>
<Text style={styles.activityName}>{item.name}</Text>
</TouchableOpacity>
);
return (
<FlatList
data={ACTIVITY_TYPES}
renderItem={renderActivity}
keyExtractor={(item) => item.id}
numColumns={4}
columnWrapperStyle={styles.row}
/>
);
}
const styles = StyleSheet.create({
row: {
justifyContent: 'space-between',
marginBottom: 12,
},
activityCard: {
width: '22%',
aspectRatio: 1,
backgroundColor: '#fff',
borderRadius: 12,
borderWidth: 2,
borderColor: '#E0E0E0',
justifyContent: 'center',
alignItems: 'center',
},
activityIcon: {
fontSize: 32,
marginBottom: 4,
},
activityName: {
fontSize: 10,
textAlign: 'center',
},
});
Complete workout logging app:
import { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Alert,
} from 'react-native';
import MapView, { Polyline } from 'react-native-maps';
export default function WorkoutLoggerScreen() {
const timer = WorkoutTimer();
const tracker = RouteTracker();
const [activityType, setActivityType] = useState('running');
const [weight, setWeight] = useState(70); // kg
const handleStart = () => {
timer.start();
if (activityType === 'running' || activityType === 'cycling') {
tracker.startTracking();
}
};
const handleStop = async () => {
const timerData = timer.stop();
tracker.stopTracking();
const calories = calculateCalories(
activityType,
timerData.duration,
weight,
tracker.distance
);
const workout = {
type: activityType,
startTime: timerData.startTime,
endTime: timerData.endTime,
duration: timerData.duration,
distance: tracker.distance,
calories,
route: tracker.route,
};
// Save locally
await saveWorkout(workout);
// Write to Health Connect
await logWorkout(workout);
Alert.alert(
'Workout Complete!',
`${activityType}\nDuration: ${timer.formattedTime}\nDistance: ${(
tracker.distance / 1000
).toFixed(2)} km\nCalories: ${calories}`,
[{ text: 'OK', onPress: () => timer.reset() }]
);
};
return (
<View style={styles.container}>
{/* Map View */}
{tracker.route.length > 0 && (
<MapView
style={styles.map}
region={{
latitude: tracker.route[tracker.route.length - 1].latitude,
longitude: tracker.route[tracker.route.length - 1].longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
>
<Polyline
coordinates={tracker.route.map((p) => ({
latitude: p.latitude,
longitude: p.longitude,
}))}
strokeColor="#FF6B6B"
strokeWidth={4}
/>
</MapView>
)}
{/* Stats Overlay */}
<View style={styles.statsOverlay}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{timer.formattedTime}</Text>
<Text style={styles.statLabel}>Time</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{(tracker.distance / 1000).toFixed(2)}
</Text>
<Text style={styles.statLabel}>km</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{tracker.distance > 0
? ((tracker.distance / 1000 / (timer.time / 3600)).toFixed(1))
: '0.0'}
</Text>
<Text style={styles.statLabel}>km/h</Text>
</View>
</View>
{/* Controls */}
<View style={styles.controls}>
{!timer.isActive ? (
<>
<ActivityTypeSelector
selectedType={activityType}
onSelect={(type) => setActivityType(type.id)}
/>
<TouchableOpacity style={styles.startButton} onPress={handleStart}>
<Text style={styles.startButtonText}>Start Workout</Text>
</TouchableOpacity>
</>
) : (
<View style={styles.activeControls}>
{!timer.isPaused ? (
<TouchableOpacity style={styles.pauseButton} onPress={timer.pause}>
<Text style={styles.buttonText}>Pause</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.resumeButton} onPress={timer.resume}>
<Text style={styles.buttonText}>Resume</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.stopButton} onPress={handleStop}>
<Text style={styles.buttonText}>Finish</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
map: {
flex: 1,
},
statsOverlay: {
position: 'absolute',
top: 60,
left: 20,
right: 20,
flexDirection: 'row',
justifyContent: 'space-between',
},
statCard: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
flex: 1,
marginHorizontal: 4,
},
statValue: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#666',
},
controls: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 20,
paddingBottom: 40,
},
startButton: {
backgroundColor: '#4CAF50',
borderRadius: 16,
padding: 20,
alignItems: 'center',
marginTop: 16,
},
startButtonText: {
color: '#fff',
fontSize: 20,
fontWeight: 'bold',
},
activeControls: {
flexDirection: 'row',
justifyContent: 'space-between',
},
pauseButton: {
flex: 1,
backgroundColor: '#FF9800',
borderRadius: 16,
padding: 20,
alignItems: 'center',
marginRight: 8,
},
resumeButton: {
flex: 1,
backgroundColor: '#4CAF50',
borderRadius: 16,
padding: 20,
alignItems: 'center',
marginRight: 8,
},
stopButton: {
flex: 1,
backgroundColor: '#F44336',
borderRadius: 16,
padding: 20,
alignItems: 'center',
marginLeft: 8,
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
});
| Pitfall | Solution |
|---|---|
| GPS drain battery | Use balanced accuracy, pause tracking when stationary |
| Inaccurate distance | Filter GPS noise, use minimum distance threshold |
| Timer continues in background | Use proper background task management |
| Route drift | Smooth GPS points, filter outliers |
| Missing workout data | Validate all fields before saving to Health Connect |
In the next lesson, we'll explore Freemium Model Design, learning how to balance free features with premium upgrades to create sustainable app monetization.