From f26a59f56988fb73b4f2cd8a5ae85d92b509543e Mon Sep 17 00:00:00 2001 From: DocNR Date: Wed, 19 Feb 2025 21:39:47 -0500 Subject: [PATCH] Library tab MVP finished --- CHANGELOG.md | 17 + app/(tabs)/library/exercises.tsx | 335 +++----- app/(tabs)/library/templates.tsx | 114 ++- components/exercises/ExerciseCard.tsx | 189 ----- components/exercises/ExerciseDetails.tsx | 351 +++++++++ components/exercises/ExerciseList.tsx | 77 -- .../exercises/SimplifiedExerciseCard.tsx | 102 +++ .../exercises/SimplifiedExerciseList.tsx | 225 ++++++ components/library/NewExerciseSheet.tsx | 98 +-- components/library/NewTemplateSheet.tsx | 734 +++++++++++++++--- components/templates/TemplateCard.tsx | 17 +- components/templates/TemplateDetails.tsx | 373 +++++++++ docs/design/library_tab.md | 435 ++++------- lib/db/services/ExerciseService.ts | 348 ++++----- lib/db/services/LibraryService.ts | 22 +- lib/hooks/useExercises.tsx | 66 +- lib/mocks/exercises.ts | 59 +- types/exercise.ts | 113 +-- types/library.ts | 50 -- types/templates.ts | 214 +++++ 20 files changed, 2653 insertions(+), 1286 deletions(-) delete mode 100644 components/exercises/ExerciseCard.tsx create mode 100644 components/exercises/ExerciseDetails.tsx delete mode 100644 components/exercises/ExerciseList.tsx create mode 100644 components/exercises/SimplifiedExerciseCard.tsx create mode 100644 components/exercises/SimplifiedExerciseList.tsx create mode 100644 components/templates/TemplateDetails.tsx delete mode 100644 types/library.ts create mode 100644 types/templates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc90f0..570a3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Detailed SQLite error logging - Improved transaction management - Added proper error types and propagation +- Template management features + - Basic template creation interface + - Favorite template functionality + - Template categories and filtering + - Quick-start template actions ### Changed - Improved exercise library interface @@ -49,6 +54,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added proper SQLite error types - Enhanced transaction rollback handling - Added detailed debug logging +- Updated type system for better data handling + - Consolidated exercise and template types + - Added proper type guards + - Improved type safety in components +- Enhanced template display UI + - Added category pills for filtering + - Improved spacing and layout + - Better visual hierarchy for favorites ### Fixed - Exercise deletion functionality @@ -57,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TypeScript parameter typing in database services - Null value handling in database operations - Development seeding duplicate prevention +- Template category spacing issues +- Exercise list rendering on iOS +- Database reset and reseeding behavior ### Technical Details 1. Database Schema Enforcement: @@ -87,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exercise creation now enforces schema constraints - Input validation prevents invalid data entry - Enhanced error messages provide better debugging information +- Template management requires updated type definitions ## [0.1.0] - 2024-02-09 diff --git a/app/(tabs)/library/exercises.tsx b/app/(tabs)/library/exercises.tsx index 681ac29..5b525bc 100644 --- a/app/(tabs)/library/exercises.tsx +++ b/app/(tabs)/library/exercises.tsx @@ -1,129 +1,78 @@ // app/(tabs)/library/exercises.tsx -import React, { useRef, useState, useCallback } from 'react'; -import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native'; +import React, { useState } from 'react'; +import { View, ActivityIndicator } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; -import { ExerciseCard } from '@/components/exercises/ExerciseCard'; +import { Input } from '@/components/ui/input'; +import { Search, Dumbbell } from 'lucide-react-native'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; -import { Dumbbell } from 'lucide-react-native'; -import { BaseExercise, Exercise } from '@/types/exercise'; +import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList'; +import { ExerciseDetails } from '@/components/exercises/ExerciseDetails'; +import { ExerciseDisplay, ExerciseType, BaseExercise } from '@/types/exercise'; import { useExercises } from '@/lib/hooks/useExercises'; export default function ExercisesScreen() { - const sectionListRef = useRef(null); const [showNewExercise, setShowNewExercise] = useState(false); - const [currentSection, setCurrentSection] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [activeFilter, setActiveFilter] = useState(null); + const [selectedExercise, setSelectedExercise] = useState(null); const { exercises, loading, error, - stats, createExercise, deleteExercise, - refreshExercises + refreshExercises, + updateFilters, + clearFilters } = useExercises(); - // Organize exercises into sections - const sections = React.useMemo(() => { - const exercisesByLetter = exercises.reduce((acc, exercise) => { - const firstLetter = exercise.title[0].toUpperCase(); - if (!acc[firstLetter]) { - acc[firstLetter] = []; + // Filter exercises based on search query + React.useEffect(() => { + if (searchQuery) { + updateFilters({ searchQuery }); + } else { + updateFilters({ searchQuery: undefined }); + } + }, [searchQuery, updateFilters]); + + // Update type filter when activeFilter changes + React.useEffect(() => { + if (activeFilter) { + updateFilters({ type: [activeFilter] }); + } else { + clearFilters(); + } + }, [activeFilter, updateFilters, clearFilters]); + + const handleExercisePress = (exercise: ExerciseDisplay) => { + setSelectedExercise(exercise); + }; + + const handleEdit = async () => { + // TODO: Implement edit functionality + setSelectedExercise(null); + }; + + const handleCreateExercise = async (exerciseData: BaseExercise) => { + // Convert BaseExercise to include required source information + const exerciseWithSource: Omit = { + ...exerciseData, + availability: { + source: ['local'] } - acc[firstLetter].push(exercise); - return acc; - }, {} as Record); - - return Object.entries(exercisesByLetter) - .map(([letter, exercises]) => ({ - title: letter, - data: exercises.sort((a, b) => a.title.localeCompare(b.title)) - })) - .sort((a, b) => a.title.localeCompare(b.title)); - }, [exercises]); - - const handleViewableItemsChanged = useCallback(({ - viewableItems - }: { - viewableItems: ViewToken[]; - }) => { - const firstSection = viewableItems.find(item => item.section)?.section?.title; - if (firstSection) { - setCurrentSection(firstSection); - } - }, []); - - const scrollToSection = useCallback((letter: string) => { - const sectionIndex = sections.findIndex(section => section.title === letter); - if (sectionIndex !== -1 && sectionListRef.current) { - // Try to scroll to section - sectionListRef.current.scrollToLocation({ - animated: true, - sectionIndex, - itemIndex: 0, - viewPosition: 0, // 0 means top of the view - }); - - // Log for debugging - if (__DEV__) { - console.log('Scrolling to section:', { - letter, - sectionIndex, - totalSections: sections.length - }); - } - } - }, [sections]); - - // Add getItemLayout to optimize scrolling - const getItemLayout = useCallback((data: any, index: number) => ({ - length: 100, // Approximate height of each item - offset: 100 * index, - index, - }), []); - - - const handleAddExercise = async (exerciseData: Omit) => { - try { - const newExercise: Omit = { - ...exerciseData, - source: 'local', - created_at: Date.now(), - availability: { - source: ['local'] - }, - format_json: exerciseData.format ? JSON.stringify(exerciseData.format) : undefined, - format_units_json: exerciseData.format_units ? JSON.stringify(exerciseData.format_units) : undefined - }; - - await createExercise(newExercise); - setShowNewExercise(false); - } catch (error) { - console.error('Error adding exercise:', error); - } + }; + + await createExercise(exerciseWithSource); + setShowNewExercise(false); }; - - const handleDelete = async (id: string) => { - try { - await deleteExercise(id); - } catch (error) { - console.error('Error deleting exercise:', error); - } - }; - - const handleExercisePress = (exerciseId: string) => { - console.log('Selected exercise:', exerciseId); - }; - - const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); - const availableLetters = new Set(sections.map(section => section.title)); - if (loading) { return ( - Loading exercises... + + Loading exercises... ); } @@ -135,7 +84,7 @@ export default function ExercisesScreen() { {error.message} ); @@ -143,122 +92,94 @@ export default function ExercisesScreen() { return ( - {/* Stats Bar */} - - - Total Exercises - {stats.totalCount} - - - - Push - {stats.byCategory['Push'] || 0} + {/* Search bar */} + + + + - - Pull - {stats.byCategory['Pull'] || 0} - - - Legs - {stats.byCategory['Legs'] || 0} - - - Core - {stats.byCategory['Core'] || 0} - - - - - {/* Exercise List with Alphabet Scroll */} - - {/* Main List */} - - item.id} - getItemLayout={getItemLayout} - renderSectionHeader={({ section }) => ( - - - {section.title} - - - )} - renderItem={({ item }) => ( - - handleExercisePress(item.id)} - onDelete={() => handleDelete(item.id)} - /> - - )} - stickySectionHeadersEnabled - initialNumToRender={10} - maxToRenderPerBatch={10} - windowSize={5} - onViewableItemsChanged={handleViewableItemsChanged} - viewabilityConfig={{ - itemVisiblePercentThreshold: 50 - }} + - - {/* Alphabet List */} - true} - onResponderMove={(evt) => { - const touch = evt.nativeEvent; - const element = evt.target; - - // Get the layout of the alphabet bar - if (element) { - const elementPosition = (element as any).measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => { - // Calculate which letter we're touching based on position - const totalHeight = height; - const letterHeight = totalHeight / alphabet.length; - const touchY = touch.pageY - pageY; - const index = Math.min( - Math.max(Math.floor(touchY / letterHeight), 0), - alphabet.length - 1 - ); - - const letter = alphabet[index]; - if (availableLetters.has(letter)) { - scrollToSection(letter); - } - }); - } - }} - > - {alphabet.map((letter) => ( - - {letter} - - ))} - + {/* Filter buttons */} + + + + + + + + {/* Exercises list */} + + + {/* Exercise details sheet */} + {selectedExercise && ( + { + if (!open) setSelectedExercise(null); + }} + onEdit={handleEdit} + /> + )} + + {/* FAB for adding new exercise */} setShowNewExercise(true)} /> + {/* New exercise sheet */} setShowNewExercise(false)} - onSubmit={handleAddExercise} + onSubmit={handleCreateExercise} /> ); diff --git a/app/(tabs)/library/templates.tsx b/app/(tabs)/library/templates.tsx index a8defda..21a36ba 100644 --- a/app/(tabs)/library/templates.tsx +++ b/app/(tabs)/library/templates.tsx @@ -1,12 +1,28 @@ // app/(tabs)/library/templates.tsx -import { View, ScrollView } from 'react-native'; -import { useState } from 'react'; +import React, { useState } from 'react'; +import { View, ScrollView, ActivityIndicator } from 'react-native'; import { Text } from '@/components/ui/text'; -import { TemplateCard } from '@/components/templates/TemplateCard'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Search, Plus } from 'lucide-react-native'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { NewTemplateSheet } from '@/components/library/NewTemplateSheet'; -import { Plus } from 'lucide-react-native'; -import { Template } from '@/types/library'; +import { TemplateCard } from '@/components/templates/TemplateCard'; +import { TemplateDetails } from '@/components/templates/TemplateDetails'; +import { + Template, + WorkoutTemplate, + TemplateCategory, + toWorkoutTemplate +} from '@/types/templates'; + +const TEMPLATE_CATEGORIES: TemplateCategory[] = [ + 'Full Body', + 'Push/Pull/Legs', + 'Upper/Lower', + 'Conditioning', + 'Custom' +]; // Mock data - move to a separate file later const initialTemplates: Template[] = [ @@ -43,14 +59,16 @@ const initialTemplates: Template[] = [ export default function TemplatesScreen() { const [showNewTemplate, setShowNewTemplate] = useState(false); const [templates, setTemplates] = useState(initialTemplates); + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); const handleDelete = (id: string) => { setTemplates(current => current.filter(t => t.id !== id)); }; const handleTemplatePress = (template: Template) => { - // TODO: Show template details - console.log('Selected template:', template); + setSelectedTemplate(toWorkoutTemplate(template)); }; const handleStartWorkout = (template: Template) => { @@ -73,17 +91,74 @@ export default function TemplatesScreen() { setShowNewTemplate(false); }; + // Filter templates based on category and search + const filteredTemplates = templates.filter(template => { + const matchesCategory = !activeCategory || template.category === activeCategory; + const matchesSearch = !searchQuery || + template.title.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesCategory && matchesSearch; + }); + // Separate favorites and regular templates - const favoriteTemplates = templates.filter(t => t.isFavorite); - const regularTemplates = templates.filter(t => !t.isFavorite); + const favoriteTemplates = filteredTemplates.filter(t => t.isFavorite); + const regularTemplates = filteredTemplates.filter(t => !t.isFavorite); return ( - + {/* Search bar */} + + + + + + + + + + {/* Category filters */} + + + + {TEMPLATE_CATEGORIES.map((category) => ( + + ))} + + + + {/* Templates list */} + {/* Favorites Section */} {favoriteTemplates.length > 0 && ( - - + + Favorites @@ -102,8 +177,8 @@ export default function TemplatesScreen() { )} {/* All Templates Section */} - - + + All Templates {regularTemplates.length > 0 ? ( @@ -132,6 +207,17 @@ export default function TemplatesScreen() { + {/* Rest of the components (sheets & FAB) remain the same */} + {selectedTemplate && ( + { + if (!open) setSelectedTemplate(null); + }} + /> + )} + setShowNewTemplate(true)} diff --git a/components/exercises/ExerciseCard.tsx b/components/exercises/ExerciseCard.tsx deleted file mode 100644 index 81a9d11..0000000 --- a/components/exercises/ExerciseCard.tsx +++ /dev/null @@ -1,189 +0,0 @@ -// components/exercises/ExerciseCard.tsx -import React, { useState } from 'react'; -import { View, TouchableOpacity } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, AlertDialogTrigger } from '@/components/ui/alert-dialog'; -import { Trash2, Star, Info } from 'lucide-react-native'; -import { Exercise } from '@/types/exercise'; - -interface ExerciseCardProps extends Exercise { - onPress: () => void; - onDelete: () => void; - onFavorite?: () => void; -} - -export function ExerciseCard({ - id, - title, - type, - category, - equipment, - description, - tags = [], - source, - instructions = [], - onPress, - onDelete, - onFavorite -}: ExerciseCardProps) { - const [showDetails, setShowDetails] = useState(false); - const [showDeleteAlert, setShowDeleteAlert] = useState(false); - - const handleDelete = () => { - onDelete(); - setShowDeleteAlert(false); - }; - - return ( - <> - - - - - - {/* Title and Source Badge */} - - {title} - - {source} - - - - {/* Category & Equipment */} - - - {category} - - {equipment && ( - - {equipment} - - )} - - - {/* Description Preview */} - {description && ( - - {description} - - )} - - {/* Tags */} - {tags.length > 0 && ( - - {tags.map(tag => ( - - {tag} - - ))} - - )} - - - {/* Action Buttons */} - - {onFavorite && ( - - )} - - - - - - - - Delete Exercise - - Are you sure you want to delete {title}? This action cannot be undone. - - - - - - - - - - - - - - - - - - - {/* Details Sheet */} - setShowDetails(false)}> - - {title} - - - - {description && ( - - Description - {description} - - )} - - - Details - - Type: {type} - Category: {category} - {equipment && Equipment: {equipment}} - Source: {source} - - - - {instructions.length > 0 && ( - - Instructions - - {instructions.map((instruction, index) => ( - - {index + 1}. {instruction} - - ))} - - - )} - - {tags.length > 0 && ( - - Tags - - {tags.map(tag => ( - - {tag} - - ))} - - - )} - - - - - ); -} \ No newline at end of file diff --git a/components/exercises/ExerciseDetails.tsx b/components/exercises/ExerciseDetails.tsx new file mode 100644 index 0000000..5cd83b7 --- /dev/null +++ b/components/exercises/ExerciseDetails.tsx @@ -0,0 +1,351 @@ +// 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/ExerciseList.tsx b/components/exercises/ExerciseList.tsx deleted file mode 100644 index bf8462b..0000000 --- a/components/exercises/ExerciseList.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// components/exercises/ExerciseList.tsx -import React, { useState, useCallback, useEffect } from 'react'; -import { View, SectionList } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { Badge } from '@/components/ui/badge'; -import { ExerciseCard } from '@/components/exercises/ExerciseCard'; -import { Exercise } from '@/types/exercise'; - -interface ExerciseListProps { - exercises: Exercise[]; - onExercisePress: (exercise: Exercise) => void; - onExerciseDelete: (id: string) => void; -} - -const ExerciseList = ({ - exercises, - onExercisePress, - onExerciseDelete -}: ExerciseListProps) => { - const [sections, setSections] = useState<{title: string, data: Exercise[]}[]>([]); - - const organizeExercises = useCallback(() => { - // Group by first letter - const grouped = exercises.reduce((acc, exercise) => { - const firstLetter = exercise.title[0].toUpperCase(); - const section = acc.find(s => s.title === firstLetter); - if (section) { - section.data.push(exercise); - } else { - acc.push({title: firstLetter, data: [exercise]}); - } - return acc; - }, [] as {title: string, data: Exercise[]}[]); - - // Sort sections alphabetically - grouped.sort((a,b) => a.title.localeCompare(b.title)); - - // Sort exercises within sections - grouped.forEach(section => { - section.data.sort((a,b) => a.title.localeCompare(b.title)); - }); - - setSections(grouped); - }, [exercises]); - - useEffect(() => { - organizeExercises(); - }, [organizeExercises]); - - const renderSectionHeader = ({ section }: { section: {title: string} }) => ( - - {section.title} - - ); - - const renderExercise = ({ item }: { item: Exercise }) => ( - - onExercisePress(item)} - onDelete={() => onExerciseDelete(item.id)} - /> - - ); - - return ( - item.id} - /> - ); -}; - -export default ExerciseList; \ No newline at end of file diff --git a/components/exercises/SimplifiedExerciseCard.tsx b/components/exercises/SimplifiedExerciseCard.tsx new file mode 100644 index 0000000..c6bd372 --- /dev/null +++ b/components/exercises/SimplifiedExerciseCard.tsx @@ -0,0 +1,102 @@ +// components/exercises/SimplifiedExerciseCard.tsx +import React from 'react'; +import { View, TouchableOpacity, Image } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Badge } from '@/components/ui/badge'; +import { ExerciseDisplay } from '@/types/exercise'; + +interface SimplifiedExerciseCardProps { + exercise: ExerciseDisplay; + onPress: () => void; +} + +export function SimplifiedExerciseCard({ exercise, onPress }: SimplifiedExerciseCardProps) { + const { + title, + category, + equipment, + type, + source, + } = exercise; + + const firstLetter = title.charAt(0).toUpperCase(); + + // Helper to check if exercise has workout-specific properties + const isWorkoutExercise = 'sets' in exercise && Array.isArray((exercise as any).sets); + + // Access sets safely if available + const workoutExercise = isWorkoutExercise ? + (exercise as ExerciseDisplay & { sets: Array<{weight?: number, reps?: number}> }) : + null; + + return ( + + {/* Image placeholder or first letter */} + + + {firstLetter} + + + + + {/* Title */} + + {title} + + + {/* Tags row */} + + {/* Category Badge */} + + {category} + + + {/* Equipment Badge (if available) */} + {equipment && ( + + {equipment} + + )} + + {/* Type Badge */} + {type && ( + + {type} + + )} + + {/* Source Badge - colored for 'powr' */} + {source && ( + + + {source} + + + )} + + + + {/* Weight/Reps information if available from sets */} + {workoutExercise?.sets?.[0] && ( + + + {workoutExercise.sets[0].weight && `${workoutExercise.sets[0].weight} lb`} + {workoutExercise.sets[0].weight && workoutExercise.sets[0].reps && ' '} + {workoutExercise.sets[0].reps && `(×${workoutExercise.sets[0].reps})`} + + + )} + + ); +} \ No newline at end of file diff --git a/components/exercises/SimplifiedExerciseList.tsx b/components/exercises/SimplifiedExerciseList.tsx new file mode 100644 index 0000000..8c4dbca --- /dev/null +++ b/components/exercises/SimplifiedExerciseList.tsx @@ -0,0 +1,225 @@ +// components/exercises/SimplifiedExerciseList.tsx +import React, { useRef, useState, useCallback } from 'react'; +import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Badge } from '@/components/ui/badge'; +import { ExerciseDisplay, WorkoutExercise } from '@/types/exercise'; + +// Create a combined interface for exercises that could have workout data +interface DisplayWorkoutExercise extends ExerciseDisplay, WorkoutExercise {} + +interface SimplifiedExerciseListProps { + exercises: ExerciseDisplay[]; + onExercisePress: (exercise: ExerciseDisplay) => void; +} + +export const SimplifiedExerciseList = ({ + exercises, + onExercisePress +}: SimplifiedExerciseListProps) => { + const sectionListRef = useRef(null); + const [currentSection, setCurrentSection] = useState(''); + + // Organize exercises into sections + const sections = React.useMemo(() => { + const exercisesByLetter = exercises.reduce((acc, exercise) => { + const firstLetter = exercise.title[0].toUpperCase(); + if (!acc[firstLetter]) { + acc[firstLetter] = []; + } + acc[firstLetter].push(exercise); + return acc; + }, {} as Record); + + return Object.entries(exercisesByLetter) + .map(([letter, exercises]) => ({ + title: letter, + data: exercises.sort((a, b) => a.title.localeCompare(b.title)) + })) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [exercises]); + + const handleViewableItemsChanged = useCallback(({ + viewableItems + }: { + viewableItems: ViewToken[]; + }) => { + const firstSection = viewableItems.find(item => item.section)?.section?.title; + if (firstSection) { + setCurrentSection(firstSection); + } + }, []); + + const scrollToSection = useCallback((letter: string) => { + const sectionIndex = sections.findIndex(section => section.title === letter); + if (sectionIndex !== -1 && sectionListRef.current) { + sectionListRef.current.scrollToLocation({ + animated: true, + sectionIndex, + itemIndex: 0, + viewPosition: 0, + }); + } + }, [sections]); + + const getItemLayout = useCallback((data: any, index: number) => ({ + length: 85, // Approximate height of each item + offset: 85 * index, + index, + }), []); + + const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + const availableLetters = new Set(sections.map(section => section.title)); + + // Updated type guard + function isWorkoutExercise(exercise: ExerciseDisplay): exercise is DisplayWorkoutExercise { + return 'sets' in exercise && Array.isArray((exercise as any).sets); + } + + const renderExerciseItem = ({ item }: { item: ExerciseDisplay }) => { + const firstLetter = item.title.charAt(0).toUpperCase(); + + return ( + onExercisePress(item)} + className="flex-row items-center px-4 py-3 border-b border-border" + > + {/* Image placeholder or first letter */} + + + {firstLetter} + + + + + {/* Title */} + + {item.title} + + + {/* Tags row */} + + {/* Category Badge */} + + {item.category} + + + {/* Equipment Badge (if available) */} + {item.equipment && ( + + {item.equipment} + + )} + + {/* Type Badge */} + + {item.type} + + + {/* Source Badge - colored for 'powr' */} + {item.source && ( + + + {item.source} + + + )} + + + + {/* Weight/Rep information if it was a WorkoutExercise */} + {isWorkoutExercise(item) && ( + + + {item.sets?.[0]?.weight && `${item.sets[0].weight} lb`} + {item.sets?.[0]?.weight && item.sets?.[0]?.reps && ' '} + {item.sets?.[0]?.reps && `(×${item.sets[0].reps})`} + + + )} + + ); + }; + + return ( + + {/* Main List */} + + item.id} + getItemLayout={getItemLayout} + renderSectionHeader={({ section }) => ( + + + {section.title} + + + )} + renderItem={renderExerciseItem} + stickySectionHeadersEnabled + initialNumToRender={15} + maxToRenderPerBatch={10} + windowSize={5} + onViewableItemsChanged={handleViewableItemsChanged} + viewabilityConfig={{ + itemVisiblePercentThreshold: 50 + }} + /> + + + {/* Alphabet List */} + true} + onResponderMove={(evt) => { + const touch = evt.nativeEvent; + const element = evt.target; + + if (element) { + (element as any).measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => { + const totalHeight = height; + const letterHeight = totalHeight / alphabet.length; + const touchY = touch.pageY - pageY; + const index = Math.min( + Math.max(Math.floor(touchY / letterHeight), 0), + alphabet.length - 1 + ); + + const letter = alphabet[index]; + if (availableLetters.has(letter)) { + scrollToSection(letter); + } + }); + } + }} + > + {alphabet.map((letter) => ( + + {letter} + + ))} + + + ); +}; + +export default SimplifiedExerciseList; \ No newline at end of file diff --git a/components/library/NewExerciseSheet.tsx b/components/library/NewExerciseSheet.tsx index 2f85a5e..75dcd82 100644 --- a/components/library/NewExerciseSheet.tsx +++ b/components/library/NewExerciseSheet.tsx @@ -1,19 +1,25 @@ // components/library/NewExerciseSheet.tsx -import React from 'react'; -import { View, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; +import React, { useState } from 'react'; +import { View, ScrollView } 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 { BaseExercise, ExerciseType, ExerciseCategory, Equipment, Exercise } from '@/types/exercise'; -import { StorageSource } from '@/types/shared'; -import { Textarea } from '@/components/ui/textarea'; 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: Omit) => void; // Changed from BaseExercise + onSubmit: (exercise: BaseExercise) => void; } const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight']; @@ -29,7 +35,7 @@ const EQUIPMENT_OPTIONS: Equipment[] = [ ]; export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) { - const [formData, setFormData] = React.useState({ + const [formData, setFormData] = useState({ title: '', type: 'strength' as ExerciseType, category: 'Push' as ExerciseCategory, @@ -41,40 +47,39 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet reps: true, rpe: true, set_type: true - }, + } as ExerciseFormat, format_units: { - weight: 'kg' as const, - reps: 'count' as const, - rpe: '0-10' as const, - set_type: 'warmup|normal|drop|failure' as const - } + weight: 'kg', + reps: 'count', + rpe: '0-10', + set_type: 'warmup|normal|drop|failure' + } as ExerciseFormatUnits }); const handleSubmit = () => { if (!formData.title || !formData.equipment) return; - // Transform the form data into an Exercise type - const exerciseData: Omit = { + 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, + tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()], format: formData.format, format_units: formData.format_units, - // Add required Exercise fields - source: 'local', - created_at: Date.now(), + created_at: timestamp, availability: { - source: ['local'] - }, - format_json: JSON.stringify(formData.format), - format_units_json: JSON.stringify(formData.format_units) + source: ['local' as StorageSource], + lastSynced: undefined + } }; - - onSubmit(exerciseData); - onClose(); + + onSubmit(exercise); // Reset form setFormData({ @@ -97,6 +102,8 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet set_type: 'warmup|normal|drop|failure' } }); + + onClose(); }; return ( @@ -105,17 +112,15 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet New Exercise - - + + Exercise Name setFormData(prev => ({ ...prev, title: text }))} + onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))} placeholder="e.g., Barbell Back Squat" + className="text-foreground" /> @@ -125,10 +130,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet {EXERCISE_TYPES.map((type) => ( @@ -142,10 +147,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet {CATEGORIES.map((category) => ( @@ -159,10 +164,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet {EQUIPMENT_OPTIONS.map((eq) => ( @@ -172,26 +177,25 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet Description -