POWR/components/workout/WorkoutCompletionFlow.tsx
2025-03-07 12:38:21 -05:00

709 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/workout/WorkoutCompletionFlow.tsx
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, ScrollView } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { useWorkoutStore } from '@/stores/workoutStore';
import { useNDK, useNDKCurrentUser } from '@/lib/hooks/useNDK';
import { WorkoutCompletionOptions } from '@/types/workout';
import {
Shield,
Lock,
FileText,
Clock,
Dumbbell,
Trophy,
Cog
} from 'lucide-react-native';
import { TemplateService } from '@/lib/db/services/TemplateService';
import Confetti from '@/components/Confetti';
/**
* Workout Completion Flow Component
*
* This component manages the multi-step process of completing a workout:
* 1. Storage Options - How the workout data should be stored (local/published)
* 2. Summary - Displaying workout statistics before finalizing
* 3. Celebration - Confirming completion and providing sharing options
*
* Key features:
* - Step-based navigation with back/next functionality
* - Nostr sharing options for completed workouts
* - Template management options if workout was based on a template
*
* This component handles UI state and user input, but delegates the actual
* workout completion to the parent component via the onComplete callback.
* The Nostr publishing happens in the WorkoutStore after receiving the
* selected options from this flow.
*/
// Storage options component
function StorageOptionsTab({
options,
setOptions,
onNext
}: {
options: WorkoutCompletionOptions,
setOptions: (options: WorkoutCompletionOptions) => void,
onNext: () => void
}) {
const { isAuthenticated } = useNDKCurrentUser();
// Handle storage option selection
const handleStorageOptionSelect = (value: 'local_only' | 'publish_complete' | 'publish_limited') => {
setOptions({
...options,
storageType: value
});
};
// Handle template action selection
const handleTemplateAction = (value: 'keep_original' | 'update_existing' | 'save_as_new') => {
setOptions({
...options,
templateAction: value
});
};
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
// Check if the workout is based on a template
const { activeWorkout } = useWorkoutStore();
// Use a try-catch block for more resilience
let hasTemplateChanges = false;
try {
if (activeWorkout && activeWorkout.templateId) {
hasTemplateChanges = TemplateService.hasTemplateChanges(activeWorkout);
}
} catch (error) {
console.error('Error checking template changes:', error);
}
return (
<ScrollView className="flex-1 px-2">
<View className="py-4 gap-6">
<Text className="text-lg font-semibold text-foreground">Storage Options</Text>
<Text className="text-muted-foreground">
Choose how you want to store your workout data
</Text>
{/* Local only option */}
<TouchableOpacity
onPress={() => handleStorageOptionSelect('local_only')}
activeOpacity={0.7}
>
<Card
style={options.storageType === 'local_only' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-muted items-center justify-center">
<Lock size={20} className="text-muted-foreground" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground">Local Only</Text>
<Text className="text-sm text-muted-foreground">
Keep workout data private on this device
</Text>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Publish complete option */}
{isAuthenticated && (
<TouchableOpacity
onPress={() => handleStorageOptionSelect('publish_complete')}
activeOpacity={0.7}
>
<Card
style={options.storageType === 'publish_complete' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-muted items-center justify-center">
<FileText size={20} className="text-muted-foreground" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground">Publish Complete</Text>
<Text className="text-sm text-muted-foreground">
Publish full workout data to Nostr network
</Text>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
)}
{/* Limited metrics option */}
{isAuthenticated && (
<TouchableOpacity
onPress={() => handleStorageOptionSelect('publish_limited')}
activeOpacity={0.7}
>
<Card
style={options.storageType === 'publish_limited' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-muted items-center justify-center">
<Shield size={20} className="text-muted-foreground" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground">Limited Metrics</Text>
<Text className="text-sm text-muted-foreground">
Publish workout with limited metrics for privacy
</Text>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
)}
{/* Template options section - only if needed */}
{hasTemplateChanges && (
<>
<Separator className="my-2" />
<Text className="text-lg font-semibold text-foreground">Template Options</Text>
<Text className="text-muted-foreground">
Your workout includes modifications to the original template
</Text>
{/* Keep original option */}
<TouchableOpacity
onPress={() => handleTemplateAction('keep_original')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'keep_original' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Keep Original</Text>
<Text className="text-sm text-muted-foreground">
Don't update the template
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Update existing option */}
<TouchableOpacity
onPress={() => handleTemplateAction('update_existing')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'update_existing' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Update Template</Text>
<Text className="text-sm text-muted-foreground">
Save these changes to the original template
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Save as new option */}
<TouchableOpacity
onPress={() => handleTemplateAction('save_as_new')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'save_as_new' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Save as New</Text>
<Text className="text-sm text-muted-foreground">
Create a new template from this workout
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Template name input if save as new is selected */}
{options.templateAction === 'save_as_new' && (
<View className="mt-2 mb-4">
<Text className="text-sm mb-2">New template name:</Text>
<Input
placeholder="My Custom Template"
value={options.newTemplateName || activeWorkout?.title || ''}
onChangeText={(text) => setOptions({
...options,
newTemplateName: text
})}
/>
</View>
)}
</>
)}
{/* Next button */}
<Button
onPress={onNext}
className="w-full mb-6"
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white font-medium">
Next
</Text>
</Button>
</View>
</ScrollView>
);
}
// Summary component
function SummaryTab({
options,
onBack,
onFinish
}: {
options: WorkoutCompletionOptions,
onBack: () => void,
onFinish: () => void
}) {
const { activeWorkout } = useWorkoutStore();
// Helper functions
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return hours > 0
? `${hours}h ${minutes % 60}m`
: `${minutes}m ${seconds % 60}s`;
};
// Get workout duration using timestamps
const getDuration = (): number => {
if (!activeWorkout || !activeWorkout.endTime) return 0;
return activeWorkout.endTime - activeWorkout.startTime;
};
const getTotalSets = (): number => {
return activeWorkout?.exercises.reduce(
(total, exercise) => total + exercise.sets.length,
0
) || 0;
};
const getCompletedSets = (): number => {
return activeWorkout?.exercises.reduce(
(total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length,
0
) || 0;
};
// Calculate volume
const getTotalVolume = (): number => {
return activeWorkout?.exercises.reduce(
(total, exercise) =>
total + exercise.sets.reduce(
(setTotal, set) => setTotal + ((set.weight || 0) * (set.reps || 0)),
0
),
0
) || 0;
};
// Mock PRs - in a real app, you'd compare with previous workouts
const mockPRs = [
{ exercise: "Bench Press", value: "80kg × 8 reps", previous: "75kg × 8 reps" },
{ exercise: "Squat", value: "120kg × 5 reps", previous: "110kg × 5 reps" }
];
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
return (
<ScrollView className="flex-1 px-2">
<View className="py-4 gap-6">
<Text className="text-lg font-semibold text-foreground">Workout Summary</Text>
<View className="gap-4">
{/* Duration card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-2">
<Clock size={18} className="text-muted-foreground" />
<Text className="font-medium text-foreground">Duration</Text>
</View>
<Text className="text-xl font-semibold text-foreground">
{formatDuration(getDuration())}
</Text>
</CardContent>
</Card>
{/* Exercise stats card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-2">
<Dumbbell size={18} className="text-muted-foreground" />
<Text className="font-medium text-foreground">Exercises</Text>
</View>
<View className="gap-3">
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Total Exercises:
</Text>
<Text className="text-base font-medium text-foreground">
{activeWorkout?.exercises.length || 0}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Sets Completed:
</Text>
<Text className="text-base font-medium text-foreground">
{getCompletedSets()} / {getTotalSets()}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Total Volume:
</Text>
<Text className="text-base font-medium text-foreground">
{getTotalVolume()} kg
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Estimated Calories:
</Text>
<Text className="text-base font-medium text-foreground">
{Math.round(getDuration() / 1000 / 60 * 5)} kcal
</Text>
</View>
</View>
</CardContent>
</Card>
{/* PRs Card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-3">
<Trophy size={18} className="text-amber-500" />
<Text className="font-medium text-foreground">Personal Records</Text>
</View>
{mockPRs.length > 0 ? (
<View className="gap-3">
{mockPRs.map((pr, index) => (
<View key={index} className="gap-1">
<Text className="font-medium">{pr.exercise}</Text>
<View className="flex-row justify-between">
<Text className="text-sm text-muted-foreground">New PR:</Text>
<Text className="text-sm font-medium text-amber-500">{pr.value}</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-sm text-muted-foreground">Previous:</Text>
<Text className="text-sm text-muted-foreground">{pr.previous}</Text>
</View>
{index < mockPRs.length - 1 && <Separator className="my-2" />}
</View>
))}
</View>
) : (
<Text className="text-muted-foreground">No personal records set in this workout</Text>
)}
</CardContent>
</Card>
{/* Selected storage option card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-2">
<Cog size={18} className="text-muted-foreground" />
<Text className="font-medium text-foreground">Selected Options</Text>
</View>
<View className="flex-row items-center">
<Text className="text-base text-muted-foreground flex-1">
Storage:
</Text>
<Badge
variant={options.storageType === 'local_only' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>
{options.storageType === 'local_only'
? 'Local Only'
: options.storageType === 'publish_complete'
? 'Full Metrics'
: 'Limited Metrics'
}
</Text>
</Badge>
</View>
</CardContent>
</Card>
</View>
{/* Navigation buttons */}
<View className="flex-row gap-3 mt-4 mb-6">
<Button
variant="outline"
className="flex-1"
onPress={onBack}
>
<Text>Back</Text>
</Button>
<Button
className="flex-1"
style={{ backgroundColor: purpleColor }}
onPress={onFinish}
>
<Text className="text-white font-medium">
Finish Workout
</Text>
</Button>
</View>
</View>
</ScrollView>
);
}
// Celebration component with share option
function CelebrationTab({
options,
onComplete
}: {
options: WorkoutCompletionOptions,
onComplete: (options: WorkoutCompletionOptions) => void
}) {
const { isAuthenticated } = useNDKCurrentUser();
const { activeWorkout } = useWorkoutStore();
const [showConfetti, setShowConfetti] = useState(true);
const [shareMessage, setShareMessage] = useState('');
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
// Generate default share message
useEffect(() => {
// Create default message based on workout data
let message = "Just completed a workout! 💪";
if (activeWorkout) {
const exerciseCount = activeWorkout.exercises.length;
const completedSets = activeWorkout.exercises.reduce(
(total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length, 0
);
// Add workout details
message = `Just completed a workout with ${exerciseCount} exercises and ${completedSets} sets! 💪`;
// Add mock PR info
if (Math.random() > 0.5) {
message += " Hit some new PRs today! 🏆";
}
}
setShareMessage(message);
}, [activeWorkout]);
const handleShare = () => {
// This will trigger a kind 1 note creation via the onComplete handler
onComplete({
...options,
shareOnSocial: true,
socialMessage: shareMessage
});
};
const handleSkip = () => {
// Complete the workout without sharing
onComplete({
...options,
shareOnSocial: false
});
};
return (
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View className="flex-1 items-center justify-center px-4 py-8">
{showConfetti && (
<Confetti onComplete={() => setShowConfetti(false)} />
)}
<View className="w-full max-w-md">
{/* Trophy and heading */}
<View className="items-center mb-8">
<Trophy size={60} color="#F59E0B" />
<Text className="text-2xl font-bold text-center mt-4 mb-2">
Workout Complete!
</Text>
<Text className="text-center text-muted-foreground">
Great job on finishing your workout!
</Text>
</View>
{/* Show sharing options for Nostr if appropriate */}
{isAuthenticated && options.storageType !== 'local_only' && (
<>
<Separator className="my-4" />
<View className="mb-4">
<Text className="text-lg font-semibold mb-3">
Share Your Achievement
</Text>
<Text className="text-muted-foreground mb-3">
Share your workout with your followers on Nostr
</Text>
<Input
multiline
numberOfLines={4}
value={shareMessage}
onChangeText={setShareMessage}
className="min-h-[120px] p-3 mb-4"
/>
<Button
className="w-full mb-3"
style={{ backgroundColor: purpleColor }}
onPress={handleShare}
>
<Text className="text-white font-medium">
Share to Nostr
</Text>
</Button>
<Button
variant="outline"
className="w-full"
onPress={handleSkip}
>
<Text>
Skip Sharing
</Text>
</Button>
</View>
</>
)}
{/* If local-only or not authenticated */}
{(options.storageType === 'local_only' || !isAuthenticated) && (
<Button
className="w-full mt-4"
style={{ backgroundColor: purpleColor }}
onPress={handleSkip}
>
<Text className="text-white font-medium">
Continue
</Text>
</Button>
)}
</View>
</View>
</ScrollView>
);
}
export function WorkoutCompletionFlow({
onComplete,
onCancel
}: {
onComplete: (options: WorkoutCompletionOptions) => void;
onCancel: () => void;
}) {
// States
const [options, setOptions] = useState<WorkoutCompletionOptions>({
storageType: 'local_only',
shareOnSocial: false,
templateAction: 'keep_original',
});
const [step, setStep] = useState<'options' | 'summary' | 'celebration'>('options');
// Navigate through steps
const handleNext = () => {
setStep('summary');
};
const handleBack = () => {
setStep('options');
};
const handleFinish = () => {
// Move to celebration screen
setStep('celebration');
};
const handleComplete = (finalOptions: WorkoutCompletionOptions) => {
// Call the completion function with the selected options
onComplete(finalOptions);
};
const renderStep = () => {
switch (step) {
case 'options':
return (
<StorageOptionsTab
options={options}
setOptions={setOptions}
onNext={handleNext}
/>
);
case 'summary':
return (
<SummaryTab
options={options}
onBack={handleBack}
onFinish={handleFinish}
/>
);
case 'celebration':
return (
<CelebrationTab
options={options}
onComplete={handleComplete}
/>
);
default:
return null;
}
};
return (
<View className="flex-1">
{renderStep()}
</View>
);
}