From 4eb9d428a2933ed0615b8337b3d3266eb91d4ba7 Mon Sep 17 00:00:00 2001 From: DocNR Date: Thu, 6 Mar 2025 09:19:16 -0500 Subject: [PATCH] updated exercise/template UI, publication queue, forking of exercises (templates next) --- CHANGELOG.md | 111 +- README.md | 129 +-- app/(tabs)/library/exercises.tsx | 153 ++- app/(tabs)/library/templates.tsx | 38 +- app/(workout)/add-exercises.tsx | 4 +- components/exercises/ExerciseDetails.tsx | 351 ------- components/exercises/ModalExerciseDetails.tsx | 556 ++++++++++ components/library/ExerciseSheet.tsx | 475 +++++++++ components/library/NewExerciseSheet.tsx | 230 ----- components/library/NewTemplateSheet.tsx | 152 +-- components/templates/ModalTemplateDetails.tsx | 947 ++++++++++++++++++ components/templates/TemplateCard.tsx | 23 +- lib/db/schema.ts | 37 +- lib/db/services/ConnectivityService.ts | 133 +++ lib/db/services/PublicationQueueService.ts | 172 ++++ lib/hooks/useExercises.tsx | 39 + lib/stores/ndk.ts | 228 +++++ package-lock.json | 10 + package.json | 1 + tsconfig.json | 1 + 20 files changed, 3010 insertions(+), 780 deletions(-) delete mode 100644 components/exercises/ExerciseDetails.tsx create mode 100644 components/exercises/ModalExerciseDetails.tsx create mode 100644 components/library/ExerciseSheet.tsx delete mode 100644 components/library/NewExerciseSheet.tsx create mode 100644 components/templates/ModalTemplateDetails.tsx create mode 100644 lib/db/services/ConnectivityService.ts create mode 100644 lib/db/services/PublicationQueueService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8689036..5d52475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Comprehensive exercise management features + - Added exercise editing functionality + - Implemented exercise forking for Nostr exercises + - Created local-first editing with offline support + - Added publication queue for deferred Nostr publishing + - Built robust exercise update workflow + - Implemented source-aware editing permissions +- Connectivity service for network state management + - Added real-time connectivity monitoring + - Implemented persistence for offline state + - Built automatic retry system for failed requests + - Created hook-based connectivity API for components +- Extended database schema for publication queuing + - Added publication_queue table + - Implemented attempt tracking and rate limiting + - Added app_status table for system-wide states - Successful Nostr protocol integration - Implemented NDK-mobile for React Native compatibility - Added secure key management with Expo SecureStore @@ -33,12 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workout persistence and recovery - Built automatic timer management with background support - Developed minimization and maximization functionality -- Zustand workout store for state management - - Created comprehensive workout state store with Zustand - - Implemented selectors for efficient state access - - Added workout persistence and recovery - - Built automatic timer management with background support - - Developed minimization and maximization functionality - Workout tracking implementation with real-time tracking - Added workout timer with proper background handling - Implemented rest timer functionality @@ -85,6 +95,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved workout history visualization ### Changed +- Enhanced exercise detail viewer + - Replaced bottom sheet with full-screen modal + - Added tabbed interface for information organization + - Implemented edit capability with ownership detection + - Added fork functionality for Nostr exercises from other users + - Improved progress visualization with charts +- Redesigned exercise editor + - Created multi-purpose editor for create/edit/fork workflows + - Added context-aware UI based on exercise source + - Implemented specialized buttons based on workflow type + - Added better form validation and feedback + - Improved keyboard handling across platforms +- Improved workflow architecture for model context protocol + - Implemented offline-first editing paradigm + - Added cryptographic signing before submission + - Built local caching with deferred publishing + - Created connectivity-aware operation queueing + - Added proper error recovery and retry mechanisms - Improved workout screen navigation consistency - Standardized screen transitions and gestures - Added back buttons for clearer navigation @@ -128,6 +156,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enhanced visual separation between template metadata and content ### Fixed +- Exercise update functionality using delete-recreate pattern +- Exercise data type handling in forking operation +- TypeScript errors in exercise component interfaces +- Nostr event queuing and retry mechanism +- Exercise ownership detection for edit vs fork workflows +- Connectivity monitoring edge cases - Workout navigation gesture handling issues - Workout timer inconsistency during app background state - Exercise deletion functionality @@ -144,7 +178,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Content rendering issues in bottom sheet components ### Technical Details -1. Nostr Integration: +1. Exercise Management: + - Implemented edit/fork/create workflows with unified interface + - Built local-first editing pattern with offline support + - Added publication queue for deferred Nostr submissions + - Created robust update mechanism in useExercises hook + - Implemented source-aware editing permissions + - Added ownership detection for exercise operations + +2. Connectivity Management: + - Implemented singleton ConnectivityService for app-wide monitoring + - Added NetInfo integration for real-time status detection + - Built React hook for component-level connectivity awareness + - Created database persistence for connectivity state + - Implemented event-based notification system + +3. Nostr Integration: - Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility - Created dedicated NDK store using Zustand for state management - Built secure key storage and retrieval using Expo SecureStore @@ -152,55 +201,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added relay connection management with status tracking - Developed proper error handling for network operations -2. Cryptographic Implementation: +4. Cryptographic Implementation: - Integrated react-native-get-random-values for crypto API polyfill - Implemented NDKMobilePrivateKeySigner for key operations - Added proper key format handling (hex, nsec) - Created secure key generation functionality - Built robust error handling for cryptographic operations -3. Programs Testing Component: +5. Programs Testing Component: - Developed dual-purpose interface for Database and Nostr testing - Implemented login system with key generation and secure storage - Built event creation interface with multiple event kinds - Added event querying and display functionality - Created detailed event inspection with tag visualization - Added relay status monitoring -4. Database Schema Enforcement: + +6. Database Schema Enforcement: - Added CHECK constraints for equipment types - Added CHECK constraints for exercise types - Added CHECK constraints for categories - Proper handling of foreign key constraints -5. Input Validation: + +7. Input Validation: - Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other - Exercise types: strength, cardio, bodyweight - Categories: Push, Pull, Legs, Core - Difficulty levels: beginner, intermediate, advanced - Movement patterns: push, pull, squat, hinge, carry, rotation -6. Error Handling: + +8. Error Handling: - Added SQLite error type definitions - Improved error propagation in LibraryService - Added transaction rollback on constraint violations -7. Database Services: + +9. Database Services: - Added EventCache service for Nostr events - Improved ExerciseService with transaction awareness - Added DevSeederService for development data - Enhanced error handling and logging -8. Workout State Management with Zustand: - - Implemented selector pattern for performance optimization - - Added module-level timer references for background operation - - Created workout persistence with auto-save functionality - - Developed state recovery for crash protection - - Added support for future Nostr integration - - Implemented workout minimization for multi-tasking -9. Template Details UI Architecture: - - Implemented MaterialTopTabNavigator for content organization - - Created screen-specific components for each tab - - Developed conditional rendering based on template source - - Implemented context-aware action buttons - - Added proper navigation state handling + +10. Workout State Management with Zustand: + - Implemented selector pattern for performance optimization + - Added module-level timer references for background operation + - Created workout persistence with auto-save functionality + - Developed state recovery for crash protection + - Added support for future Nostr integration + - Implemented workout minimization for multi-tasking + +11. Template Details UI Architecture: + - Implemented MaterialTopTabNavigator for content organization + - Created screen-specific components for each tab + - Developed conditional rendering based on template source + - Implemented context-aware action buttons + - Added proper navigation state handling ### Migration Notes +- Exercise editing now follows an offline-first approach with Nostr awareness +- ExerciseSheet component replaces separate create/edit components +- Exercise updates require proper source and metadata handling +- Publication queue provides automatic retry for Nostr events - Exercise creation now enforces schema constraints - Input validation prevents invalid data entry - Enhanced error messages provide better debugging information diff --git a/README.md b/README.md index 813574f..beb47d1 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,30 @@ # POWR - Cross-Platform Fitness Tracking App -POWR is a local-first fitness tracking application built with React Native and Expo, featuring planned Nostr protocol integration for decentralized social features. +POWR is a local-first fitness tracking application built with React Native and Expo, featuring integration with the Nostr protocol for decentralized social features and improved control of your fitness data. ## Features ### Current -- Exercise library management +- Exercise library management with local SQLite database - Workout template creation -- Local-first data architecture +- Local-first data architecture with Nostr sync capability - Cross-platform support (iOS, Android) -- Dark mode support +- Dark/light mode support +- Nostr authentication and event publishing ### Planned - Workout record and template sharing -- Nostr integration -- Social features +- Enhanced social features - Training programs - Performance analytics +- Public/private workout sharing options ## Getting Started ### Prerequisites - Node.js (v18 or later) - npm or yarn -- Expo CLI +- EAS CLI (`npm install -g eas-cli`) - iOS Simulator (for iOS development) - Android Studio (for Android development) @@ -40,78 +41,96 @@ cd powr npm install ``` -3. Start the development server +3. Install development client modules ```bash -npx expo start +npx expo install expo-dev-client expo-crypto expo-nip55 ``` -### Development Options -- Press 'i' for iOS simulator -- Press 'a' for Android simulator -- Scan QR code with Expo Go app for physical device +### Development Using Expo Dev Client + +POWR now uses Expo Dev Client for development instead of Expo Go. This allows us to use native modules required for Nostr integration. + +1. Configure EAS (if not already done) +```bash +eas build:configure +``` + +2. Create a development build +```bash +# For Android +eas build --profile development --platform android + +# For iOS +eas build --profile development --platform ios +``` + +3. Start the development server with dev client +```bash +npx expo start --dev-client +``` + +4. Install the build on your device and scan the QR code to connect ## Project Structure ```plaintext powr/ ├── app/ # Main application code -│ ├── (tabs)/ # Tab-based navigation -│ └── components/ # Shared components -├── assets/ # Static assets -├── docs/ # Documentation -│ └── design/ # Design documents -├── lib/ # Shared utilities -└── types/ # TypeScript definitions +│ ├── (tabs)/ # Tab-based navigation +│ ├── (workout)/ # Workout screens +│ └── _layout.tsx # Root layout +├── components/ # Shared components +│ ├── ui/ # UI components +│ ├── sheets/ # Bottom sheets +│ └── library/ # Library components +├── lib/ # Shared utilities +│ ├── db/ # Database services +│ ├── hooks/ # Custom React hooks +│ ├── stores/ # Zustand stores +│ └── mobile-signer.ts # Nostr signer implementation +├── types/ # TypeScript definitions +└── utils/ # Utility functions ``` ## Technology Stack ### Core - React Native -- Expo +- Expo (with Dev Client) - TypeScript - SQLite (via expo-sqlite) +- Zustand (state management) ### UI Components -- NativeWind +- NativeWind/Tailwind - React Navigation - Lucide Icons -### Testing -- Jest -- React Native Testing Library +### Nostr Integration +- NDK (Nostr Development Kit) +- Custom mobile signer implementation +- Local event caching -## Development +## Database Architecture -### Environment Setup -1. Install development tools -```bash -npm install -g expo-cli -``` +POWR uses a SQLite database with a service-oriented architecture: +- Exercise data +- Workout templates +- Nostr event caching +- User profiles -2. Configure environment -```bash -cp .env.example .env -``` +Each domain has dedicated service classes for data operations. -3. Configure development settings -```bash -npm run setup-dev -``` +## Nostr Integration -### Running Tests -```bash -# Run all tests -npm test +POWR implements the Nostr protocol via NDK with: +- Secure key management using expo-secure-store +- Event publishing for exercises, templates, and workouts +- Profile discovery and following +- Custom event kinds for fitness data -# Run with coverage -npm test -- --coverage +## Building for Production -# Run in watch mode -npm test -- --watch -``` - -### Building for Production ```bash # Build for iOS eas build -p ios @@ -128,15 +147,6 @@ eas build -p android 4. Push to the branch 5. Open a Pull Request -Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and development process. - -## Documentation - -- [Project Overview](docs/project-overview.md) -- [Architecture Guide](docs/architecture.md) -- [API Documentation](docs/api.md) -- [Testing Guide](docs/testing.md) - ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. @@ -145,4 +155,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [Expo](https://expo.dev/) - [React Native](https://reactnative.dev/) +- [NDK](https://github.com/nostr-dev-kit/ndk) - [Nostr Protocol](https://github.com/nostr-protocol/nostr) \ No newline at end of file diff --git a/app/(tabs)/library/exercises.tsx b/app/(tabs)/library/exercises.tsx index 79fddba..db3d3b1 100644 --- a/app/(tabs)/library/exercises.tsx +++ b/app/(tabs)/library/exercises.tsx @@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Search, Dumbbell, ListFilter } from 'lucide-react-native'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; -import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; +import { ExerciseSheet } from '@/components/library/ExerciseSheet'; import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList'; -import { ExerciseDetails } from '@/components/exercises/ExerciseDetails'; +import { ModalExerciseDetails } from '@/components/exercises/ModalExerciseDetails'; import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise'; import { useExercises } from '@/lib/hooks/useExercises'; import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; import { useWorkoutStore } from '@/stores/workoutStore'; +import { generateId } from '@/utils/ids'; +import { useNDKStore } from '@/lib/stores/ndk'; // Default available filters const availableFilters = { @@ -28,13 +30,23 @@ const initialFilters: FilterOptions = { }; export default function ExercisesScreen() { - const [showNewExercise, setShowNewExercise] = useState(false); + // Basic state const [searchQuery, setSearchQuery] = useState(''); - const [selectedExercise, setSelectedExercise] = useState(null); const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [currentFilters, setCurrentFilters] = useState(initialFilters); const [activeFilters, setActiveFilters] = useState(0); + + // Exercise sheet state + const [showExerciseSheet, setShowExerciseSheet] = useState(false); + const [exerciseToEdit, setExerciseToEdit] = useState(undefined); + const [editMode, setEditMode] = useState<'create' | 'edit' | 'fork'>('create'); + + // Exercise details state + const [selectedExercise, setSelectedExercise] = useState(null); + + // Other hooks const { isActive, isMinimized } = useWorkoutStore(); + const { currentUser } = useNDKStore(); const shouldShowFAB = !isActive || !isMinimized; const { @@ -43,6 +55,7 @@ export default function ExercisesScreen() { error, createExercise, deleteExercise, + updateExercise, refreshExercises, updateFilters, clearFilters @@ -61,22 +74,98 @@ export default function ExercisesScreen() { setSelectedExercise(exercise); }; - const handleEdit = async () => { - // TODO: Implement edit functionality - setSelectedExercise(null); + // Mock exercise update function + const handleUpdateExercise = async (id: string, updatedData: Partial): Promise => { + try { + // Since we don't have a real update function, we'll fake it with delete + create + // In a real app, this would be replaced with an actual update API call + console.log(`Updating exercise ${id} with data:`, updatedData); + + // Delete the old exercise + await deleteExercise(id); + + // Create a new exercise with the same ID and updated data + await createExercise({ + ...updatedData, + availability: updatedData.availability || { source: ['local'] } + } as Omit); + + // Refresh the exercise list + refreshExercises(); + } catch (error) { + console.error('Error updating exercise:', error); + } }; - const handleCreateExercise = async (exerciseData: BaseExercise) => { - // Convert BaseExercise to include required source information - const exerciseWithSource: Omit = { - ...exerciseData, - availability: { - source: ['local'] - } - }; + // Handle editing an exercise + const handleEdit = () => { + if (!selectedExercise) return; - await createExercise(exerciseWithSource); - setShowNewExercise(false); + // Close the details modal + setSelectedExercise(null); + + // Determine if we should edit or fork based on Nostr ownership + const isNostrExercise = selectedExercise.source === 'nostr'; + const isCurrentUserAuthor = isNostrExercise && + selectedExercise.availability?.lastSynced?.nostr?.metadata?.pubkey === currentUser?.pubkey; + + const mode = isNostrExercise && !isCurrentUserAuthor ? 'fork' : 'edit'; + + // Set up edit state + setEditMode(mode); + setExerciseToEdit(selectedExercise); + + // Open the exercise sheet + setShowExerciseSheet(true); + }; + + // Handle creating a new exercise + const handleCreateExercise = () => { + setEditMode('create'); + setExerciseToEdit(undefined); + setShowExerciseSheet(true); + }; + + // Handle submitting exercise form (create, edit, or fork) + const handleSubmitExercise = async (exerciseData: BaseExercise) => { + try { + if (editMode === 'create') { + // For new exercises, ensure the availability is set + const exerciseWithSource: Omit = { + ...exerciseData, + availability: { + source: ['local'] + } + }; + // Remove the ID from the data for new creation + delete (exerciseWithSource as any).id; + + await createExercise(exerciseWithSource); + } + else if (editMode === 'edit') { + // Use the new updateExercise function directly + await updateExercise(exerciseData.id, exerciseData); + } + else if (editMode === 'fork') { + // For forking, create a new exercise but keep the original data + const { id: _, ...forkedExerciseData } = exerciseData; + const forkedExercise: Omit = { + ...forkedExerciseData, + availability: { + source: ['local'] // Start as a local exercise + } + }; + await createExercise(forkedExercise); + } + + // Refresh the exercise list after changes + refreshExercises(); + } catch (error) { + console.error('Error handling exercise submission:', error); + } + + // Close the sheet regardless of success/failure + setShowExerciseSheet(false); }; const handleApplyFilters = (filters: FilterOptions) => { @@ -158,30 +247,28 @@ export default function ExercisesScreen() { /> {/* Exercise details sheet */} - {selectedExercise && ( - { - if (!open) setSelectedExercise(null); - }} - onEdit={handleEdit} - /> - )} + setSelectedExercise(null)} + onEdit={handleEdit} + /> {/* FAB for adding new exercise */} {shouldShowFAB && ( setShowNewExercise(true)} + onPress={handleCreateExercise} /> )} - {/* New exercise sheet */} - setShowNewExercise(false)} - onSubmit={handleCreateExercise} + {/* Exercise sheet for create/edit/fork */} + setShowExerciseSheet(false)} + onSubmit={handleSubmitExercise} + exerciseToEdit={exerciseToEdit} + mode={editMode} /> ); diff --git a/app/(tabs)/library/templates.tsx b/app/(tabs)/library/templates.tsx index 6f8638c..1456fec 100644 --- a/app/(tabs)/library/templates.tsx +++ b/app/(tabs)/library/templates.tsx @@ -10,6 +10,7 @@ import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { NewTemplateSheet } from '@/components/library/NewTemplateSheet'; import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; import { TemplateCard } from '@/components/templates/TemplateCard'; +import { ModalTemplateDetails } from '@/components/templates/ModalTemplateDetails'; import { Button } from '@/components/ui/button'; import { Template, @@ -74,12 +75,21 @@ export default function TemplatesScreen() { const { isActive, isMinimized } = useWorkoutStore(); const shouldShowFAB = !isActive || !isMinimized; + // State for the modal template details + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const [showTemplateModal, setShowTemplateModal] = useState(false); + const handleDelete = (id: string) => { setTemplates(current => current.filter(t => t.id !== id)); }; const handleTemplatePress = (template: Template) => { - router.push(`/template/${template.id}`); + // Just open the modal without navigating to a route + setSelectedTemplateId(template.id); + setShowTemplateModal(true); + + // We're no longer using this: + // router.push(`/template/${template.id}`); }; const handleStartWorkout = async (template: Template) => { @@ -127,6 +137,23 @@ export default function TemplatesScreen() { setActiveFilters(totalFilters); }; + // Handle modal close + const handleModalClose = () => { + setShowTemplateModal(false); + }; + + // Handle favorite change from modal + const handleModalFavoriteChange = (templateId: string, isFavorite: boolean) => { + // Update local state to reflect change + setTemplates(current => + current.map(t => + t.id === templateId + ? { ...t, isFavorite } + : t + ) + ); + }; + useFocusEffect( React.useCallback(() => { // Refresh template favorite status when tab gains focus @@ -275,6 +302,15 @@ export default function TemplatesScreen() { /> )} + {/* Template Details Modal */} + + + {/* New Template Sheet */} setShowNewTemplate(false)} diff --git a/app/(workout)/add-exercises.tsx b/app/(workout)/add-exercises.tsx index b32ff5e..270f616 100644 --- a/app/(workout)/add-exercises.tsx +++ b/app/(workout)/add-exercises.tsx @@ -13,7 +13,7 @@ import { TabScreen } from '@/components/layout/TabScreen'; import { ChevronLeft, Search, Plus } from 'lucide-react-native'; import { BaseExercise } from '@/types/exercise'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; +import { ExerciseSheet } from '@/components/library/ExerciseSheet'; export default function AddExercisesScreen() { const db = useSQLiteContext(); @@ -178,7 +178,7 @@ export default function AddExercisesScreen() { {/* New Exercise Sheet */} - setIsNewExerciseSheetOpen(false)} onSubmit={handleNewExerciseSubmit} diff --git a/components/exercises/ExerciseDetails.tsx b/components/exercises/ExerciseDetails.tsx deleted file mode 100644 index 5cd83b7..0000000 --- a/components/exercises/ExerciseDetails.tsx +++ /dev/null @@ -1,351 +0,0 @@ -// components/exercises/ExerciseDetails.tsx -import React from 'react'; -import { View, ScrollView } from 'react-native'; -import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { - Edit2, - Dumbbell, - Target, - Calendar, - Hash, - AlertCircle, - LineChart, - Settings -} from 'lucide-react-native'; -import { ExerciseDisplay } from '@/types/exercise'; -import { useTheme } from '@react-navigation/native'; -import type { CustomTheme } from '@/lib/theme'; - -const Tab = createMaterialTopTabNavigator(); - -interface ExerciseDetailsProps { - exercise: ExerciseDisplay; - open: boolean; - onOpenChange: (open: boolean) => void; - onEdit?: () => void; -} - -// Info Tab Component -function InfoTab({ exercise, onEdit }: { exercise: ExerciseDisplay; onEdit?: () => void }) { - const { - title, - type, - category, - equipment, - description, - instructions = [], - tags = [], - source = 'local', - usageCount, - lastUsed - } = exercise; - - return ( - - - {/* Basic Info Section */} - - - {source} - - - {type} - - - - - - {/* Category & Equipment Section */} - - - - - - - Category - {category} - - - - {equipment && ( - - - - - - Equipment - {equipment} - - - )} - - - {/* Description Section */} - {description && ( - - Description - {description} - - )} - - {/* Tags Section */} - {tags.length > 0 && ( - - - - Tags - - - {tags.map((tag: string) => ( - - {tag} - - ))} - - - )} - - {/* Usage Stats Section */} - {(usageCount || lastUsed) && ( - - - - Usage - - - {usageCount && ( - - Used {usageCount} times - - )} - {lastUsed && ( - - Last used: {lastUsed.toLocaleDateString()} - - )} - - - )} - - {/* Edit Button */} - {onEdit && ( - - )} - - - ); -} - -// Progress Tab Component -function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) { - return ( - - - {/* Placeholder for Charts */} - - - Progress charts coming soon - - - {/* Personal Records Section */} - - Personal Records - - - Max Weight - -- kg - - - Max Reps - -- - - - Best Volume - -- kg - - - - - - ); -} - -// Form Tab Component -function FormTab({ exercise }: { exercise: ExerciseDisplay }) { - const { instructions = [] } = exercise; - - return ( - - - {/* Instructions Section */} - {instructions.length > 0 ? ( - - Instructions - - {instructions.map((instruction: string, index: number) => ( - - - {index + 1}. - - {instruction} - - ))} - - - ) : ( - - - No form instructions available - - )} - - {/* Placeholder for Media */} - - Video demos coming soon - - - - ); -} - -// Settings Tab Component -function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) { - return ( - - - {/* Format Settings */} - - Exercise Settings - - - Format - - {exercise.format && Object.entries(exercise.format).map(([key, enabled]) => ( - enabled && ( - - {key} - - ) - ))} - - - - Units - - {exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => ( - - {key}: {String(unit)} - - ))} - - - - - - - ); -} - -export function ExerciseDetails({ - exercise, - open, - onOpenChange, - onEdit -}: ExerciseDetailsProps) { - const theme = useTheme() as CustomTheme; - - return ( - onOpenChange(false)}> - - - {exercise.title} - - - - - - - {() => } - - - {() => } - - - {() => } - - - {() => } - - - - - - ); -} \ No newline at end of file diff --git a/components/exercises/ModalExerciseDetails.tsx b/components/exercises/ModalExerciseDetails.tsx new file mode 100644 index 0000000..2054e9a --- /dev/null +++ b/components/exercises/ModalExerciseDetails.tsx @@ -0,0 +1,556 @@ +// components/exercises/ModalExerciseDetails.tsx +import React from 'react'; +import { View, ScrollView, Modal, TouchableOpacity } from 'react-native'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import { NavigationContainer } from '@react-navigation/native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + Dumbbell, + Target, + Calendar, + Hash, + AlertCircle, + LineChart, + Settings, + X +} from 'lucide-react-native'; +import { ExerciseDisplay } from '@/types/exercise'; +import { useTheme } from '@react-navigation/native'; +import { useColorScheme } from '@/lib/useColorScheme'; +import type { CustomTheme } from '@/lib/theme'; + +const Tab = createMaterialTopTabNavigator(); + +interface ModalExerciseDetailsProps { + exercise: ExerciseDisplay | null; + open: boolean; + onClose: () => void; + onEdit?: () => void; +} + +// Info Tab Component +function InfoTab({ exercise, onEdit }: { exercise: ExerciseDisplay; onEdit?: () => void }) { + const { + title, + type, + category, + equipment, + description, + instructions = [], + tags = [], + source = 'local', + usageCount, + lastUsed + } = exercise; + + return ( + + + {/* Basic Info Section */} + + + {source} + + + {type} + + + + + + {/* Category & Equipment Section */} + + + + + + + Category + {category} + + + + {equipment && ( + + + + + + Equipment + {equipment} + + + )} + + + {/* Description Section */} + {description && ( + + Description + {description} + + )} + + {/* Tags Section */} + {tags.length > 0 && ( + + + + Tags + + + {tags.map((tag: string) => ( + + {tag} + + ))} + + + )} + + {/* Usage Stats Section */} + {(usageCount || lastUsed) && ( + + + + Usage + + + {usageCount && ( + + Used {usageCount} times + + )} + {lastUsed && ( + + Last used: {lastUsed.toLocaleDateString()} + + )} + + + )} + + {/* Edit Button */} + {onEdit && ( + + )} + + + ); +} + +// Progress Tab Component +function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) { + // Mock data for the progress chart + const weightData = [ + { date: 'Jan 10', weight: 135 }, + { date: 'Jan 17', weight: 140 }, + { date: 'Jan 24', weight: 145 }, + { date: 'Jan 31', weight: 150 }, + { date: 'Feb 7', weight: 155 }, + { date: 'Feb 14', weight: 155 }, + { date: 'Feb 21', weight: 160 }, + { date: 'Feb 28', weight: 165 }, + ]; + + const volumeData = [ + { date: 'Jan 10', volume: 1350 }, + { date: 'Jan 17', volume: 1400 }, + { date: 'Jan 24', volume: 1450 }, + { date: 'Jan 31', volume: 1500 }, + { date: 'Feb 7', volume: 1550 }, + { date: 'Feb 14', volume: 1550 }, + { date: 'Feb 21', volume: 1600 }, + { date: 'Feb 28', volume: 1650 }, + ]; + + return ( + + + {/* Progress Chart */} + + Weight Progress + + + Max: 165 kg + Last 8 weeks + + + {/* Chart Container */} + + {/* Y-axis labels */} + + 165kg + 150kg + 135kg + + + {/* Chart visualization */} + + {weightData.map((item, index) => ( + + + + {item.date.split(' ')[1]} + + + ))} + + + {/* Grid lines */} + + + + + + + + + + + {/* Volume Chart */} + + Volume Progress + + {/* Chart Content */} + + Total: 10,500 kg + Last 8 weeks + + + {/* Line chart for volume */} + + {/* Y-axis labels */} + + 1650 + 1500 + 1350 + + + {/* Line chart */} + + {/* Draw the line */} + + + + + + + + + + + {/* Data points */} + {volumeData.map((item, index) => ( + + ))} + + + {/* X-axis labels */} + + {volumeData.map((item, index) => ( + + {item.date.split(' ')[1]} + + ))} + + + + {/* Grid lines */} + + + + + + + + + + + {/* Personal Records Section */} + + Personal Records + + + Max Weight + 165 kg + Achieved on Feb 28, 2025 + + + Max Reps + 12 reps at 135 kg + Achieved on Jan 10, 2025 + + + Best Volume + 1650 kg + Achieved on Feb 28, 2025 + + + + + + ); +} + +// Form Tab Component +function FormTab({ exercise }: { exercise: ExerciseDisplay }) { + const { instructions = [] } = exercise; + + return ( + + + {/* Instructions Section */} + {instructions.length > 0 ? ( + + Instructions + + {instructions.map((instruction: string, index: number) => ( + + + {index + 1}. + + {instruction} + + ))} + + + ) : ( + + + No form instructions available + + )} + + {/* Placeholder for Media */} + + Video demos coming soon + + + + ); +} + +// Settings Tab Component +function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) { + return ( + + + {/* Format Settings */} + + Exercise Settings + + + Format + + {exercise.format && Object.entries(exercise.format).map(([key, enabled]) => ( + enabled && ( + + {key} + + ) + ))} + + + + Units + + {exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => ( + + {key}: {String(unit)} + + ))} + + + + + + + ); +} + +export function ModalExerciseDetails({ + exercise, + open, + onClose, + onEdit +}: ModalExerciseDetailsProps) { + const theme = useTheme() as CustomTheme; + const { isDarkColorScheme } = useColorScheme(); + + // Return null if not open or if exercise is null + if (!open || !exercise) return null; + + return ( + + + + {/* Header */} + + {exercise.title} + + + + + + {/* Tab Navigator */} + + {/* Using NavigationContainer without the independent prop */} + {/* Let's use a more compatible approach without NavigationContainer */} + + + {() => } + + + {() => } + + + {() => } + + + {() => } + + + + + + + ); +} \ No newline at end of file diff --git a/components/library/ExerciseSheet.tsx b/components/library/ExerciseSheet.tsx new file mode 100644 index 0000000..cce4f32 --- /dev/null +++ b/components/library/ExerciseSheet.tsx @@ -0,0 +1,475 @@ +// 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/useColorScheme'; +import { + BaseExercise, + ExerciseType, + ExerciseCategory, + Equipment, + ExerciseFormat, + ExerciseFormatUnits, + ExerciseDisplay +} from '@/types/exercise'; +import { StorageSource } from '@/types/shared'; +import { useNDKStore } from '@/lib/stores/ndk'; +import { useEventCache } 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 eventCache = useEventCache(); + + // 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 = await ndkStore.createEvent(33401, exercise.description || '', nostrTags); + + if (event) { + // Queue for publication (this will publish immediately if online) + await ndkStore.queueEventForPublishing(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 ( + + + + {/* Header */} + + {getTitle()} + + + + + + {/* Content */} + + + + + + {/* Source badge for edit/fork mode */} + {(isEditMode || isForkMode) && ( + + + + {exerciseToEdit?.source === 'nostr' ? 'Nostr' : exerciseToEdit?.source} + + + + {/* Show forked badge when in fork mode */} + {isForkMode && ( + + + Creating Local Copy + + + )} + + )} + + + Exercise Name + setFormData(prev => ({ ...prev, title: text }))} + placeholder="e.g., Barbell Back Squat" + className="text-foreground" + /> + {!formData.title && ( + + * Required field + + )} + + + + Type + + {EXERCISE_TYPES.map((type) => ( + + ))} + + + + + Category + + {CATEGORIES.map((category) => ( + + ))} + + + + + Equipment + + {EQUIPMENT_OPTIONS.map((eq) => ( + + ))} + + {!formData.equipment && ( + + * Required field + + )} + + + + Description + setFormData(prev => ({ ...prev, description: text }))} + placeholder="Exercise description..." + multiline + numberOfLines={4} + textAlignVertical="top" + className="min-h-24 py-2" + /> + + + + Tags + { + const tags = text.split(',') + .map(tag => tag.trim()) + .filter(tag => tag.length > 0); + setFormData(prev => ({ ...prev, tags })); + }} + placeholder="strength, compound, legs..." + className="text-foreground" + /> + + Separate tags with commas + + + + {/* Additional Nostr information */} + {exerciseToEdit?.source === 'nostr' && exerciseToEdit?.availability?.lastSynced?.nostr && ( + + + Last synced with Nostr: {new Date(exerciseToEdit.availability.lastSynced.nostr.timestamp).toLocaleString()} + + + {isEditMode && !isCurrentUserAuthor && ( + + You're not the original author. Use the "Fork" option to create your own copy. + + )} + + {isEditMode && isCurrentUserAuthor && !ndkStore.isAuthenticated && ( + + Changes will be saved locally and synced to Nostr when you're online and logged in. + + )} + + {isForkMode && ( + + Creating a local copy of this exercise that you can customize + + )} + + {isNostrExercise && exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey && ( + + Author: {exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey.substring(0, 8)}... + + )} + + )} + + + + {/* Create/Update button at bottom */} + + {/* Show fork button when editing Nostr content we don't own */} + {isEditMode && isNostrExercise && !isCurrentUserAuthor ? ( + + + + + ) : ( + // Regular submit button for create/edit/fork + + )} + + + + + + + + ); +} \ No newline at end of file diff --git a/components/library/NewExerciseSheet.tsx b/components/library/NewExerciseSheet.tsx deleted file mode 100644 index 0388866..0000000 --- a/components/library/NewExerciseSheet.tsx +++ /dev/null @@ -1,230 +0,0 @@ -// components/library/NewExerciseSheet.tsx -import React, { useState } from 'react'; -import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { generateId } from '@/utils/ids'; -import { - BaseExercise, - ExerciseType, - ExerciseCategory, - Equipment, - ExerciseFormat, - ExerciseFormatUnits -} from '@/types/exercise'; -import { StorageSource } from '@/types/shared'; - -interface NewExerciseSheetProps { - isOpen: boolean; - onClose: () => void; - onSubmit: (exercise: BaseExercise) => void; -} - -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' -]; - -export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) { - const [formData, setFormData] = useState({ - 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 - }); - - const handleSubmit = () => { - if (!formData.title || !formData.equipment) return; - - const timestamp = Date.now(); - - // Create BaseExercise - const exercise: BaseExercise = { - 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, - created_at: timestamp, - availability: { - source: ['local' as StorageSource], - lastSynced: undefined - } - }; - - onSubmit(exercise); - - // Reset form - setFormData({ - title: '', - type: 'strength', - category: 'Push', - equipment: undefined, - description: '', - tags: [], - format: { - weight: true, - reps: true, - rpe: true, - set_type: true - }, - format_units: { - weight: 'kg', - reps: 'count', - rpe: '0-10', - set_type: 'warmup|normal|drop|failure' - } - }); - - onClose(); - }; - - // Purple color used throughout the app - const purpleColor = 'hsl(261, 90%, 66%)'; - - return ( - - - - - - - Create New Exercise - - - - - Exercise Name - setFormData(prev => ({ ...prev, title: text }))} - placeholder="e.g., Barbell Back Squat" - className="text-foreground" - /> - {!formData.title && ( - - * Required field - - )} - - - - Type - - {EXERCISE_TYPES.map((type) => ( - - ))} - - - - - Category - - {CATEGORIES.map((category) => ( - - ))} - - - - - Equipment - - {EQUIPMENT_OPTIONS.map((eq) => ( - - ))} - - {!formData.equipment && ( - - * Required field - - )} - - - - Description - setFormData(prev => ({ ...prev, description: text }))} - placeholder="Exercise description..." - multiline - numberOfLines={4} - textAlignVertical="top" - className="min-h-24 py-2" - /> - - - - - - - - - - - ); -} \ No newline at end of file diff --git a/components/library/NewTemplateSheet.tsx b/components/library/NewTemplateSheet.tsx index 09aaed6..0f294b9 100644 --- a/components/library/NewTemplateSheet.tsx +++ b/components/library/NewTemplateSheet.tsx @@ -1,11 +1,10 @@ // components/library/NewTemplateSheet.tsx import React, { useState, useEffect } from 'react'; -import { View, ScrollView, TouchableOpacity } from 'react-native'; +import { View, ScrollView, TouchableOpacity, Modal } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Badge } from '@/components/ui/badge'; import { Template, @@ -17,7 +16,8 @@ import { ExerciseDisplay } from '@/types/exercise'; import { generateId } from '@/utils/ids'; import { useSQLiteContext } from 'expo-sqlite'; import { LibraryService } from '@/lib/db/services/LibraryService'; -import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List, Search } from 'lucide-react-native'; +import { ChevronLeft, Dumbbell, Clock, RotateCw, List, Search, X } from 'lucide-react-native'; +import { useColorScheme } from '@/lib/useColorScheme'; interface NewTemplateSheetProps { isOpen: boolean; @@ -71,7 +71,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) { return ( - + Select the type of workout template you want to create: @@ -92,9 +92,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) { - - - ) : ( @@ -117,10 +114,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) { ))} - - ); @@ -152,7 +145,7 @@ function BasicInfoStep({ return ( - + Workout Name - + @@ -463,13 +449,11 @@ function ReviewStep({ - - + @@ -486,6 +470,7 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet const [exercises, setExercises] = useState([]); const [selectedExercises, setSelectedExercises] = useState([]); const [configuredExercises, setConfiguredExercises] = useState([]); + const { isDarkColorScheme } = useColorScheme(); // Template info const [templateInfo, setTemplateInfo] = useState<{ @@ -519,16 +504,21 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet // Reset state when sheet closes useEffect(() => { if (!isOpen) { - setStep('type'); - setWorkoutType('strength'); - setSelectedExercises([]); - setConfiguredExercises([]); - setTemplateInfo({ - title: '', - description: '', - category: 'Full Body', - tags: ['strength'] - }); + // Add a delay to ensure the closing animation completes first + const timer = setTimeout(() => { + setStep('type'); + setWorkoutType('strength'); + setSelectedExercises([]); + setConfiguredExercises([]); + setTemplateInfo({ + title: '', + description: '', + category: 'Full Body', + tags: ['strength'] + }); + }, 300); + + return () => clearTimeout(timer); } }, [isOpen]); @@ -594,11 +584,28 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet isFavorite: false }; - onSubmit(newTemplate); + // Close first, then submit with a small delay onClose(); + setTimeout(() => { + onSubmit(newTemplate); + }, 50); }; - // Render different content based on current step + // Get title based on current step + const getStepTitle = () => { + switch (step) { + case 'type': return 'Select Workout Type'; + case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`; + case 'exercises': return 'Select Exercises'; + case 'config': return 'Configure Exercises'; + case 'review': return 'Review Template'; + } + }; + + // Show back button for all steps except the first + const showBackButton = step !== 'type'; + + // Render content based on current step const renderContent = () => { switch (step) { case 'type': @@ -658,40 +665,45 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet } }; - // Get title based on current step - const getStepTitle = () => { - switch (step) { - case 'type': return 'Select Workout Type'; - case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`; - case 'exercises': return 'Select Exercises'; - case 'config': return 'Configure Exercises'; - case 'review': return 'Review Template'; - } - }; - - // Show back button for all steps except the first - const showBackButton = step !== 'type'; + // Return null if not open + if (!isOpen) return null; return ( - - - - {showBackButton && ( - - )} - {getStepTitle()} + + + + {/* Header */} + + + {showBackButton && ( + + + + )} + {getStepTitle()} + + + + + + + {/* Content */} + + {renderContent()} + - - - {renderContent()} - - + + ); } \ No newline at end of file diff --git a/components/templates/ModalTemplateDetails.tsx b/components/templates/ModalTemplateDetails.tsx new file mode 100644 index 0000000..6f08338 --- /dev/null +++ b/components/templates/ModalTemplateDetails.tsx @@ -0,0 +1,947 @@ +// components/templates/ModalTemplateDetails.tsx +import React, { useState, useEffect, createContext, useContext } from 'react'; +import { View, ScrollView, Modal, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Card, CardContent } from '@/components/ui/card'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import { NavigationContainer } from '@react-navigation/native'; +import { useTheme } from '@react-navigation/native'; +import { useColorScheme } from '@/lib/useColorScheme'; +import { WorkoutTemplate } from '@/types/templates'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { formatTime } from '@/utils/formatTime'; +import { Calendar } from 'lucide-react-native'; +import { + X, + ChevronLeft, + Star, + Copy, + Heart, + Dumbbell, + Target, + Hash, + Clock, + MessageSquare, + Zap, + Repeat, + Bookmark +} from 'lucide-react-native'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { cn } from '@/lib/utils'; +import type { CustomTheme } from '@/lib/theme'; + +// Create the tab navigator +const Tab = createMaterialTopTabNavigator(); + +// Create a context to share the template with the tabs +interface TemplateContextType { + template: WorkoutTemplate | null; + onStartWorkout?: () => void; +} + +const TemplateContext = createContext({ + template: null +}); + +// Custom hook to access the template +function useTemplate() { + const context = useContext(TemplateContext); + if (!context.template) { + throw new Error('useTemplate must be used within a TemplateContextProvider'); + } + return { template: context.template, onStartWorkout: context.onStartWorkout }; +} + +interface ModalTemplateDetailsProps { + templateId: string; + open: boolean; + onClose: () => void; + onFavoriteChange?: (templateId: string, isFavorite: boolean) => void; +} + +// Overview Tab Component +function OverviewTab() { + const { template } = useTemplate(); + + const { + title, + type, + category, + description, + exercises = [], + tags = [], + metadata, + availability + } = template; + + // Calculate source type from availability + const sourceType = availability.source.includes('nostr') + ? 'nostr' + : availability.source.includes('powr') + ? 'powr' + : 'local'; + + const isEditable = sourceType === 'local'; + + return ( + + + {/* Basic Info Section */} + + + {sourceType === 'local' ? 'My Template' : sourceType === 'powr' ? 'POWR Template' : 'Nostr Template'} + + + {type} + + + + + + {/* Category Section */} + + + + + + Category + {category} + + + + {/* Description Section */} + {description && ( + + Description + {description} + + )} + + {/* Exercises Section */} + + + + Exercises + + + {exercises.map((exerciseConfig, index) => ( + + + {exerciseConfig.exercise.title} + + + {exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps + + {exerciseConfig.notes && ( + + {exerciseConfig.notes} + + )} + + ))} + + + + {/* Tags Section */} + {tags.length > 0 && ( + + + + Tags + + + {tags.map(tag => ( + + {tag} + + ))} + + + )} + + {/* Workout Parameters Section */} + + + + Workout Parameters + + + {template.rounds && ( + + Rounds: + {template.rounds} + + )} + {template.duration && ( + + Duration: + {formatTime(template.duration * 1000)} + + )} + {template.interval && ( + + Interval: + {formatTime(template.interval * 1000)} + + )} + {template.restBetweenRounds && ( + + Rest Between Rounds: + + {formatTime(template.restBetweenRounds * 1000)} + + + )} + {metadata?.averageDuration && ( + + Average Completion Time: + + {Math.round(metadata.averageDuration / 60)} minutes + + + )} + + + + {/* Usage Stats Section */} + {metadata && ( + + + + Usage + + + {metadata.useCount && ( + + Used {metadata.useCount} times + + )} + {metadata.lastUsed && ( + + Last used: {new Date(metadata.lastUsed).toLocaleDateString()} + + )} + + + )} + + {/* Action Buttons */} + + {isEditable ? ( + + ) : ( + + )} + + + + + + ); +} + +// History Tab Component +function HistoryTab() { + const { template } = useTemplate(); + const [isLoading, setIsLoading] = useState(false); + + // Format date helper + const formatDate = (date: Date) => { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; + + // Mock workout history - this would come from your database in a real app + const mockWorkoutHistory = [ + { + id: 'hist1', + date: new Date(2024, 1, 25), + duration: 62, // minutes + completed: true, + notes: "Increased weight on squats by 10lbs", + exercises: [ + { name: "Barbell Squat", sets: 3, reps: 8, weight: 215 }, + { name: "Bench Press", sets: 3, reps: 8, weight: 175 }, + { name: "Bent Over Row", sets: 3, reps: 8, weight: 155 } + ] + }, + { + id: 'hist2', + date: new Date(2024, 1, 18), + duration: 58, // minutes + completed: true, + exercises: [ + { name: "Barbell Squat", sets: 3, reps: 8, weight: 205 }, + { name: "Bench Press", sets: 3, reps: 8, weight: 175 }, + { name: "Bent Over Row", sets: 3, reps: 8, weight: 155 } + ] + }, + { + id: 'hist3', + date: new Date(2024, 1, 11), + duration: 65, // minutes + completed: false, + notes: "Stopped early due to time constraints", + exercises: [ + { name: "Barbell Squat", sets: 3, reps: 8, weight: 205 }, + { name: "Bench Press", sets: 3, reps: 8, weight: 170 }, + { name: "Bent Over Row", sets: 2, reps: 8, weight: 150 } + ] + } + ]; + + // Simulate loading history data + useEffect(() => { + const loadHistory = async () => { + setIsLoading(true); + // Simulate loading delay + await new Promise(resolve => setTimeout(resolve, 500)); + setIsLoading(false); + }; + + loadHistory(); + }, [template.id]); + + return ( + + + {/* Performance Summary */} + + Performance Summary + + + + + Total Workouts + {mockWorkoutHistory.length} + + + + Avg Duration + + {Math.round(mockWorkoutHistory.reduce((acc, w) => acc + w.duration, 0) / mockWorkoutHistory.length)} min + + + + + Completion + + {Math.round(mockWorkoutHistory.filter(w => w.completed).length / mockWorkoutHistory.length * 100)}% + + + + + + + + {/* History List */} + + Workout History + + {isLoading ? ( + + + Loading history... + + ) : mockWorkoutHistory.length > 0 ? ( + + {mockWorkoutHistory.map((workout) => ( + + + + {formatDate(workout.date)} + + {workout.completed ? "Completed" : "Incomplete"} + + + + + + Duration + {workout.duration} min + + + + Sets + + {workout.exercises.reduce((acc, ex) => acc + ex.sets, 0)} + + + + + Volume + + {workout.exercises.reduce((acc, ex) => acc + (ex.sets * ex.reps * ex.weight), 0)} lbs + + + + + {workout.notes && ( + + {workout.notes} + + )} + + + + + ))} + + ) : ( + + + + No workout history available yet + + + Complete a workout using this template to see your history + + + )} + + + + ); +} + +// Social Tab Component +function SocialTab() { + const { template } = useTemplate(); + const [isLoading, setIsLoading] = useState(false); + + // Mock social feed data + const mockSocialFeed = [ + { + id: 'social1', + userName: 'FitnessFanatic', + userAvatar: 'https://randomuser.me/api/portraits/men/32.jpg', + pubkey: 'npub1q8s7vw...', + timestamp: new Date(Date.now() - 3600000 * 2), // 2 hours ago + content: 'Just crushed this Full Body workout! New PR on bench press 🎉', + metrics: { + duration: 58, // in minutes + volume: 4500, // total weight + exercises: 5 + }, + likes: 12, + comments: 3, + zaps: 5, + reposts: 2, + bookmarked: false + }, + { + id: 'social2', + userName: 'StrengthJourney', + userAvatar: 'https://randomuser.me/api/portraits/women/44.jpg', + pubkey: 'npub1z92r3...', + timestamp: new Date(Date.now() - 3600000 * 24), // 1 day ago + content: 'Modified this workout with extra leg exercises. Feeling the burn!', + metrics: { + duration: 65, + volume: 5200, + exercises: "5+2" + }, + likes: 8, + comments: 1, + zaps: 3, + reposts: 0, + bookmarked: false + }, + { + id: 'social3', + userName: 'GymCoach', + userAvatar: 'https://randomuser.me/api/portraits/men/62.jpg', + pubkey: 'npub1xne8q...', + timestamp: new Date(Date.now() - 3600000 * 48), // 2 days ago + content: 'Great template for beginners! I recommend starting with lighter weights.', + metrics: { + duration: 45, + volume: 3600, + exercises: 5 + }, + likes: 24, + comments: 7, + zaps: 15, + reposts: 6, + bookmarked: true + } + ]; + + // Social Feed Item Component + function SocialFeedItem({ item }: { item: typeof mockSocialFeed[0] }) { + const [liked, setLiked] = useState(false); + const [zapCount, setZapCount] = useState(item.zaps); + const [bookmarked, setBookmarked] = useState(item.bookmarked); + const [reposted, setReposted] = useState(false); + const [commentCount, setCommentCount] = useState(item.comments); + + const formatDate = (date: Date) => { + const now = new Date(); + const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); + + if (diffInHours < 1) { + return 'now'; + } else if (diffInHours < 24) { + return `${diffInHours}h`; + } else { + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}d`; + } + }; + + return ( + + {/* User info and timestamp */} + + + + + {item.userName.substring(0, 2)} + + + + + + {item.userName} + + {formatDate(item.timestamp)} + + + + @{item.pubkey.substring(0, 10)}... + + + + + {/* Post content */} + {item.content} + + {/* Workout metrics */} + + + Duration + {item.metrics.duration} min + + + + Volume + {item.metrics.volume} lbs + + + + Exercises + {item.metrics.exercises} + + + + {/* Twitter-like action buttons */} + + {/* Comment button */} + setCommentCount(prev => prev + 1)} + > + + {commentCount > 0 && ( + {commentCount} + )} + + + {/* Repost button */} + setReposted(!reposted)} + > + + {(reposted || item.reposts > 0) && ( + + {reposted ? item.reposts + 1 : item.reposts} + + )} + + + {/* Like button */} + setLiked(!liked)} + > + + {(liked || item.likes > 0) && ( + + {liked ? item.likes + 1 : item.likes} + + )} + + + {/* Zap button */} + setZapCount(prev => prev + 1)} + > + + {zapCount > 0 && ( + {zapCount} + )} + + + {/* Bookmark button */} + setBookmarked(!bookmarked)} + > + + + + + ); + } + + return ( + + + + Recent Activity + + + Nostr + + + + {isLoading ? ( + + + Loading activity... + + ) : mockSocialFeed.length > 0 ? ( + + {mockSocialFeed.map((item) => ( + + ))} + + ) : ( + + + No social activity found + + This workout hasn't been shared on Nostr yet + + + )} + + ); +} + +export function ModalTemplateDetails({ + templateId, + open, + onClose, + onFavoriteChange +}: ModalTemplateDetailsProps) { + const [isLoading, setIsLoading] = useState(true); + const [workoutTemplate, setWorkoutTemplate] = useState(null); + const [isFavorite, setIsFavorite] = useState(false); + const [toggleCounter, setToggleCounter] = useState(0); + + const theme = useTheme() as CustomTheme; + const { isDarkColorScheme } = useColorScheme(); + + // Use the workoutStore + const { + startWorkoutFromTemplate, + checkFavoriteStatus, + addFavorite, + removeFavorite + } = useWorkoutStore(); + + // Load template data when the modal opens or templateId changes + useEffect(() => { + async function loadTemplate() { + // Don't load data if the modal is closed + if (!open) return; + + try { + setIsLoading(true); + + if (!templateId) { + setIsLoading(false); + return; + } + + // Always fetch the current favorite status from the store + const currentIsFavorite = checkFavoriteStatus(templateId); + setIsFavorite(currentIsFavorite); + console.log(`Initial load: Template ${templateId} isFavorite: ${currentIsFavorite}`); + + // TODO: Implement fetching from database if needed + // For now, create a mock template if we couldn't find it + const mockTemplate: WorkoutTemplate = { + id: templateId, + title: "Sample Workout", + type: "strength", + category: "Full Body", + exercises: [{ + exercise: { + id: "ex1", + title: "Barbell Squat", + type: "strength", + category: "Legs", + tags: ["compound", "legs"], + availability: { source: ["local"] }, + created_at: Date.now() + }, + targetSets: 3, + targetReps: 8 + }], + isPublic: false, + tags: ["strength", "beginner"], + version: 1, + created_at: Date.now(), + availability: { source: ["local"] } + }; + + setWorkoutTemplate(mockTemplate); + setIsLoading(false); + } catch (error) { + console.error("Error loading template:", error); + setIsLoading(false); + } + } + + loadTemplate(); + }, [templateId, open, checkFavoriteStatus]); + + const handleStartWorkout = async () => { + if (!workoutTemplate) return; + + try { + // Use the workoutStore action to start a workout from template + await startWorkoutFromTemplate(workoutTemplate.id); + + // Close the modal + onClose(); + + // Navigate to the active workout screen + // We'll leave navigation to the parent component + } catch (error) { + console.error("Error starting workout:", error); + } + }; + + // Inside your handleToggleFavorite function, update it to: + const handleToggleFavorite = async () => { + if (!workoutTemplate) return; + + try { + // Get the current state directly from the store + const currentIsFavorite = checkFavoriteStatus(workoutTemplate.id); + console.log(`Current store state: Template ${workoutTemplate.id} isFavorite: ${currentIsFavorite}`); + + // The new state will be the opposite + const newFavoriteStatus = !currentIsFavorite; + + // Update the store first + if (currentIsFavorite) { + await removeFavorite(workoutTemplate.id); + console.log(`Removed template with ID "${workoutTemplate.id}" from favorites (store action)`); + } else { + await addFavorite(workoutTemplate); + console.log(`Added template "${workoutTemplate.title}" to favorites (store action)`); + } + + // Verify the change took effect in the store + const updatedIsFavorite = checkFavoriteStatus(workoutTemplate.id); + console.log(`After store update: Template ${workoutTemplate.id} isFavorite: ${updatedIsFavorite}`); + + // Now update our local state + setIsFavorite(updatedIsFavorite); + + // Force re-render if needed + setToggleCounter(prev => prev + 1); + + // Notify parent component + if (onFavoriteChange) { + onFavoriteChange(workoutTemplate.id, updatedIsFavorite); + } + + console.log(`Toggled favorite UI: ${workoutTemplate.id} is now ${updatedIsFavorite ? 'favorited' : 'unfavorited'}`); + } catch (error) { + console.error("Error toggling favorite:", error); + } + }; + + console.log(`Template ${workoutTemplate?.id} isFavorite: ${isFavorite}`); + + // Don't render anything if the modal is closed + if (!open) return null; + + return ( + + + + {/* Loading State */} + {isLoading || !workoutTemplate ? ( + + + Loading template... + + ) : ( + <> + {/* Header */} + + + + {workoutTemplate.title} + + + + + + + + + + + + + {/* Tab Navigator */} + + + + + {() => } + + + {() => } + + + {() => } + + + + + + {/* Footer with Start button */} + + + + + )} + + + + )}; \ No newline at end of file diff --git a/components/templates/TemplateCard.tsx b/components/templates/TemplateCard.tsx index d3c0453..48acb90 100644 --- a/components/templates/TemplateCard.tsx +++ b/components/templates/TemplateCard.tsx @@ -1,7 +1,6 @@ // components/templates/TemplateCard.tsx import React from 'react'; import { View, TouchableOpacity, Platform } from 'react-native'; -import { router } from 'expo-router'; // Add this import import { Text } from '@/components/ui/text'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -55,13 +54,23 @@ export function TemplateCard({ setShowDeleteAlert(false); }; - // Handle navigation to template details - const handleTemplatePress = () => { - router.push(`/template/${id}`); + // Prevent event propagation when clicking on action buttons + const handleStartWorkout = (e: any) => { + if (e && e.stopPropagation) { + e.stopPropagation(); + } + onStartWorkout(); + }; + + const handleFavorite = (e: any) => { + if (e && e.stopPropagation) { + e.stopPropagation(); + } + onFavorite(); }; return ( - + @@ -134,7 +143,7 @@ export function TemplateCard({