context and goals definition added

This commit is contained in:
echo 2025-11-26 01:30:15 +01:00
parent 28b5b52a8f
commit 803c205994
10 changed files with 1073 additions and 170 deletions

Binary file not shown.

View File

@ -28,7 +28,7 @@ try {
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles(id) ON DELETE CASCADE FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles(userId) ON DELETE CASCADE
) )
`); `);

View File

@ -11,6 +11,7 @@
"@clerk/clerk-expo": "^2.18.3", "@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0", "@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0", "@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1", "@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
@ -4029,6 +4030,29 @@
} }
} }
}, },
"node_modules/@react-native-community/datetimepicker": {
"version": "8.4.4",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": ">=52.0.0",
"react": "*",
"react-native": "*",
"react-native-windows": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"react-native-windows": {
"optional": true
}
}
},
"node_modules/@react-native-picker/picker": { "node_modules/@react-native-picker/picker": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",

View File

@ -17,6 +17,7 @@
"@clerk/clerk-expo": "^2.18.3", "@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0", "@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0", "@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1", "@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",

View File

@ -11,44 +11,26 @@ import {
} from "react-native"; } from "react-native";
import { useAuth } from "@clerk/clerk-expo"; import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api"; import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from "../../services/fitnessGoals";
import { GoalProgressCard } from "../../components/GoalProgressCard";
interface Recommendation { import { GoalCreationModal } from "../../components/GoalCreationModal";
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
content: string;
status: "pending" | "completed";
createdAt: string;
}
export default function GoalsScreen() { export default function GoalsScreen() {
const { userId, getToken } = useAuth(); const { userId, getToken } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]); const [goals, setGoals] = useState<FitnessGoal[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const fetchRecommendations = async () => { const fetchGoals = async () => {
if (!userId) return; if (!userId) return;
try { try {
const token = await getToken(); const token = await getToken();
const response = await fetch( const data = await fitnessGoalsService.getGoals(userId, token);
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`, setGoals(data);
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
} else {
console.error("Failed to fetch recommendations");
}
} catch (error) { } catch (error) {
console.error("Error fetching recommendations:", error); console.error("Error fetching fitness goals:", error);
Alert.alert("Error", "Failed to load goals. Please try again.");
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@ -56,99 +38,53 @@ export default function GoalsScreen() {
}; };
useEffect(() => { useEffect(() => {
fetchRecommendations(); fetchGoals();
}, [userId]); }, [userId]);
const onRefresh = () => { const onRefresh = () => {
setRefreshing(true); setRefreshing(true);
fetchRecommendations(); fetchGoals();
}; };
const toggleStatus = async (id: string, currentStatus: string) => { const handleCreateGoal = async (goalData: CreateGoalData) => {
const newStatus = currentStatus === "pending" ? "completed" : "pending";
// Optimistic update
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: newStatus } : r)
);
try { try {
const token = await getToken(); const token = await getToken();
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, { const newGoal = await fitnessGoalsService.createGoal(goalData, token);
method: "PUT", setGoals(prev => [newGoal, ...prev]);
headers: { Alert.alert("Success", "Goal created successfully!");
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
id,
status: newStatus,
}),
});
if (!response.ok) {
// Revert on failure
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
} catch (error) { } catch (error) {
console.error(error); console.error("Error creating goal:", error);
// Revert on error throw error;
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
} }
}; };
const renderSection = ( const handleCompleteGoal = async (goalId: string) => {
title: string, try {
type: "short_term" | "medium_term" | "long_term" const token = await getToken();
) => { const updatedGoal = await fitnessGoalsService.completeGoal(goalId, token);
const items = recommendations.filter((r) => r.type === type); setGoals(prev => prev.map(g => g.id === goalId ? updatedGoal : g));
Alert.alert("Success", "Goal completed! 🎉");
return ( } catch (error) {
<View style={styles.section}> console.error("Error completing goal:", error);
<Text style={styles.sectionTitle}>{title}</Text> Alert.alert("Error", "Failed to complete goal. Please try again.");
{items.length === 0 ? ( }
<Text style={styles.emptyText}>No goals set yet.</Text>
) : (
items.map((rec) => (
<TouchableOpacity
key={rec.id}
style={[
styles.card,
rec.status === "completed" && styles.cardCompleted,
]}
onPress={() => toggleStatus(rec.id, rec.status)}
>
<View style={styles.checkbox}>
{rec.status === "completed" && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</View>
<View style={styles.cardContent}>
<Text
style={[
styles.cardText,
rec.status === "completed" && styles.cardTextCompleted,
]}
>
{rec.content}
</Text>
<Text style={styles.dateText}>
{new Date(rec.createdAt).toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
))
)}
</View>
);
}; };
const handleDeleteGoal = async (goalId: string) => {
try {
const token = await getToken();
await fitnessGoalsService.deleteGoal(goalId, token);
setGoals(prev => prev.filter(g => g.id !== goalId));
Alert.alert("Success", "Goal deleted");
} catch (error) {
console.error("Error deleting goal:", error);
Alert.alert("Error", "Failed to delete goal. Please try again.");
}
};
const activeGoals = goals.filter(g => g.status === 'active');
const completedGoals = goals.filter(g => g.status === 'completed');
if (loading && !refreshing) { if (loading && !refreshing) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
@ -158,25 +94,105 @@ export default function GoalsScreen() {
} }
return ( return (
<View style={styles.container}>
<ScrollView <ScrollView
style={styles.container}
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
} }
> >
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.headerTitle}>My Goals</Text> <View>
<Text style={styles.headerTitle}>My Fitness Goals</Text>
<Text style={styles.headerSubtitle}> <Text style={styles.headerSubtitle}>
Track your fitness journey progress Track your fitness journey progress
</Text> </Text>
</View> </View>
</View>
{renderSection("Short Term Goals", "short_term")} {/* Stats Summary */}
{renderSection("Medium Term Goals", "medium_term")} {goals.length > 0 && (
{renderSection("Long Term Goals", "long_term")} <View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{activeGoals.length}</Text>
<Text style={styles.statLabel}>Active</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{completedGoals.length}</Text>
<Text style={styles.statLabel}>Completed</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{activeGoals.length > 0
? Math.round(
activeGoals.reduce((sum, g) => sum + g.progress, 0) /
activeGoals.length
)
: 0}%
</Text>
<Text style={styles.statLabel}>Avg Progress</Text>
</View>
</View>
)}
{/* Active Goals */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Active Goals ({activeGoals.length})
</Text>
{activeGoals.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyText}>No active goals yet</Text>
<Text style={styles.emptySubtext}>
Tap the + button to create your first goal
</Text>
</View>
) : (
activeGoals.map((goal) => (
<GoalProgressCard
key={goal.id}
goal={goal}
onComplete={() => handleCompleteGoal(goal.id)}
onDelete={() => handleDeleteGoal(goal.id)}
/>
))
)}
</View>
{/* Completed Goals */}
{completedGoals.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Completed Goals ({completedGoals.length})
</Text>
{completedGoals.map((goal) => (
<GoalProgressCard
key={goal.id}
goal={goal}
onDelete={() => handleDeleteGoal(goal.id)}
/>
))}
</View>
)}
<View style={styles.footer} /> <View style={styles.footer} />
</ScrollView> </ScrollView>
{/* Floating Action Button */}
<TouchableOpacity
style={styles.fab}
onPress={() => setShowCreateModal(true)}
>
<Ionicons name="add" size={28} color="#fff" />
</TouchableOpacity>
{/* Create Goal Modal */}
<GoalCreationModal
visible={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateGoal}
/>
</View>
); );
} }
@ -205,6 +221,34 @@ const styles = StyleSheet.create({
color: "#6b7280", color: "#6b7280",
marginTop: 4, marginTop: 4,
}, },
statsContainer: {
flexDirection: "row",
padding: 16,
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: "#fff",
padding: 16,
borderRadius: 12,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
statValue: {
fontSize: 24,
fontWeight: "bold",
color: "#2563eb",
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: "#6b7280",
fontWeight: "500",
},
section: { section: {
padding: 20, padding: 20,
paddingTop: 10, paddingTop: 10,
@ -215,58 +259,38 @@ const styles = StyleSheet.create({
color: "#374151", color: "#374151",
marginBottom: 12, marginBottom: 12,
}, },
emptyText: { emptyState: {
fontStyle: "italic",
color: "#9ca3af",
},
card: {
backgroundColor: "#fff",
padding: 16,
borderRadius: 12,
marginBottom: 12,
flexDirection: "row",
alignItems: "flex-start",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
cardCompleted: {
backgroundColor: "#f0fdf4", // light green
borderColor: "#bbf7d0",
borderWidth: 1,
},
checkbox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: "#d1d5db",
marginRight: 12,
marginTop: 2,
justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor: "#fff", paddingVertical: 40,
}, },
cardContent: { emptyText: {
flex: 1,
},
cardText: {
fontSize: 16, fontSize: 16,
color: "#1f2937", fontWeight: "500",
lineHeight: 24, color: "#6b7280",
marginTop: 12,
}, },
cardTextCompleted: { emptySubtext: {
textDecorationLine: "line-through", fontSize: 14,
color: "#9ca3af",
},
dateText: {
fontSize: 12,
color: "#9ca3af", color: "#9ca3af",
marginTop: 4, marginTop: 4,
}, },
footer: { footer: {
height: 40, height: 100,
},
fab: {
position: "absolute",
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#2563eb",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
}, },
}); });

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; 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 { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api"; import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
interface Recommendation { interface Recommendation {
@ -77,6 +78,15 @@ export default function RecommendationsScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.header}>AI Recommendations</Text> <Text style={styles.header}>AI Recommendations</Text>
{/* AI Context Info Banner */}
<View style={styles.infoBanner}>
<Ionicons name="sparkles" size={20} color="#2563eb" />
<Text style={styles.infoBannerText}>
Personalized based on your active fitness goals and progress
</Text>
</View>
<FlatList <FlatList
data={recommendations} data={recommendations}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
@ -129,6 +139,24 @@ const styles = StyleSheet.create({
paddingBottom: 12, paddingBottom: 12,
color: '#1a1a1a', color: '#1a1a1a',
}, },
infoBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#eff6ff',
marginHorizontal: 16,
marginBottom: 12,
padding: 12,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#2563eb',
gap: 8,
},
infoBannerText: {
flex: 1,
fontSize: 13,
color: '#1e40af',
lineHeight: 18,
},
centered: { centered: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',

View File

@ -0,0 +1,414 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TextInput,
TouchableOpacity,
ScrollView,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import type { CreateGoalData } from '../services/fitnessGoals';
interface GoalCreationModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (goalData: CreateGoalData) => Promise<void>;
}
const GOAL_TYPES = [
{ value: 'weight_target', label: 'Weight Target' },
{ value: 'strength_milestone', label: 'Strength Milestone' },
{ value: 'endurance_target', label: 'Endurance Target' },
{ value: 'flexibility_goal', label: 'Flexibility Goal' },
{ value: 'habit_building', label: 'Habit Building' },
{ value: 'custom', label: 'Custom Goal' },
] as const;
const PRIORITIES = [
{ value: 'low', label: 'Low', color: '#10b981' },
{ value: 'medium', label: 'Medium', color: '#f59e0b' },
{ value: 'high', label: 'High', color: '#ef4444' },
] as const;
export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationModalProps) {
const [goalType, setGoalType] = useState<CreateGoalData['goalType']>('weight_target');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [targetValue, setTargetValue] = useState('');
const [currentValue, setCurrentValue] = useState('');
const [unit, setUnit] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [targetDate, setTargetDate] = useState<Date | undefined>();
const [showDatePicker, setShowDatePicker] = useState(false);
const [submitting, setSubmitting] = useState(false);
const resetForm = () => {
setGoalType('weight_target');
setTitle('');
setDescription('');
setTargetValue('');
setCurrentValue('');
setUnit('');
setPriority('medium');
setTargetDate(undefined);
};
const handleSubmit = async () => {
if (!title.trim()) {
alert('Please enter a goal title');
return;
}
setSubmitting(true);
try {
const goalData: CreateGoalData = {
goalType,
title: title.trim(),
description: description.trim() || undefined,
targetValue: targetValue ? parseFloat(targetValue) : undefined,
currentValue: currentValue ? parseFloat(currentValue) : undefined,
unit: unit.trim() || undefined,
targetDate: targetDate?.toISOString(),
priority,
};
await onSubmit(goalData);
resetForm();
onClose();
} catch (error) {
console.error('Error creating goal:', error);
alert('Failed to create goal. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleClose}
>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Create Fitness Goal</Text>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#111827" />
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* Goal Type */}
<View style={styles.field}>
<Text style={styles.label}>Goal Type *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.typeScroll}>
{GOAL_TYPES.map((type) => (
<TouchableOpacity
key={type.value}
style={[
styles.typeButton,
goalType === type.value && styles.typeButtonActive,
]}
onPress={() => setGoalType(type.value)}
>
<Text
style={[
styles.typeButtonText,
goalType === type.value && styles.typeButtonTextActive,
]}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Title */}
<View style={styles.field}>
<Text style={styles.label}>Title *</Text>
<TextInput
style={styles.input}
value={title}
onChangeText={setTitle}
placeholder="e.g., Lose 5kg"
placeholderTextColor="#9ca3af"
/>
</View>
{/* Description */}
<View style={styles.field}>
<Text style={styles.label}>Description</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={description}
onChangeText={setDescription}
placeholder="Optional description"
placeholderTextColor="#9ca3af"
multiline
numberOfLines={3}
/>
</View>
{/* Target Value & Unit */}
<View style={styles.row}>
<View style={[styles.field, styles.flex1]}>
<Text style={styles.label}>Target Value</Text>
<TextInput
style={styles.input}
value={targetValue}
onChangeText={setTargetValue}
placeholder="e.g., 70"
placeholderTextColor="#9ca3af"
keyboardType="numeric"
/>
</View>
<View style={[styles.field, styles.flex1]}>
<Text style={styles.label}>Unit</Text>
<TextInput
style={styles.input}
value={unit}
onChangeText={setUnit}
placeholder="e.g., kg"
placeholderTextColor="#9ca3af"
/>
</View>
</View>
{/* Current Value */}
<View style={styles.field}>
<Text style={styles.label}>Current Value</Text>
<TextInput
style={styles.input}
value={currentValue}
onChangeText={setCurrentValue}
placeholder="Starting value (optional)"
placeholderTextColor="#9ca3af"
keyboardType="numeric"
/>
</View>
{/* Target Date */}
<View style={styles.field}>
<Text style={styles.label}>Target Date</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowDatePicker(true)}
>
<Text style={targetDate ? styles.dateText : styles.datePlaceholder}>
{targetDate ? targetDate.toLocaleDateString() : 'Select target date'}
</Text>
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{showDatePicker && (
<DateTimePicker
value={targetDate || new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(event, selectedDate) => {
setShowDatePicker(Platform.OS === 'ios');
if (selectedDate) {
setTargetDate(selectedDate);
}
}}
minimumDate={new Date()}
/>
)}
{/* Priority */}
<View style={styles.field}>
<Text style={styles.label}>Priority</Text>
<View style={styles.priorityContainer}>
{PRIORITIES.map((p) => (
<TouchableOpacity
key={p.value}
style={[
styles.priorityButton,
priority === p.value && { backgroundColor: p.color },
]}
onPress={() => setPriority(p.value)}
>
<Text
style={[
styles.priorityButtonText,
priority === p.value && styles.priorityButtonTextActive,
]}
>
{p.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.submitButton, submitting && styles.submitButtonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.submitButtonText}>
{submitting ? 'Creating...' : 'Create Goal'}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: Platform.OS === 'ios' ? 60 : 20,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
color: '#111827',
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 20,
},
field: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#111827',
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
row: {
flexDirection: 'row',
gap: 12,
},
flex1: {
flex: 1,
},
typeScroll: {
flexGrow: 0,
},
typeButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#fff',
marginRight: 8,
},
typeButtonActive: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
typeButtonText: {
fontSize: 14,
color: '#374151',
},
typeButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
dateButton: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
},
dateText: {
fontSize: 16,
color: '#111827',
},
datePlaceholder: {
fontSize: 16,
color: '#9ca3af',
},
priorityContainer: {
flexDirection: 'row',
gap: 12,
},
priorityButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#fff',
alignItems: 'center',
},
priorityButtonText: {
fontSize: 14,
color: '#374151',
fontWeight: '500',
},
priorityButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
footer: {
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
submitButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
});

View File

@ -0,0 +1,251 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { FitnessGoal } from '../services/fitnessGoals';
interface GoalProgressCardProps {
goal: FitnessGoal;
onPress?: () => void;
onComplete?: () => void;
onDelete?: () => void;
}
export function GoalProgressCard({ goal, onPress, onComplete, onDelete }: GoalProgressCardProps) {
const isCompleted = goal.status === 'completed';
const progress = goal.progress || 0;
// Calculate days remaining
const daysRemaining = goal.targetDate
? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null;
const getGoalTypeIcon = (type: string) => {
switch (type) {
case 'weight_target': return 'scale-outline';
case 'strength_milestone': return 'barbell-outline';
case 'endurance_target': return 'bicycle-outline';
case 'flexibility_goal': return 'body-outline';
case 'habit_building': return 'calendar-outline';
default: return 'flag-outline';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return '#ef4444';
case 'medium': return '#f59e0b';
case 'low': return '#10b981';
default: return '#6b7280';
}
};
const handleDelete = () => {
Alert.alert(
'Delete Goal',
'Are you sure you want to delete this goal?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: onDelete },
]
);
};
return (
<TouchableOpacity
style={[styles.card, isCompleted && styles.cardCompleted]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.header}>
<View style={styles.titleRow}>
<Ionicons
name={getGoalTypeIcon(goal.goalType) as any}
size={24}
color={isCompleted ? '#9ca3af' : '#2563eb'}
/>
<View style={styles.titleContainer}>
<Text style={[styles.title, isCompleted && styles.titleCompleted]}>
{goal.title}
</Text>
{goal.description && (
<Text style={styles.description} numberOfLines={2}>
{goal.description}
</Text>
)}
</View>
</View>
<View style={styles.actions}>
{!isCompleted && onComplete && (
<TouchableOpacity onPress={onComplete} style={styles.actionButton}>
<Ionicons name="checkmark-circle-outline" size={24} color="#10b981" />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
<Ionicons name="trash-outline" size={22} color="#ef4444" />
</TouchableOpacity>
)}
</View>
</View>
{goal.targetValue && (
<View style={styles.progressSection}>
<View style={styles.progressInfo}>
<Text style={styles.progressText}>
{goal.currentValue || 0} / {goal.targetValue} {goal.unit || ''}
</Text>
<Text style={styles.progressPercentage}>{progress.toFixed(0)}%</Text>
</View>
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${Math.min(progress, 100)}%` },
isCompleted && styles.progressBarCompleted
]}
/>
</View>
</View>
)}
<View style={styles.footer}>
<View style={[styles.priorityBadge, { backgroundColor: getPriorityColor(goal.priority) }]}>
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
</View>
{daysRemaining !== null && !isCompleted && (
<Text style={[styles.daysRemaining, daysRemaining < 0 && styles.overdue]}>
{daysRemaining < 0
? `${Math.abs(daysRemaining)} days overdue`
: `${daysRemaining} days remaining`
}
</Text>
)}
{isCompleted && goal.completedDate && (
<Text style={styles.completedDate}>
Completed {new Date(goal.completedDate).toLocaleDateString()}
</Text>
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardCompleted: {
backgroundColor: '#f0fdf4',
borderColor: '#bbf7d0',
borderWidth: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
flex: 1,
},
titleContainer: {
marginLeft: 12,
flex: 1,
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
titleCompleted: {
color: '#9ca3af',
textDecorationLine: 'line-through',
},
description: {
fontSize: 14,
color: '#6b7280',
lineHeight: 20,
},
actions: {
flexDirection: 'row',
gap: 8,
},
actionButton: {
padding: 4,
},
progressSection: {
marginBottom: 12,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
progressText: {
fontSize: 14,
fontWeight: '500',
color: '#374151',
},
progressPercentage: {
fontSize: 14,
fontWeight: '600',
color: '#2563eb',
},
progressBarContainer: {
height: 8,
backgroundColor: '#e5e7eb',
borderRadius: 4,
overflow: 'hidden',
},
progressBar: {
height: '100%',
backgroundColor: '#2563eb',
borderRadius: 4,
},
progressBarCompleted: {
backgroundColor: '#10b981',
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
priorityBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
priorityText: {
fontSize: 10,
fontWeight: '600',
color: '#fff',
},
daysRemaining: {
fontSize: 12,
color: '#6b7280',
},
overdue: {
color: '#ef4444',
fontWeight: '600',
},
completedDate: {
fontSize: 12,
color: '#10b981',
fontWeight: '500',
},
});

View File

@ -18,4 +18,12 @@ export const API_ENDPOINTS = {
HISTORY: '/api/attendance/history', HISTORY: '/api/attendance/history',
}, },
RECOMMENDATIONS: '/api/recommendations', RECOMMENDATIONS: '/api/recommendations',
FITNESS_GOALS: {
LIST: '/api/fitness-goals',
CREATE: '/api/fitness-goals',
GET: (id: string) => `/api/fitness-goals/${id}`,
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
DELETE: (id: string) => `/api/fitness-goals/${id}`,
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
},
} }

View File

@ -0,0 +1,153 @@
import { API_BASE_URL, API_ENDPOINTS } from '../config/api';
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;
}
export interface CreateGoalData {
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<HeadersInit> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
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}`;
if (status && status !== 'all') {
url += `&status=${status}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch goals: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching 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),
});
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;
}
}
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');
}
return await response.json();
} catch (error) {
console.error('Error updating fitness goal:', error);
throw error;
}
}
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) {
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;
}
}
}
export const fitnessGoalsService = new FitnessGoalsService();