p 1 and 2 compeleted
This commit is contained in:
1 parent
6adb541967
commit
3573709d99
28 files changed
+2533
-240
No files matched your search
@@ -1,14 +1,41 @@
|
||||
# =============================================================================
|
||||
# FitAI Mobile App Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values
|
||||
# All variables prefixed with EXPO_PUBLIC_ are accessible in the app code
|
||||
|
||||
# =============================================================================
|
||||
# Clerk Authentication
|
||||
# Get these values from https://dashboard.clerk.com
|
||||
# Make sure to use the correct publishable key for your environment
|
||||
# IMPORTANT: Must start with EXPO_PUBLIC_ to be available in the app
|
||||
# =============================================================================
|
||||
# Get your publishable key from https://dashboard.clerk.com
|
||||
# Navigate to: API Keys > Copy Publishable Key
|
||||
# IMPORTANT: Use the correct key for your environment (development/production)
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
|
||||
# =============================================================================
|
||||
# API Configuration
|
||||
# Development: Use ngrok or local network IP (e.g., http://192.168.1.100:3000)
|
||||
# Production: Use your production API URL
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000/api
|
||||
# =============================================================================
|
||||
# Backend API base URL (without /api suffix)
|
||||
#
|
||||
# Options:
|
||||
# 1. Local development (emulator/simulator on same machine):
|
||||
# EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
#
|
||||
# 2. Physical device or external testing (using ngrok):
|
||||
# EXPO_PUBLIC_API_URL=https://your-ngrok-url.ngrok-free.app
|
||||
# Start ngrok: ngrok http 3000
|
||||
#
|
||||
# 3. Local network (physical device on same WiFi):
|
||||
# EXPO_PUBLIC_API_URL=http://192.168.1.100:3000
|
||||
# Replace with your computer's local IP address
|
||||
#
|
||||
# 4. Production:
|
||||
# EXPO_PUBLIC_API_URL=https://api.yourapp.com
|
||||
#
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
|
||||
# App Configuration
|
||||
# =============================================================================
|
||||
# App Configuration (Optional)
|
||||
# =============================================================================
|
||||
EXPO_PUBLIC_APP_NAME=FitAI
|
||||
EXPO_PUBLIC_APP_VERSION=1.0.0
|
||||
@@ -5,47 +5,57 @@ import log from "../utils/logger";
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
goals?: string;
|
||||
fitnessLevel: "beginner" | "intermediate" | "advanced";
|
||||
age?: number;
|
||||
gender?: "male" | "female" | "other" | "prefer_not_to_say";
|
||||
fitnessGoals?: string[];
|
||||
activityLevel?:
|
||||
| "sedentary"
|
||||
| "lightly_active"
|
||||
| "moderately_active"
|
||||
| "very_active"
|
||||
| "extremely_active";
|
||||
medicalConditions?: string;
|
||||
dietaryRestrictions?: string;
|
||||
preferredWorkoutTime?: "morning" | "afternoon" | "evening";
|
||||
workoutFrequency?: number;
|
||||
allergies?: string;
|
||||
injuries?: string;
|
||||
preferences?: string;
|
||||
}
|
||||
|
||||
export const fitnessProfileApi = {
|
||||
getFitnessProfile: async (
|
||||
userId: string,
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
getFitnessProfile: async (token: string): Promise<FitnessProfile | null> => {
|
||||
try {
|
||||
log.debug("Getting fitness profile", { userId });
|
||||
const response = await apiClient.get(
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
log.debug("Getting fitness profile");
|
||||
const response = await apiClient.get(`${API_ENDPOINTS.PROFILE.FITNESS}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
});
|
||||
return response.data.profile || null;
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to fetch fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
checkProfileExists: async (token: string): Promise<boolean> => {
|
||||
try {
|
||||
const profile = await fitnessProfileApi.getFitnessProfile(token);
|
||||
return profile !== null;
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to check if fitness profile exists", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
updateFitnessProfile: async (
|
||||
userId: string,
|
||||
data: Partial<FitnessProfile>,
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
`${API_ENDPOINTS.PROFILE.FITNESS}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
@@ -66,7 +76,7 @@ export const fitnessProfileApi = {
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`${API_ENDPOINTS.USERS}/${data.clientId}/fitness-profile`,
|
||||
`${API_ENDPOINTS.PROFILE.FITNESS}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
||||
import { API_BASE_URL } from "@/config/api";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
@@ -30,7 +31,7 @@ export default function OnboardingScreen() {
|
||||
try {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const res = await fetch("/api/gyms", {
|
||||
const res = await fetch(`${API_BASE_URL}/api/gyms`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -48,6 +49,7 @@ export default function OnboardingScreen() {
|
||||
const [fitnessProfile, setFitnessProfile] = useState({
|
||||
height: "",
|
||||
weight: "",
|
||||
age: "",
|
||||
goals: "",
|
||||
fitnessLevel: "beginner",
|
||||
medicalConditions: "",
|
||||
@@ -63,8 +65,12 @@ export default function OnboardingScreen() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Validate required fields
|
||||
if (!fitnessProfile.height || !fitnessProfile.weight) {
|
||||
Alert.alert("Error", "Please enter your height and weight");
|
||||
if (
|
||||
!fitnessProfile.height ||
|
||||
!fitnessProfile.weight ||
|
||||
!fitnessProfile.age
|
||||
) {
|
||||
Alert.alert("Error", "Please enter your height, weight, and age");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +82,7 @@ export default function OnboardingScreen() {
|
||||
// If gym was selected or cleared, patch user's gym selection first
|
||||
// selectedGymId: string gym id, or null to proceed without gym
|
||||
try {
|
||||
await fetch("/api/users/gym", {
|
||||
await fetch(`${API_BASE_URL}/api/users/gym`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -89,21 +95,13 @@ export default function OnboardingScreen() {
|
||||
}
|
||||
|
||||
const fitnessData = {
|
||||
clientId: user.id,
|
||||
userId: user.id,
|
||||
height: parseFloat(fitnessProfile.height),
|
||||
weight: parseFloat(fitnessProfile.weight),
|
||||
goals: fitnessProfile.goals || undefined,
|
||||
fitnessLevel: fitnessProfile.fitnessLevel as
|
||||
| "beginner"
|
||||
| "intermediate"
|
||||
| "advanced",
|
||||
age: parseInt(fitnessProfile.age),
|
||||
fitnessGoals: fitnessProfile.goals ? [fitnessProfile.goals] : [],
|
||||
medicalConditions: fitnessProfile.medicalConditions || undefined,
|
||||
dietaryRestrictions: fitnessProfile.dietaryRestrictions || undefined,
|
||||
preferredWorkoutTime: fitnessProfile.preferredWorkoutTime as
|
||||
| "morning"
|
||||
| "afternoon"
|
||||
| "evening",
|
||||
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
||||
allergies: fitnessProfile.dietaryRestrictions || undefined,
|
||||
};
|
||||
|
||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||
@@ -127,6 +125,24 @@ export default function OnboardingScreen() {
|
||||
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>
|
||||
@@ -134,6 +150,14 @@ export default function OnboardingScreen() {
|
||||
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
|
||||
@@ -157,6 +181,17 @@ export default function OnboardingScreen() {
|
||||
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]}
|
||||
@@ -409,4 +444,25 @@ const styles = StyleSheet.create({
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export default function SignUpScreen() {
|
||||
// Redirect if already signed in
|
||||
useEffect(() => {
|
||||
if (isSignedIn) {
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/(auth)/onboarding");
|
||||
}
|
||||
}, [isSignedIn]);
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function SignUpScreen() {
|
||||
|
||||
if (completeSignUp.status === "complete") {
|
||||
await setActive({ session: completeSignUp.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/(auth)/onboarding");
|
||||
} else {
|
||||
log.warn("Verification incomplete", { status: completeSignUp.status });
|
||||
setError("Verification incomplete. Please try again.");
|
||||
@@ -87,7 +87,7 @@ export default function SignUpScreen() {
|
||||
// Handle specific error codes
|
||||
if (getClerkErrorCode(err) === "session_exists") {
|
||||
// User is already signed in, just redirect
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/(auth)/onboarding");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CustomTabBar } from "../../components/CustomTabBar";
|
||||
import { fitnessProfileApi } from "../../api/fitnessProfile";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isSignedIn, isLoaded } = useAuth();
|
||||
const { isSignedIn, isLoaded, getToken } = useAuth();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const [onboardingChecked, setOnboardingChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
@@ -16,10 +19,40 @@ export default function TabLayout() {
|
||||
if (!isSignedIn && !inAuthGroup) {
|
||||
// Redirect to sign-in if not authenticated
|
||||
router.replace("/(auth)/sign-in");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding
|
||||
if (isSignedIn && !onboardingChecked) {
|
||||
checkOnboardingStatus();
|
||||
}
|
||||
}, [isSignedIn, isLoaded, segments]);
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
log.warn("No token available for onboarding check");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasProfile = await fitnessProfileApi.checkProfileExists(token);
|
||||
|
||||
if (!hasProfile) {
|
||||
// User hasn't completed onboarding, redirect to onboarding screen
|
||||
log.info("User has not completed onboarding, redirecting");
|
||||
router.replace("/(auth)/onboarding");
|
||||
} else {
|
||||
setOnboardingChecked(true);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to check onboarding status", error);
|
||||
// On error, allow access (fail open to prevent blocking legitimate users)
|
||||
setOnboardingChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoaded || !isSignedIn || !onboardingChecked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const API_ENDPOINTS = {
|
||||
REGISTER: "/api/auth/register",
|
||||
},
|
||||
PROFILE: {
|
||||
FITNESS: "/api/profile/fitness",
|
||||
FITNESS: "/api/fitness-profile",
|
||||
},
|
||||
CLIENTS: "/api/clients",
|
||||
USERS: "/api/users",
|
||||
|
||||
Reference in new issue
Block a user