p 1 and 2 compeleted

This commit is contained in:
dimitar committed 2026-03-11 01:43:19 +01:00
1 parent 6adb541967
commit 3573709d99
28 files changed
+2533 -240

No files matched your search

+34 -7
View File
@@ -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
+32 -22
View File
@@ -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: {
+72 -16
View File
@@ -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",
},
});
+3 -3
View File
@@ -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;
}
+36 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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",