733 lines
22 KiB
TypeScript
733 lines
22 KiB
TypeScript
import React, { useState, useCallback } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
RefreshControl,
|
|
TouchableOpacity,
|
|
Alert,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useUser } from "@clerk/clerk-expo";
|
|
import { useFocusEffect } from "expo-router";
|
|
import * as SecureStore from "expo-secure-store";
|
|
import { useTheme } from "../../contexts/ThemeContext";
|
|
import { MinimalCard } from "../../components/MinimalCard";
|
|
import { SectionHeader } from "../../components/SectionHeader";
|
|
import { MinimalButton } from "../../components/MinimalButton";
|
|
import { Badge } from "../../components/Badge";
|
|
import { ProgressBar } from "../../components/ProgressBar";
|
|
import { GoalProgressCard } from "../../components/GoalProgressCard";
|
|
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
|
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
|
|
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
|
|
import { useMembership } from "../../hooks/useMembership";
|
|
import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals";
|
|
import { useStatistics } from "../../contexts/StatisticsContext";
|
|
import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
|
|
import { useRecommendations } from "../../contexts/RecommendationsContext";
|
|
import log from "../../utils/logger";
|
|
|
|
export default function GoalsScreen() {
|
|
const { user } = useUser();
|
|
const { colors, typography } = useTheme();
|
|
const {
|
|
statistics,
|
|
refetchStatistics,
|
|
clearCache: clearStatsCache,
|
|
} = useStatistics();
|
|
const {
|
|
goals,
|
|
loading,
|
|
refetchGoals,
|
|
createGoal,
|
|
completeGoal,
|
|
deleteGoal,
|
|
clearCache: clearGoalsCache,
|
|
} = useFitnessGoals();
|
|
const {
|
|
recommendations,
|
|
clearCache: clearRecommendationsCache,
|
|
refetchRecommendations,
|
|
generateSelfPlan,
|
|
} = useRecommendations();
|
|
const { membershipType, features } = useMembership();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
|
const [showAnalytics, setShowAnalytics] = useState(false);
|
|
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
|
|
const [showFullPlan, setShowFullPlan] = useState(false);
|
|
|
|
const loadData = useCallback(async () => {
|
|
await refetchGoals();
|
|
await refetchRecommendations();
|
|
await refetchStatistics();
|
|
}, [refetchGoals, refetchRecommendations, refetchStatistics]);
|
|
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
|
|
clearStatsCache();
|
|
clearGoalsCache();
|
|
clearRecommendationsCache();
|
|
|
|
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(() => {
|
|
loadData();
|
|
}, [loadData]),
|
|
);
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true);
|
|
await loadData();
|
|
setRefreshing(false);
|
|
};
|
|
|
|
const handleCreateGoal = async (newGoal: CreateGoalData) => {
|
|
await createGoal(newGoal);
|
|
setIsModalVisible(false);
|
|
};
|
|
|
|
const handleCompleteGoal = async (goal: FitnessGoal) => {
|
|
await completeGoal(goal.id);
|
|
};
|
|
|
|
const handleDeleteGoal = async (goalId: string) => {
|
|
await deleteGoal(goalId);
|
|
};
|
|
|
|
const activeGoals = goals?.filter((g) => g.status === "active") || [];
|
|
const completedGoals = goals?.filter((g) => g.status === "completed") || [];
|
|
const avgProgress =
|
|
activeGoals.length > 0
|
|
? Math.round(
|
|
activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
|
activeGoals.length,
|
|
)
|
|
: 0;
|
|
|
|
const latestApprovedRecommendation = [...recommendations]
|
|
.filter((rec) => rec.status === "approved")
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime(),
|
|
)[0];
|
|
|
|
const isGoalAiAligned = (goal: FitnessGoal) => {
|
|
if (goal.notes?.startsWith("[AI_LINKED]")) return true;
|
|
if (!latestApprovedRecommendation?.activityPlan) return false;
|
|
|
|
const planText = latestApprovedRecommendation.activityPlan.toLowerCase();
|
|
const title = goal.title.toLowerCase();
|
|
const description = (goal.description || "").toLowerCase();
|
|
|
|
const content = `${title} ${description}`;
|
|
|
|
if (goal.goalType === "strength_milestone") {
|
|
return planText.includes("strength") || planText.includes("weight");
|
|
}
|
|
if (goal.goalType === "endurance_target") {
|
|
return (
|
|
planText.includes("cardio") ||
|
|
planText.includes("endurance") ||
|
|
planText.includes("run")
|
|
);
|
|
}
|
|
if (goal.goalType === "flexibility_goal") {
|
|
return planText.includes("stretch") || planText.includes("mobility");
|
|
}
|
|
if (goal.goalType === "habit_building") {
|
|
return planText.includes("habit") || planText.includes("daily");
|
|
}
|
|
|
|
return (
|
|
planText.includes(content.split(" ")[0] || "") ||
|
|
content
|
|
.split(" ")
|
|
.some((word) => word.length > 4 && planText.includes(word))
|
|
);
|
|
};
|
|
|
|
const handleGenerateAiPlan = async () => {
|
|
if (!features.recommendationsPerMonth || membershipType === "basic") {
|
|
Alert.alert(
|
|
"Premium Feature",
|
|
"AI plan generation is available on Premium and VIP memberships.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsGeneratingPlan(true);
|
|
await generateSelfPlan();
|
|
await Promise.all([refetchRecommendations(), refetchGoals()]);
|
|
Alert.alert(
|
|
"Plan Ready",
|
|
"Your new activity plan is ready and active goals were added.",
|
|
);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to generate AI activity plan.";
|
|
Alert.alert("Could not generate plan", message);
|
|
} finally {
|
|
setIsGeneratingPlan(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={[
|
|
typography.h1,
|
|
{ color: colors.textPrimary, fontSize: 32 },
|
|
]}
|
|
>
|
|
Goals
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.body,
|
|
{ color: colors.textSecondary, marginTop: 8 },
|
|
]}
|
|
>
|
|
{activeGoals.length === 0
|
|
? "Ready to crush some goals?"
|
|
: activeGoals.length === 1
|
|
? "You're on a mission! Keep it up!"
|
|
: `${activeGoals.length} goals in progress. Let's go!`}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={clearClerkCache}
|
|
style={styles.debugButton}
|
|
>
|
|
<Ionicons
|
|
name="refresh-circle-outline"
|
|
size={24}
|
|
color={colors.textTertiary}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Stats Summary */}
|
|
{goals && goals.length > 0 && (
|
|
<View style={styles.section}>
|
|
<View style={styles.statsRow}>
|
|
<MinimalCard
|
|
variant="elevated"
|
|
style={[styles.statCard, { backgroundColor: colors.primary }]}
|
|
>
|
|
<Text
|
|
style={[
|
|
typography.statLarge,
|
|
{ color: colors.white, fontSize: 36 },
|
|
]}
|
|
>
|
|
{activeGoals.length}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.label,
|
|
{ color: "rgba(255,255,255,0.8)", marginTop: 4 },
|
|
]}
|
|
>
|
|
ACTIVE
|
|
</Text>
|
|
</MinimalCard>
|
|
|
|
<MinimalCard
|
|
variant="elevated"
|
|
style={[styles.statCard, { backgroundColor: colors.success }]}
|
|
>
|
|
<Text
|
|
style={[
|
|
typography.statLarge,
|
|
{ color: colors.white, fontSize: 36 },
|
|
]}
|
|
>
|
|
{completedGoals.length}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.label,
|
|
{ color: "rgba(255,255,255,0.8)", marginTop: 4 },
|
|
]}
|
|
>
|
|
COMPLETED
|
|
</Text>
|
|
</MinimalCard>
|
|
|
|
<MinimalCard
|
|
variant="elevated"
|
|
style={[styles.statCard, { backgroundColor: colors.accent }]}
|
|
>
|
|
<Text
|
|
style={[
|
|
typography.statLarge,
|
|
{ color: colors.white, fontSize: 36 },
|
|
]}
|
|
>
|
|
{avgProgress}%
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.label,
|
|
{ color: "rgba(255,255,255,0.8)", marginTop: 4 },
|
|
]}
|
|
>
|
|
PROGRESS
|
|
</Text>
|
|
</MinimalCard>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Analytics Section */}
|
|
{statistics &&
|
|
(statistics.weeklyTrend.length > 0 ||
|
|
statistics.goals.goalsByType.length > 0) && (
|
|
<View style={styles.section}>
|
|
<TouchableOpacity
|
|
onPress={() => setShowAnalytics(!showAnalytics)}
|
|
activeOpacity={0.85}
|
|
>
|
|
<MinimalCard variant="elevated" style={styles.analyticsCard}>
|
|
<View style={styles.analyticsHeader}>
|
|
<View style={styles.analyticsHeaderLeft}>
|
|
<View
|
|
style={[
|
|
styles.analyticsIcon,
|
|
{ backgroundColor: `${colors.primary}15` },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name="bar-chart"
|
|
size={24}
|
|
color={colors.primary}
|
|
/>
|
|
</View>
|
|
<View>
|
|
<Text
|
|
style={[typography.h3, { color: colors.textPrimary }]}
|
|
>
|
|
Progress Analytics
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{ color: colors.textTertiary, marginTop: 2 },
|
|
]}
|
|
>
|
|
{showAnalytics ? "Tap to collapse" : "Tap to expand"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View
|
|
style={[
|
|
styles.analyticsToggle,
|
|
{ backgroundColor: colors.surfaceElevated },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={showAnalytics ? "chevron-up" : "chevron-down"}
|
|
size={20}
|
|
color={colors.textSecondary}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{showAnalytics && (
|
|
<View style={styles.analyticsContent}>
|
|
{statistics.weeklyTrend.length > 0 && (
|
|
<View style={styles.chartSection}>
|
|
<Text
|
|
style={[
|
|
typography.h4,
|
|
{ color: colors.textPrimary, marginBottom: 16 },
|
|
]}
|
|
>
|
|
Weekly Trend
|
|
</Text>
|
|
<WeeklyProgressChart
|
|
weeklyData={statistics.weeklyTrend}
|
|
/>
|
|
</View>
|
|
)}
|
|
{statistics.goals.goalsByType.length > 0 && (
|
|
<View style={styles.chartSection}>
|
|
<Text
|
|
style={[
|
|
typography.h4,
|
|
{ color: colors.textPrimary, marginBottom: 16 },
|
|
]}
|
|
>
|
|
Goals by Type
|
|
</Text>
|
|
<GoalTypeBreakdownChart
|
|
data={statistics.goals.goalsByType}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</MinimalCard>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{/* Active Goals */}
|
|
<View style={styles.section}>
|
|
<MinimalCard variant="elevated" style={styles.aiPlanCard}>
|
|
<View style={styles.aiPlanHeader}>
|
|
<View style={styles.aiPlanHeaderLeft}>
|
|
<View
|
|
style={[
|
|
styles.aiPlanIcon,
|
|
{ backgroundColor: `${colors.primary}15` },
|
|
]}
|
|
>
|
|
<Ionicons name="sparkles" size={20} color={colors.primary} />
|
|
</View>
|
|
<View>
|
|
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
|
AI Activity Plan
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{ color: colors.textTertiary, marginTop: 2 },
|
|
]}
|
|
>
|
|
{latestApprovedRecommendation
|
|
? `Generated ${new Date(latestApprovedRecommendation.generatedAt).toLocaleDateString()}`
|
|
: "Generate a plan aligned to your fitness profile"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Badge
|
|
variant={membershipType === "basic" ? "neutral" : "success"}
|
|
label={membershipType.toUpperCase()}
|
|
size="sm"
|
|
/>
|
|
</View>
|
|
|
|
{latestApprovedRecommendation ? (
|
|
<>
|
|
<Text
|
|
style={[
|
|
typography.body,
|
|
{ color: colors.textSecondary, marginTop: 14 },
|
|
]}
|
|
numberOfLines={showFullPlan ? undefined : 4}
|
|
>
|
|
{latestApprovedRecommendation.activityPlan}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => setShowFullPlan((prev) => !prev)}
|
|
style={styles.showPlanButton}
|
|
>
|
|
<Text style={[typography.caption, { color: colors.primary }]}>
|
|
{showFullPlan ? "Show less" : "View full plan"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
) : (
|
|
<Text
|
|
style={[
|
|
typography.body,
|
|
{ color: colors.textSecondary, marginTop: 14 },
|
|
]}
|
|
>
|
|
No AI activity plan yet. Generate one to get personalized
|
|
guidance.
|
|
</Text>
|
|
)}
|
|
|
|
<MinimalButton
|
|
title={
|
|
membershipType === "basic"
|
|
? "Upgrade to Generate Plan"
|
|
: latestApprovedRecommendation
|
|
? "Regenerate Plan"
|
|
: "Generate Plan"
|
|
}
|
|
onPress={handleGenerateAiPlan}
|
|
disabled={isGeneratingPlan}
|
|
loading={isGeneratingPlan}
|
|
size="md"
|
|
fullWidth
|
|
style={{ marginTop: 14 }}
|
|
/>
|
|
</MinimalCard>
|
|
|
|
<SectionHeader
|
|
title={`Active Goals (${activeGoals.length})`}
|
|
subtitle="Keep pushing forward!"
|
|
actionLabel="+ Add New"
|
|
onActionPress={() => setIsModalVisible(true)}
|
|
/>
|
|
{activeGoals.length === 0 ? (
|
|
<MinimalCard variant="default">
|
|
<View style={styles.emptyState}>
|
|
<Text style={{ fontSize: 64 }}>🎯</Text>
|
|
<Text
|
|
style={[
|
|
typography.bodyEmphasis,
|
|
{ color: colors.textSecondary, marginTop: 12 },
|
|
]}
|
|
>
|
|
No active goals yet
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{ color: colors.textTertiary, marginTop: 4 },
|
|
]}
|
|
>
|
|
Tap "Add New" to set your first goal! 💪
|
|
</Text>
|
|
</View>
|
|
</MinimalCard>
|
|
) : (
|
|
<View style={styles.goalsList}>
|
|
{activeGoals.map((goal) => (
|
|
<GoalProgressCard
|
|
key={goal.id}
|
|
goal={goal}
|
|
aiAligned={isGoalAiAligned(goal)}
|
|
onComplete={() => handleCompleteGoal(goal)}
|
|
onDelete={() => handleDeleteGoal(goal.id)}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Completed Goals */}
|
|
{completedGoals.length > 0 && (
|
|
<View style={styles.section}>
|
|
<SectionHeader
|
|
title={`Completed (${completedGoals.length})`}
|
|
subtitle="Great work!"
|
|
/>
|
|
<View style={styles.goalsList}>
|
|
{completedGoals.map((goal) => (
|
|
<GoalProgressCard
|
|
key={goal.id}
|
|
goal={goal}
|
|
onDelete={() => handleDeleteGoal(goal.id)}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.footer} />
|
|
</ScrollView>
|
|
|
|
{/* Floating Action Button - Minimal Style */}
|
|
<View style={styles.fabContainer}>
|
|
<TouchableOpacity
|
|
onPress={() => setIsModalVisible(true)}
|
|
activeOpacity={0.8}
|
|
style={[
|
|
styles.fab,
|
|
{
|
|
backgroundColor: colors.primary,
|
|
shadowColor: colors.primary,
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons name="add" size={28} color={colors.white} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Create Goal Modal */}
|
|
<GoalCreationModal
|
|
visible={isModalVisible}
|
|
onClose={() => setIsModalVisible(false)}
|
|
onSubmit={handleCreateGoal}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingBottom: 20,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
paddingHorizontal: 20,
|
|
paddingTop: 60,
|
|
paddingBottom: 20,
|
|
},
|
|
debugButton: {
|
|
padding: 8,
|
|
},
|
|
section: {
|
|
paddingHorizontal: 20,
|
|
marginBottom: 24,
|
|
},
|
|
statsRow: {
|
|
flexDirection: "row",
|
|
gap: 12,
|
|
},
|
|
statCard: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
paddingVertical: 20,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 20,
|
|
},
|
|
analyticsCard: {
|
|
padding: 20,
|
|
},
|
|
analyticsHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
},
|
|
analyticsHeaderLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
analyticsIcon: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 14,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginRight: 14,
|
|
},
|
|
analyticsToggle: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
analyticsContent: {
|
|
paddingTop: 24,
|
|
marginTop: 20,
|
|
borderTopWidth: 1,
|
|
borderTopColor: "rgba(0,0,0,0.05)",
|
|
},
|
|
aiPlanCard: {
|
|
padding: 18,
|
|
marginBottom: 16,
|
|
},
|
|
aiPlanHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
},
|
|
aiPlanHeaderLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
marginRight: 12,
|
|
},
|
|
aiPlanIcon: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 12,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginRight: 12,
|
|
},
|
|
showPlanButton: {
|
|
marginTop: 8,
|
|
alignSelf: "flex-start",
|
|
},
|
|
chartSection: {
|
|
marginBottom: 20,
|
|
},
|
|
goalsList: {
|
|
gap: 16,
|
|
},
|
|
emptyState: {
|
|
alignItems: "center",
|
|
paddingVertical: 40,
|
|
},
|
|
footer: {
|
|
height: 100,
|
|
},
|
|
fabContainer: {
|
|
position: "absolute",
|
|
right: 20,
|
|
bottom: 90,
|
|
},
|
|
fab: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 22,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
shadowOffset: { width: 0, height: 6 },
|
|
shadowOpacity: 0.35,
|
|
shadowRadius: 12,
|
|
elevation: 8,
|
|
},
|
|
});
|