phase 1-6 implemented
This commit is contained in:
1 parent
22c274bb83
commit
2c4927478a
73 files changed
+9702
-2354
No files matched your search
@@ -1,10 +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
|
||||
# IMPORTANT: Must start with EXPO_PUBLIC_ to be available in the app
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
|
||||
# API Configuration
|
||||
# Update this to point to your backend API
|
||||
# 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
|
||||
|
||||
# App Configuration
|
||||
|
||||
+11
-2
@@ -39,6 +39,15 @@ android/app/build/generated/
|
||||
# Bundle artifact
|
||||
*.jsbundle
|
||||
|
||||
# Environment
|
||||
# Environment variables (all .env files)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
import { getErrorMessage } from "../utils/error-helpers";
|
||||
import log from "../utils/logger";
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
@@ -20,10 +22,7 @@ export const fitnessProfileApi = {
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
console.log(
|
||||
"Getting fitness profile with URL:",
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
);
|
||||
log.debug("Getting fitness profile", { userId });
|
||||
const response = await apiClient.get(
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
{
|
||||
@@ -33,12 +32,8 @@ export const fitnessProfileApi = {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error fetching fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to fetch fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -59,12 +54,8 @@ export const fitnessProfileApi = {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error updating fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to update fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -84,12 +75,8 @@ export const fitnessProfileApi = {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error creating fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to create fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const { user } = useUser();
|
||||
@@ -37,7 +38,7 @@ export default function OnboardingScreen() {
|
||||
setGyms(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch gyms:", e);
|
||||
log.error("Failed to fetch gyms", e);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export default function OnboardingScreen() {
|
||||
body: JSON.stringify({ gymId: selectedGymId }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Failed to update gym selection:", e);
|
||||
log.warn("Failed to update gym selection", { gymId: selectedGymId });
|
||||
}
|
||||
|
||||
const fitnessData = {
|
||||
@@ -108,7 +109,7 @@ export default function OnboardingScreen() {
|
||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||
router.replace("/(tabs)");
|
||||
} catch (error) {
|
||||
console.error("Error creating fitness profile:", error);
|
||||
log.error("Failed to create fitness profile", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to create fitness profile. Please try again.",
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { OAuthButtons } from "../../components/auth/OAuthButtons";
|
||||
import { getErrorMessage, getClerkErrorCode } from "../../utils/error-helpers";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function SignInScreen() {
|
||||
const { signIn, setActive, isLoaded } = useSignIn();
|
||||
@@ -47,25 +49,24 @@ export default function SignInScreen() {
|
||||
await setActive({ session: signInAttempt.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
console.error(
|
||||
"Sign-in incomplete:",
|
||||
JSON.stringify(signInAttempt, null, 2),
|
||||
);
|
||||
log.warn("Sign-in incomplete", { status: signInAttempt.status });
|
||||
setError("Sign-in incomplete. Please try again.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Sign-in error:", JSON.stringify(err, null, 2));
|
||||
} catch (err: unknown) {
|
||||
log.error("Sign-in failed", err);
|
||||
|
||||
// Handle specific error codes
|
||||
if (err.errors?.[0]?.code === "session_exists") {
|
||||
if (getClerkErrorCode(err) === "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.",
|
||||
getErrorMessage(
|
||||
err,
|
||||
"Failed to sign in. Please check your credentials.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { OAuthButtons } from "../../components/auth/OAuthButtons";
|
||||
import { getErrorMessage, getClerkErrorCode } from "../../utils/error-helpers";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function SignUpScreen() {
|
||||
const { signUp, setActive, isLoaded } = useSignUp();
|
||||
@@ -53,11 +55,9 @@ export default function SignUpScreen() {
|
||||
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.",
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
log.error("Failed to sign up", err);
|
||||
setError(getErrorMessage(err, "Failed to sign up. Please try again."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,23 +78,20 @@ export default function SignUpScreen() {
|
||||
await setActive({ session: completeSignUp.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
console.error(
|
||||
"Verification incomplete:",
|
||||
JSON.stringify(completeSignUp, null, 2),
|
||||
);
|
||||
log.warn("Verification incomplete", { status: completeSignUp.status });
|
||||
setError("Verification incomplete. Please try again.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Verification error:", JSON.stringify(err, null, 2));
|
||||
} catch (err: unknown) {
|
||||
log.error("Verification failed", err);
|
||||
|
||||
// Handle specific error codes
|
||||
if (err.errors?.[0]?.code === "session_exists") {
|
||||
if (getClerkErrorCode(err) === "session_exists") {
|
||||
// User is already signed in, just redirect
|
||||
router.replace("/(tabs)");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.errors?.[0]?.message || "Invalid verification code.");
|
||||
setError(getErrorMessage(err, "Invalid verification code."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { attendanceApi, Attendance } from '../../api/attendance'
|
||||
import { theme } from '../../styles/theme'
|
||||
import { Animated } from 'react-native'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { attendanceApi, Attendance } from "../../api/attendance";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { Animated } from "react-native";
|
||||
import { getErrorMessage } from "../../utils/error-helpers";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const { getToken, userId } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
|
||||
const [history, setHistory] = useState<Attendance[]>([])
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current
|
||||
const { getToken, userId } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
||||
const [history, setHistory] = useState<Attendance[]>([]);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCheckIn) {
|
||||
@@ -28,75 +38,75 @@ export default function AttendanceScreen() {
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
pulse.start()
|
||||
return () => pulse.stop()
|
||||
]),
|
||||
);
|
||||
pulse.start();
|
||||
return () => pulse.stop();
|
||||
}
|
||||
}, [activeCheckIn])
|
||||
}, [activeCheckIn]);
|
||||
|
||||
const fetchAttendance = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
setLoading(true);
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
console.log('Fetching attendance history')
|
||||
const data = await attendanceApi.getHistory(token)
|
||||
setHistory(data)
|
||||
log.debug("Fetching attendance history");
|
||||
const data = await attendanceApi.getHistory(token);
|
||||
setHistory(data);
|
||||
|
||||
// Check if there's an active check-in (latest one has no checkOutTime)
|
||||
if (data.length > 0 && !data[0].checkOutTime) {
|
||||
setActiveCheckIn(data[0])
|
||||
setActiveCheckIn(data[0]);
|
||||
} else {
|
||||
setActiveCheckIn(null)
|
||||
setActiveCheckIn(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendance:', error)
|
||||
Alert.alert('Error', 'Failed to load attendance data')
|
||||
log.error("Failed to fetch attendance", error);
|
||||
Alert.alert("Error", "Failed to load attendance data");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendance()
|
||||
}, [])
|
||||
fetchAttendance();
|
||||
}, []);
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
await attendanceApi.checkIn('gym', token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked in successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-in error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check in')
|
||||
await attendanceApi.checkIn("gym", token);
|
||||
fetchAttendance();
|
||||
Alert.alert("Success", "Checked in successfully!");
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to check in", error);
|
||||
Alert.alert("Error", getErrorMessage(error, "Failed to check in"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
await attendanceApi.checkOut(token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked out successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-out error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check out')
|
||||
await attendanceApi.checkOut(token);
|
||||
fetchAttendance();
|
||||
Alert.alert("Success", "Checked out successfully!");
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to check out", error);
|
||||
Alert.alert("Error", getErrorMessage(error, "Failed to check out"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !history.length) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,7 +124,7 @@ export default function AttendanceScreen() {
|
||||
<View style={styles.actionContainer}>
|
||||
{activeCheckIn ? (
|
||||
<LinearGradient
|
||||
colors={['rgba(16, 185, 129, 0.15)', 'rgba(5, 150, 105, 0.1)']}
|
||||
colors={["rgba(16, 185, 129, 0.15)", "rgba(5, 150, 105, 0.1)"]}
|
||||
style={[styles.activeCard, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.activeCardContent}>
|
||||
@@ -129,7 +139,11 @@ export default function AttendanceScreen() {
|
||||
<View style={styles.activeTextContainer}>
|
||||
<Text style={styles.activeText}>Currently Checked In</Text>
|
||||
<Text style={styles.timeText}>
|
||||
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
Since{" "}
|
||||
{new Date(activeCheckIn.checkInTime).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -140,7 +154,12 @@ export default function AttendanceScreen() {
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.checkOutButton, theme.shadows.medium]}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Ionicons
|
||||
name="log-out-outline"
|
||||
size={20}
|
||||
color="#fff"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.buttonText}>Check Out</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -154,7 +173,12 @@ export default function AttendanceScreen() {
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.checkInButton, theme.shadows.glow]}
|
||||
>
|
||||
<Ionicons name="log-in-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Ionicons
|
||||
name="log-in-outline"
|
||||
size={24}
|
||||
color="#fff"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.checkInButtonText}>Check In</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -166,13 +190,17 @@ export default function AttendanceScreen() {
|
||||
{history.map((item) => (
|
||||
<LinearGradient
|
||||
key={item.id}
|
||||
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
|
||||
colors={["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const}
|
||||
style={[styles.historyItem, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.historyLeft}>
|
||||
<View style={styles.historyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={item.checkOutTime ? theme.gradients.success : theme.gradients.primary}
|
||||
colors={
|
||||
item.checkOutTime
|
||||
? theme.gradients.success
|
||||
: theme.gradients.primary
|
||||
}
|
||||
style={styles.historyIcon}
|
||||
>
|
||||
<Ionicons
|
||||
@@ -191,18 +219,26 @@ export default function AttendanceScreen() {
|
||||
</View>
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.historyTime}>
|
||||
In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
In:{" "}
|
||||
{new Date(item.checkInTime).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
{item.checkOutTime && (
|
||||
<Text style={styles.historyTime}>
|
||||
Out: {new Date(item.checkOutTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
Out:{" "}
|
||||
{new Date(item.checkOutTime).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -215,8 +251,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
@@ -227,14 +263,14 @@ const styles = StyleSheet.create({
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontSize: theme.typography.fontSize["3xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
},
|
||||
actionContainer: {
|
||||
marginBottom: 32,
|
||||
@@ -244,9 +280,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
checkInButtonText: {
|
||||
color: theme.colors.white,
|
||||
@@ -257,9 +293,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonText: {
|
||||
@@ -271,11 +307,11 @@ const styles = StyleSheet.create({
|
||||
padding: 20,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(16, 185, 129, 0.2)',
|
||||
borderColor: "rgba(16, 185, 129, 0.2)",
|
||||
},
|
||||
activeCardContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
activeIconContainer: {
|
||||
@@ -285,8 +321,8 @@ const styles = StyleSheet.create({
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
activeTextContainer: {
|
||||
flex: 1,
|
||||
@@ -313,15 +349,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
historyLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
historyIconContainer: {
|
||||
@@ -331,8 +367,8 @@ const styles = StyleSheet.create({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
dateText: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
@@ -345,10 +381,10 @@ const styles = StyleSheet.create({
|
||||
marginTop: 2,
|
||||
},
|
||||
timeContainer: {
|
||||
alignItems: 'flex-end',
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
historyTime: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
})
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../../services/fitnessGoals";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const { user } = useUser();
|
||||
@@ -35,11 +36,11 @@ export default function GoalsScreen() {
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const token = await getToken();
|
||||
console.log(
|
||||
"Token obtained:",
|
||||
token ? "Yes (" + token.substring(0, 20) + "...)" : "No",
|
||||
);
|
||||
console.log("User ID:", user.id);
|
||||
log.debug("Token obtained", {
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 20) + "..." : "No",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Decode and log token details for debugging
|
||||
if (token) {
|
||||
@@ -47,21 +48,20 @@ export default function GoalsScreen() {
|
||||
const parts = token.split(".");
|
||||
if (parts.length === 3) {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
console.log("Token issuer:", payload.iss);
|
||||
console.log(
|
||||
"Token kid from header:",
|
||||
JSON.parse(atob(parts[0])).kid,
|
||||
);
|
||||
log.debug("Token details", {
|
||||
issuer: payload.iss,
|
||||
kid: JSON.parse(atob(parts[0])).kid,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Could not decode token");
|
||||
log.warn("Could not decode token");
|
||||
}
|
||||
}
|
||||
|
||||
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
||||
setGoals(loadedGoals);
|
||||
} catch (error) {
|
||||
console.error("Error loading goals:", error);
|
||||
log.error("Failed to load goals", error);
|
||||
}
|
||||
}, [user?.id, getToken]);
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function GoalsScreen() {
|
||||
"Cache cleared! Please sign out and sign back in.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error clearing cache:", error);
|
||||
log.error("Failed to clear cache", error);
|
||||
Alert.alert("Error", "Failed to clear cache");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AnimatedButton } from "../../components/AnimatedButton";
|
||||
import { GradientBackground } from "../../components/GradientBackground";
|
||||
import { useState, useEffect } from "react";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user } = useUser();
|
||||
@@ -27,7 +28,7 @@ export default function ProfileScreen() {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (err) {
|
||||
console.error("Error signing out:", err);
|
||||
log.error("Failed to sign out", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,26 +70,32 @@ export default function ProfileScreen() {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`;
|
||||
console.log("Profile.loadGyms fetching:", url);
|
||||
log.debug("Loading gyms", { url });
|
||||
const res = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Failed to fetch gyms: non-OK response", {
|
||||
status: res.status,
|
||||
body: text?.slice(0, 200),
|
||||
});
|
||||
log.error(
|
||||
"Failed to fetch gyms - non-OK response",
|
||||
new Error(text.slice(0, 200)),
|
||||
{
|
||||
status: res.status,
|
||||
},
|
||||
);
|
||||
setGyms([]);
|
||||
return;
|
||||
}
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Failed to fetch gyms: expected JSON", {
|
||||
contentType,
|
||||
body: text?.slice(0, 200),
|
||||
});
|
||||
log.error(
|
||||
"Failed to fetch gyms - expected JSON",
|
||||
new Error(text.slice(0, 200)),
|
||||
{
|
||||
contentType,
|
||||
},
|
||||
);
|
||||
setGyms([]);
|
||||
return;
|
||||
}
|
||||
@@ -97,12 +104,9 @@ export default function ProfileScreen() {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error(
|
||||
"Failed to parse gyms JSON:",
|
||||
e,
|
||||
"body:",
|
||||
text?.slice(0, 200),
|
||||
);
|
||||
log.error("Failed to parse gyms JSON", e, {
|
||||
bodyPreview: text?.slice(0, 200),
|
||||
});
|
||||
setGyms([]);
|
||||
return;
|
||||
}
|
||||
@@ -119,7 +123,7 @@ export default function ProfileScreen() {
|
||||
if (selectedGymId === null) setSelectedGymId(gid);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch gyms:", err);
|
||||
log.error("Failed to fetch gyms", err);
|
||||
setGyms([]);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
@@ -130,12 +134,7 @@ export default function ProfileScreen() {
|
||||
try {
|
||||
const token = await getToken();
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.USERS}/gym`;
|
||||
console.log(
|
||||
"Profile.handleApplyGym PATCH:",
|
||||
url,
|
||||
"gymId:",
|
||||
selectedGymId,
|
||||
);
|
||||
log.debug("Updating gym selection", { url, gymId: selectedGymId });
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -147,25 +146,25 @@ export default function ProfileScreen() {
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Failed to update gym selection: non-OK response", {
|
||||
status: res.status,
|
||||
body: text?.slice(0, 200),
|
||||
});
|
||||
log.error(
|
||||
"Failed to update gym selection - non-OK response",
|
||||
new Error(text.slice(0, 200)),
|
||||
{
|
||||
status: res.status,
|
||||
},
|
||||
);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
return;
|
||||
}
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
console.log("Gym selection updated:", data);
|
||||
log.debug("Gym selection updated", { data });
|
||||
} catch (e) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error(
|
||||
"Failed to parse update response JSON:",
|
||||
e,
|
||||
"body:",
|
||||
text?.slice(0, 200),
|
||||
);
|
||||
log.error("Failed to parse update response JSON", e, {
|
||||
bodyPreview: text?.slice(0, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Update current gym state for immediate UI reflection
|
||||
@@ -179,14 +178,14 @@ export default function ProfileScreen() {
|
||||
try {
|
||||
await (user as any)?.reload?.();
|
||||
} catch (e) {
|
||||
console.log("Profile.handleApplyGym: failed to reload user", e);
|
||||
log.debug("Failed to reload user after gym update", { error: e });
|
||||
}
|
||||
Alert.alert(
|
||||
"Success",
|
||||
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to update gym selection:", err);
|
||||
log.error("Failed to update gym selection", err);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,291 +1,319 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||
import { theme } from "../../styles/theme";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function RecommendationsScreen() {
|
||||
const { getToken, userId } = useAuth();
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { getToken, userId } = useAuth();
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
if (!userId) {
|
||||
console.error('No userId available');
|
||||
return;
|
||||
}
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
if (!userId) {
|
||||
log.warn("No userId available");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const token = await getToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
|
||||
console.log('Fetching recommendations from:', url);
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
|
||||
log.apiRequest("GET", url);
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
const res = await fetch(url, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error('API Error:', res.status, errorText);
|
||||
throw new Error(`Network response was not ok: ${res.status}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
log.error("API error fetching recommendations", {
|
||||
status: res.status,
|
||||
errorText,
|
||||
});
|
||||
throw new Error(`Network response was not ok: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log('Recommendations data:', data);
|
||||
setRecommendations(data.recommendations || data || []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load recommendations', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchRecommendations();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
const data = await res.json();
|
||||
log.debug("Recommendations fetched", {
|
||||
count: data.recommendations?.length || data.length || 0,
|
||||
});
|
||||
setRecommendations(data.recommendations || data || []);
|
||||
} catch (e) {
|
||||
log.error("Failed to load recommendations", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchRecommendations();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
||||
</LinearGradient>
|
||||
|
||||
{/* AI Context Info Banner with Glassmorphism */}
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.15)', 'rgba(139, 92, 246, 0.1)'] as const}
|
||||
style={styles.infoBanner}
|
||||
>
|
||||
<View style={styles.infoBannerIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.infoBannerIcon}
|
||||
>
|
||||
<Ionicons name="sparkles" size={16} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.infoBannerText}>
|
||||
Personalized based on your active fitness goals and progress
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(209, 213, 219, 0.3)', 'rgba(209, 213, 219, 0.1)'] as const}
|
||||
style={styles.emptyIconGradient}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.statusBadge}
|
||||
>
|
||||
<Text style={styles.statusText}>{item.status.toUpperCase()}</Text>
|
||||
</LinearGradient>
|
||||
<Text style={styles.date}>{new Date(item.createdAt).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
||||
|
||||
{item.activityPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Activity Plan</Text>
|
||||
<Text style={styles.content}>{item.activityPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.dietPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Diet Plan</Text>
|
||||
<Text style={styles.content}>{item.dietPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
||||
</LinearGradient>
|
||||
|
||||
{/* AI Context Info Banner with Glassmorphism */}
|
||||
<LinearGradient
|
||||
colors={
|
||||
["rgba(59, 130, 246, 0.15)", "rgba(139, 92, 246, 0.1)"] as const
|
||||
}
|
||||
style={styles.infoBanner}
|
||||
>
|
||||
<View style={styles.infoBannerIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.infoBannerIcon}
|
||||
>
|
||||
<Ionicons name="sparkles" size={16} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.infoBannerText}>
|
||||
Personalized based on your active fitness goals and progress
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"rgba(209, 213, 219, 0.3)",
|
||||
"rgba(209, 213, 219, 0.1)",
|
||||
] as const
|
||||
}
|
||||
style={styles.emptyIconGradient}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<LinearGradient
|
||||
colors={
|
||||
["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const
|
||||
}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.statusBadge}
|
||||
>
|
||||
<Text style={styles.statusText}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
<Text style={styles.date}>
|
||||
{new Date(item.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
||||
|
||||
{item.activityPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Activity Plan</Text>
|
||||
<Text style={styles.content}>{item.activityPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.dietPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Diet Plan</Text>
|
||||
<Text style={styles.content}>{item.dietPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
infoBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.2)',
|
||||
gap: 10,
|
||||
},
|
||||
infoBannerIconContainer: {
|
||||
marginRight: 4,
|
||||
},
|
||||
infoBannerIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoBannerText: {
|
||||
flex: 1,
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 18,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.gray200,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
content: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIconGradient: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: theme.colors.gray700,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptySub: {
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize["3xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
infoBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.2)",
|
||||
gap: 10,
|
||||
},
|
||||
infoBannerIconContainer: {
|
||||
marginRight: 4,
|
||||
},
|
||||
infoBannerIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
infoBannerText: {
|
||||
flex: 1,
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 18,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.gray200,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
content: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIconGradient: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
empty: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: theme.colors.gray700,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptySub: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
});
|
||||
@@ -3,39 +3,56 @@ import { Stack } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { View, Text } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { validateEnv } from "../utils/env";
|
||||
import log from "../utils/logger";
|
||||
|
||||
console.log("========================================");
|
||||
console.log("🚀 _layout.tsx loaded at:", new Date().toISOString());
|
||||
console.log(
|
||||
"📦 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY:",
|
||||
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
console.log("========================================");
|
||||
// Validate environment variables on app startup
|
||||
try {
|
||||
const env = validateEnv();
|
||||
log.info("Environment validation passed", {
|
||||
appName: env.EXPO_PUBLIC_APP_NAME,
|
||||
apiUrl: env.EXPO_PUBLIC_API_URL,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Environment validation failed", error);
|
||||
// In development, show the error but allow app to continue
|
||||
// In production, this will crash the app (as it should)
|
||||
if (!__DEV__) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Token cache for Clerk
|
||||
const tokenCache = {
|
||||
async getToken(key: string) {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
console.log(`[TokenCache] Getting key: ${key}, exists: ${!!value}`);
|
||||
if (value && value.length > 50) {
|
||||
console.log(`[TokenCache] Value preview: ${value.substring(0, 50)}...`);
|
||||
}
|
||||
log.debug("Getting token from cache", {
|
||||
key,
|
||||
exists: !!value,
|
||||
preview:
|
||||
value && value.length > 50
|
||||
? value.substring(0, 50) + "..."
|
||||
: undefined,
|
||||
});
|
||||
return value;
|
||||
} catch (err) {
|
||||
console.error("Error getting token:", err);
|
||||
log.error("Failed to get token from cache", err, { key });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async saveToken(key: string, value: string) {
|
||||
try {
|
||||
console.log(`[TokenCache] Saving key: ${key}`);
|
||||
if (value && value.length > 50) {
|
||||
console.log(`[TokenCache] Value preview: ${value.substring(0, 50)}...`);
|
||||
}
|
||||
log.debug("Saving token to cache", {
|
||||
key,
|
||||
preview:
|
||||
value && value.length > 50
|
||||
? value.substring(0, 50) + "..."
|
||||
: undefined,
|
||||
});
|
||||
return SecureStore.setItemAsync(key, value);
|
||||
} catch (err) {
|
||||
console.error("Error saving token:", err);
|
||||
log.error("Failed to save token to cache", err, { key });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -44,18 +61,17 @@ export default function RootLayout() {
|
||||
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
const [cacheCleared, setCacheCleared] = useState(false);
|
||||
|
||||
console.log(
|
||||
"🔑 [RootLayout] Component rendering with publishableKey:",
|
||||
publishableKey,
|
||||
);
|
||||
log.debug("RootLayout rendering", {
|
||||
hasPublishableKey: !!publishableKey,
|
||||
});
|
||||
|
||||
// TEMPORARY: Clear Clerk cache on app start to fix instance mismatch
|
||||
useEffect(() => {
|
||||
console.log("⚡ [RootLayout] useEffect triggered - starting cache cleanup");
|
||||
log.debug("Starting cache cleanup");
|
||||
|
||||
const clearOldClerkCache = async () => {
|
||||
try {
|
||||
console.log("🔍 [RootLayout] Checking for old Clerk tokens...");
|
||||
log.debug("Checking for old Clerk tokens");
|
||||
|
||||
const keysToCheck = [
|
||||
"__clerk_client_jwt",
|
||||
@@ -70,34 +86,30 @@ export default function RootLayout() {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
if (value) {
|
||||
console.log(`📌 [RootLayout] Found token at ${key}`);
|
||||
console.log(
|
||||
`📝 [RootLayout] Token preview: ${value.substring(0, 80)}...`,
|
||||
);
|
||||
log.debug("Found token", {
|
||||
key,
|
||||
preview: value.substring(0, 80) + "...",
|
||||
});
|
||||
|
||||
// Check if it's from the old instance
|
||||
if (value.includes("pleasing-pheasant-20")) {
|
||||
console.log(`🗑️ [RootLayout] DELETING old token from ${key}`);
|
||||
log.debug("Deleting old token", { key });
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
} else if (value.includes("needed-elephant-64")) {
|
||||
console.log(
|
||||
`✅ [RootLayout] Token is from correct instance (needed-elephant-64)`,
|
||||
);
|
||||
log.debug("Token is from correct instance");
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ [RootLayout] Token doesn't match known instances`,
|
||||
);
|
||||
log.warn("Token doesn't match known instances", { key });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ [RootLayout] Error checking key ${key}:`, e);
|
||||
log.error("Error checking token key", e, { key });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ [RootLayout] Old token cleanup complete");
|
||||
log.info("Old token cleanup complete");
|
||||
setCacheCleared(true);
|
||||
} catch (error) {
|
||||
console.error("❌ [RootLayout] Error clearing old cache:", error);
|
||||
log.error("Error clearing old cache", error);
|
||||
setCacheCleared(true); // Continue anyway
|
||||
}
|
||||
};
|
||||
@@ -106,7 +118,7 @@ export default function RootLayout() {
|
||||
}, []);
|
||||
|
||||
if (!publishableKey) {
|
||||
console.log("❌ [RootLayout] No publishable key found!");
|
||||
log.error("No Clerk publishable key found");
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Missing Clerk Publishable Key</Text>
|
||||
@@ -120,7 +132,7 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
if (!cacheCleared) {
|
||||
console.log("⏳ [RootLayout] Waiting for cache to clear...");
|
||||
log.debug("Waiting for cache to clear");
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Clearing old authentication cache...</Text>
|
||||
@@ -131,10 +143,9 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"🚀 [RootLayout] Rendering ClerkProvider with key:",
|
||||
publishableKey?.substring(0, 20) + "...",
|
||||
);
|
||||
log.debug("Rendering ClerkProvider", {
|
||||
keyPreview: publishableKey?.substring(0, 20) + "...",
|
||||
});
|
||||
|
||||
return (
|
||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||
|
||||
+170
-134
@@ -1,105 +1,118 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { profileApi } from '../api/profile'
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { profileApi } from "../api/profile";
|
||||
import { getErrorMessage } from "../utils/error-helpers";
|
||||
|
||||
interface FitnessProfile {
|
||||
height: string
|
||||
weight: string
|
||||
age: string
|
||||
gender: 'male' | 'female' | 'other'
|
||||
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active'
|
||||
fitnessGoals: string[]
|
||||
exerciseHabits: string
|
||||
dietHabits: string
|
||||
medicalConditions: string
|
||||
height: string;
|
||||
weight: string;
|
||||
age: string;
|
||||
gender: "male" | "female" | "other";
|
||||
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
|
||||
fitnessGoals: string[];
|
||||
exerciseHabits: string;
|
||||
dietHabits: string;
|
||||
medicalConditions: string;
|
||||
}
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const [profile, setProfile] = useState<FitnessProfile>({
|
||||
height: '',
|
||||
weight: '',
|
||||
age: '',
|
||||
gender: 'male',
|
||||
activityLevel: 'moderate',
|
||||
height: "",
|
||||
weight: "",
|
||||
age: "",
|
||||
gender: "male",
|
||||
activityLevel: "moderate",
|
||||
fitnessGoals: [],
|
||||
exerciseHabits: '',
|
||||
dietHabits: '',
|
||||
medicalConditions: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { getToken } = useAuth()
|
||||
exerciseHabits: "",
|
||||
dietHabits: "",
|
||||
medicalConditions: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { getToken } = useAuth();
|
||||
|
||||
const fitnessGoalsOptions = [
|
||||
'Weight Loss',
|
||||
'Muscle Gain',
|
||||
'Improve Endurance',
|
||||
'Better Flexibility',
|
||||
'General Fitness',
|
||||
'Strength Training',
|
||||
'Cardio Health'
|
||||
]
|
||||
"Weight Loss",
|
||||
"Muscle Gain",
|
||||
"Improve Endurance",
|
||||
"Better Flexibility",
|
||||
"General Fitness",
|
||||
"Strength Training",
|
||||
"Cardio Health",
|
||||
];
|
||||
|
||||
const activityLevels = [
|
||||
{ value: 'sedentary', label: 'Sedentary (little or no exercise)' },
|
||||
{ value: 'light', label: 'Light (1-3 days/week)' },
|
||||
{ value: 'moderate', label: 'Moderate (3-5 days/week)' },
|
||||
{ value: 'active', label: 'Active (6-7 days/week)' },
|
||||
{ value: 'very_active', label: 'Very Active (twice per day)' }
|
||||
]
|
||||
const activityLevels: Array<{
|
||||
value: FitnessProfile["activityLevel"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "sedentary", label: "Sedentary (little or no exercise)" },
|
||||
{ value: "light", label: "Light (1-3 days/week)" },
|
||||
{ value: "moderate", label: "Moderate (3-5 days/week)" },
|
||||
{ value: "active", label: "Active (6-7 days/week)" },
|
||||
{ value: "very_active", label: "Very Active (twice per day)" },
|
||||
];
|
||||
|
||||
const toggleGoal = (goal: string) => {
|
||||
setProfile(prev => ({
|
||||
setProfile((prev) => ({
|
||||
...prev,
|
||||
fitnessGoals: prev.fitnessGoals.includes(goal)
|
||||
? prev.fitnessGoals.filter(g => g !== goal)
|
||||
: [...prev.fitnessGoals, goal]
|
||||
}))
|
||||
}
|
||||
? prev.fitnessGoals.filter((g) => g !== goal)
|
||||
: [...prev.fitnessGoals, goal],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!profile.height || !profile.weight || !profile.age) {
|
||||
Alert.alert('Error', 'Please fill in all required fields')
|
||||
return
|
||||
Alert.alert("Error", "Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getToken()
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication required')
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const user = await SecureStore.getItemAsync('user')
|
||||
const user = await SecureStore.getItemAsync("user");
|
||||
if (!user) {
|
||||
throw new Error('No user found')
|
||||
throw new Error("No user found");
|
||||
}
|
||||
|
||||
const userData = JSON.parse(user)
|
||||
const userData = JSON.parse(user);
|
||||
|
||||
await profileApi.createFitnessProfile(
|
||||
{
|
||||
userId: userData.id,
|
||||
...profile
|
||||
...profile,
|
||||
},
|
||||
token
|
||||
)
|
||||
token,
|
||||
);
|
||||
|
||||
Alert.alert('Success', 'Profile completed successfully!', [
|
||||
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
|
||||
])
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('Profile save error:', error)
|
||||
Alert.alert('Error', error.response?.data?.error || 'Failed to save profile. Please try again.')
|
||||
Alert.alert("Success", "Profile completed successfully!", [
|
||||
{ text: "OK", onPress: () => router.replace("/(tabs)") },
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
console.log("Profile save error:", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
getErrorMessage(error, "Failed to save profile. Please try again."),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
@@ -116,7 +129,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={profile.height}
|
||||
onChangeText={(text) => setProfile({ ...profile, height: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, height: text })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="170"
|
||||
/>
|
||||
@@ -127,7 +142,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={profile.weight}
|
||||
onChangeText={(text) => setProfile({ ...profile, weight: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, weight: text })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="70"
|
||||
/>
|
||||
@@ -149,19 +166,22 @@ export default function WelcomeScreen() {
|
||||
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
|
||||
<Text style={styles.label}>Gender</Text>
|
||||
<View style={styles.genderRow}>
|
||||
{(['male', 'female', 'other'] as const).map((gender) => (
|
||||
{(["male", "female", "other"] as const).map((gender) => (
|
||||
<TouchableOpacity
|
||||
key={gender}
|
||||
style={[
|
||||
styles.genderButton,
|
||||
profile.gender === gender && styles.genderButtonSelected
|
||||
profile.gender === gender && styles.genderButtonSelected,
|
||||
]}
|
||||
onPress={() => setProfile({ ...profile, gender })}
|
||||
>
|
||||
<Text style={[
|
||||
styles.genderButtonText,
|
||||
profile.gender === gender && styles.genderButtonTextSelected
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.genderButtonText,
|
||||
profile.gender === gender &&
|
||||
styles.genderButtonTextSelected,
|
||||
]}
|
||||
>
|
||||
{gender.charAt(0).toUpperCase() + gender.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -178,14 +198,20 @@ export default function WelcomeScreen() {
|
||||
key={level.value}
|
||||
style={[
|
||||
styles.activityOption,
|
||||
profile.activityLevel === level.value && styles.activityOptionSelected
|
||||
profile.activityLevel === level.value &&
|
||||
styles.activityOptionSelected,
|
||||
]}
|
||||
onPress={() => setProfile({ ...profile, activityLevel: level.value as any })}
|
||||
onPress={() =>
|
||||
setProfile({ ...profile, activityLevel: level.value })
|
||||
}
|
||||
>
|
||||
<Text style={[
|
||||
styles.activityText,
|
||||
profile.activityLevel === level.value && styles.activityTextSelected
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.activityText,
|
||||
profile.activityLevel === level.value &&
|
||||
styles.activityTextSelected,
|
||||
]}
|
||||
>
|
||||
{level.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -201,14 +227,18 @@ export default function WelcomeScreen() {
|
||||
key={goal}
|
||||
style={[
|
||||
styles.goalButton,
|
||||
profile.fitnessGoals.includes(goal) && styles.goalButtonSelected
|
||||
profile.fitnessGoals.includes(goal) &&
|
||||
styles.goalButtonSelected,
|
||||
]}
|
||||
onPress={() => toggleGoal(goal)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.goalButtonText,
|
||||
profile.fitnessGoals.includes(goal) && styles.goalButtonTextSelected
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.goalButtonText,
|
||||
profile.fitnessGoals.includes(goal) &&
|
||||
styles.goalButtonTextSelected,
|
||||
]}
|
||||
>
|
||||
{goal}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -221,7 +251,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={profile.exerciseHabits}
|
||||
onChangeText={(text) => setProfile({ ...profile, exerciseHabits: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, exerciseHabits: text })
|
||||
}
|
||||
placeholder="Describe your current exercise routine..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
@@ -233,7 +265,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={profile.dietHabits}
|
||||
onChangeText={(text) => setProfile({ ...profile, dietHabits: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, dietHabits: text })
|
||||
}
|
||||
placeholder="Describe your current eating habits..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
@@ -245,7 +279,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={profile.medicalConditions}
|
||||
onChangeText={(text) => setProfile({ ...profile, medicalConditions: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, medicalConditions: text })
|
||||
}
|
||||
placeholder="Any medical conditions we should know about..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
@@ -258,51 +294,51 @@ export default function WelcomeScreen() {
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Saving...' : 'Complete Profile'}
|
||||
{loading ? "Saving..." : "Complete Profile"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontWeight: "600",
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
color: "#333",
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
flexDirection: "row",
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
@@ -310,105 +346,105 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
fontWeight: "500",
|
||||
marginBottom: 6,
|
||||
color: '#333',
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: "white",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
genderRow: {
|
||||
flexDirection: 'row',
|
||||
flexDirection: "row",
|
||||
},
|
||||
genderButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
marginRight: 4,
|
||||
},
|
||||
genderButtonSelected: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
genderButtonText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
},
|
||||
genderButtonTextSelected: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
},
|
||||
activityOption: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: "white",
|
||||
},
|
||||
activityOptionSelected: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
activityText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
color: "#333",
|
||||
},
|
||||
activityTextSelected: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
},
|
||||
goalsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginHorizontal: -4,
|
||||
},
|
||||
goalButton: {
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: "white",
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
margin: 4,
|
||||
},
|
||||
goalButtonSelected: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
goalButtonText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
},
|
||||
goalButtonTextSelected: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#9ca3af',
|
||||
backgroundColor: "#9ca3af",
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontWeight: "600",
|
||||
},
|
||||
})
|
||||
});
|
||||
@@ -1,6 +1,27 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? "https://fd87cefe27ae.ngrok-free.app"
|
||||
: "https://your-production-url.com";
|
||||
// Use environment variable for API URL
|
||||
// Set EXPO_PUBLIC_API_URL in your .env file
|
||||
const getApiBaseUrl = (): string => {
|
||||
const envUrl = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
if (envUrl) {
|
||||
return envUrl;
|
||||
}
|
||||
|
||||
// Fallback for development if not set
|
||||
if (__DEV__) {
|
||||
console.warn(
|
||||
"EXPO_PUBLIC_API_URL not set in .env - using default localhost",
|
||||
);
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
// Production MUST have URL set
|
||||
throw new Error(
|
||||
"EXPO_PUBLIC_API_URL must be set in production environment variables",
|
||||
);
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
|
||||
@@ -1,153 +1,187 @@
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../config/api';
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
|
||||
import log from "../utils/logger";
|
||||
|
||||
export interface FitnessGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType: 'weight_target' | 'strength_milestone' | 'endurance_target' | 'flexibility_goal' | 'habit_building' | 'custom';
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: string;
|
||||
targetDate?: string;
|
||||
completedDate?: string;
|
||||
status: 'active' | 'completed' | 'abandoned' | 'paused';
|
||||
progress: number;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType:
|
||||
| "weight_target"
|
||||
| "strength_milestone"
|
||||
| "endurance_target"
|
||||
| "flexibility_goal"
|
||||
| "habit_building"
|
||||
| "custom";
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: string;
|
||||
targetDate?: string;
|
||||
completedDate?: string;
|
||||
status: "active" | "completed" | "abandoned" | "paused";
|
||||
progress: number;
|
||||
priority: "low" | "medium" | "high";
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateGoalData {
|
||||
goalType: FitnessGoal['goalType'];
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
targetDate?: string;
|
||||
priority?: FitnessGoal['priority'];
|
||||
notes?: string;
|
||||
goalType: FitnessGoal["goalType"];
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
targetDate?: string;
|
||||
priority?: FitnessGoal["priority"];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class FitnessGoalsService {
|
||||
private async getAuthHeaders(token: string | null): Promise<any> {
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
private async getAuthHeaders(token: string | null): Promise<any> {
|
||||
const headers: any = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
async getGoals(userId: string, token: string | null, status?: string): Promise<FitnessGoal[]> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
async getGoals(
|
||||
userId: string,
|
||||
token: string | null,
|
||||
status?: string,
|
||||
): Promise<FitnessGoal[]> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (status && status !== "all") {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch goals: ${response.status}`);
|
||||
}
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goals:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch goals: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch fitness goals", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createGoal(goalData: CreateGoalData, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(goalData),
|
||||
});
|
||||
async createGoal(
|
||||
goalData: CreateGoalData,
|
||||
token: string | null,
|
||||
): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(goalData),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to create goal');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create goal");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error creating fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to create fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGoal(id: string, updates: Partial<FitnessGoal>, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
async updateGoal(
|
||||
id: string,
|
||||
updates: Partial<FitnessGoal>,
|
||||
token: string | null,
|
||||
): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(updates),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update goal');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update goal");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error updating fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to update fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateProgress(id: string, currentValue: number, token: string | null): Promise<FitnessGoal> {
|
||||
return this.updateGoal(id, { currentValue }, token);
|
||||
async updateProgress(
|
||||
id: string,
|
||||
currentValue: number,
|
||||
token: string | null,
|
||||
): Promise<FitnessGoal> {
|
||||
return this.updateGoal(id, { currentValue }, token);
|
||||
}
|
||||
|
||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to complete goal");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to complete fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to complete goal');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error completing fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete goal');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete goal");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fitnessGoalsService = new FitnessGoalsService();
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Environment variable validation for mobile app
|
||||
* Validates that all required environment variables are set
|
||||
*/
|
||||
|
||||
interface RequiredEnvVars {
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: string;
|
||||
EXPO_PUBLIC_API_URL: string;
|
||||
}
|
||||
|
||||
interface OptionalEnvVars {
|
||||
EXPO_PUBLIC_APP_NAME?: string;
|
||||
EXPO_PUBLIC_APP_VERSION?: string;
|
||||
NODE_ENV?: string;
|
||||
}
|
||||
|
||||
type EnvVars = RequiredEnvVars & OptionalEnvVars;
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
* Throws error if any required variable is missing
|
||||
*/
|
||||
export function validateEnv(): EnvVars {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required variables
|
||||
const requiredVars = [
|
||||
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
|
||||
"EXPO_PUBLIC_API_URL",
|
||||
] as const;
|
||||
|
||||
for (const varName of requiredVars) {
|
||||
if (!process.env[varName]) {
|
||||
errors.push(`Missing required environment variable: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = [
|
||||
"❌ Environment validation failed:",
|
||||
...errors,
|
||||
"",
|
||||
"Please check your .env file and ensure all required variables are set.",
|
||||
"See .env.example for the list of required variables.",
|
||||
"",
|
||||
"To create your .env file:",
|
||||
"1. Copy .env.example to .env",
|
||||
"2. Fill in the required values",
|
||||
].join("\n");
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
const apiUrl = process.env.EXPO_PUBLIC_API_URL!;
|
||||
try {
|
||||
new URL(apiUrl);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Invalid EXPO_PUBLIC_API_URL: "${apiUrl}". Must be a valid URL (e.g., http://192.168.1.100:3000)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!,
|
||||
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL!,
|
||||
EXPO_PUBLIC_APP_NAME: process.env.EXPO_PUBLIC_APP_NAME || "FitAI",
|
||||
EXPO_PUBLIC_APP_VERSION: process.env.EXPO_PUBLIC_APP_VERSION || "1.0.0",
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated environment variables
|
||||
* Caches the result after first validation
|
||||
*/
|
||||
let cachedEnv: EnvVars | null = null;
|
||||
|
||||
export function getEnv(): EnvVars {
|
||||
if (!cachedEnv) {
|
||||
cachedEnv = validateEnv();
|
||||
}
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
return __DEV__;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
return !__DEV__;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Error handling utilities for type-safe error processing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clerk error structure
|
||||
*/
|
||||
interface ClerkError {
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
code?: string;
|
||||
}>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if error is a Clerk error
|
||||
*/
|
||||
function isClerkError(error: unknown): error is ClerkError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
("errors" in error || "message" in error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error type
|
||||
* Handles Clerk errors, standard Error objects, and unknown types
|
||||
*
|
||||
* @param error - The caught error
|
||||
* @param fallback - Fallback message if no message can be extracted
|
||||
* @returns Error message string
|
||||
*/
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "An unknown error occurred",
|
||||
): string {
|
||||
// Handle Clerk errors with nested error array
|
||||
if (isClerkError(error)) {
|
||||
if (error.errors?.[0]?.message) {
|
||||
return error.errors[0].message;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle standard Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle objects with message property
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof (error as Record<string, unknown>).message === "string"
|
||||
) {
|
||||
return (error as { message: string }).message;
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Clerk error code if available
|
||||
*/
|
||||
export function getClerkErrorCode(error: unknown): string | undefined {
|
||||
if (isClerkError(error) && error.errors?.[0]?.code) {
|
||||
return error.errors[0].code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract gymId from Clerk user's public metadata
|
||||
*
|
||||
* @param metadata - Clerk publicMetadata object
|
||||
* @returns gymId string or null if not found
|
||||
*/
|
||||
export function getGymIdFromMetadata(metadata: unknown): string | null {
|
||||
if (
|
||||
typeof metadata === "object" &&
|
||||
metadata !== null &&
|
||||
"gymId" in metadata &&
|
||||
typeof (metadata as Record<string, unknown>).gymId === "string"
|
||||
) {
|
||||
return (metadata as { gymId: string }).gymId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Centralized logging utility for FitAI Mobile App
|
||||
* Provides structured logging with appropriate log levels
|
||||
*
|
||||
* In production, logs can be sent to a remote logging service
|
||||
* For now, we use console with prefixes for easy filtering
|
||||
*/
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
*/
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
/**
|
||||
* Check if we're in development mode
|
||||
*/
|
||||
const isDevelopment = __DEV__;
|
||||
|
||||
/**
|
||||
* Log level configuration
|
||||
* - Development: debug and above
|
||||
* - Production: info and above
|
||||
*/
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const currentLogLevel: LogLevel = isDevelopment ? "debug" : "info";
|
||||
const currentLogLevelValue = LOG_LEVELS[currentLogLevel];
|
||||
|
||||
/**
|
||||
* Check if a log level should be output
|
||||
*/
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= currentLogLevelValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log message with timestamp and level
|
||||
*/
|
||||
function formatMessage(level: LogLevel, message: string): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized logger
|
||||
*/
|
||||
export const log = {
|
||||
/**
|
||||
* Log debug information (only in development)
|
||||
*/
|
||||
debug: (message: string, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("debug")) {
|
||||
console.log(formatMessage("debug", message), data || "");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log general information
|
||||
*/
|
||||
info: (message: string, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("info")) {
|
||||
console.log(formatMessage("info", message), data || "");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log warnings
|
||||
*/
|
||||
warn: (message: string, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("warn")) {
|
||||
console.warn(formatMessage("warn", message), data || "");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log errors with optional error object
|
||||
*/
|
||||
error: (message: string, error?: unknown, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("error")) {
|
||||
const errorInfo: Record<string, unknown> = {
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorInfo.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error) {
|
||||
errorInfo.error = error;
|
||||
}
|
||||
|
||||
console.error(formatMessage("error", message), errorInfo);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API requests
|
||||
*/
|
||||
apiRequest: (method: string, url: string, data?: Record<string, unknown>) => {
|
||||
log.debug(`API ${method} ${url}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API responses
|
||||
*/
|
||||
apiResponse: (
|
||||
method: string,
|
||||
url: string,
|
||||
status: number,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
const level = status >= 400 ? "error" : status >= 300 ? "warn" : "info";
|
||||
if (level === "error") {
|
||||
log.error(`API ${method} ${url} - ${status}`, undefined, data);
|
||||
} else if (level === "warn") {
|
||||
log.warn(`API ${method} ${url} - ${status}`, data);
|
||||
} else {
|
||||
log.debug(`API ${method} ${url} - ${status}`, data);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log authentication events
|
||||
*/
|
||||
auth: (event: string, data?: Record<string, unknown>) => {
|
||||
log.info(`Auth: ${event}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log navigation events
|
||||
*/
|
||||
navigation: (screen: string, data?: Record<string, unknown>) => {
|
||||
log.debug(`Navigation: ${screen}`, data);
|
||||
},
|
||||
};
|
||||
|
||||
export default log;
|
||||
|
||||
/**
|
||||
* TODO: In production, consider integrating with:
|
||||
* - Sentry for error tracking
|
||||
* - LogRocket for session replay
|
||||
* - Custom logging service for analytics
|
||||
*/
|
||||
Reference in new issue
Block a user