3 and 4 completed

This commit is contained in:
dimitar committed 2026-03-11 02:38:43 +01:00
1 parent 3573709d99
commit 97436c6823
21 files changed
+2168 -558

No files matched your search

+346 -228
View File
@@ -1,88 +1,101 @@
import { useEffect, useState } from "react";
import React, { useState, useCallback } from "react";
import {
View,
Text,
FlatList,
ActivityIndicator,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
ActivityIndicator,
Alert,
} 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 { Ionicons } from "@expo/vector-icons";
import { useUser } from "@clerk/clerk-expo";
import { useFocusEffect } from "expo-router";
import { theme } from "../../styles/theme";
import {
getRecommendations,
generateRecommendation,
type Recommendation,
} from "../../api/recommendations";
import log from "../../utils/logger";
interface Recommendation {
id: string;
userId: string;
recommendationText: string;
activityPlan?: string;
dietPlan?: string;
status: string;
createdAt: string;
}
export default function RecommendationsScreen() {
const { getToken, userId } = useAuth();
const { user } = useUser();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const fetchRecommendations = async () => {
const loadRecommendations = useCallback(async () => {
if (!user?.id) return;
try {
if (!userId) {
log.warn("No userId available");
return;
}
const token = await getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
log.apiRequest("GET", url);
const res = await fetch(url, { headers });
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();
log.debug("Recommendations fetched", {
count: data.recommendations?.length || data.length || 0,
});
setRecommendations(data.recommendations || data || []);
} catch (e) {
log.error("Failed to load recommendations", e);
setLoading(true);
const data = await getRecommendations(user.id);
// Filter to show only approved recommendations for regular users
const approved = data.filter((rec) => rec.status === "approved");
setRecommendations(approved);
} catch (error) {
log.error("Failed to load recommendations", error);
Alert.alert("Error", "Failed to load recommendations");
} finally {
setLoading(false);
setRefreshing(false);
}
};
}, [user?.id]);
useEffect(() => {
fetchRecommendations();
}, []);
useFocusEffect(
useCallback(() => {
loadRecommendations();
}, [loadRecommendations]),
);
const onRefresh = () => {
const onRefresh = async () => {
setRefreshing(true);
fetchRecommendations();
await loadRecommendations();
setRefreshing(false);
};
if (loading) {
const handleGenerateRecommendation = async () => {
if (!user?.id) return;
Alert.alert(
"Generate AI Recommendation",
"Generate a personalized fitness and nutrition plan based on your profile and goals?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Generate",
onPress: async () => {
try {
setGenerating(true);
await generateRecommendation({
userId: user.id,
modelProvider: "openai",
useExternalModel: true,
});
Alert.alert(
"Success",
"AI recommendation generated! It will appear here once approved by your trainer.",
);
await loadRecommendations();
} catch (error) {
log.error("Failed to generate recommendation", error);
Alert.alert(
"Error",
"Failed to generate recommendation. Please try again.",
);
} finally {
setGenerating(false);
}
},
},
],
);
};
if (loading && recommendations.length === 0) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={theme.colors.primary} />
@@ -92,101 +105,179 @@ export default function RecommendationsScreen() {
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}
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.colors.primary}
/>
}
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>
>
{/* Header */}
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<View>
<Text style={styles.headerTitle}>AI Recommendations</Text>
<Text style={styles.headerSubtitle}>
Personalized fitness & nutrition plans
</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.iconContainer}>
<Ionicons name="sparkles" size={32} color="#fff" />
</View>
</LinearGradient>
{/* Generate Button */}
<View style={styles.actionContainer}>
<TouchableOpacity
onPress={handleGenerateRecommendation}
disabled={generating}
activeOpacity={0.8}
>
<View style={styles.cardHeader}>
<LinearGradient
colors={theme.gradients.purple}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.generateButton, theme.shadows.medium]}
>
{generating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons
name="bulb"
size={24}
color="#fff"
style={{ marginRight: 12 }}
/>
<Text style={styles.generateButtonText}>
Generate New Plan
</Text>
</>
)}
</LinearGradient>
</TouchableOpacity>
</View>
{/* Recommendations List */}
<View style={styles.section}>
{recommendations.length === 0 ? (
<View style={styles.emptyState}>
<LinearGradient
colors={theme.gradients.success}
style={styles.statusBadge}
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
style={styles.emptyCard}
>
<Text style={styles.statusText}>
{item.status.toUpperCase()}
<Ionicons
name="sparkles-outline"
size={64}
color={theme.colors.purple}
/>
<Text style={styles.emptyTitle}>No Recommendations Yet</Text>
<Text style={styles.emptyText}>
Tap "Generate New Plan" to get personalized AI-powered fitness
and nutrition recommendations based on your profile and goals.
</Text>
</LinearGradient>
<Text style={styles.date}>
{new Date(item.createdAt).toLocaleDateString()}
</View>
) : (
recommendations.map((recommendation) => (
<RecommendationCard
key={recommendation.id}
recommendation={recommendation}
/>
))
)}
</View>
</ScrollView>
</View>
);
}
interface RecommendationCardProps {
recommendation: Recommendation;
}
function RecommendationCard({ recommendation }: RecommendationCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<View style={styles.card}>
<LinearGradient
colors={["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"]}
style={[styles.cardContent, theme.shadows.medium]}
>
{/* Header */}
<View style={styles.cardHeader}>
<View style={styles.cardHeaderLeft}>
<LinearGradient
colors={theme.gradients.success}
style={styles.cardIcon}
>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
</LinearGradient>
<View>
<Text style={styles.cardTitle}>AI Fitness Plan</Text>
<Text style={styles.cardDate}>
{new Date(recommendation.generatedAt).toLocaleDateString()}
</Text>
</View>
</View>
<TouchableOpacity onPress={() => setExpanded(!expanded)}>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={24}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
<Text style={styles.sectionTitle}>Daily Advice</Text>
<Text style={styles.content}>{item.recommendationText}</Text>
{/* Summary */}
<View style={styles.cardSummary}>
<Text
style={styles.summaryText}
numberOfLines={expanded ? undefined : 3}
>
{recommendation.recommendationText}
</Text>
</View>
{item.activityPlan && (
<>
<Text style={styles.sectionTitle}>Activity Plan</Text>
<Text style={styles.content}>{item.activityPlan}</Text>
</>
)}
{/* Expanded Content */}
{expanded && (
<View style={styles.expandedContent}>
{/* Activity Plan */}
<View style={styles.planSection}>
<View style={styles.planHeader}>
<Ionicons
name="barbell"
size={20}
color={theme.colors.primary}
/>
<Text style={styles.planTitle}>Activity Plan</Text>
</View>
<Text style={styles.planText}>{recommendation.activityPlan}</Text>
</View>
{item.dietPlan && (
<>
<Text style={styles.sectionTitle}>Diet Plan</Text>
<Text style={styles.content}>{item.dietPlan}</Text>
</>
)}
</LinearGradient>
{/* Diet Plan */}
<View style={styles.planSection}>
<View style={styles.planHeader}>
<Ionicons
name="restaurant"
size={20}
color={theme.colors.success}
/>
<Text style={styles.planTitle}>Diet Plan</Text>
</View>
<Text style={styles.planText}>{recommendation.dietPlan}</Text>
</View>
</View>
)}
/>
</LinearGradient>
</View>
);
}
@@ -196,10 +287,23 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: theme.colors.background,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.background,
},
scrollContent: {
paddingBottom: 100,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 24,
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 24,
marginBottom: 20,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
@@ -208,112 +312,126 @@ const styles = StyleSheet.create({
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
},
infoBanner: {
headerSubtitle: {
fontSize: theme.typography.fontSize.base,
color: "rgba(255, 255, 255, 0.9)",
marginTop: 4,
},
iconContainer: {
backgroundColor: "rgba(255, 255, 255, 0.2)",
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
},
actionContainer: {
paddingHorizontal: 20,
marginBottom: 20,
},
generateButton: {
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",
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: theme.borderRadius.xl,
},
generateButtonText: {
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
},
section: {
paddingHorizontal: 20,
},
emptyState: {
paddingVertical: 40,
},
emptyCard: {
borderRadius: theme.borderRadius["2xl"],
padding: 32,
alignItems: "center",
},
infoBannerText: {
flex: 1,
fontSize: theme.typography.fontSize.sm,
emptyTitle: {
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray700,
lineHeight: 18,
fontWeight: theme.typography.fontWeight.medium,
marginTop: 16,
marginBottom: 8,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.background,
},
listContent: {
padding: 16,
emptyText: {
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray500,
textAlign: "center",
lineHeight: 24,
},
card: {
padding: 18,
marginBottom: 14,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: "rgba(59, 130, 246, 0.1)",
marginBottom: 16,
},
cardContent: {
borderRadius: theme.borderRadius["2xl"],
padding: 20,
},
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,
cardHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
cardIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
},
empty: {
textAlign: "center",
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray700,
fontWeight: theme.typography.fontWeight.semibold,
marginBottom: 4,
cardTitle: {
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray800,
},
emptySub: {
textAlign: "center",
cardDate: {
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray500,
marginTop: 2,
},
cardSummary: {
marginBottom: 12,
},
summaryText: {
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray700,
lineHeight: 24,
},
expandedContent: {
marginTop: 12,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: theme.colors.gray200,
},
planSection: {
marginBottom: 16,
},
planHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 8,
},
planTitle: {
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray800,
},
planText: {
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray600,
lineHeight: 22,
},
});