context and goals definition added
This commit is contained in:
parent
28b5b52a8f
commit
803c205994
Binary file not shown.
@ -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
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
24
apps/mobile/package-lock.json
generated
24
apps/mobile/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
414
apps/mobile/src/components/GoalCreationModal.tsx
Normal file
414
apps/mobile/src/components/GoalCreationModal.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
251
apps/mobile/src/components/GoalProgressCard.tsx
Normal file
251
apps/mobile/src/components/GoalProgressCard.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
153
apps/mobile/src/services/fitnessGoals.ts
Normal file
153
apps/mobile/src/services/fitnessGoals.ts
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user