459 lines
13 KiB
TypeScript
459 lines
13 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
ScrollView,
|
|
Alert,
|
|
ActivityIndicator,
|
|
} from "react-native";
|
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
|
import { useRouter } from "expo-router";
|
|
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
|
import { gymsApi, type Gym } from "@/api/gyms";
|
|
import log from "../../utils/logger";
|
|
|
|
export default function OnboardingScreen() {
|
|
const { user } = useUser();
|
|
const { getToken } = useAuth();
|
|
const router = useRouter();
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [gyms, setGyms] = useState<Gym[]>([]);
|
|
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
|
|
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const loadGyms = async () => {
|
|
try {
|
|
setGymsLoading(true);
|
|
const token = await getToken();
|
|
const data = await gymsApi.getGyms(token);
|
|
setGyms(data);
|
|
} catch (e) {
|
|
log.error("Failed to fetch gyms", e);
|
|
} finally {
|
|
setGymsLoading(false);
|
|
}
|
|
};
|
|
loadGyms();
|
|
}, []);
|
|
const [fitnessProfile, setFitnessProfile] = useState({
|
|
height: "",
|
|
weight: "",
|
|
age: "",
|
|
goals: "",
|
|
fitnessLevel: "beginner",
|
|
medicalConditions: "",
|
|
dietaryRestrictions: "",
|
|
preferredWorkoutTime: "morning",
|
|
workoutFrequency: "3",
|
|
});
|
|
|
|
const handleSubmit = async () => {
|
|
if (!user?.id) return;
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
|
|
// Validate required fields
|
|
if (
|
|
!fitnessProfile.height ||
|
|
!fitnessProfile.weight ||
|
|
!fitnessProfile.age
|
|
) {
|
|
Alert.alert("Error", "Please enter your height, weight, and age");
|
|
return;
|
|
}
|
|
|
|
const token = await getToken();
|
|
if (!token) {
|
|
throw new Error("Authentication token not available");
|
|
}
|
|
|
|
// If gym was selected or cleared, patch user's gym selection first
|
|
// selectedGymId: string gym id, or null to proceed without gym
|
|
try {
|
|
await gymsApi.updateUserGym(selectedGymId, token);
|
|
} catch (e) {
|
|
log.warn("Failed to update gym selection", { gymId: selectedGymId });
|
|
}
|
|
|
|
const fitnessData = {
|
|
userId: user.id,
|
|
height: fitnessProfile.height
|
|
? parseFloat(fitnessProfile.height)
|
|
: undefined,
|
|
weight: fitnessProfile.weight
|
|
? parseFloat(fitnessProfile.weight)
|
|
: undefined,
|
|
age: fitnessProfile.age ? parseInt(fitnessProfile.age, 10) : undefined,
|
|
fitnessGoals: fitnessProfile.goals ? [fitnessProfile.goals] : [],
|
|
medicalConditions: fitnessProfile.medicalConditions || undefined,
|
|
allergies: fitnessProfile.dietaryRestrictions || undefined,
|
|
};
|
|
|
|
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
|
router.replace("/(tabs)");
|
|
} catch (error) {
|
|
log.error("Failed to create fitness profile", error);
|
|
Alert.alert(
|
|
"Error",
|
|
"Failed to create fitness profile. Please try again.",
|
|
);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleLevelSelect = (level: string) => {
|
|
setFitnessProfile({ ...fitnessProfile, fitnessLevel: level });
|
|
};
|
|
|
|
const handleTimeSelect = (time: string) => {
|
|
setFitnessProfile({ ...fitnessProfile, preferredWorkoutTime: time });
|
|
};
|
|
|
|
// Calculate progress based on filled fields
|
|
const calculateProgress = () => {
|
|
const fields = [
|
|
fitnessProfile.height,
|
|
fitnessProfile.weight,
|
|
fitnessProfile.age,
|
|
fitnessProfile.goals,
|
|
fitnessProfile.medicalConditions || "n/a", // Optional fields count as filled
|
|
fitnessProfile.dietaryRestrictions || "n/a",
|
|
];
|
|
const filledFields = fields.filter(
|
|
(field) => field && field.trim() !== "",
|
|
).length;
|
|
return Math.round((filledFields / fields.length) * 100);
|
|
};
|
|
|
|
const progress = calculateProgress();
|
|
|
|
return (
|
|
<ScrollView style={styles.container}>
|
|
<Text style={styles.title}>Set Up Your Fitness Profile</Text>
|
|
<Text style={styles.subtitle}>
|
|
Help us personalize your fitness journey
|
|
</Text>
|
|
|
|
{/* Progress indicator */}
|
|
<View style={styles.progressContainer}>
|
|
<View style={styles.progressBarBackground}>
|
|
<View style={[styles.progressBarFill, { width: `${progress}%` }]} />
|
|
</View>
|
|
<Text style={styles.progressText}>{progress}% Complete</Text>
|
|
</View>
|
|
|
|
<View style={styles.form}>
|
|
<Text style={styles.label}>Height (cm)</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={fitnessProfile.height}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({ ...fitnessProfile, height: value })
|
|
}
|
|
keyboardType="numeric"
|
|
placeholder="Enter height in cm"
|
|
/>
|
|
|
|
<Text style={styles.label}>Weight (kg)</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={fitnessProfile.weight}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({ ...fitnessProfile, weight: value })
|
|
}
|
|
keyboardType="numeric"
|
|
placeholder="Enter weight in kg"
|
|
/>
|
|
|
|
<Text style={styles.label}>Age</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={fitnessProfile.age}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({ ...fitnessProfile, age: value })
|
|
}
|
|
keyboardType="numeric"
|
|
placeholder="Enter your age"
|
|
/>
|
|
|
|
<Text style={styles.label}>Fitness Goals</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.textArea]}
|
|
value={fitnessProfile.goals}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({ ...fitnessProfile, goals: value })
|
|
}
|
|
multiline
|
|
numberOfLines={3}
|
|
placeholder="What are your fitness goals?"
|
|
/>
|
|
|
|
<Text style={styles.label}>Fitness Level</Text>
|
|
<View style={styles.buttonGroup}>
|
|
{["beginner", "intermediate", "advanced"].map((level) => (
|
|
<TouchableOpacity
|
|
key={level}
|
|
style={[
|
|
styles.levelButton,
|
|
fitnessProfile.fitnessLevel === level && styles.selectedButton,
|
|
]}
|
|
onPress={() => handleLevelSelect(level)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.levelButtonText,
|
|
fitnessProfile.fitnessLevel === level &&
|
|
styles.selectedButtonText,
|
|
]}
|
|
>
|
|
{level.charAt(0).toUpperCase() + level.slice(1)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
<Text style={styles.label}>Medical Conditions</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.textArea]}
|
|
value={fitnessProfile.medicalConditions}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({ ...fitnessProfile, medicalConditions: value })
|
|
}
|
|
multiline
|
|
numberOfLines={3}
|
|
placeholder="Any medical conditions we should know about?"
|
|
/>
|
|
|
|
<Text style={styles.label}>Dietary Restrictions</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.textArea]}
|
|
value={fitnessProfile.dietaryRestrictions}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({
|
|
...fitnessProfile,
|
|
dietaryRestrictions: value,
|
|
})
|
|
}
|
|
multiline
|
|
numberOfLines={3}
|
|
placeholder="Any dietary restrictions?"
|
|
/>
|
|
|
|
<Text style={styles.label}>Preferred Workout Time</Text>
|
|
<View style={styles.buttonGroup}>
|
|
{["morning", "afternoon", "evening"].map((time) => (
|
|
<TouchableOpacity
|
|
key={time}
|
|
style={[
|
|
styles.timeButton,
|
|
fitnessProfile.preferredWorkoutTime === time &&
|
|
styles.selectedButton,
|
|
]}
|
|
onPress={() => handleTimeSelect(time)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.timeButtonText,
|
|
fitnessProfile.preferredWorkoutTime === time &&
|
|
styles.selectedButtonText,
|
|
]}
|
|
>
|
|
{time.charAt(0).toUpperCase() + time.slice(1)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
<Text style={styles.label}>Workouts per Week</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={fitnessProfile.workoutFrequency}
|
|
onChangeText={(value) =>
|
|
setFitnessProfile({ ...fitnessProfile, workoutFrequency: value })
|
|
}
|
|
keyboardType="numeric"
|
|
placeholder="Number of workouts per week"
|
|
/>
|
|
|
|
<Text style={styles.label}>Select a Gym</Text>
|
|
{gymsLoading ? (
|
|
<ActivityIndicator />
|
|
) : (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
<View style={{ flexDirection: "row" }}>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.levelButton,
|
|
selectedGymId === null && styles.selectedButton,
|
|
]}
|
|
onPress={() => setSelectedGymId(null)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.levelButtonText,
|
|
selectedGymId === null && styles.selectedButtonText,
|
|
]}
|
|
>
|
|
Proceed without gym
|
|
</Text>
|
|
</TouchableOpacity>
|
|
{gyms.map((gym) => (
|
|
<TouchableOpacity
|
|
key={gym.id}
|
|
style={[
|
|
styles.levelButton,
|
|
selectedGymId === gym.id && styles.selectedButton,
|
|
]}
|
|
onPress={() => setSelectedGymId(gym.id)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.levelButtonText,
|
|
selectedGymId === gym.id && styles.selectedButtonText,
|
|
]}
|
|
>
|
|
{gym.name}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</ScrollView>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={styles.submitButton}
|
|
onPress={handleSubmit}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<ActivityIndicator color="white" />
|
|
) : (
|
|
<Text style={styles.submitButtonText}>Complete Setup</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: "#f5f5f5",
|
|
},
|
|
title: {
|
|
fontSize: 24,
|
|
fontWeight: "bold",
|
|
textAlign: "center",
|
|
marginTop: 40,
|
|
marginBottom: 8,
|
|
},
|
|
subtitle: {
|
|
fontSize: 16,
|
|
color: "#666",
|
|
textAlign: "center",
|
|
marginBottom: 32,
|
|
},
|
|
form: {
|
|
padding: 20,
|
|
},
|
|
label: {
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
color: "#374151",
|
|
marginBottom: 4,
|
|
},
|
|
input: {
|
|
borderWidth: 1,
|
|
borderColor: "#ddd",
|
|
borderRadius: 8,
|
|
padding: 12,
|
|
marginBottom: 16,
|
|
backgroundColor: "white",
|
|
},
|
|
textArea: {
|
|
height: 80,
|
|
textAlignVertical: "top",
|
|
},
|
|
buttonGroup: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 16,
|
|
},
|
|
levelButton: {
|
|
flex: 1,
|
|
backgroundColor: "#f3f4f6",
|
|
padding: 10,
|
|
borderRadius: 8,
|
|
marginHorizontal: 4,
|
|
alignItems: "center",
|
|
},
|
|
timeButton: {
|
|
flex: 1,
|
|
backgroundColor: "#f3f4f6",
|
|
padding: 10,
|
|
borderRadius: 8,
|
|
marginHorizontal: 4,
|
|
alignItems: "center",
|
|
},
|
|
selectedButton: {
|
|
backgroundColor: "#3b82f6",
|
|
},
|
|
levelButtonText: {
|
|
color: "#374151",
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
},
|
|
timeButtonText: {
|
|
color: "#374151",
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
},
|
|
selectedButtonText: {
|
|
color: "white",
|
|
},
|
|
submitButton: {
|
|
backgroundColor: "#3b82f6",
|
|
padding: 16,
|
|
borderRadius: 8,
|
|
alignItems: "center",
|
|
marginTop: 24,
|
|
},
|
|
submitButtonText: {
|
|
color: "white",
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
progressContainer: {
|
|
paddingHorizontal: 20,
|
|
marginBottom: 16,
|
|
},
|
|
progressBarBackground: {
|
|
height: 8,
|
|
backgroundColor: "#e5e7eb",
|
|
borderRadius: 4,
|
|
overflow: "hidden",
|
|
marginBottom: 8,
|
|
},
|
|
progressBarFill: {
|
|
height: "100%",
|
|
backgroundColor: "#3b82f6",
|
|
borderRadius: 4,
|
|
},
|
|
progressText: {
|
|
fontSize: 12,
|
|
color: "#6b7280",
|
|
textAlign: "center",
|
|
},
|
|
});
|