393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
import React, { useState, useCallback, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
RefreshControl,
|
|
TouchableOpacity,
|
|
Animated,
|
|
Alert,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { theme } from "../../styles/theme";
|
|
import { GoalProgressCard } from "../../components/GoalProgressCard";
|
|
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
|
import {
|
|
fitnessGoalsService,
|
|
type FitnessGoal,
|
|
type CreateGoalData,
|
|
} from "../../services/fitnessGoals";
|
|
import { useFocusEffect } from "expo-router";
|
|
import * as SecureStore from "expo-secure-store";
|
|
import log from "../../utils/logger";
|
|
|
|
export default function GoalsScreen() {
|
|
const { user } = useUser();
|
|
const { getToken } = useAuth();
|
|
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
|
const fabScale = useRef(new Animated.Value(1)).current;
|
|
|
|
const loadGoals = useCallback(async () => {
|
|
if (!user?.id) return;
|
|
try {
|
|
const token = await getToken();
|
|
log.debug("Token obtained", {
|
|
hasToken: !!token,
|
|
tokenPreview: token ? token.substring(0, 20) + "..." : "No",
|
|
userId: user.id,
|
|
});
|
|
|
|
// Decode and log token details for debugging
|
|
if (token) {
|
|
try {
|
|
const parts = token.split(".");
|
|
if (parts.length === 3) {
|
|
const payload = JSON.parse(atob(parts[1]));
|
|
log.debug("Token details", {
|
|
issuer: payload.iss,
|
|
kid: JSON.parse(atob(parts[0])).kid,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
log.warn("Could not decode token");
|
|
}
|
|
}
|
|
|
|
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
|
setGoals(loadedGoals);
|
|
} catch (error) {
|
|
log.error("Failed to load goals", error);
|
|
}
|
|
}, [user?.id, getToken]);
|
|
|
|
const clearClerkCache = async () => {
|
|
Alert.alert(
|
|
"Clear Clerk Cache",
|
|
"This will clear all cached Clerk tokens. You will need to sign out and sign back in.",
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Clear Cache",
|
|
style: "destructive",
|
|
onPress: async () => {
|
|
try {
|
|
// Clear all possible Clerk token keys
|
|
const keysToDelete = [
|
|
"__clerk_client_jwt",
|
|
"__clerk_db_jwt",
|
|
"__clerk_client_uat",
|
|
"__clerk_session_id",
|
|
"__clerk_refresh_token",
|
|
"__clerk_session_jwt",
|
|
];
|
|
|
|
for (const key of keysToDelete) {
|
|
try {
|
|
await SecureStore.deleteItemAsync(key);
|
|
} catch (e) {
|
|
// Key might not exist
|
|
}
|
|
}
|
|
|
|
Alert.alert(
|
|
"Success",
|
|
"Cache cleared! Please sign out and sign back in.",
|
|
);
|
|
} catch (error) {
|
|
log.error("Failed to clear cache", error);
|
|
Alert.alert("Error", "Failed to clear cache");
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
loadGoals();
|
|
}, [loadGoals]),
|
|
);
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true);
|
|
await loadGoals();
|
|
setRefreshing(false);
|
|
};
|
|
|
|
const handleCreateGoal = async (newGoal: CreateGoalData) => {
|
|
const token = await getToken();
|
|
await fitnessGoalsService.createGoal(newGoal, token);
|
|
await loadGoals();
|
|
setIsModalVisible(false);
|
|
};
|
|
|
|
const handleCompleteGoal = async (goal: FitnessGoal) => {
|
|
const token = await getToken();
|
|
await fitnessGoalsService.completeGoal(goal.id, token);
|
|
await loadGoals();
|
|
};
|
|
|
|
const handleDeleteGoal = async (goalId: string) => {
|
|
const token = await getToken();
|
|
await fitnessGoalsService.deleteGoal(goalId, token);
|
|
await loadGoals();
|
|
};
|
|
|
|
const activeGoals = goals.filter((g) => g.status === "active");
|
|
const completedGoals = goals.filter((g) => g.status === "completed");
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
tintColor={theme.colors.primary}
|
|
/>
|
|
}
|
|
>
|
|
<LinearGradient colors={theme.gradients.primary} style={styles.header}>
|
|
<View style={styles.headerContent}>
|
|
<View>
|
|
<Text style={styles.headerTitle}>Fitness Goals</Text>
|
|
<Text style={styles.headerSubtitle}>
|
|
Track your fitness journey progress
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={clearClerkCache}
|
|
style={styles.debugButton}
|
|
>
|
|
<Ionicons name="refresh-circle-outline" size={24} color="#fff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</LinearGradient>
|
|
|
|
{/* Stats Summary */}
|
|
{goals.length > 0 && (
|
|
<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),
|
|
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)}
|
|
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} />
|
|
</ScrollView>
|
|
|
|
{/* Floating Action Button */}
|
|
<Animated.View
|
|
style={[styles.fabContainer, { transform: [{ scale: fabScale }] }]}
|
|
>
|
|
<TouchableOpacity
|
|
onPress={() => setIsModalVisible(true)}
|
|
onPressIn={() => {
|
|
Animated.spring(fabScale, {
|
|
toValue: 0.9,
|
|
friction: 8,
|
|
tension: 100,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
}}
|
|
onPressOut={() => {
|
|
Animated.spring(fabScale, {
|
|
toValue: 1,
|
|
friction: 8,
|
|
tension: 100,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
}}
|
|
activeOpacity={0.9}
|
|
>
|
|
<LinearGradient
|
|
colors={theme.gradients.primary}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.fab}
|
|
>
|
|
<Ionicons name="add" size={28} color="#fff" />
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
|
|
{/* Create Goal Modal */}
|
|
<GoalCreationModal
|
|
visible={isModalVisible}
|
|
onClose={() => setIsModalVisible(false)}
|
|
onSubmit={handleCreateGoal}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.background,
|
|
},
|
|
scrollContent: {
|
|
paddingBottom: 20,
|
|
},
|
|
header: {
|
|
padding: 24,
|
|
paddingTop: 60,
|
|
paddingBottom: 24,
|
|
marginBottom: 10,
|
|
borderBottomLeftRadius: theme.borderRadius.xl,
|
|
borderBottomRightRadius: theme.borderRadius.xl,
|
|
},
|
|
headerContent: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
},
|
|
debugButton: {
|
|
padding: 8,
|
|
},
|
|
headerTitle: {
|
|
fontSize: theme.typography.fontSize["3xl"],
|
|
fontWeight: theme.typography.fontWeight.bold,
|
|
color: theme.colors.white,
|
|
},
|
|
headerSubtitle: {
|
|
fontSize: theme.typography.fontSize.base,
|
|
color: "rgba(255, 255, 255, 0.9)",
|
|
marginTop: 4,
|
|
},
|
|
statsContainer: {
|
|
flexDirection: "row",
|
|
padding: 16,
|
|
gap: 12,
|
|
},
|
|
statCard: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.white,
|
|
padding: 16,
|
|
borderRadius: theme.borderRadius.xl,
|
|
alignItems: "center",
|
|
...theme.shadows.medium,
|
|
borderWidth: 1,
|
|
borderColor: "rgba(59, 130, 246, 0.1)",
|
|
},
|
|
statValue: {
|
|
fontSize: theme.typography.fontSize["2xl"],
|
|
fontWeight: theme.typography.fontWeight.bold,
|
|
color: theme.colors.primary,
|
|
marginBottom: 4,
|
|
},
|
|
statLabel: {
|
|
fontSize: 12,
|
|
color: "#6b7280",
|
|
fontWeight: "500",
|
|
},
|
|
section: {
|
|
padding: 20,
|
|
paddingTop: 10,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 18,
|
|
fontWeight: "600",
|
|
color: "#374151",
|
|
marginBottom: 12,
|
|
},
|
|
emptyState: {
|
|
alignItems: "center",
|
|
paddingVertical: 40,
|
|
},
|
|
emptyText: {
|
|
fontSize: 16,
|
|
fontWeight: "500",
|
|
color: "#6b7280",
|
|
marginTop: 12,
|
|
},
|
|
emptySubtext: {
|
|
fontSize: 14,
|
|
color: "#9ca3af",
|
|
marginTop: 4,
|
|
},
|
|
footer: {
|
|
height: 100,
|
|
},
|
|
fabContainer: {
|
|
position: "absolute",
|
|
right: 20,
|
|
bottom: 110, // Adjusted for tab bar height
|
|
},
|
|
fab: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
...theme.shadows.glow,
|
|
},
|
|
});
|