clerkauth

implemented, sync with db to be added
This commit is contained in:
dimitar committed 2025-11-10 04:16:31 +01:00
1 parent ca790a7b97
commit 3a58d420d6
49 files changed
+9423 -779

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Clerk Authentication
# Get these values from https://dashboard.clerk.com
# Make sure to use the correct publishable key for your environment
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cGxlYXNpbmctcGhlYXNhbnQtMjAuY2xlcmsuYWNjb3VudHMuZGV2JA
# API Configuration
# Update this to point to your backend API
EXPO_PUBLIC_API_URL=http://localhost:3000/api
# App Configuration
EXPO_PUBLIC_APP_NAME=FitAI
EXPO_PUBLIC_APP_VERSION=1.0.0
+11
View File
@@ -0,0 +1,11 @@
# npm configuration for FitAI mobile app
# This ensures consistent dependency resolution across the team
# Use legacy peer deps to resolve React Native dependency conflicts
legacy-peer-deps=true
# Prefer offline mode for faster installs (optional)
# prefer-offline=true
# Save exact versions to package.json
# save-exact=true
+1290 -10
View File
File diff suppressed because it is too large. Load diff
+12 -4
View File
@@ -14,6 +14,7 @@
"test": "jest"
},
"dependencies": {
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^5.0.0",
@@ -21,29 +22,36 @@
"ajv-keywords": "^5.1.0",
"axios": "^1.6.0",
"expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.9",
"react": "19.1.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2",
"zod": "^3.22.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/react-native": "^12.4.0",
"@types/react": "~19.1.10",
"@types/react-native": "^0.73.0",
"typescript": "^5.1.3",
"eslint": "^8.45.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"babel-preset-expo": "~54.0.7",
"eslint": "^8.45.0",
"jest": "^29.2.1",
"@testing-library/react-native": "^12.4.0",
"react-test-renderer": "19.1.0",
"babel-preset-expo": "~54.0.7"
"typescript": "^5.1.3"
}
}
+102
View File
@@ -0,0 +1,102 @@
import axios from "axios";
import { Platform } from "react-native";
const API_BASE_URL = Platform.select({
android: "http://10.0.2.2:3000", // Android emulator maps this to host's localhost
ios: "http://localhost:3000",
default: process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000",
});
export interface FitnessProfile {
id?: string;
clientId: string;
height?: number;
weight?: number;
goals?: string;
fitnessLevel: "beginner" | "intermediate" | "advanced";
medicalConditions?: string;
dietaryRestrictions?: string;
preferredWorkoutTime?: "morning" | "afternoon" | "evening";
workoutFrequency?: number;
}
export const fitnessProfileApi = {
getFitnessProfile: async (
userId: string,
token: string,
): Promise<FitnessProfile> => {
try {
console.log(
"Getting fitness profile with URL:",
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
);
const response = await axios.get(
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return response.data;
} catch (error: any) {
console.error(
"Error fetching fitness profile:",
error.message,
error.response?.data,
);
throw error;
}
},
updateFitnessProfile: async (
userId: string,
data: Partial<FitnessProfile>,
token: string,
): Promise<FitnessProfile> => {
try {
const response = await axios.put(
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
data,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return response.data;
} catch (error: any) {
console.error(
"Error updating fitness profile:",
error.message,
error.response?.data,
);
throw error;
}
},
createFitnessProfile: async (
data: Omit<FitnessProfile, "id">,
token: string,
): Promise<FitnessProfile> => {
try {
const response = await axios.post(
`${API_BASE_URL}/api/users/${data.clientId}/fitness-profile`,
data,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return response.data;
} catch (error: any) {
console.error(
"Error creating fitness profile:",
error.message,
error.response?.data,
);
throw error;
}
},
};
+16
View File
@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "#f5f5f5" },
}}
>
<Stack.Screen name="sign-in" />
<Stack.Screen name="sign-up" />
<Stack.Screen name="onboarding" />
</Stack>
);
}
+320
View File
@@ -0,0 +1,320 @@
import React, { useState } 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";
export default function OnboardingScreen() {
const { user } = useUser();
const { getToken } = useAuth();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [fitnessProfile, setFitnessProfile] = useState({
height: "",
weight: "",
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) {
Alert.alert("Error", "Please enter your height and weight");
return;
}
const fitnessData = {
clientId: user.id,
height: parseFloat(fitnessProfile.height),
weight: parseFloat(fitnessProfile.weight),
goals: fitnessProfile.goals || undefined,
fitnessLevel: fitnessProfile.fitnessLevel as
| "beginner"
| "intermediate"
| "advanced",
medicalConditions: fitnessProfile.medicalConditions || undefined,
dietaryRestrictions: fitnessProfile.dietaryRestrictions || undefined,
preferredWorkoutTime: fitnessProfile.preferredWorkoutTime as
| "morning"
| "afternoon"
| "evening",
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
};
const token = await getToken();
if (!token) {
throw new Error("Authentication token not available");
}
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
router.replace("/(tabs)");
} catch (error) {
console.error("Error creating 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 });
};
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>
<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}>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"
/>
<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",
},
});
+259
View File
@@ -0,0 +1,259 @@
import React from "react";
import { useSignIn, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
} from "react-native";
export default function SignInScreen() {
const { signIn, setActive, isLoaded } = useSignIn();
const { isSignedIn } = useAuth();
const router = useRouter();
const [emailAddress, setEmailAddress] = React.useState("");
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
// Redirect if already signed in
React.useEffect(() => {
if (isSignedIn) {
router.replace("/(tabs)");
}
}, [isSignedIn]);
const onSignInPress = async () => {
if (!isLoaded) return;
setLoading(true);
setError("");
try {
const signInAttempt = await signIn.create({
identifier: emailAddress,
password,
});
if (signInAttempt.status === "complete") {
await setActive({ session: signInAttempt.createdSessionId });
router.replace("/(tabs)");
} else {
console.error(
"Sign-in incomplete:",
JSON.stringify(signInAttempt, null, 2),
);
setError("Sign-in incomplete. Please try again.");
}
} catch (err: any) {
console.error("Sign-in error:", JSON.stringify(err, null, 2));
// Handle specific error codes
if (err.errors?.[0]?.code === "session_exists") {
// User is already signed in, just redirect
router.replace("/(tabs)");
return;
}
setError(
err.errors?.[0]?.message ||
"Failed to sign in. Please check your credentials.",
);
} finally {
setLoading(false);
}
};
// Don't render the form if already signed in
if (isSignedIn) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Redirecting...</Text>
</View>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Sign in to continue to FitAI</Text>
{error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
autoCapitalize="none"
value={emailAddress}
placeholder="Enter your email"
placeholderTextColor="#999"
onChangeText={setEmailAddress}
keyboardType="email-address"
autoComplete="email"
editable={!loading}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
placeholder="Enter your password"
placeholderTextColor="#999"
secureTextEntry={true}
onChangeText={setPassword}
autoComplete="password"
editable={!loading}
/>
</View>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={onSignInPress}
disabled={loading || !emailAddress || !password}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Don't have an account? </Text>
<TouchableOpacity
onPress={() => router.push("/(auth)/sign-up")}
disabled={loading}
>
<Text style={styles.linkText}>Sign Up</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
centerContent: {
justifyContent: "center",
alignItems: "center",
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: "#666",
},
scrollContent: {
flexGrow: 1,
justifyContent: "center",
},
content: {
flex: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 40,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#1a1a1a",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#666",
marginBottom: 32,
},
errorContainer: {
backgroundColor: "#fee",
padding: 12,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#f44",
},
errorText: {
color: "#c00",
fontSize: 14,
},
form: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
input: {
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: "#1a1a1a",
},
button: {
backgroundColor: "#2563eb",
paddingVertical: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
buttonDisabled: {
backgroundColor: "#93c5fd",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
footer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
footerText: {
fontSize: 14,
color: "#666",
},
linkText: {
fontSize: 14,
color: "#2563eb",
fontWeight: "600",
},
});
+375
View File
@@ -0,0 +1,375 @@
import React from "react";
import { useSignUp, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
} from "react-native";
export default function SignUpScreen() {
const { signUp, setActive, isLoaded } = useSignUp();
const { isSignedIn } = useAuth();
const router = useRouter();
const [emailAddress, setEmailAddress] = React.useState("");
const [password, setPassword] = React.useState("");
const [firstName, setFirstName] = React.useState("");
const [lastName, setLastName] = React.useState("");
const [pendingVerification, setPendingVerification] = React.useState(false);
const [code, setCode] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
// Redirect if already signed in
React.useEffect(() => {
if (isSignedIn) {
router.replace("/(tabs)");
}
}, [isSignedIn]);
const onSignUpPress = async () => {
if (!isLoaded) return;
setLoading(true);
setError("");
try {
await signUp.create({
emailAddress,
password,
firstName,
lastName,
});
// Send email verification code
await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
setPendingVerification(true);
} catch (err: any) {
console.error("Sign-up error:", JSON.stringify(err, null, 2));
setError(
err.errors?.[0]?.message || "Failed to sign up. Please try again.",
);
} finally {
setLoading(false);
}
};
const onVerifyPress = async () => {
if (!isLoaded) return;
setLoading(true);
setError("");
try {
const completeSignUp = await signUp.attemptEmailAddressVerification({
code,
});
if (completeSignUp.status === "complete") {
await setActive({ session: completeSignUp.createdSessionId });
router.replace("/(tabs)");
} else {
console.error(
"Verification incomplete:",
JSON.stringify(completeSignUp, null, 2),
);
setError("Verification incomplete. Please try again.");
}
} catch (err: any) {
console.error("Verification error:", JSON.stringify(err, null, 2));
// Handle specific error codes
if (err.errors?.[0]?.code === "session_exists") {
// User is already signed in, just redirect
router.replace("/(tabs)");
return;
}
setError(err.errors?.[0]?.message || "Invalid verification code.");
} finally {
setLoading(false);
}
};
// Don't render the form if already signed in
if (isSignedIn) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Redirecting...</Text>
</View>
);
}
if (pendingVerification) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text style={styles.title}>Verify Email</Text>
<Text style={styles.subtitle}>
Enter the verification code sent to {emailAddress}
</Text>
{error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>Verification Code</Text>
<TextInput
style={styles.input}
value={code}
placeholder="Enter verification code"
placeholderTextColor="#999"
onChangeText={setCode}
keyboardType="number-pad"
editable={!loading}
/>
</View>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={onVerifyPress}
disabled={loading || !code}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Verify Email</Text>
)}
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Sign up to get started with FitAI</Text>
{error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>First Name</Text>
<TextInput
style={styles.input}
value={firstName}
placeholder="Enter your first name"
placeholderTextColor="#999"
onChangeText={setFirstName}
autoComplete="given-name"
editable={!loading}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Last Name</Text>
<TextInput
style={styles.input}
value={lastName}
placeholder="Enter your last name"
placeholderTextColor="#999"
onChangeText={setLastName}
autoComplete="family-name"
editable={!loading}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
autoCapitalize="none"
value={emailAddress}
placeholder="Enter your email"
placeholderTextColor="#999"
onChangeText={setEmailAddress}
keyboardType="email-address"
autoComplete="email"
editable={!loading}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
placeholder="Create a password"
placeholderTextColor="#999"
secureTextEntry={true}
onChangeText={setPassword}
autoComplete="password-new"
editable={!loading}
/>
<Text style={styles.hint}>Must be at least 8 characters</Text>
</View>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={onSignUpPress}
disabled={
loading || !emailAddress || !password || !firstName || !lastName
}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Already have an account? </Text>
<TouchableOpacity
onPress={() => router.push("/(auth)/sign-in")}
disabled={loading}
>
<Text style={styles.linkText}>Sign In</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
centerContent: {
justifyContent: "center",
alignItems: "center",
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: "#666",
},
scrollContent: {
flexGrow: 1,
justifyContent: "center",
},
content: {
flex: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 40,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#1a1a1a",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#666",
marginBottom: 32,
},
errorContainer: {
backgroundColor: "#fee",
padding: 12,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#f44",
},
errorText: {
color: "#c00",
fontSize: 14,
},
form: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
input: {
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: "#1a1a1a",
},
hint: {
fontSize: 12,
color: "#999",
marginTop: 4,
},
button: {
backgroundColor: "#2563eb",
paddingVertical: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
buttonDisabled: {
backgroundColor: "#93c5fd",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
footer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
footerText: {
fontSize: 14,
color: "#666",
},
linkText: {
fontSize: 14,
color: "#2563eb",
fontWeight: "600",
},
});
+52 -16
View File
@@ -1,36 +1,72 @@
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "@clerk/clerk-expo";
import { useEffect } from "react";
export default function TabLayout() {
const { isSignedIn, isLoaded } = useAuth();
const router = useRouter();
const segments = useSegments();
useEffect(() => {
if (!isLoaded) return;
const inAuthGroup = segments[0] === "(auth)";
if (!isSignedIn && !inAuthGroup) {
// Redirect to sign-in if not authenticated
router.replace("/(auth)/sign-in");
}
}, [isSignedIn, isLoaded, segments]);
if (!isLoaded || !isSignedIn) {
return null;
}
return (
<Tabs>
<Tabs
screenOptions={{
tabBarActiveTintColor: "#2563eb",
tabBarInactiveTintColor: "#6b7280",
headerShown: true,
headerStyle: {
backgroundColor: "#fff",
},
headerTitleStyle: {
fontWeight: "600",
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
title: "Home",
headerTitle: "FitAI",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: 'Attendance',
title: "Attendance",
headerTitle: "Attendance",
tabBarIcon: ({ color, size }) => (
<Ionicons name="calendar" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
headerTitle: "My Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
)
}
);
}
+274 -26
View File
@@ -1,45 +1,293 @@
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { useRequireAuth } from '@/hooks/useRequireAuth'
import React from "react";
import { View, Text, StyleSheet, ScrollView } from "react-native";
import { useUser } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
export default function HomeScreen() {
const { user } = useRequireAuth()
const { user, isLoaded } = useUser();
if (!user) {
return null
if (!isLoaded || !user) {
return (
<View style={styles.container}>
<Text>Loading...</Text>
</View>
);
}
const firstName = user.firstName || "User";
const greeting = getGreeting();
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome back!</Text>
<Text style={styles.subtitle}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
)
<ScrollView style={styles.container}>
<View style={styles.content}>
{/* Welcome Header */}
<View style={styles.header}>
<Text style={styles.greeting}>{greeting}!</Text>
<Text style={styles.name}>{firstName}</Text>
</View>
{/* Quick Stats */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Ionicons name="calendar-outline" size={32} color="#2563eb" />
<Text style={styles.statValue}>0</Text>
<Text style={styles.statLabel}>This Month</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="flame-outline" size={32} color="#ef4444" />
<Text style={styles.statValue}>0</Text>
<Text style={styles.statLabel}>Day Streak</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="trophy-outline" size={32} color="#f59e0b" />
<Text style={styles.statValue}>0</Text>
<Text style={styles.statLabel}>Total Visits</Text>
</View>
</View>
{/* Quick Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>Check In</Text>
<Text style={styles.actionSubtitle}>
Start your workout session
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="calendar-outline" size={24} color="#10b981" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>View Schedule</Text>
<Text style={styles.actionSubtitle}>
Check your upcoming classes
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="card-outline" size={24} color="#8b5cf6" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>Payments</Text>
<Text style={styles.actionSubtitle}>View payment history</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
</View>
{/* Membership Info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Membership</Text>
<View style={styles.membershipCard}>
<View style={styles.membershipHeader}>
<Text style={styles.membershipType}>Basic Plan</Text>
<View style={styles.statusBadge}>
<Text style={styles.statusText}>Active</Text>
</View>
</View>
<Text style={styles.membershipEmail}>
{user.primaryEmailAddress?.emailAddress}
</Text>
<Text style={styles.membershipDate}>
Member since {new Date(user.createdAt!).toLocaleDateString()}
</Text>
</View>
</View>
{/* Recent Activity */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Recent Activity</Text>
<View style={styles.emptyState}>
<Ionicons name="barbell-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyStateText}>No recent activity</Text>
<Text style={styles.emptyStateSubtext}>
Check in to start tracking your workouts
</Text>
</View>
</View>
</View>
</ScrollView>
);
}
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
backgroundColor: "#f5f5f5",
},
title: {
content: {
padding: 20,
},
header: {
marginBottom: 24,
},
greeting: {
fontSize: 16,
color: "#6b7280",
marginBottom: 4,
},
name: {
fontSize: 32,
fontWeight: 'bold',
fontWeight: "bold",
color: "#1a1a1a",
},
statsContainer: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
},
statCard: {
flex: 1,
backgroundColor: "white",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginHorizontal: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
statValue: {
fontSize: 24,
fontWeight: "bold",
color: "#1a1a1a",
marginTop: 8,
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: "#6b7280",
textAlign: "center",
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 12,
},
actionButton: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "white",
borderRadius: 12,
padding: 16,
marginBottom: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
actionIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "#f0f9ff",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
actionContent: {
flex: 1,
},
actionTitle: {
fontSize: 16,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 2,
},
actionSubtitle: {
fontSize: 14,
color: "#6b7280",
},
membershipCard: {
backgroundColor: "white",
borderRadius: 12,
padding: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
membershipHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
subtitle: {
fontSize: 24,
fontWeight: '600',
membershipType: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
},
statusBadge: {
backgroundColor: "#dcfce7",
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 12,
fontWeight: "600",
color: "#16a34a",
},
membershipEmail: {
fontSize: 14,
color: "#6b7280",
marginBottom: 4,
color: '#333',
},
email: {
membershipDate: {
fontSize: 12,
color: "#9ca3af",
},
emptyState: {
backgroundColor: "white",
borderRadius: 12,
padding: 32,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
emptyStateText: {
fontSize: 16,
color: '#666',
fontWeight: "600",
color: "#6b7280",
marginTop: 12,
marginBottom: 4,
},
})
emptyStateSubtext: {
fontSize: 14,
color: "#9ca3af",
textAlign: "center",
},
});
+266 -75
View File
@@ -1,116 +1,307 @@
import React from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ScrollView,
} from "react-native";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function ProfileScreen() {
const { user, logout } = useAuth()
const router = useRouter()
const { user } = useUser();
const { signOut } = useAuth();
const router = useRouter();
const handleLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try {
await logout()
router.replace('/login')
} catch (error) {
Alert.alert('Error', 'Failed to logout')
}
},
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
await signOut();
router.replace("/(auth)/sign-in");
} catch (error) {
Alert.alert("Error", "Failed to sign out");
}
},
]
)
},
]);
};
if (!user) {
return (
<View style={styles.container}>
<Text>Loading...</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.profileCard}>
<Text style={styles.title}>Profile</Text>
<Text style={styles.name}>
{user?.firstName} {user?.lastName}
</Text>
<Text style={styles.email}>{user?.email}</Text>
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>}
<View style={styles.roleBadge}>
<Text style={styles.roleText}>
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
</Text>
</View>
</View>
<ScrollView style={styles.container}>
<View style={styles.content}>
{/* Profile Header */}
<View style={styles.profileCard}>
<View style={styles.avatarContainer}>
{user.imageUrl ? (
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0)}
</Text>
</View>
) : (
<View style={styles.avatar}>
<Ionicons name="person" size={40} color="#fff" />
</View>
)}
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
)
<Text style={styles.name}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.email}>
{user.primaryEmailAddress?.emailAddress}
</Text>
{user.primaryPhoneNumber && (
<Text style={styles.phone}>
{user.primaryPhoneNumber.phoneNumber}
</Text>
)}
</View>
{/* Account Information */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account Information</Text>
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons name="mail-outline" size={20} color="#666" />
<Text style={styles.infoLabelText}>Email</Text>
</View>
<Text style={styles.infoValue}>
{user.primaryEmailAddress?.emailAddress}
</Text>
</View>
{user.primaryPhoneNumber && (
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons name="call-outline" size={20} color="#666" />
<Text style={styles.infoLabelText}>Phone</Text>
</View>
<Text style={styles.infoValue}>
{user.primaryPhoneNumber.phoneNumber}
</Text>
</View>
)}
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons name="calendar-outline" size={20} color="#666" />
<Text style={styles.infoLabelText}>Member Since</Text>
</View>
<Text style={styles.infoValue}>
{new Date(user.createdAt!).toLocaleDateString()}
</Text>
</View>
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons
name="shield-checkmark-outline"
size={20}
color="#666"
/>
<Text style={styles.infoLabelText}>Email Verified</Text>
</View>
<Text style={styles.infoValue}>
{user.primaryEmailAddress?.verification?.status === "verified"
? "Yes"
: "No"}
</Text>
</View>
</View>
{/* Quick Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="person-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Edit Profile</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="notifications-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Notifications</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="card-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Payment History</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="settings-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Settings</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={20} color="#fff" />
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
{/* App Version */}
<Text style={styles.version}>Version 1.0.0</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
content: {
padding: 20,
backgroundColor: '#f5f5f5',
},
profileCard: {
backgroundColor: 'white',
borderRadius: 12,
backgroundColor: "white",
borderRadius: 16,
padding: 24,
alignItems: 'center',
shadowColor: '#000',
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
shadowRadius: 8,
elevation: 4,
marginBottom: 24,
},
title: {
fontSize: 24,
fontWeight: 'bold',
avatarContainer: {
marginBottom: 16,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "#2563eb",
justifyContent: "center",
alignItems: "center",
},
avatarText: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
},
name: {
fontSize: 20,
fontWeight: '600',
fontSize: 24,
fontWeight: "bold",
color: "#1a1a1a",
marginBottom: 4,
},
email: {
fontSize: 16,
color: '#666',
color: "#666",
marginBottom: 4,
},
phone: {
fontSize: 16,
color: '#666',
fontSize: 14,
color: "#999",
},
section: {
backgroundColor: "white",
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 16,
},
roleBadge: {
backgroundColor: '#3b82f6',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
roleText: {
color: 'white',
infoLabel: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
infoLabelText: {
fontSize: 14,
fontWeight: '600',
color: "#666",
fontWeight: "500",
},
infoValue: {
fontSize: 14,
color: "#1a1a1a",
fontWeight: "500",
},
actionButton: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
actionButtonText: {
flex: 1,
fontSize: 16,
color: "#1a1a1a",
marginLeft: 12,
fontWeight: "500",
},
logoutButton: {
backgroundColor: '#ef4444',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginTop: 24,
backgroundColor: "#ef4444",
paddingVertical: 16,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 8,
marginTop: 8,
marginBottom: 16,
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
logoutText: {
color: 'white',
color: "white",
fontSize: 16,
fontWeight: '600',
fontWeight: "600",
},
})
version: {
textAlign: "center",
fontSize: 12,
color: "#999",
marginTop: 8,
marginBottom: 20,
},
});
+49 -12
View File
@@ -1,15 +1,52 @@
import { AuthProvider } from '@/contexts/AuthContext'
import { Stack } from 'expo-router'
import { View, Text } from 'react-native'
import { ClerkProvider, ClerkLoaded } from "@clerk/clerk-expo";
import { Stack } from "expo-router";
import * as SecureStore from "expo-secure-store";
import { View, Text } from "react-native";
// Token cache for Clerk
const tokenCache = {
async getToken(key: string) {
try {
return SecureStore.getItemAsync(key);
} catch (err) {
console.error("Error getting token:", err);
return null;
}
},
async saveToken(key: string, value: string) {
try {
return SecureStore.setItemAsync(key, value);
} catch (err) {
console.error("Error saving token:", err);
}
},
};
export default function RootLayout() {
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
if (!publishableKey) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Missing Clerk Publishable Key</Text>
<Text
style={{ marginTop: 8, textAlign: "center", paddingHorizontal: 20 }}
>
Please add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file
</Text>
</View>
);
}
return (
<AuthProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="register" options={{ headerShown: false }} />
</Stack>
</AuthProvider>
)
}
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<Stack>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="welcome" options={{ headerShown: false }} />
</Stack>
</ClerkLoaded>
</ClerkProvider>
);
}
-161
View File
@@ -1,161 +0,0 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import * as SecureStore from 'expo-secure-store'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
export default function LoginScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleLogin = async () => {
if (!formData.email || !formData.password) {
Alert.alert('Error', 'Please fill in all fields')
return
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData)
if (response.data.user) {
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user))
// Check if user has completed fitness profile
try {
const profileResponse = await axios.get(
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}?userId=${response.data.user.id}`
)
if (profileResponse.data.profile) {
// User has profile, go to main app
Alert.alert('Success', 'Login successful!', [
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
])
} else {
// New user, go to welcome page
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
{ text: 'OK', onPress: () => router.replace('/welcome') }
])
}
} catch (profileError) {
// Profile doesn't exist or server error, treat as new user
console.log('Profile check failed:', profileError)
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
{ text: 'OK', onPress: () => router.replace('/welcome') }
])
}
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Login to your FitAI account</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Logging in...' : 'Login'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/register')}
>
<Text style={styles.linkText}>
Don't have an account? Register
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})
-164
View File
@@ -1,164 +0,0 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
export default function RegisterScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
phone: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleRegister = async () => {
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
Alert.alert('Error', 'Please fill in all required fields')
return
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.REGISTER}`, formData)
if (response.status === 201) {
Alert.alert('Success', 'Registration successful! Please login.', [
{ text: 'OK', onPress: () => router.push('/login') }
])
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join FitAI today</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="First Name"
value={formData.firstName}
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Last Name"
value={formData.lastName}
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Phone (optional)"
value={formData.phone}
onChangeText={(text) => setFormData({ ...formData, phone: text })}
keyboardType="phone-pad"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Creating Account...' : 'Create Account'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/login')}
>
<Text style={styles.linkText}>
Already have an account? Login
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})
-76
View File
@@ -1,76 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import * as SecureStore from 'expo-secure-store'
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
phone?: string
}
interface AuthContextType {
user: User | null
login: (user: User) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadUser()
}, [])
const loadUser = async () => {
try {
const userData = await SecureStore.getItemAsync('user')
if (userData) {
setUser(JSON.parse(userData))
}
} catch (error) {
console.error('Failed to load user:', error)
} finally {
setIsLoading(false)
}
}
const login = async (userData: User) => {
try {
await SecureStore.setItemAsync('user', JSON.stringify(userData))
setUser(userData)
} catch (error) {
console.error('Failed to save user:', error)
throw error
}
}
const logout = async () => {
try {
await SecureStore.deleteItemAsync('user')
setUser(null)
} catch (error) {
console.error('Failed to logout:', error)
throw error
}
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
+22 -11
View File
@@ -1,16 +1,27 @@
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { useAuth, useUser } from "@clerk/clerk-expo";
import { useRouter, useSegments } from "expo-router";
import { useEffect } from "react";
export function useRequireAuth() {
const { user, isLoading } = useAuth()
const router = useRouter()
const { isLoaded, isSignedIn } = useAuth();
const { user } = useUser();
const router = useRouter();
const segments = useSegments();
useEffect(() => {
if (!isLoading && !user) {
router.replace('/login')
}
}, [user, isLoading, router])
if (!isLoaded) return;
return { user, isLoading }
}
const inAuthGroup = segments[0] === "(auth)";
if (!isSignedIn && !inAuthGroup) {
// Redirect to sign-in if not authenticated
router.replace("/(auth)/sign-in");
}
}, [isSignedIn, isLoaded, segments]);
return {
user,
isLoading: !isLoaded,
isSignedIn,
};
}