// components/library/ExerciseSheet.tsx import React, { useState, useEffect } from 'react'; import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard, Modal, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { generateId } from '@/utils/ids'; import { X } from 'lucide-react-native'; import { useColorScheme } from '@/lib/theme/useColorScheme'; import { BaseExercise, ExerciseType, ExerciseCategory, Equipment, ExerciseFormat, ExerciseFormatUnits, ExerciseDisplay } from '@/types/exercise'; import { StorageSource } from '@/types/shared'; import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import { useNDKStore } from '@/lib/stores/ndk'; import { useExerciseService, usePublicationQueue } from '@/components/DatabaseProvider'; interface ExerciseSheetProps { isOpen: boolean; onClose: () => void; onSubmit: (exercise: BaseExercise) => void; exerciseToEdit?: ExerciseDisplay; // Optional - if provided, we're in edit mode mode?: 'create' | 'edit' | 'fork'; // Optional - defaults to 'create' or 'edit' based on exerciseToEdit } const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight']; const CATEGORIES: ExerciseCategory[] = ['Push', 'Pull', 'Legs', 'Core']; const EQUIPMENT_OPTIONS: Equipment[] = [ 'bodyweight', 'barbell', 'dumbbell', 'kettlebell', 'machine', 'cable', 'other' ]; // Default empty form data const DEFAULT_FORM_DATA = { title: '', type: 'strength' as ExerciseType, category: 'Push' as ExerciseCategory, equipment: undefined as Equipment | undefined, description: '', tags: [] as string[], format: { weight: true, reps: true, rpe: true, set_type: true } as ExerciseFormat, format_units: { weight: 'kg', reps: 'count', rpe: '0-10', set_type: 'warmup|normal|drop|failure' } as ExerciseFormatUnits }; export function ExerciseSheet({ isOpen, onClose, onSubmit, exerciseToEdit, mode: explicitMode }: ExerciseSheetProps) { const { isDarkColorScheme } = useColorScheme(); const [formData, setFormData] = useState(DEFAULT_FORM_DATA); const ndkStore = useNDKStore(); const publicationQueue = usePublicationQueue(); // Determine if we're in edit, create, or fork mode const hasExercise = !!exerciseToEdit; const isNostrExercise = exerciseToEdit?.source === 'nostr'; const isCurrentUserAuthor = isNostrExercise && exerciseToEdit?.availability?.lastSynced?.nostr?.metadata?.pubkey === ndkStore.currentUser?.pubkey; // Use explicit mode if provided, otherwise determine based on context const mode = explicitMode || (hasExercise ? (isNostrExercise && !isCurrentUserAuthor ? 'fork' : 'edit') : 'create'); const isEditMode = mode === 'edit'; const isForkMode = mode === 'fork'; // Load data from exerciseToEdit when in edit mode useEffect(() => { if (isOpen && exerciseToEdit) { setFormData({ title: exerciseToEdit.title, type: exerciseToEdit.type, category: exerciseToEdit.category, equipment: exerciseToEdit.equipment, description: exerciseToEdit.description || '', tags: exerciseToEdit.tags || [], format: exerciseToEdit.format || DEFAULT_FORM_DATA.format, format_units: exerciseToEdit.format_units || DEFAULT_FORM_DATA.format_units }); } else if (isOpen && !exerciseToEdit) { // Reset form when opening in create mode setFormData(DEFAULT_FORM_DATA); } }, [isOpen, exerciseToEdit]); // Reset form data when modal closes useEffect(() => { if (!isOpen) { // Add a delay to ensure the closing animation completes first const timer = setTimeout(() => { setFormData(DEFAULT_FORM_DATA); }, 300); return () => clearTimeout(timer); } }, [isOpen]); const handleSubmit = async () => { if (!formData.title || !formData.equipment) return; const timestamp = Date.now(); const isNostrExercise = exerciseToEdit?.source === 'nostr'; const canEditNostr = isNostrExercise && isCurrentUserAuthor; // Create BaseExercise const exercise: BaseExercise = { // Generate new ID when forking, otherwise use existing or generate new id: isForkMode ? generateId() : (exerciseToEdit?.id || generateId()), title: formData.title, type: formData.type, category: formData.category, equipment: formData.equipment, description: formData.description, tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()], format: formData.format, format_units: formData.format_units, // Use current timestamp for fork, otherwise preserve original or use current created_at: isForkMode ? timestamp : (exerciseToEdit?.created_at || timestamp), // For forked exercises, create new local availability availability: isForkMode ? { source: ['local' as StorageSource], lastSynced: undefined } : (exerciseToEdit?.availability || { source: ['local' as StorageSource], lastSynced: undefined }) }; // If this is a Nostr exercise we can edit OR a new exercise while authenticated, // we should create and possibly publish the Nostr event if ((canEditNostr || (!exerciseToEdit && ndkStore.isAuthenticated)) && !isForkMode) { try { // Create tags for the exercise const nostrTags = [ ['d', exercise.id], // Use the same 'd' tag to make it replaceable ['title', exercise.title], ['type', exercise.type], ['category', exercise.category], ['equipment', exercise.equipment || ''], ...(exercise.tags.map(tag => ['t', tag])), // Format tags - handle possible undefined with null coalescing operator ['format', ...Object.keys(exercise.format || {}).filter(k => exercise.format && exercise.format[k as keyof ExerciseFormat] )] ]; // Add format units if they exist if (exercise.format_units) { const unitEntries = Object.entries(exercise.format_units); if (unitEntries.length > 0) { nostrTags.push(['format_units', ...unitEntries.flat()]); } } // Create and attempt to publish the event const event = new NDKEvent(ndkStore.ndk || undefined); event.kind = 33401; // Or whatever kind you need event.content = exercise.description || ''; event.tags = nostrTags; await event.sign(); if (event) { // Queue for publication (this will publish immediately if online) await publicationQueue.queueEvent(event); // If this is a new exercise, add nostr to sources if (!exerciseToEdit) { exercise.availability.source.push('nostr'); // Add nostr metadata exercise.availability.lastSynced = { ...exercise.availability.lastSynced, nostr: { timestamp: Date.now(), metadata: { id: event.id || exercise.id, pubkey: ndkStore.currentUser?.pubkey || '', relayUrl: 'wss://relay.damus.io', // Default relay created_at: event.created_at || Math.floor(Date.now() / 1000) } } }; } console.log(isEditMode ? 'Exercise updated on Nostr' : 'Exercise published to Nostr'); } } catch (error) { console.error('Error with Nostr event:', error); // Continue with local update even if Nostr fails } } // Close first, then submit with a small delay onClose(); setTimeout(() => { onSubmit(exercise); }, 50); }; // Purple color used throughout the app const purpleColor = 'hsl(261, 90%, 66%)'; // Get title and button text based on mode const getTitle = () => { if (isEditMode) return "Edit Exercise"; if (isForkMode) return "Fork Exercise"; return "Create New Exercise"; }; const getButtonText = () => { if (isEditMode) return "Update Exercise"; if (isForkMode) return "Save as My Exercise"; return "Create Exercise"; }; // Return null if not open if (!isOpen) return null; return ( <Modal visible={isOpen} transparent={true} animationType="slide" onRequestClose={onClose} > <View className="flex-1 justify-center items-center bg-black/70"> <View className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`} style={{ maxHeight: 700 }} > {/* Header */} <View className="flex-row justify-between items-center p-4 border-b border-border"> <Text className="text-xl font-bold text-foreground">{getTitle()}</Text> <TouchableOpacity onPress={onClose} className="p-1"> <X size={24} /> </TouchableOpacity> </View> {/* Content */} <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }} > <TouchableWithoutFeedback onPress={Keyboard.dismiss}> <View style={{ flex: 1 }}> <ScrollView className="flex-1" showsVerticalScrollIndicator={false}> <View className="gap-5 py-5 px-4"> {/* Source badge for edit/fork mode */} {(isEditMode || isForkMode) && ( <View className="flex-row mb-2 items-center gap-2"> <View className={`px-2 py-1 rounded-md ${exerciseToEdit?.source === 'nostr' ? 'bg-purple-100 dark:bg-purple-900' : 'bg-blue-100 dark:bg-blue-900'}`}> <Text className={`text-xs ${exerciseToEdit?.source === 'nostr' ? 'text-purple-800 dark:text-purple-200' : 'text-blue-800 dark:text-blue-200'}`}> {exerciseToEdit?.source === 'nostr' ? 'Nostr' : exerciseToEdit?.source} </Text> </View> {/* Show forked badge when in fork mode */} {isForkMode && ( <View className="px-2 py-1 rounded-md bg-amber-100 dark:bg-amber-900"> <Text className="text-xs text-amber-800 dark:text-amber-200"> Creating Local Copy </Text> </View> )} </View> )} <View> <Text className="text-base font-medium mb-2">Exercise Name</Text> <Input value={formData.title} onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))} placeholder="e.g., Barbell Back Squat" className="text-foreground" /> {!formData.title && ( <Text className="text-xs text-muted-foreground mt-1 ml-1"> * Required field </Text> )} </View> <View> <Text className="text-base font-medium mb-2">Type</Text> <View className="flex-row flex-wrap gap-2"> {EXERCISE_TYPES.map((type) => ( <Button key={type} variant={formData.type === type ? 'default' : 'outline'} onPress={() => setFormData(prev => ({ ...prev, type }))} style={formData.type === type ? { backgroundColor: purpleColor } : {}} > <Text className={formData.type === type ? 'text-white' : ''}> {type.charAt(0).toUpperCase() + type.slice(1)} </Text> </Button> ))} </View> </View> <View> <Text className="text-base font-medium mb-2">Category</Text> <View className="flex-row flex-wrap gap-2"> {CATEGORIES.map((category) => ( <Button key={category} variant={formData.category === category ? 'default' : 'outline'} onPress={() => setFormData(prev => ({ ...prev, category }))} style={formData.category === category ? { backgroundColor: purpleColor } : {}} > <Text className={formData.category === category ? 'text-white' : ''}> {category} </Text> </Button> ))} </View> </View> <View> <Text className="text-base font-medium mb-2">Equipment</Text> <View className="flex-row flex-wrap gap-2"> {EQUIPMENT_OPTIONS.map((eq) => ( <Button key={eq} variant={formData.equipment === eq ? 'default' : 'outline'} onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))} style={formData.equipment === eq ? { backgroundColor: purpleColor } : {}} > <Text className={formData.equipment === eq ? 'text-white' : ''}> {eq.charAt(0).toUpperCase() + eq.slice(1)} </Text> </Button> ))} </View> {!formData.equipment && ( <Text className="text-xs text-muted-foreground mt-1 ml-1"> * Required field </Text> )} </View> <View> <Text className="text-base font-medium mb-2">Description</Text> <Input value={formData.description} onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))} placeholder="Exercise description..." multiline numberOfLines={4} textAlignVertical="top" className="min-h-24 py-2" /> </View> <View> <Text className="text-base font-medium mb-2">Tags</Text> <Input value={formData.tags.join(', ')} onChangeText={(text) => { const tags = text.split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0); setFormData(prev => ({ ...prev, tags })); }} placeholder="strength, compound, legs..." className="text-foreground" /> <Text className="text-xs text-muted-foreground mt-1 ml-1"> Separate tags with commas </Text> </View> {/* Additional Nostr information */} {exerciseToEdit?.source === 'nostr' && exerciseToEdit?.availability?.lastSynced?.nostr && ( <View className="mt-2 p-3 bg-muted rounded-md"> <Text className="text-sm text-muted-foreground"> Last synced with Nostr: {new Date(exerciseToEdit.availability.lastSynced.nostr.timestamp).toLocaleString()} </Text> {isEditMode && !isCurrentUserAuthor && ( <Text className="text-xs text-amber-500 mt-1"> You're not the original author. Use the "Fork" option to create your own copy. </Text> )} {isEditMode && isCurrentUserAuthor && !ndkStore.isAuthenticated && ( <Text className="text-xs"> Changes will be saved locally and synced to Nostr when you're online and logged in. </Text> )} {isForkMode && ( <Text className="text-xs text-green-500 mt-1"> Creating a local copy of this exercise that you can customize </Text> )} {isNostrExercise && exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey && ( <Text className="text-xs text-muted-foreground mt-1"> Author: {exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey.substring(0, 8)}... </Text> )} </View> )} </View> </ScrollView> {/* Create/Update button at bottom */} <View className="p-4 border-t border-border"> {/* Show fork button when editing Nostr content we don't own */} {isEditMode && isNostrExercise && !isCurrentUserAuthor ? ( <View className="flex-row gap-2"> <Button className="flex-1 py-5" variant='outline' onPress={onClose} > <Text>Cancel</Text> </Button> <Button className="flex-1 py-5" variant='default' onPress={() => { // Close this modal and reopen in fork mode onClose(); // This would be implemented in the parent component // by reopening the modal with mode="fork" }} style={{ backgroundColor: purpleColor }} > <Text className="text-white font-semibold"> Fork Exercise </Text> </Button> </View> ) : ( // Regular submit button for create/edit/fork <Button className="w-full py-5" variant='default' onPress={handleSubmit} disabled={!formData.title || !formData.equipment} style={(!formData.title || !formData.equipment) ? {} : { backgroundColor: purpleColor }} > <Text className={(!formData.title || !formData.equipment) ? 'text-white opacity-50' : 'text-white font-semibold'}> {getButtonText()} </Text> </Button> )} </View> </View> </TouchableWithoutFeedback> </KeyboardAvoidingView> </View> </View> </Modal> ); }