334 lines
9.0 KiB
TypeScript
334 lines
9.0 KiB
TypeScript
import React, { useEffect, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
Alert,
|
|
Animated,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import type { FitnessGoal } from "../services/fitnessGoals";
|
|
import { useTheme } from "../contexts/ThemeContext";
|
|
import { MinimalCard } from "./MinimalCard";
|
|
import { Badge } from "./Badge";
|
|
import { ProgressBar } from "./ProgressBar";
|
|
import { IconContainer } from "./IconContainer";
|
|
|
|
interface GoalProgressCardProps {
|
|
goal: FitnessGoal;
|
|
onPress?: () => void;
|
|
onComplete?: () => void;
|
|
onDelete?: () => void;
|
|
}
|
|
|
|
export function GoalProgressCard({
|
|
goal,
|
|
onPress,
|
|
onComplete,
|
|
onDelete,
|
|
}: GoalProgressCardProps) {
|
|
const { colors, typography } = useTheme();
|
|
const isCompleted = goal.status === "completed";
|
|
const progress = (goal.progress || 0) / 100; // Convert to 0-1 scale
|
|
const scaleAnim = useRef(new Animated.Value(1)).current;
|
|
|
|
// Celebration animation when goal is completed
|
|
useEffect(() => {
|
|
if (isCompleted) {
|
|
Animated.sequence([
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1.05,
|
|
friction: 3,
|
|
tension: 40,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1,
|
|
friction: 3,
|
|
tension: 40,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [isCompleted]);
|
|
|
|
const handleComplete = () => {
|
|
if (onComplete) {
|
|
// Trigger celebration animation
|
|
Animated.sequence([
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1.1,
|
|
friction: 3,
|
|
tension: 40,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1,
|
|
friction: 3,
|
|
tension: 40,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => {
|
|
onComplete();
|
|
});
|
|
}
|
|
};
|
|
|
|
// 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 colors.danger;
|
|
case "medium":
|
|
return colors.warning;
|
|
case "low":
|
|
return colors.success;
|
|
default:
|
|
return colors.primary;
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
|
<TouchableOpacity onPress={onPress} activeOpacity={0.85}>
|
|
<MinimalCard
|
|
variant="elevated"
|
|
style={[
|
|
styles.card,
|
|
isCompleted && {
|
|
backgroundColor: colors.surfaceElevated,
|
|
opacity: 0.8,
|
|
},
|
|
]}
|
|
padding={20}
|
|
>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<View style={styles.titleRow}>
|
|
<IconContainer
|
|
variant="colored"
|
|
backgroundColor={
|
|
isCompleted ? colors.success : getPriorityColor(goal.priority)
|
|
}
|
|
>
|
|
<Ionicons
|
|
name={getGoalTypeIcon(goal.goalType) as any}
|
|
size={20}
|
|
color={colors.white}
|
|
/>
|
|
</IconContainer>
|
|
|
|
<View style={styles.titleContainer}>
|
|
<Text
|
|
style={[
|
|
typography.h3,
|
|
{ color: colors.textPrimary },
|
|
isCompleted && {
|
|
color: colors.textSecondary,
|
|
textDecorationLine: "line-through",
|
|
},
|
|
]}
|
|
>
|
|
{goal.title}
|
|
</Text>
|
|
{goal.description && (
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{ color: colors.textTertiary, marginTop: 2 },
|
|
]}
|
|
numberOfLines={2}
|
|
>
|
|
{goal.description}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Action Buttons */}
|
|
<View style={styles.actions}>
|
|
{!isCompleted && onComplete && (
|
|
<TouchableOpacity
|
|
onPress={handleComplete}
|
|
style={styles.actionButton}
|
|
>
|
|
<Ionicons
|
|
name="checkmark-circle-outline"
|
|
size={24}
|
|
color={colors.success}
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
{onDelete && (
|
|
<TouchableOpacity
|
|
onPress={handleDelete}
|
|
style={styles.actionButton}
|
|
>
|
|
<Ionicons
|
|
name="trash-outline"
|
|
size={22}
|
|
color={colors.danger}
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Progress Section */}
|
|
{goal.targetValue && (
|
|
<View style={styles.progressSection}>
|
|
<View style={styles.progressInfo}>
|
|
<Text
|
|
style={[typography.caption, { color: colors.textSecondary }]}
|
|
>
|
|
{goal.currentValue || 0} / {goal.targetValue}{" "}
|
|
{goal.unit || ""}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.bodyEmphasis,
|
|
{
|
|
color: isCompleted ? colors.success : colors.primary,
|
|
},
|
|
]}
|
|
>
|
|
{(progress * 100).toFixed(0)}%
|
|
</Text>
|
|
</View>
|
|
|
|
<ProgressBar
|
|
progress={progress}
|
|
color={
|
|
isCompleted ? colors.success : getPriorityColor(goal.priority)
|
|
}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<View style={styles.footer}>
|
|
{isCompleted ? (
|
|
<Badge variant="success" label="COMPLETED" />
|
|
) : (
|
|
<View>
|
|
{goal.priority === "high" && (
|
|
<Badge variant="danger" label={goal.priority.toUpperCase()} />
|
|
)}
|
|
{goal.priority === "medium" && (
|
|
<Badge
|
|
variant="warning"
|
|
label={goal.priority.toUpperCase()}
|
|
/>
|
|
)}
|
|
{goal.priority === "low" && (
|
|
<Badge
|
|
variant="success"
|
|
label={goal.priority.toUpperCase()}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{daysRemaining !== null && !isCompleted && (
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{
|
|
color:
|
|
daysRemaining < 0 ? colors.danger : colors.textTertiary,
|
|
},
|
|
]}
|
|
>
|
|
{daysRemaining < 0
|
|
? `${Math.abs(daysRemaining)} days overdue`
|
|
: `${daysRemaining} days remaining`}
|
|
</Text>
|
|
)}
|
|
|
|
{isCompleted && goal.completedDate && (
|
|
<Text style={[typography.caption, { color: colors.success }]}>
|
|
Completed {new Date(goal.completedDate).toLocaleDateString()}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</MinimalCard>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
card: {
|
|
marginBottom: 16,
|
|
borderRadius: 20,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
marginBottom: 16,
|
|
},
|
|
titleRow: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
flex: 1,
|
|
},
|
|
titleContainer: {
|
|
flex: 1,
|
|
marginLeft: 14,
|
|
},
|
|
actions: {
|
|
flexDirection: "row",
|
|
gap: 12,
|
|
},
|
|
actionButton: {
|
|
padding: 6,
|
|
},
|
|
progressSection: {
|
|
marginBottom: 16,
|
|
},
|
|
progressInfo: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 10,
|
|
},
|
|
footer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
});
|