Library tab MVP finished

This commit is contained in:
DocNR 2025-02-19 21:39:47 -05:00
parent 083367e872
commit f26a59f569
20 changed files with 2653 additions and 1286 deletions

View File

@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Detailed SQLite error logging - Detailed SQLite error logging
- Improved transaction management - Improved transaction management
- Added proper error types and propagation - 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 ### Changed
- Improved exercise library interface - 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 - Added proper SQLite error types
- Enhanced transaction rollback handling - Enhanced transaction rollback handling
- Added detailed debug logging - 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 ### Fixed
- Exercise deletion functionality - 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 - TypeScript parameter typing in database services
- Null value handling in database operations - Null value handling in database operations
- Development seeding duplicate prevention - Development seeding duplicate prevention
- Template category spacing issues
- Exercise list rendering on iOS
- Database reset and reseeding behavior
### Technical Details ### Technical Details
1. Database Schema Enforcement: 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 - Exercise creation now enforces schema constraints
- Input validation prevents invalid data entry - Input validation prevents invalid data entry
- Enhanced error messages provide better debugging information - Enhanced error messages provide better debugging information
- Template management requires updated type definitions
## [0.1.0] - 2024-02-09 ## [0.1.0] - 2024-02-09

View File

@ -1,129 +1,78 @@
// app/(tabs)/library/exercises.tsx // app/(tabs)/library/exercises.tsx
import React, { useRef, useState, useCallback } from 'react'; import React, { useState } from 'react';
import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native'; import { View, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; 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 { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
import { Dumbbell } from 'lucide-react-native'; import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList';
import { BaseExercise, Exercise } from '@/types/exercise'; import { ExerciseDetails } from '@/components/exercises/ExerciseDetails';
import { ExerciseDisplay, ExerciseType, BaseExercise } from '@/types/exercise';
import { useExercises } from '@/lib/hooks/useExercises'; import { useExercises } from '@/lib/hooks/useExercises';
export default function ExercisesScreen() { export default function ExercisesScreen() {
const sectionListRef = useRef<SectionList>(null);
const [showNewExercise, setShowNewExercise] = useState(false); const [showNewExercise, setShowNewExercise] = useState(false);
const [currentSection, setCurrentSection] = useState<string>(''); const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState<ExerciseType | null>(null);
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
const { const {
exercises, exercises,
loading, loading,
error, error,
stats,
createExercise, createExercise,
deleteExercise, deleteExercise,
refreshExercises refreshExercises,
updateFilters,
clearFilters
} = useExercises(); } = useExercises();
// Organize exercises into sections // Filter exercises based on search query
const sections = React.useMemo(() => { React.useEffect(() => {
const exercisesByLetter = exercises.reduce((acc, exercise) => { if (searchQuery) {
const firstLetter = exercise.title[0].toUpperCase(); updateFilters({ searchQuery });
if (!acc[firstLetter]) { } else {
acc[firstLetter] = []; 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<BaseExercise, 'id'> = {
...exerciseData,
availability: {
source: ['local']
} }
acc[firstLetter].push(exercise); };
return acc;
}, {} as Record<string, Exercise[]>); await createExercise(exerciseWithSource);
setShowNewExercise(false);
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<BaseExercise, 'id' | 'availability' | 'created_at'>) => {
try {
const newExercise: Omit<Exercise, 'id'> = {
...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);
}
}; };
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) { if (loading) {
return ( return (
<View className="flex-1 items-center justify-center bg-background"> <View className="flex-1 items-center justify-center bg-background">
<Text>Loading exercises...</Text> <ActivityIndicator size="large" color="#8B5CF6" />
<Text className="mt-4 text-muted-foreground">Loading exercises...</Text>
</View> </View>
); );
} }
@ -135,7 +84,7 @@ export default function ExercisesScreen() {
{error.message} {error.message}
</Text> </Text>
<Button onPress={refreshExercises}> <Button onPress={refreshExercises}>
<Text>Retry</Text> <Text className="text-primary-foreground">Retry</Text>
</Button> </Button>
</View> </View>
); );
@ -143,122 +92,94 @@ export default function ExercisesScreen() {
return ( return (
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
{/* Stats Bar */} {/* Search bar */}
<View className="flex-row justify-between items-center p-4 bg-card border-b border-border"> <View className="px-4 py-3">
<View> <View className="relative flex-row items-center bg-muted rounded-xl">
<Text className="text-sm text-muted-foreground">Total Exercises</Text> <View className="absolute left-3 z-10">
<Text className="text-2xl font-bold">{stats.totalCount}</Text> <Search size={18} className="text-muted-foreground" />
</View>
<View className="flex-row gap-4">
<View>
<Text className="text-xs text-muted-foreground">Push</Text>
<Text className="text-base font-medium">{stats.byCategory['Push'] || 0}</Text>
</View> </View>
<View> <Input
<Text className="text-xs text-muted-foreground">Pull</Text> value={searchQuery}
<Text className="text-base font-medium">{stats.byCategory['Pull'] || 0}</Text> onChangeText={setSearchQuery}
</View> placeholder="Search"
<View> className="pl-9 bg-transparent h-10 flex-1"
<Text className="text-xs text-muted-foreground">Legs</Text>
<Text className="text-base font-medium">{stats.byCategory['Legs'] || 0}</Text>
</View>
<View>
<Text className="text-xs text-muted-foreground">Core</Text>
<Text className="text-base font-medium">{stats.byCategory['Core'] || 0}</Text>
</View>
</View>
</View>
{/* Exercise List with Alphabet Scroll */}
<View className="flex-1 flex-row">
{/* Main List */}
<View className="flex-1">
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
renderSectionHeader={({ section }) => (
<View className="py-2 px-4 bg-background/80">
<Text className="text-lg font-semibold text-foreground">
{section.title}
</Text>
</View>
)}
renderItem={({ item }) => (
<View className="px-4 py-1">
<ExerciseCard
{...item}
onPress={() => handleExercisePress(item.id)}
onDelete={() => handleDelete(item.id)}
/>
</View>
)}
stickySectionHeadersEnabled
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
onViewableItemsChanged={handleViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}
/> />
</View> </View>
{/* Alphabet List */}
<View
className="w-8 justify-center bg-transparent px-1"
onStartShouldSetResponder={() => 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) => (
<Text
key={letter}
className={
letter === currentSection
? 'text-xs text-center text-primary font-bold py-0.5'
: availableLetters.has(letter)
? 'text-xs text-center text-primary font-medium py-0.5'
: 'text-xs text-center text-muted-foreground py-0.5'
}
>
{letter}
</Text>
))}
</View>
</View> </View>
{/* Filter buttons */}
<View className="flex-row px-4 pb-2 gap-2">
<Button
variant={activeFilter === null ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(null)}
>
<Text className={activeFilter === null ? "text-primary-foreground" : ""}>
All
</Text>
</Button>
<Button
variant={activeFilter === "strength" ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(activeFilter === "strength" ? null : "strength")}
>
<Text className={activeFilter === "strength" ? "text-primary-foreground" : ""}>
Strength
</Text>
</Button>
<Button
variant={activeFilter === "bodyweight" ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(activeFilter === "bodyweight" ? null : "bodyweight")}
>
<Text className={activeFilter === "bodyweight" ? "text-primary-foreground" : ""}>
Bodyweight
</Text>
</Button>
<Button
variant={activeFilter === "cardio" ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveFilter(activeFilter === "cardio" ? null : "cardio")}
>
<Text className={activeFilter === "cardio" ? "text-primary-foreground" : ""}>
Cardio
</Text>
</Button>
</View>
{/* Exercises list */}
<SimplifiedExerciseList
exercises={exercises}
onExercisePress={handleExercisePress}
/>
{/* Exercise details sheet */}
{selectedExercise && (
<ExerciseDetails
exercise={selectedExercise}
open={!!selectedExercise}
onOpenChange={(open) => {
if (!open) setSelectedExercise(null);
}}
onEdit={handleEdit}
/>
)}
{/* FAB for adding new exercise */}
<FloatingActionButton <FloatingActionButton
icon={Dumbbell} icon={Dumbbell}
onPress={() => setShowNewExercise(true)} onPress={() => setShowNewExercise(true)}
/> />
{/* New exercise sheet */}
<NewExerciseSheet <NewExerciseSheet
isOpen={showNewExercise} isOpen={showNewExercise}
onClose={() => setShowNewExercise(false)} onClose={() => setShowNewExercise(false)}
onSubmit={handleAddExercise} onSubmit={handleCreateExercise}
/> />
</View> </View>
); );

View File

@ -1,12 +1,28 @@
// app/(tabs)/library/templates.tsx // app/(tabs)/library/templates.tsx
import { View, ScrollView } from 'react-native'; import React, { useState } from 'react';
import { useState } from 'react'; import { View, ScrollView, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text'; 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 { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet'; import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
import { Plus } from 'lucide-react-native'; import { TemplateCard } from '@/components/templates/TemplateCard';
import { Template } from '@/types/library'; 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 // Mock data - move to a separate file later
const initialTemplates: Template[] = [ const initialTemplates: Template[] = [
@ -43,14 +59,16 @@ const initialTemplates: Template[] = [
export default function TemplatesScreen() { export default function TemplatesScreen() {
const [showNewTemplate, setShowNewTemplate] = useState(false); const [showNewTemplate, setShowNewTemplate] = useState(false);
const [templates, setTemplates] = useState(initialTemplates); const [templates, setTemplates] = useState(initialTemplates);
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<TemplateCategory | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<WorkoutTemplate | null>(null);
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
setTemplates(current => current.filter(t => t.id !== id)); setTemplates(current => current.filter(t => t.id !== id));
}; };
const handleTemplatePress = (template: Template) => { const handleTemplatePress = (template: Template) => {
// TODO: Show template details setSelectedTemplate(toWorkoutTemplate(template));
console.log('Selected template:', template);
}; };
const handleStartWorkout = (template: Template) => { const handleStartWorkout = (template: Template) => {
@ -73,17 +91,74 @@ export default function TemplatesScreen() {
setShowNewTemplate(false); 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 // Separate favorites and regular templates
const favoriteTemplates = templates.filter(t => t.isFavorite); const favoriteTemplates = filteredTemplates.filter(t => t.isFavorite);
const regularTemplates = templates.filter(t => !t.isFavorite); const regularTemplates = filteredTemplates.filter(t => !t.isFavorite);
return ( return (
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
<ScrollView className="flex-1"> {/* Search bar */}
<View className="px-4 py-2">
<View className="relative flex-row items-center bg-muted rounded-xl">
<View className="absolute left-3 z-10">
<Search size={18} className="text-muted-foreground" />
</View>
<Input
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search templates"
className="pl-9 bg-transparent h-10 flex-1"
/>
</View>
</View>
{/* Category filters */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-4 py-2 border-b border-border"
>
<View className="flex-row gap-2">
<Button
variant={activeCategory === null ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveCategory(null)}
>
<Text className={activeCategory === null ? "text-primary-foreground" : ""}>
All
</Text>
</Button>
{TEMPLATE_CATEGORIES.map((category) => (
<Button
key={category}
variant={activeCategory === category ? "default" : "outline"}
size="sm"
className="rounded-full"
onPress={() => setActiveCategory(activeCategory === category ? null : category)}
>
<Text className={activeCategory === category ? "text-primary-foreground" : ""}>
{category}
</Text>
</Button>
))}
</View>
</ScrollView>
{/* Templates list */}
<ScrollView>
{/* Favorites Section */} {/* Favorites Section */}
{favoriteTemplates.length > 0 && ( {favoriteTemplates.length > 0 && (
<View className="py-4"> <View>
<Text className="text-lg font-semibold mb-4 px-4"> <Text className="text-lg font-semibold px-4 py-2">
Favorites Favorites
</Text> </Text>
<View className="gap-3"> <View className="gap-3">
@ -102,8 +177,8 @@ export default function TemplatesScreen() {
)} )}
{/* All Templates Section */} {/* All Templates Section */}
<View className="py-4"> <View>
<Text className="text-lg font-semibold mb-4 px-4"> <Text className="text-lg font-semibold px-4 py-2">
All Templates All Templates
</Text> </Text>
{regularTemplates.length > 0 ? ( {regularTemplates.length > 0 ? (
@ -132,6 +207,17 @@ export default function TemplatesScreen() {
<View className="h-20" /> <View className="h-20" />
</ScrollView> </ScrollView>
{/* Rest of the components (sheets & FAB) remain the same */}
{selectedTemplate && (
<TemplateDetails
template={selectedTemplate}
open={!!selectedTemplate}
onOpenChange={(open) => {
if (!open) setSelectedTemplate(null);
}}
/>
)}
<FloatingActionButton <FloatingActionButton
icon={Plus} icon={Plus}
onPress={() => setShowNewTemplate(true)} onPress={() => setShowNewTemplate(true)}

View File

@ -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 (
<>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<Card>
<CardContent className="p-4">
<View className="flex-row justify-between items-start">
<View className="flex-1">
{/* Title and Source Badge */}
<View className="flex-row items-center gap-2 mb-2">
<Text className="text-lg font-semibold text-foreground">{title}</Text>
<Badge variant={source === 'local' ? 'outline' : 'secondary'} className="capitalize">
<Text>{source}</Text>
</Badge>
</View>
{/* Category & Equipment */}
<View className="flex-row flex-wrap gap-2 mb-2">
<Badge variant="outline">
<Text>{category}</Text>
</Badge>
{equipment && (
<Badge variant="outline">
<Text>{equipment}</Text>
</Badge>
)}
</View>
{/* Description Preview */}
{description && (
<Text className="text-sm text-muted-foreground mb-2 native:pr-12" numberOfLines={2}>
{description}
</Text>
)}
{/* Tags */}
{tags.length > 0 && (
<View className="flex-row flex-wrap gap-1">
{tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">
<Text>{tag}</Text>
</Badge>
))}
</View>
)}
</View>
{/* Action Buttons */}
<View className="flex-row gap-1">
{onFavorite && (
<Button variant="ghost" size="icon" onPress={onFavorite} className="h-9 w-9">
<Star className="text-muted-foreground" size={18} />
</Button>
)}
<Button
variant="ghost"
size="icon"
onPress={() => setShowDetails(true)}
className="h-9 w-9"
>
<Info className="text-muted-foreground" size={18} />
</Button>
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<Trash2 className="text-destructive" size={18} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Exercise</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {title}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<View className="flex-row justify-end gap-3 mt-4">
<AlertDialogCancel asChild>
<Button variant="outline">
<Text>Cancel</Text>
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onPress={handleDelete}>
<Text className="text-white">Delete</Text>
</Button>
</AlertDialogAction>
</View>
</AlertDialogContent>
</AlertDialog>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Details Sheet */}
<Sheet isOpen={showDetails} onClose={() => setShowDetails(false)}>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<SheetContent>
<View className="gap-6">
{description && (
<View>
<Text className="text-base font-semibold mb-2">Description</Text>
<Text className="text-base">{description}</Text>
</View>
)}
<View>
<Text className="text-base font-semibold mb-2">Details</Text>
<View className="gap-2">
<Text className="text-base">Type: {type}</Text>
<Text className="text-base">Category: {category}</Text>
{equipment && <Text className="text-base">Equipment: {equipment}</Text>}
<Text className="text-base">Source: {source}</Text>
</View>
</View>
{instructions.length > 0 && (
<View>
<Text className="text-base font-semibold mb-2">Instructions</Text>
<View className="gap-2">
{instructions.map((instruction, index) => (
<Text key={index} className="text-base">
{index + 1}. {instruction}
</Text>
))}
</View>
</View>
)}
{tags.length > 0 && (
<View>
<Text className="text-base font-semibold mb-2">Tags</Text>
<View className="flex-row flex-wrap gap-2">
{tags.map(tag => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
</View>
</SheetContent>
</Sheet>
</>
);
}

View File

@ -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 (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={source === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{source}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category & Equipment Section */}
<View className="space-y-4">
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{equipment && (
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Dumbbell size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Equipment</Text>
<Text className="text-base font-medium text-foreground capitalize">{equipment}</Text>
</View>
</View>
)}
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map((tag: string) => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Usage Stats Section */}
{(usageCount || lastUsed) && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{usageCount && (
<Text className="text-base text-muted-foreground">
Used {usageCount} times
</Text>
)}
{lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {lastUsed.toLocaleDateString()}
</Text>
)}
</View>
</View>
)}
{/* Edit Button */}
{onEdit && (
<Button
onPress={onEdit}
className="w-full mt-2"
>
<Edit2 size={18} className="mr-2 text-primary-foreground" />
<Text className="text-primary-foreground font-semibold">
Edit Exercise
</Text>
</Button>
)}
</View>
</ScrollView>
);
}
// Progress Tab Component
function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Placeholder for Charts */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<LineChart size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">Progress charts coming soon</Text>
</View>
{/* Personal Records Section */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Personal Records</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Weight</Text>
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Reps</Text>
<Text className="text-lg font-semibold text-foreground">--</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Best Volume</Text>
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
// Form Tab Component
function FormTab({ exercise }: { exercise: ExerciseDisplay }) {
const { instructions = [] } = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Instructions Section */}
{instructions.length > 0 ? (
<View>
<Text className="text-base font-semibold text-foreground mb-4">Instructions</Text>
<View className="gap-4">
{instructions.map((instruction: string, index: number) => (
<View key={index} className="flex-row gap-3">
<Text className="text-sm font-medium text-muted-foreground min-w-[24px]">
{index + 1}.
</Text>
<Text className="text-base text-foreground flex-1">{instruction}</Text>
</View>
))}
</View>
</View>
) : (
<View className="items-center justify-center py-8">
<AlertCircle size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">No form instructions available</Text>
</View>
)}
{/* Placeholder for Media */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<Text className="text-muted-foreground">Video demos coming soon</Text>
</View>
</View>
</ScrollView>
);
}
// Settings Tab Component
function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Format Settings */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Exercise Settings</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Format</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format && Object.entries(exercise.format).map(([key, enabled]) => (
enabled && (
<Badge key={key} variant="secondary">
<Text>{key}</Text>
</Badge>
)
))}
</View>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Units</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => (
<Badge key={key} variant="secondary">
<Text>{key}: {String(unit)}</Text>
</Badge>
))}
</View>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
export function ExerciseDetails({
exercise,
open,
onOpenChange,
onEdit
}: ExerciseDetailsProps) {
const theme = useTheme() as CustomTheme;
return (
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
<SheetHeader>
<SheetTitle>
<Text className="text-xl font-bold text-foreground">{exercise.title}</Text>
</SheetTitle>
</SheetHeader>
<SheetContent>
<View style={{ flex: 1, minHeight: 400 }} className="rounded-t-[10px]">
<Tab.Navigator
style={{ flex: 1 }}
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen
name="info"
options={{ title: 'Info' }}
>
{() => <InfoTab exercise={exercise} onEdit={onEdit} />}
</Tab.Screen>
<Tab.Screen
name="progress"
options={{ title: 'Progress' }}
>
{() => <ProgressTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen
name="form"
options={{ title: 'Form' }}
>
{() => <FormTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen
name="settings"
options={{ title: 'Settings' }}
>
{() => <SettingsTab exercise={exercise} />}
</Tab.Screen>
</Tab.Navigator>
</View>
</SheetContent>
</Sheet>
);
}

View File

@ -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} }) => (
<View className="sticky top-0 z-10 bg-background border-b border-border py-2 px-4">
<Text className="text-lg font-semibold">{section.title}</Text>
</View>
);
const renderExercise = ({ item }: { item: Exercise }) => (
<View className="px-4 py-1">
<ExerciseCard
{...item}
onPress={() => onExercisePress(item)}
onDelete={() => onExerciseDelete(item.id)}
/>
</View>
);
return (
<SectionList
sections={sections}
renderItem={renderExercise}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled
keyExtractor={item => item.id}
/>
);
};
export default ExerciseList;

View File

@ -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 (
<TouchableOpacity
activeOpacity={0.7}
onPress={onPress}
className="flex-row items-center py-3 border-b border-border"
>
{/* Image placeholder or first letter */}
<View className="w-12 h-12 rounded-full bg-card flex items-center justify-center mr-3 overflow-hidden">
<Text className="text-2xl font-bold text-foreground">
{firstLetter}
</Text>
</View>
<View className="flex-1">
{/* Title */}
<Text className="text-base font-semibold text-foreground mb-1">
{title}
</Text>
{/* Tags row */}
<View className="flex-row flex-wrap gap-1">
{/* Category Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{category}</Text>
</Badge>
{/* Equipment Badge (if available) */}
{equipment && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{equipment}</Text>
</Badge>
)}
{/* Type Badge */}
{type && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{type}</Text>
</Badge>
)}
{/* Source Badge - colored for 'powr' */}
{source && (
<Badge
variant={source === 'powr' ? 'default' : 'secondary'}
className={`rounded-full py-0.5 ${
source === 'powr' ? 'bg-violet-500' : ''
}`}
>
<Text className={`text-xs ${
source === 'powr' ? 'text-white' : ''
}`}>
{source}
</Text>
</Badge>
)}
</View>
</View>
{/* Weight/Reps information if available from sets */}
{workoutExercise?.sets?.[0] && (
<View className="items-end">
<Text className="text-muted-foreground text-sm">
{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})`}
</Text>
</View>
)}
</TouchableOpacity>
);
}

View File

@ -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<SectionList>(null);
const [currentSection, setCurrentSection] = useState<string>('');
// 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<string, ExerciseDisplay[]>);
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 (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => onExercisePress(item)}
className="flex-row items-center px-4 py-3 border-b border-border"
>
{/* Image placeholder or first letter */}
<View className="w-12 h-12 rounded-full bg-card flex items-center justify-center mr-3 overflow-hidden">
<Text className="text-2xl font-bold text-foreground">
{firstLetter}
</Text>
</View>
<View className="flex-1">
{/* Title */}
<Text className="text-base font-semibold text-foreground mb-1">
{item.title}
</Text>
{/* Tags row */}
<View className="flex-row flex-wrap gap-1">
{/* Category Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{item.category}</Text>
</Badge>
{/* Equipment Badge (if available) */}
{item.equipment && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{item.equipment}</Text>
</Badge>
)}
{/* Type Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{item.type}</Text>
</Badge>
{/* Source Badge - colored for 'powr' */}
{item.source && (
<Badge
variant={item.source === 'powr' ? 'default' : 'secondary'}
className={`rounded-full py-0.5 ${
item.source === 'powr' ? 'bg-violet-500' : ''
}`}
>
<Text className={`text-xs ${
item.source === 'powr' ? 'text-white' : ''
}`}>
{item.source}
</Text>
</Badge>
)}
</View>
</View>
{/* Weight/Rep information if it was a WorkoutExercise */}
{isWorkoutExercise(item) && (
<View className="items-end">
<Text className="text-muted-foreground text-sm">
{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})`}
</Text>
</View>
)}
</TouchableOpacity>
);
};
return (
<View className="flex-1 flex-row bg-background">
{/* Main List */}
<View className="flex-1">
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
renderSectionHeader={({ section }) => (
<View className="py-2 px-4 bg-muted/80 border-b border-border">
<Text className="text-base font-semibold text-foreground">
{section.title}
</Text>
</View>
)}
renderItem={renderExerciseItem}
stickySectionHeadersEnabled
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={5}
onViewableItemsChanged={handleViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}
/>
</View>
{/* Alphabet List */}
<View
className="w-8 justify-center bg-transparent px-1"
onStartShouldSetResponder={() => 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) => (
<Text
key={letter}
className={
letter === currentSection
? 'text-xs text-center text-primary font-bold py-0.5'
: availableLetters.has(letter)
? 'text-xs text-center text-primary font-medium py-0.5'
: 'text-xs text-center text-muted-foreground py-0.5'
}
>
{letter}
</Text>
))}
</View>
</View>
);
};
export default SimplifiedExerciseList;

View File

@ -1,19 +1,25 @@
// components/library/NewExerciseSheet.tsx // components/library/NewExerciseSheet.tsx
import React from 'react'; import React, { useState } from 'react';
import { View, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; import { View, ScrollView } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; 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 { generateId } from '@/utils/ids';
import {
BaseExercise,
ExerciseType,
ExerciseCategory,
Equipment,
ExerciseFormat,
ExerciseFormatUnits
} from '@/types/exercise';
import { StorageSource } from '@/types/shared';
interface NewExerciseSheetProps { interface NewExerciseSheetProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (exercise: Omit<Exercise, 'id'>) => void; // Changed from BaseExercise onSubmit: (exercise: BaseExercise) => void;
} }
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight']; const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
@ -29,7 +35,7 @@ const EQUIPMENT_OPTIONS: Equipment[] = [
]; ];
export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) { export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) {
const [formData, setFormData] = React.useState({ const [formData, setFormData] = useState({
title: '', title: '',
type: 'strength' as ExerciseType, type: 'strength' as ExerciseType,
category: 'Push' as ExerciseCategory, category: 'Push' as ExerciseCategory,
@ -41,40 +47,39 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
reps: true, reps: true,
rpe: true, rpe: true,
set_type: true set_type: true
}, } as ExerciseFormat,
format_units: { format_units: {
weight: 'kg' as const, weight: 'kg',
reps: 'count' as const, reps: 'count',
rpe: '0-10' as const, rpe: '0-10',
set_type: 'warmup|normal|drop|failure' as const set_type: 'warmup|normal|drop|failure'
} } as ExerciseFormatUnits
}); });
const handleSubmit = () => { const handleSubmit = () => {
if (!formData.title || !formData.equipment) return; if (!formData.title || !formData.equipment) return;
// Transform the form data into an Exercise type const timestamp = Date.now();
const exerciseData: Omit<Exercise, 'id'> = {
// Create BaseExercise
const exercise: BaseExercise = {
id: generateId(),
title: formData.title, title: formData.title,
type: formData.type, type: formData.type,
category: formData.category, category: formData.category,
equipment: formData.equipment, equipment: formData.equipment,
description: formData.description, description: formData.description,
tags: formData.tags, tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()],
format: formData.format, format: formData.format,
format_units: formData.format_units, format_units: formData.format_units,
// Add required Exercise fields created_at: timestamp,
source: 'local',
created_at: Date.now(),
availability: { availability: {
source: ['local'] source: ['local' as StorageSource],
}, lastSynced: undefined
format_json: JSON.stringify(formData.format), }
format_units_json: JSON.stringify(formData.format_units)
}; };
onSubmit(exerciseData); onSubmit(exercise);
onClose();
// Reset form // Reset form
setFormData({ setFormData({
@ -97,6 +102,8 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
set_type: 'warmup|normal|drop|failure' set_type: 'warmup|normal|drop|failure'
} }
}); });
onClose();
}; };
return ( return (
@ -105,17 +112,15 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
<SheetTitle>New Exercise</SheetTitle> <SheetTitle>New Exercise</SheetTitle>
</SheetHeader> </SheetHeader>
<SheetContent> <SheetContent>
<KeyboardAvoidingView <ScrollView className="flex-1">
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} <View className="gap-4 py-4">
className="flex-1"
>
<ScrollView className="gap-4">
<View> <View>
<Text className="text-base font-medium mb-2">Exercise Name</Text> <Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input <Input
value={formData.title} value={formData.title}
onChangeText={(text: string) => setFormData(prev => ({ ...prev, title: text }))} onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat" placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/> />
</View> </View>
@ -125,10 +130,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
{EXERCISE_TYPES.map((type) => ( {EXERCISE_TYPES.map((type) => (
<Button <Button
key={type} key={type}
variant={formData.type === type ? 'purple' : 'outline'} variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))} onPress={() => setFormData(prev => ({ ...prev, type }))}
> >
<Text className={formData.type === type ? 'text-white' : ''}> <Text className={formData.type === type ? 'text-primary-foreground' : ''}>
{type} {type}
</Text> </Text>
</Button> </Button>
@ -142,10 +147,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
{CATEGORIES.map((category) => ( {CATEGORIES.map((category) => (
<Button <Button
key={category} key={category}
variant={formData.category === category ? 'purple' : 'outline'} variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))} onPress={() => setFormData(prev => ({ ...prev, category }))}
> >
<Text className={formData.category === category ? 'text-white' : ''}> <Text className={formData.category === category ? 'text-primary-foreground' : ''}>
{category} {category}
</Text> </Text>
</Button> </Button>
@ -159,10 +164,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
{EQUIPMENT_OPTIONS.map((eq) => ( {EQUIPMENT_OPTIONS.map((eq) => (
<Button <Button
key={eq} key={eq}
variant={formData.equipment === eq ? 'purple' : 'outline'} variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))} onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
> >
<Text className={formData.equipment === eq ? 'text-white' : ''}> <Text className={formData.equipment === eq ? 'text-primary-foreground' : ''}>
{eq} {eq}
</Text> </Text>
</Button> </Button>
@ -172,26 +177,25 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
<View> <View>
<Text className="text-base font-medium mb-2">Description</Text> <Text className="text-base font-medium mb-2">Description</Text>
<Textarea <Input
value={formData.description} value={formData.description}
onChangeText={(text: string) => setFormData(prev => ({ ...prev, description: text }))} onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..." placeholder="Exercise description..."
numberOfLines={6} multiline
className="min-h-[120px]" numberOfLines={4}
style={{ maxHeight: 200 }}
/> />
</View> </View>
<Button <Button
className="mt-4" className="mt-4"
variant='purple' variant='default'
onPress={handleSubmit} onPress={handleSubmit}
disabled={!formData.title || !formData.equipment} disabled={!formData.title || !formData.equipment}
> >
<Text className="text-white font-semibold">Create Exercise</Text> <Text className="text-primary-foreground font-semibold">Create Exercise</Text>
</Button> </Button>
</ScrollView> </View>
</KeyboardAvoidingView> </ScrollView>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );

View File

@ -1,13 +1,23 @@
// components/library/NewTemplateSheet.tsx // components/library/NewTemplateSheet.tsx
import React from 'react'; import React, { useState, useEffect } from 'react';
import { View } from 'react-native'; import { View, ScrollView, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; 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 { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import {
Template,
TemplateType,
TemplateCategory,
TemplateExerciseDisplay
} from '@/types/templates';
import { ExerciseDisplay } from '@/types/exercise';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
import { TemplateType, TemplateCategory } from '@/types/library'; import { useSQLiteContext } from 'expo-sqlite';
import { cn } from '@/lib/utils'; import { LibraryService } from '@/lib/db/services/LibraryService';
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List } from 'lucide-react-native';
interface NewTemplateSheetProps { interface NewTemplateSheetProps {
isOpen: boolean; isOpen: boolean;
@ -15,131 +25,629 @@ interface NewTemplateSheetProps {
onSubmit: (template: Template) => void; onSubmit: (template: Template) => void;
} }
const WORKOUT_TYPES: TemplateType[] = ['strength', 'circuit', 'emom', 'amrap']; // Steps in template creation
type CreationStep = 'type' | 'info' | 'exercises' | 'config' | 'review';
const CATEGORIES: TemplateCategory[] = [ // Step 0: Workout Type Selection
'Full Body', interface WorkoutTypeStepProps {
'Upper/Lower', onSelectType: (type: TemplateType) => void;
'Push/Pull/Legs', onCancel: () => void;
'Cardio', }
'CrossFit',
'Strength', function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
'Conditioning', const workoutTypes = [
'Custom' {
]; type: 'strength' as TemplateType,
title: 'Strength Workout',
description: 'Traditional sets and reps with rest periods',
icon: Dumbbell,
available: true
},
{
type: 'circuit' as TemplateType,
title: 'Circuit Training',
description: 'Multiple exercises performed in sequence',
icon: RotateCw,
available: false
},
{
type: 'emom' as TemplateType,
title: 'EMOM Workout',
description: 'Every Minute On the Minute timed exercises',
icon: Clock,
available: false
},
{
type: 'amrap' as TemplateType,
title: 'AMRAP Workout',
description: 'As Many Rounds As Possible in a time cap',
icon: List,
available: false
}
];
return (
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<Text className="text-base mb-4">Select the type of workout template you want to create:</Text>
<View className="gap-3">
{workoutTypes.map(workout => (
<View key={workout.type} className="p-4 bg-card border border-border rounded-md">
{workout.available ? (
<TouchableOpacity
onPress={() => onSelectType(workout.type)}
className="flex-row justify-between items-center"
>
<View className="flex-row items-center gap-3">
<workout.icon size={24} className="text-foreground" />
<View className="flex-1">
<Text className="text-lg font-semibold">{workout.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">
{workout.description}
</Text>
</View>
</View>
<View className="pl-2 pr-1">
<ChevronRight className="text-muted-foreground" size={20} />
</View>
</TouchableOpacity>
) : (
<View>
<View className="flex-row items-center gap-3 opacity-70">
<workout.icon size={24} className="text-muted-foreground" />
<View className="flex-1">
<Text className="text-lg font-semibold">{workout.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">
{workout.description}
</Text>
</View>
</View>
<View className="mt-3 opacity-70">
<Badge variant="outline" className="bg-muted self-start">
<Text className="text-xs">Coming Soon</Text>
</Badge>
</View>
</View>
)}
</View>
))}
</View>
<Button variant="outline" onPress={onCancel} className="mt-4">
<Text>Cancel</Text>
</Button>
</View>
</ScrollView>
);
}
// Step 1: Basic Info
interface BasicInfoStepProps {
title: string;
description: string;
category: TemplateCategory;
onTitleChange: (title: string) => void;
onDescriptionChange: (description: string) => void;
onCategoryChange: (category: string) => void;
onNext: () => void;
onCancel: () => void;
}
function BasicInfoStep({
title,
description,
category,
onTitleChange,
onDescriptionChange,
onCategoryChange,
onNext,
onCancel
}: BasicInfoStepProps) {
const categories: TemplateCategory[] = ['Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Conditioning'];
return (
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<View>
<Text className="text-base font-medium mb-2">Workout Name</Text>
<Input
value={title}
onChangeText={onTitleChange}
placeholder="e.g., Full Body Strength"
className="text-foreground"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Description (Optional)</Text>
<Textarea
value={description}
onChangeText={onDescriptionChange}
placeholder="Describe this workout..."
numberOfLines={4}
className="bg-input placeholder:text-muted-foreground"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{categories.map((cat) => (
<Button
key={cat}
variant={category === cat ? 'default' : 'outline'}
onPress={() => onCategoryChange(cat)}
>
<Text className={category === cat ? 'text-primary-foreground' : ''}>
{cat}
</Text>
</Button>
))}
</View>
</View>
<View className="flex-row justify-end gap-3 mt-4">
<Button variant="outline" onPress={onCancel}>
<Text>Cancel</Text>
</Button>
<Button onPress={onNext} disabled={!title}>
<Text className="text-primary-foreground">Next</Text>
</Button>
</View>
</View>
</ScrollView>
);
}
// Step 2: Exercise Selection
interface ExerciseSelectionStepProps {
exercises: ExerciseDisplay[];
onExercisesSelected: (selected: ExerciseDisplay[]) => void;
onBack: () => void;
}
function ExerciseSelectionStep({
exercises,
onExercisesSelected,
onBack
}: ExerciseSelectionStepProps) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [search, setSearch] = useState('');
const filteredExercises = exercises.filter(e =>
e.title.toLowerCase().includes(search.toLowerCase()) ||
e.tags.some(t => t.toLowerCase().includes(search.toLowerCase()))
);
const handleToggleSelection = (id: string) => {
setSelectedIds(prev =>
prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
};
const handleContinue = () => {
const selected = exercises.filter(e => selectedIds.includes(e.id));
onExercisesSelected(selected);
};
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-2">
<Input
placeholder="Search exercises..."
value={search}
onChangeText={setSearch}
className="text-foreground"
/>
</View>
<ScrollView className="flex-1">
<View className="px-4">
<Text className="mb-4 text-muted-foreground">
Selected: {selectedIds.length} exercises
</Text>
<View className="gap-3">
{filteredExercises.map(exercise => (
<View key={exercise.id} className="p-4 bg-card border border-border rounded-md">
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">{exercise.category}</Text>
{exercise.equipment && (
<Text className="text-xs text-muted-foreground mt-0.5">{exercise.equipment}</Text>
)}
</View>
<Button
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
onPress={() => handleToggleSelection(exercise.id)}
size="sm"
>
<Text className={selectedIds.includes(exercise.id) ? 'text-primary-foreground' : ''}>
{selectedIds.includes(exercise.id) ? 'Selected' : 'Add'}
</Text>
</Button>
</View>
</View>
))}
</View>
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button
onPress={handleContinue}
disabled={selectedIds.length === 0}
>
<Text className="text-primary-foreground">
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
</Text>
</Button>
</View>
</View>
);
}
// Step 3: Exercise Configuration
interface ExerciseConfigStepProps {
exercises: ExerciseDisplay[];
config: TemplateExerciseDisplay[];
onUpdateConfig: (index: number, sets: number, reps: number) => void;
onNext: () => void;
onBack: () => void;
}
function ExerciseConfigStep({
exercises,
config,
onUpdateConfig,
onNext,
onBack
}: ExerciseConfigStepProps) {
return (
<View className="flex-1">
<ScrollView className="flex-1">
<View className="px-4 gap-3">
{exercises.map((exercise, index) => (
<View key={exercise.id} className="p-4 bg-card border border-border rounded-md">
<Text className="text-lg font-semibold mb-2">{exercise.title}</Text>
<View className="flex-row gap-4 mt-2">
<View className="flex-1">
<Text className="text-sm text-muted-foreground mb-1">Sets</Text>
<Input
keyboardType="numeric"
value={config[index]?.targetSets ? config[index].targetSets.toString() : ''}
onChangeText={(text) => {
const sets = text ? parseInt(text) : 0;
const reps = config[index]?.targetReps || 0;
onUpdateConfig(index, sets, reps);
}}
placeholder="Optional"
className="bg-input placeholder:text-muted-foreground"
/>
</View>
<View className="flex-1">
<Text className="text-sm text-muted-foreground mb-1">Reps</Text>
<Input
keyboardType="numeric"
value={config[index]?.targetReps ? config[index].targetReps.toString() : ''}
onChangeText={(text) => {
const reps = text ? parseInt(text) : 0;
const sets = config[index]?.targetSets || 0;
onUpdateConfig(index, sets, reps);
}}
placeholder="Optional"
className="bg-input placeholder:text-muted-foreground"
/>
</View>
</View>
</View>
))}
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button onPress={onNext}>
<Text className="text-primary-foreground">Review Template</Text>
</Button>
</View>
</View>
);
}
// Step 4: Review
interface ReviewStepProps {
title: string;
description: string;
category: TemplateCategory;
type: TemplateType;
exercises: Template['exercises'];
onSubmit: () => void;
onBack: () => void;
}
function ReviewStep({
title,
description,
category,
type,
exercises,
onSubmit,
onBack
}: ReviewStepProps) {
return (
<View className="flex-1">
<ScrollView className="flex-1">
<View className="p-4 gap-6">
<View className="mb-6">
<Text className="text-xl font-bold">{title}</Text>
{description ? (
<Text className="mt-2 text-muted-foreground">{description}</Text>
) : null}
<View className="flex-row gap-2 mt-3">
<Badge variant="outline">
<Text className="text-xs">{type}</Text>
</Badge>
<Badge variant="outline">
<Text className="text-xs">{category}</Text>
</Badge>
</View>
</View>
<View>
<Text className="text-lg font-semibold mb-4">Exercises</Text>
<View className="gap-3">
{exercises.map((exercise, index) => (
<View key={index} className="p-4 bg-card border border-border rounded-md">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm mt-1 text-muted-foreground">
{exercise.targetSets || exercise.targetReps ?
`${exercise.targetSets || ''} sets × ${exercise.targetReps || ''} reps` :
'No prescription set'
}
</Text>
</View>
))}
</View>
</View>
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button onPress={onSubmit}>
<Text className="text-primary-foreground">Create Template</Text>
</Button>
</View>
</View>
);
}
export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheetProps) { export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheetProps) {
const [formData, setFormData] = React.useState({ const db = useSQLiteContext();
const [libraryService] = useState(() => new LibraryService(db));
const [step, setStep] = useState<CreationStep>('type');
const [workoutType, setWorkoutType] = useState<TemplateType>('strength');
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]);
const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]);
// Template info
const [templateInfo, setTemplateInfo] = useState<{
title: string;
description: string;
category: TemplateCategory;
tags: string[];
}>({
title: '', title: '',
type: '' as TemplateType,
category: '' as TemplateCategory,
description: '', description: '',
category: 'Full Body',
tags: ['strength']
}); });
const handleSubmit = () => { // Load exercises on mount
const template: Template = { useEffect(() => {
id: generateId(), const loadExercises = async () => {
title: formData.title, try {
type: formData.type, const data = await libraryService.getExercises();
category: formData.category, setExercises(data);
description: formData.description, } catch (error) {
exercises: [], console.error('Failed to load exercises:', error);
tags: [], }
source: 'local',
isFavorite: false,
created_at: Date.now(),
}; };
if (isOpen) {
loadExercises();
}
}, [isOpen, libraryService]);
onSubmit(template); // Reset state when sheet closes
onClose(); useEffect(() => {
setFormData({ if (!isOpen) {
title: '', setStep('type');
type: '' as TemplateType, setWorkoutType('strength');
category: '' as TemplateCategory, setSelectedExercises([]);
description: '', setConfiguredExercises([]);
setTemplateInfo({
title: '',
description: '',
category: 'Full Body',
tags: ['strength']
});
}
}, [isOpen]);
const handleGoBack = () => {
switch(step) {
case 'info': setStep('type'); break;
case 'exercises': setStep('info'); break;
case 'config': setStep('exercises'); break;
case 'review': setStep('config'); break;
}
};
const handleSelectType = (type: TemplateType) => {
setWorkoutType(type);
setStep('info');
};
const handleSubmitInfo = () => {
if (!templateInfo.title) return;
setStep('exercises');
};
const handleSelectExercises = (selected: ExerciseDisplay[]) => {
setSelectedExercises(selected);
// Pre-populate configured exercises
const initialConfig = selected.map(exercise => ({
title: exercise.title,
targetSets: 0,
targetReps: 0
}));
setConfiguredExercises(initialConfig);
setStep('config');
};
const handleUpdateExerciseConfig = (index: number, sets: number, reps: number) => {
setConfiguredExercises(prev => {
const updated = [...prev];
updated[index] = {
...updated[index],
targetSets: sets,
targetReps: reps
};
return updated;
}); });
}; };
const handleConfigComplete = () => {
setStep('review');
};
const handleCreateTemplate = () => {
const newTemplate: Template = {
id: generateId(),
title: templateInfo.title,
description: templateInfo.description,
type: workoutType,
category: templateInfo.category,
exercises: configuredExercises,
tags: templateInfo.tags,
source: 'local',
isFavorite: false
};
onSubmit(newTemplate);
onClose();
};
// Render different content based on current step
const renderContent = () => {
switch (step) {
case 'type':
return (
<WorkoutTypeStep
onSelectType={handleSelectType}
onCancel={onClose}
/>
);
case 'info':
return (
<BasicInfoStep
title={templateInfo.title}
description={templateInfo.description}
category={templateInfo.category}
onTitleChange={(title) => setTemplateInfo(prev => ({ ...prev, title }))}
onDescriptionChange={(description) => setTemplateInfo(prev => ({ ...prev, description }))}
onCategoryChange={(category) => setTemplateInfo(prev => ({ ...prev, category: category as TemplateCategory }))}
onNext={handleSubmitInfo}
onCancel={onClose}
/>
);
case 'exercises':
return (
<ExerciseSelectionStep
exercises={exercises}
onExercisesSelected={handleSelectExercises}
onBack={() => setStep('info')}
/>
);
case 'config':
return (
<ExerciseConfigStep
exercises={selectedExercises}
config={configuredExercises}
onUpdateConfig={handleUpdateExerciseConfig}
onNext={handleConfigComplete}
onBack={() => setStep('exercises')}
/>
);
case 'review':
return (
<ReviewStep
title={templateInfo.title}
description={templateInfo.description}
category={templateInfo.category}
type={workoutType}
exercises={configuredExercises}
onSubmit={handleCreateTemplate}
onBack={() => setStep('config')}
/>
);
}
};
// 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 ( return (
<Sheet isOpen={isOpen} onClose={onClose}> <Sheet isOpen={isOpen} onClose={onClose}>
<SheetHeader> <SheetHeader>
<SheetTitle>New Template</SheetTitle> <View className="flex-row items-center">
{showBackButton && (
<Button
variant="ghost"
size="icon"
className="mr-2"
onPress={handleGoBack}
>
<ChevronLeft className="text-foreground" size={20} />
</Button>
)}
<SheetTitle>{getStepTitle()}</SheetTitle>
</View>
</SheetHeader> </SheetHeader>
<SheetContent> <SheetContent>
<View className="gap-4"> {renderContent()}
<View>
<Text className="text-base font-medium mb-2">Template Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Full Body Strength"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Workout Type</Text>
<View className="flex-row flex-wrap gap-2">
{WORKOUT_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'purple' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
>
<Text
className={cn(
"text-base font-medium capitalize",
formData.type === type ? "text-white" : "text-foreground"
)}
>
{type}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'purple' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
>
<Text
className={cn(
"text-base font-medium",
formData.category === category ? "text-white" : "text-foreground"
)}
>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Template description..."
multiline
numberOfLines={4}
/>
</View>
<Button
variant="purple"
className="mt-4"
onPress={handleSubmit}
disabled={!formData.title || !formData.type || !formData.category}
>
<Text className="text-white font-semibold">Create Template</Text>
</Button>
</View>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );

View File

@ -18,7 +18,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Template } from '@/types/library'; import { Template, TemplateExerciseDisplay } from '@/types/templates';
interface TemplateCardProps { interface TemplateCardProps {
template: Template; template: Template;
@ -47,10 +47,12 @@ export function TemplateCard({
description, description,
tags = [], tags = [],
source, source,
lastUsed, metadata,
isFavorite isFavorite
} = template; } = template;
const lastUsed = metadata?.lastUsed ? new Date(metadata.lastUsed) : undefined;
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
onDelete(id); onDelete(id);
setShowDeleteAlert(false); setShowDeleteAlert(false);
@ -95,7 +97,7 @@ export function TemplateCard({
Exercises: Exercises:
</Text> </Text>
<View className="gap-1"> <View className="gap-1">
{exercises.slice(0, 3).map((exercise, index) => ( {exercises.slice(0, 3).map((exercise: TemplateExerciseDisplay, index: number) => (
<Text key={index} className="text-sm text-muted-foreground"> <Text key={index} className="text-sm text-muted-foreground">
{exercise.title} ({exercise.targetSets}×{exercise.targetReps}) {exercise.title} ({exercise.targetSets}×{exercise.targetReps})
</Text> </Text>
@ -117,7 +119,7 @@ export function TemplateCard({
{tags.length > 0 && ( {tags.length > 0 && (
<View className="flex-row flex-wrap gap-2 mt-2"> <View className="flex-row flex-wrap gap-2 mt-2">
{tags.map(tag => ( {tags.map((tag: string) => (
<Badge key={tag} variant="outline" className="text-xs"> <Badge key={tag} variant="outline" className="text-xs">
<Text>{tag}</Text> <Text>{tag}</Text>
</Badge> </Badge>
@ -216,12 +218,15 @@ export function TemplateCard({
<Text className="text-base">Type: {type}</Text> <Text className="text-base">Type: {type}</Text>
<Text className="text-base">Category: {category}</Text> <Text className="text-base">Category: {category}</Text>
<Text className="text-base">Source: {source}</Text> <Text className="text-base">Source: {source}</Text>
{metadata?.useCount && (
<Text className="text-base">Times Used: {metadata.useCount}</Text>
)}
</View> </View>
</View> </View>
<View> <View>
<Text className="text-base font-semibold mb-2">Exercises</Text> <Text className="text-base font-semibold mb-2">Exercises</Text>
<View className="gap-2"> <View className="gap-2">
{exercises.map((exercise, index) => ( {exercises.map((exercise: TemplateExerciseDisplay, index: number) => (
<Text key={index} className="text-base"> <Text key={index} className="text-base">
{exercise.title} ({exercise.targetSets}×{exercise.targetReps}) {exercise.title} ({exercise.targetSets}×{exercise.targetReps})
</Text> </Text>
@ -232,7 +237,7 @@ export function TemplateCard({
<View> <View>
<Text className="text-base font-semibold mb-2">Tags</Text> <Text className="text-base font-semibold mb-2">Tags</Text>
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
{tags.map(tag => ( {tags.map((tag: string) => (
<Badge key={tag} variant="secondary"> <Badge key={tag} variant="secondary">
<Text>{tag}</Text> <Text>{tag}</Text>
</Badge> </Badge>

View File

@ -0,0 +1,373 @@
// components/templates/TemplateDetails.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,
ClipboardList,
Settings
} from 'lucide-react-native';
import {
WorkoutTemplate,
TemplateSource,
getSourceDisplay
} from '@/types/templates';
import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme';
const Tab = createMaterialTopTabNavigator();
interface TemplateDetailsProps {
template: WorkoutTemplate;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit?: () => void;
}
// Overview Tab Component
function OverviewTab({ template, onEdit }: { template: WorkoutTemplate; onEdit?: () => void }) {
const {
title,
type,
category,
description,
exercises = [],
tags = [],
metadata,
availability // Replace source with availability
} = template;
// Calculate source type from availability
const sourceType = availability.source.includes('nostr')
? 'nostr'
: availability.source.includes('powr')
? 'powr'
: 'local';
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={sourceType === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{getSourceDisplay(template)}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category Section */}
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Exercises Section */}
<View>
<View className="flex-row items-center gap-2 mb-2">
<Dumbbell size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Exercises</Text>
</View>
<View className="gap-2">
{exercises.map((exerciseConfig, index) => (
<View key={index} className="bg-card p-3 rounded-lg">
<Text className="text-base font-medium text-foreground">
{exerciseConfig.exercise.title}
</Text>
<Text className="text-sm text-muted-foreground">
{exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps
</Text>
{exerciseConfig.notes && (
<Text className="text-sm text-muted-foreground mt-1">
{exerciseConfig.notes}
</Text>
)}
</View>
))}
</View>
</View>
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map(tag => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Usage Stats Section */}
{metadata && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{metadata.useCount && (
<Text className="text-base text-muted-foreground">
Used {metadata.useCount} times
</Text>
)}
{metadata.lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {new Date(metadata.lastUsed).toLocaleDateString()}
</Text>
)}
{metadata.averageDuration && (
<Text className="text-base text-muted-foreground">
Average duration: {Math.round(metadata.averageDuration / 60)} minutes
</Text>
)}
</View>
</View>
)}
{/* Edit Button */}
{onEdit && (
<Button
onPress={onEdit}
className="w-full mt-2"
>
<Edit2 size={18} className="mr-2 text-primary-foreground" />
<Text className="text-primary-foreground font-semibold">
Edit Template
</Text>
</Button>
)}
</View>
</ScrollView>
);
}
// Function to format template source for display
function formatTemplateSource(source: TemplateSource | undefined, templateSource: 'local' | 'powr' | 'nostr'): string {
if (!source) {
return templateSource === 'local' ? 'Local Template' : templateSource.toUpperCase();
}
const author = source.authorName || 'Unknown Author';
if (source.version) {
return `Modified from ${author} (v${source.version})`;
}
return `Original by ${author}`;
}
// History Tab Component
function HistoryTab({ template }: { template: WorkoutTemplate }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Performance Stats */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text>
<View className="flex-row gap-4">
<View className="flex-1 bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Avg. Duration</Text>
<Text className="text-lg font-semibold text-foreground">
{template.metadata?.averageDuration
? `${Math.round(template.metadata.averageDuration / 60)}m`
: '--'}
</Text>
</View>
<View className="flex-1 bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Completion Rate</Text>
<Text className="text-lg font-semibold text-foreground">--</Text>
</View>
</View>
</View>
{/* History List */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Recent Workouts</Text>
<View className="gap-3">
{/* Placeholder for when no history exists */}
<View className="bg-muted p-8 rounded-lg items-center justify-center">
<ClipboardList size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground text-center">
No workout history available yet
</Text>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
// Settings Tab Component
function SettingsTab({ template }: { template: WorkoutTemplate }) {
const {
type,
rounds,
duration,
interval,
restBetweenRounds,
} = template;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Workout Configuration */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Workout Settings</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Type</Text>
<Text className="text-base font-medium text-foreground capitalize">{type}</Text>
</View>
{rounds && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Rounds</Text>
<Text className="text-base font-medium text-foreground">{rounds}</Text>
</View>
)}
{duration && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Duration</Text>
<Text className="text-base font-medium text-foreground">
{Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')}
</Text>
</View>
)}
{interval && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Interval</Text>
<Text className="text-base font-medium text-foreground">
{Math.floor(interval / 60)}:{(interval % 60).toString().padStart(2, '0')}
</Text>
</View>
)}
{restBetweenRounds && (
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Rest Between Rounds</Text>
<Text className="text-base font-medium text-foreground">
{Math.floor(restBetweenRounds / 60)}:{(restBetweenRounds % 60).toString().padStart(2, '0')}
</Text>
</View>
)}
</View>
</View>
</View>
</ScrollView>
);
}
export function TemplateDetails({
template,
open,
onOpenChange,
onEdit
}: TemplateDetailsProps) {
const theme = useTheme() as CustomTheme;
return (
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
<SheetHeader>
<SheetTitle>
<Text className="text-xl font-bold text-foreground">{template.title}</Text>
</SheetTitle>
</SheetHeader>
<SheetContent>
<View style={{ flex: 1, minHeight: 400 }}>
<Tab.Navigator
style={{ flex: 1 }}
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen
name="overview"
options={{ title: 'Overview' }}
>
{() => <OverviewTab template={template} onEdit={onEdit} />}
</Tab.Screen>
<Tab.Screen
name="history"
options={{ title: 'History' }}
>
{() => <HistoryTab template={template} />}
</Tab.Screen>
<Tab.Screen
name="settings"
options={{ title: 'Settings' }}
>
{() => <SettingsTab template={template} />}
</Tab.Screen>
</Tab.Navigator>
</View>
</SheetContent>
</Sheet>
);
}

View File

@ -1,343 +1,186 @@
# POWR Library Tab PRD # POWR Library Tab PRD
Updated: 2025-02-19
## Overview ## Overview
### Problem Statement ### Problem Statement
Users need a centralized location to manage their fitness content (exercises and workout templates) while supporting both local content creation and Nostr-based content discovery. The library must maintain usability in offline scenarios while preparing for future social features. Users need a centralized location to manage their fitness content (exercises and workout templates) while supporting both local content creation and Nostr-based content discovery. The library must maintain usability in offline scenarios while preparing for future social features.
### Goals ### Goals Status
1. Provide organized access to exercises and workout templates 1. ✅ Provide organized access to exercises and workout templates
2. Enable efficient content discovery and reuse - Implemented main navigation and content organization
3. Support clear content ownership and source tracking - Search and filtering working
4. Maintain offline-first functionality - Basic content management in place
5. Prepare for future Nostr integration
## Feature Requirements 2. ✅ Enable efficient content discovery and reuse
- Search functionality implemented
- Category filtering working
- Alphabetical organization with quick scroll
### Navigation Structure 3. 🟡 Support clear content ownership and source tracking
- Material Top Tabs navigation with three sections: - Basic source badges implemented
- Templates (default tab) - Nostr attribution pending
- Exercises - Content history tracking in progress
- Programs (placeholder for future implementation)
4. 🟡 Maintain offline-first functionality
- Basic local storage implemented
- SQLite integration complete
- Advanced offline features pending
5. ❌ Prepare for future Nostr integration
- Types and schemas defined
- Implementation not started
## Current Implementation Status
### Navigation Structure ✅
- Material Top Tabs navigation implemented with three sections:
- Templates (default tab) - COMPLETE
- Exercises - COMPLETE
- Programs - PLACEHOLDER
### Templates Tab ### Templates Tab
#### Content Organization #### Content Organization
- Favorites section - ✅ Basic template list with sections
- Recently performed section - ✅ Favorites section
- Alphabetical list of remaining templates - ✅ Source badges (Local/POWR/Nostr)
- Clear source badges (Local/POWR/Nostr) - 🟡 Recently performed section (partial)
- ❌ Usage statistics
#### Template Item Display #### Template Item Display
- Template title - Template title
- Workout type (strength, circuit, EMOM, etc.) - ✅ Workout type indication
- Preview of included exercises (first 3) - ✅ Exercise preview (first 3)
- Source badge - Source badges
- Favorite star button - ✅ Favorite functionality
- Usage stats - 🟡 Usage stats (partial)
#### Search & Filtering #### Search & Filtering
- Persistent search bar with real-time filtering - ✅ Real-time search
- Filter options: - ✅ Basic filtering options
- Workout type - 🟡 Advanced filters (partial)
- Equipment needed
- Tags
### Exercises Tab ### Exercises Tab
#### Content Organization #### Content Organization
- Recent section (10 most recent exercises) - ✅ Alphabetical list with quick scroll
- Alphabetical list of all exercises - ✅ Categorization system
- Tag-based categorization - ✅ Source badges
- Clear source badges - ❌ Recent section
- ❌ Usage tracking
#### Exercise Item Display #### Exercise Item Display
- Exercise name - Exercise name
- Category/tags - Category/tags
- Equipment type - Equipment type
- Source badge - Source badge
- Usage stats - Usage stats
#### Search & Filtering #### Search & Filtering
- Persistent search bar with real-time filtering - ✅ Real-time search
- Filter options: - ✅ Basic filters
- Equipment - 🟡 Advanced filtering options
- Tags
- Source
### Programs Tab (Future) ### Technical Implementation
- Placeholder implementation
- "Coming Soon" messaging
- Basic description of future functionality
## Content Interaction #### Data Layer ✅
- SQLite integration complete
- Basic schema implemented
- CRUD operations working
- Development seeding functional
### Progressive Disclosure Pattern #### Content Management
- ✅ Exercise/template creation
- ✅ Basic content validation
- 🟡 Tag management
- ❌ Media support
#### 1. Card Display #### State Management
- Basic info - ✅ Basic state handling
- Source badge (Local/POWR/Nostr) - ✅ Form state management
- Quick stats/preview - 🟡 Complex state interactions
- Favorite button (templates only) - ❌ Global state optimization
#### 2. Quick Preview (Hover/Long Press) ## Next Development Priorities
- Extended preview info
- Key stats
- Quick actions
#### 3. Bottom Sheet Details ### Phase 1: Core Enhancements
- Basic Information: 1. Template Management
- Full title and description - Implement history tracking
- Category/tags - Add usage statistics
- Equipment requirements - Enhance template details view
- Stats & History:
- Personal records
- Usage history
- Performance trends
- Source Information:
- For local content:
- Creation date
- Last modified
- For Nostr content:
- Author information
- Original post date
- Relay source
- Action Buttons:
- For local content:
- Start Workout (templates)
- Edit
- Publish to Nostr
- Delete
- For Nostr content:
- Start Workout (templates)
- Delete from Library
#### 4. Full Details Modal 2. Progressive Disclosure
- Comprehensive view - Add long press preview
- Complete history - Improve bottom sheet details
- Advanced options - Create full screen edit mode
## Technical Requirements 3. Exercise Management
- Implement usage tracking
- Add performance metrics
- Enhance filtering system
### Data Storage ### Phase 2: Advanced Features
- SQLite for local storage 1. Media Support
- Schema supporting: - Image/video linking
- Exercise templates - Local caching
- Workout templates - Placeholder system
- Usage history
- Source tracking
- Nostr metadata
### Content Management 2. History & Stats
- No limit on custom exercises/templates - Usage tracking
- Tag character limit: 30 characters - Performance metrics
- Support for external media links (images/videos) - Trend visualization
- Local caching of Nostr content
### Media Content Handling 3. Enhanced Filtering
- For Nostr content: - Combined filters
- Store media URLs in metadata - Smart suggestions
- Cache images locally when saved - Recent searches
- Lazy load images when online
- Show placeholders when offline
- For local content:
- Optional image/video links
- No direct media upload in MVP
### Offline Capabilities
- Full functionality without internet
- Local-first architecture
- Graceful degradation of Nostr features
- Clear offline state indicators
## User Interface Components
### Core Components
1. MaterialTopTabs navigation
2. Persistent search header
3. Filter button and sheet
4. Content cards
5. Bottom sheet previews
6. Tab-specific FABs:
- Templates Tab: FAB for creating new workout templates
- Exercises Tab: FAB for creating new custom exercises
- Programs Tab: FAB for creating training programs (future)
### Component Details
#### Templates Tab FAB
- Primary action: Create new workout template
- Icon: Layout/Template icon
- Navigation: Routes to template creation flow
- Fixed position at bottom right
#### Exercises Tab FAB
- Primary action: Create new exercise
- Icon: Dumbbell icon
- Navigation: Routes to exercise creation flow
- Fixed position at bottom right
#### Programs Tab FAB (Future)
- Primary action: Create new program
- Icon: Calendar/Program icon
- Navigation: Routes to program creation flow
- Fixed position at bottom right
### Component States
1. Loading states
2. Empty states
3. Error states
4. Offline states
5. Content creation/editing modes
## Implementation Phases
### Phase 1: Core Structure
1. Tab navigation setup
2. Basic content display
3. Search and filtering
4. Local content management
### Phase 2: Enhanced Features
1. Favorite system
2. History tracking
3. Performance stats
4. Tag management
### Phase 3: Nostr Integration ### Phase 3: Nostr Integration
1. Content syncing 1. Event Handling
2. Publishing flow - Event processing
3. Author attribution - Content validation
4. Media handling - Relay management
## Success Metrics 2. Sync System
- Content synchronization
- Conflict resolution
- Offline handling
### Performance ## MVP Assessment
- Search response: < 100ms
- Scroll performance: 60fps
- Image load time: < 500ms
### User Experience ### Current MVP Features (✅ Complete)
- Content discovery time 1. Core Navigation
- Search success rate - Tab structure
- Template reuse rate - Content organization
- Exercise reference frequency - Basic routing
### Technical 2. Exercise Management
- Offline reliability - Create/edit/delete
- Storage efficiency - Categorization
- Cache hit rate - Search/filter
- Sync success rate
## Future Considerations 3. Template Management
- Template creation
- Exercise inclusion
- Favorites system
### Programs Tab Development 4. Data Foundation
- Program creation - SQLite integration
- Calendar integration - Basic CRUD
- Progress tracking - Schema structure
- Social sharing
### Enhanced Social Features ### MVP Technical Metrics
- Content recommendations - Search response: < 100ms
- Author following - Scroll performance: 60fps ✅
- Usage analytics - Database operations: < 50ms
- Community features
### Additional Enhancements ### Known Limitations
- Advanced media support 1. No media support in current version
- Custom collections 2. Limited performance tracking
- Export/import functionality 3. Basic filtering only
- Backup solutions 4. No offline state handling
5. No Nostr integration
2025-02-09 Update ## Conclusion
The Library tab has reached MVP status with core functionality implemented and working. While several planned features remain to be implemented, the current version provides the essential functionality needed to support workout creation and management. Recommendation is to proceed with Workout component development while maintaining a backlog of Library enhancements for future iterations.
Progress Analysis:
✅ COMPLETED:
1. Navigation Structure
- Implemented Material Top Tabs with Templates, Exercises, and Programs sections
- Clear visual hierarchy with proper styling
2. Basic Content Management
- Search functionality
- Filter system with proper categorization
- Source badges (Local/POWR/Nostr)
- Basic CRUD operations for exercises and templates
3. UI Components
- SearchHeader component
- FilterSheet with proper categorization
- Content cards with consistent styling
- FAB for content creation
- Sheet components for new content creation
🟡 IN PROGRESS/PARTIAL:
1. Content Organization
- We have basic favorites for templates but need to implement:
- Recently performed section
- Usage stats tracking
- Better categorization system
2. Progressive Disclosure Pattern
- We have basic cards and creation sheets but need:
- Quick Preview on long press
- Bottom Sheet Details view
- Full Details Modal
3. Content Interaction
- Basic CRUD operations exist but need:
- Performance tracking
- History integration
- Better stats visualization
❌ NOT STARTED:
1. Technical Implementation
- Nostr integration preparation
- SQLite database setup
- Proper caching system
- Offline capabilities
2. Advanced Features
- Performance tracking
- Usage history
- Media content handling
- Import/export functionality
Recommended Next Steps:
1. Data Layer Implementation
```typescript
// First set up SQLite database schema and service
class LibraryService {
// Exercise management
getExercises(): Promise<Exercise[]>
createExercise(exercise: Exercise): Promise<string>
updateExercise(id: string, exercise: Partial<Exercise>): Promise<void>
deleteExercise(id: string): Promise<void>
// Template management
getTemplates(): Promise<Template[]>
createTemplate(template: Template): Promise<string>
updateTemplate(id: string, template: Partial<Template>): Promise<void>
deleteTemplate(id: string): Promise<void>
// Usage tracking
logExerciseUse(exerciseId: string): Promise<void>
logTemplateUse(templateId: string): Promise<void>
getExerciseHistory(exerciseId: string): Promise<ExerciseHistory[]>
getTemplateHistory(templateId: string): Promise<TemplateHistory[]>
}
```
2. Detail Views
- Create a detailed view component for exercises and templates
- Implement proper state management for tracking usage
- Add performance metrics visualization
3. Progressive Disclosure
- Implement long press preview
- Create bottom sheet details view
- Add full screen modal for editing

View File

@ -1,13 +1,19 @@
// lib/db/services/ExerciseService.ts // lib/db/services/ExerciseService.ts
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; import {
BaseExercise,
ExerciseDisplay,
ExerciseType,
ExerciseCategory,
Equipment,
toExerciseDisplay
} from '@/types/exercise';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
export class ExerciseService { export class ExerciseService {
constructor(private db: SQLiteDatabase) {} constructor(private db: SQLiteDatabase) {}
// Add this new method async getAllExercises(): Promise<ExerciseDisplay[]> {
async getAllExercises(): Promise<Exercise[]> {
try { try {
const exercises = await this.db.getAllAsync<any>(` const exercises = await this.db.getAllAsync<any>(`
SELECT * FROM exercises ORDER BY created_at DESC SELECT * FROM exercises ORDER BY created_at DESC
@ -29,27 +35,60 @@ export class ExerciseService {
return acc; return acc;
}, {} as Record<string, string[]>); }, {} as Record<string, string[]>);
return exercises.map(exercise => ({ return exercises.map(exercise => {
...exercise, const baseExercise: BaseExercise = {
tags: tagsByExercise[exercise.id] || [], ...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined, tags: tagsByExercise[exercise.id] || [],
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined, format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
availability: { source: [exercise.source] } format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
})); availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
});
} catch (error) { } catch (error) {
console.error('Error getting all exercises:', error); console.error('Error getting all exercises:', error);
throw error; throw error;
} }
} }
// Update createExercise to handle all required fields async getExercise(id: string): Promise<ExerciseDisplay | null> {
try {
// Get exercise data
const exercise = await this.db.getFirstAsync<any>(
`SELECT * FROM exercises WHERE id = ?`,
[id]
);
if (!exercise) return null;
// Get tags
const tags = await this.db.getAllAsync<{ tag: string }>(
'SELECT tag FROM exercise_tags WHERE exercise_id = ?',
[id]
);
const baseExercise: BaseExercise = {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tags.map(t => t.tag),
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
} catch (error) {
console.error('Error getting exercise:', error);
throw error;
}
}
async createExercise( async createExercise(
exercise: Omit<Exercise, 'id' | 'availability'>, exercise: Omit<BaseExercise, 'id'>,
inTransaction: boolean = false inTransaction: boolean = false
): Promise<string> { ): Promise<string> {
const id = generateId(); const id = generateId();
const timestamp = Date.now(); const timestamp = Date.now();
try { try {
const runQueries = async () => { const runQueries = async () => {
await this.db.runAsync( await this.db.runAsync(
@ -68,10 +107,10 @@ export class ExerciseService {
exercise.format_units ? JSON.stringify(exercise.format_units) : null, exercise.format_units ? JSON.stringify(exercise.format_units) : null,
timestamp, timestamp,
timestamp, timestamp,
exercise.source || 'local' exercise.availability.source[0]
] ]
); );
if (exercise.tags?.length) { if (exercise.tags?.length) {
for (const tag of exercise.tags) { for (const tag of exercise.tags) {
await this.db.runAsync( await this.db.runAsync(
@ -81,13 +120,13 @@ export class ExerciseService {
} }
} }
}; };
if (inTransaction) { if (inTransaction) {
await runQueries(); await runQueries();
} else { } else {
await this.db.withTransactionAsync(runQueries); await this.db.withTransactionAsync(runQueries);
} }
return id; return id;
} catch (error) { } catch (error) {
console.error('Error creating exercise:', error); console.error('Error creating exercise:', error);
@ -95,135 +134,13 @@ export class ExerciseService {
} }
} }
async getExercise(id: string): Promise<Exercise | null> {
try {
// Get exercise data
const exercise = await this.db.getFirstAsync<Exercise>(
`SELECT * FROM exercises WHERE id = ?`,
[id]
);
if (!exercise) return null;
// Get tags
const tags = await this.db.getAllAsync<{ tag: string }>(
'SELECT tag FROM exercise_tags WHERE exercise_id = ?',
[id]
);
return {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tags.map(t => t.tag)
};
} catch (error) {
console.error('Error getting exercise:', error);
throw error;
}
}
async updateExercise(id: string, exercise: Partial<Exercise>): Promise<void> {
const timestamp = Date.now();
try {
await this.db.withTransactionAsync(async () => {
// Build update query dynamically based on provided fields
const updates: string[] = [];
const values: any[] = [];
if (exercise.title !== undefined) {
updates.push('title = ?');
values.push(exercise.title);
}
if (exercise.type !== undefined) {
updates.push('type = ?');
values.push(exercise.type);
}
if (exercise.category !== undefined) {
updates.push('category = ?');
values.push(exercise.category);
}
if (exercise.equipment !== undefined) {
updates.push('equipment = ?');
values.push(exercise.equipment);
}
if (exercise.description !== undefined) {
updates.push('description = ?');
values.push(exercise.description);
}
if (exercise.format !== undefined) {
updates.push('format_json = ?');
values.push(JSON.stringify(exercise.format));
}
if (exercise.format_units !== undefined) {
updates.push('format_units_json = ?');
values.push(JSON.stringify(exercise.format_units));
}
if (updates.length > 0) {
updates.push('updated_at = ?');
values.push(timestamp);
// Add id to values array
values.push(id);
await this.db.runAsync(
`UPDATE exercises SET ${updates.join(', ')} WHERE id = ?`,
values
);
}
// Update tags if provided
if (exercise.tags !== undefined) {
// Delete existing tags
await this.db.runAsync(
'DELETE FROM exercise_tags WHERE exercise_id = ?',
[id]
);
// Insert new tags
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
}
});
} catch (error) {
console.error('Error updating exercise:', error);
throw error;
}
}
async deleteExercise(id: string): Promise<void> {
try {
console.log('Deleting exercise:', id);
await this.db.withTransactionAsync(async () => {
// Due to ON DELETE CASCADE, we only need to delete from exercises
const result = await this.db.runAsync('DELETE FROM exercises WHERE id = ?', [id]);
console.log('Delete result:', result);
});
} catch (error) {
console.error('Error deleting exercise:', error);
throw error;
}
}
async searchExercises(query?: string, filters?: { async searchExercises(query?: string, filters?: {
types?: ExerciseType[]; types?: ExerciseType[];
categories?: ExerciseCategory[]; categories?: ExerciseCategory[];
equipment?: Equipment[]; equipment?: Equipment[];
tags?: string[]; tags?: string[];
source?: 'local' | 'powr' | 'nostr'; source?: 'local' | 'powr' | 'nostr';
}): Promise<Exercise[]> { }): Promise<ExerciseDisplay[]> {
try { try {
let sql = ` let sql = `
SELECT DISTINCT e.* SELECT DISTINCT e.*
@ -278,7 +195,7 @@ export class ExerciseService {
sql += ' ORDER BY e.title ASC'; sql += ' ORDER BY e.title ASC';
// Get exercises // Get exercises
const exercises = await this.db.getAllAsync<Exercise>(sql, params); const exercises = await this.db.getAllAsync<any>(sql, params);
// Get tags for all exercises // Get tags for all exercises
const exerciseIds = exercises.map(e => e.id); const exerciseIds = exercises.map(e => e.id);
@ -297,23 +214,27 @@ export class ExerciseService {
return acc; return acc;
}, {} as Record<string, string[]>); }, {} as Record<string, string[]>);
// Add tags to exercises // Convert to ExerciseDisplay
return exercises.map(exercise => ({ return exercises.map(exercise => {
...exercise, const baseExercise: BaseExercise = {
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined, ...exercise,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined, format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
tags: tagsByExercise[exercise.id] || [] format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
})); tags: tagsByExercise[exercise.id] || [],
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
});
} }
return exercises; return [];
} catch (error) { } catch (error) {
console.error('Error searching exercises:', error); console.error('Error searching exercises:', error);
throw error; throw error;
} }
} }
async getRecentExercises(limit: number = 10): Promise<Exercise[]> { async getRecentExercises(limit: number = 10): Promise<ExerciseDisplay[]> {
try { try {
const sql = ` const sql = `
SELECT e.* SELECT e.*
@ -322,7 +243,7 @@ export class ExerciseService {
LIMIT ? LIMIT ?
`; `;
const exercises = await this.db.getAllAsync<Exercise>(sql, [limit]); const exercises = await this.db.getAllAsync<any>(sql, [limit]);
// Get tags for these exercises // Get tags for these exercises
const exerciseIds = exercises.map(e => e.id); const exerciseIds = exercises.map(e => e.id);
@ -341,55 +262,111 @@ export class ExerciseService {
return acc; return acc;
}, {} as Record<string, string[]>); }, {} as Record<string, string[]>);
return exercises.map(exercise => ({ return exercises.map(exercise => {
...exercise, const baseExercise: BaseExercise = {
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined, ...exercise,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined, format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
tags: tagsByExercise[exercise.id] || [] format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
})); tags: tagsByExercise[exercise.id] || [],
availability: { source: [exercise.source] }
};
return toExerciseDisplay(baseExercise);
});
} }
return exercises; return [];
} catch (error) { } catch (error) {
console.error('Error getting recent exercises:', error); console.error('Error getting recent exercises:', error);
throw error; throw error;
} }
} }
async getExerciseTags(): Promise<{ tag: string; count: number }[]> { async updateExercise(id: string, exercise: Partial<BaseExercise>): Promise<void> {
try { const timestamp = Date.now();
return await this.db.getAllAsync<{ tag: string; count: number }>(
`SELECT tag, COUNT(*) as count
FROM exercise_tags
GROUP BY tag
ORDER BY count DESC, tag ASC`
);
} catch (error) {
console.error('Error getting exercise tags:', error);
throw error;
}
}
async bulkImport(exercises: Omit<Exercise, 'id'>[]): Promise<string[]> {
const ids: string[] = [];
try { try {
await this.db.withTransactionAsync(async () => { await this.db.withTransactionAsync(async () => {
for (const exercise of exercises) { // Build update query dynamically based on provided fields
const id = await this.createExercise(exercise); const updates: string[] = [];
ids.push(id); const values: any[] = [];
if (exercise.title !== undefined) {
updates.push('title = ?');
values.push(exercise.title);
}
if (exercise.type !== undefined) {
updates.push('type = ?');
values.push(exercise.type);
}
if (exercise.category !== undefined) {
updates.push('category = ?');
values.push(exercise.category);
}
if (exercise.equipment !== undefined) {
updates.push('equipment = ?');
values.push(exercise.equipment);
}
if (exercise.description !== undefined) {
updates.push('description = ?');
values.push(exercise.description);
}
if (exercise.format !== undefined) {
updates.push('format_json = ?');
values.push(JSON.stringify(exercise.format));
}
if (exercise.format_units !== undefined) {
updates.push('format_units_json = ?');
values.push(JSON.stringify(exercise.format_units));
}
if (exercise.availability !== undefined) {
updates.push('source = ?');
values.push(exercise.availability.source[0]);
}
if (updates.length > 0) {
updates.push('updated_at = ?');
values.push(timestamp);
// Add id to values array
values.push(id);
await this.db.runAsync(
`UPDATE exercises SET ${updates.join(', ')} WHERE id = ?`,
values
);
}
// Update tags if provided
if (exercise.tags !== undefined) {
// Delete existing tags
await this.db.runAsync(
'DELETE FROM exercise_tags WHERE exercise_id = ?',
[id]
);
// Insert new tags
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
} }
}); });
return ids;
} catch (error) { } catch (error) {
console.error('Error bulk importing exercises:', error); console.error('Error updating exercise:', error);
throw error; throw error;
} }
} }
// Helper method to sync with Nostr events async syncWithNostrEvent(eventId: string, exercise: Omit<BaseExercise, 'id'>): Promise<string> {
async syncWithNostrEvent(eventId: string, exercise: Omit<Exercise, 'id'>): Promise<string> {
try { try {
// Check if we already have this exercise // Check if we already have this exercise
const existing = await this.db.getFirstAsync<{ id: string }>( const existing = await this.db.getFirstAsync<{ id: string }>(
@ -445,9 +422,4 @@ export class ExerciseService {
throw error; throw error;
} }
} }
} }
// Helper function to create an instance
export const createExerciseService = (db: SQLiteDatabase) => new ExerciseService(db);
// Also export a type for the service if needed
export type ExerciseServiceType = ExerciseService;

View File

@ -1,7 +1,7 @@
// lib/db/services/LibraryService.ts // lib/db/services/LibraryService.ts
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { DbService } from '../db-service'; import { DbService } from '../db-service';
import { BaseExercise, Exercise } from '@/types/exercise'; import { BaseExercise, ExerciseDisplay } from '@/types/exercise';
import { StorageSource } from '@/types/shared'; import { StorageSource } from '@/types/shared';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
@ -13,7 +13,7 @@ export class LibraryService {
this.db = new DbService(database); this.db = new DbService(database);
} }
async getExercises(): Promise<Exercise[]> { async getExercises(): Promise<ExerciseDisplay[]> {
try { try {
const result = await this.db.getAllAsync<{ const result = await this.db.getAllAsync<{
id: string; id: string;
@ -41,19 +41,19 @@ export class LibraryService {
return result.map(row => ({ return result.map(row => ({
id: row.id, id: row.id,
title: row.title, title: row.title,
type: row.type as Exercise['type'], type: row.type as ExerciseDisplay['type'],
category: row.category as Exercise['category'], category: row.category as ExerciseDisplay['category'],
equipment: row.equipment as Exercise['equipment'] || undefined, equipment: row.equipment as ExerciseDisplay['equipment'] || undefined,
description: row.description || undefined, description: row.description || undefined,
instructions: row.instructions ? row.instructions.split(',') : undefined, instructions: row.instructions ? row.instructions.split(',') : undefined,
tags: row.tags ? row.tags.split(',') : [], tags: row.tags ? row.tags.split(',') : [],
created_at: row.created_at, created_at: row.created_at,
source: row.source as Exercise['source'], source: row.source as ExerciseDisplay['source'],
availability: { availability: {
source: [row.source as StorageSource] source: [row.source as StorageSource]
}, },
format_json: row.format_json || undefined, format: row.format_json ? JSON.parse(row.format_json) : undefined,
format_units_json: row.format_units_json || undefined format_units: row.format_units_json ? JSON.parse(row.format_units_json) : undefined
})); }));
} catch (error) { } catch (error) {
console.error('Error getting exercises:', error); console.error('Error getting exercises:', error);
@ -61,7 +61,7 @@ export class LibraryService {
} }
} }
async addExercise(exercise: Omit<Exercise, 'id'>): Promise<string> { async addExercise(exercise: Omit<ExerciseDisplay, 'id'>): Promise<string> {
try { try {
const id = generateId(); const id = generateId();
const timestamp = Date.now(); // Use same timestamp for both created_at and updated_at initially const timestamp = Date.now(); // Use same timestamp for both created_at and updated_at initially
@ -83,8 +83,8 @@ export class LibraryService {
timestamp, // created_at timestamp, // created_at
timestamp, // updated_at timestamp, // updated_at
exercise.source, exercise.source,
exercise.format_json || null, exercise.format ? JSON.stringify(exercise.format) : null,
exercise.format_units_json || null exercise.format_units ? JSON.stringify(exercise.format_units) : null
] ]
); );

View File

@ -1,7 +1,14 @@
// lib/hooks/useExercises.ts // lib/hooks/useExercises.ts
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { Exercise, ExerciseCategory, Equipment, ExerciseType } from '@/types/exercise'; import {
ExerciseDisplay,
ExerciseCategory,
Equipment,
ExerciseType,
BaseExercise,
toExerciseDisplay
} from '@/types/exercise';
import { LibraryService } from '../db/services/LibraryService'; import { LibraryService } from '../db/services/LibraryService';
// Filtering types // Filtering types
@ -10,29 +17,29 @@ export interface ExerciseFilters {
category?: ExerciseCategory[]; category?: ExerciseCategory[];
equipment?: Equipment[]; equipment?: Equipment[];
tags?: string[]; tags?: string[];
source?: Exercise['source'][]; source?: ('local' | 'powr' | 'nostr')[];
searchQuery?: string; searchQuery?: string;
} }
interface ExerciseStats { interface ExerciseStats {
totalCount: number; totalCount: number;
byCategory: Record<ExerciseCategory, number>; byCategory: Partial<Record<ExerciseCategory, number>>;
byType: Record<ExerciseType, number>; byType: Partial<Record<ExerciseType, number>>;
byEquipment: Record<Equipment, number>; byEquipment: Partial<Record<Equipment, number>>;
} }
const initialStats: ExerciseStats = { const initialStats: ExerciseStats = {
totalCount: 0, totalCount: 0,
byCategory: {} as Record<ExerciseCategory, number>, byCategory: {},
byType: {} as Record<ExerciseType, number>, byType: {},
byEquipment: {} as Record<Equipment, number>, byEquipment: {},
}; };
export function useExercises() { export function useExercises() {
const db = useSQLiteContext(); const db = useSQLiteContext();
const libraryService = React.useMemo(() => new LibraryService(db), [db]); const libraryService = React.useMemo(() => new LibraryService(db), [db]);
const [exercises, setExercises] = useState<Exercise[]>([]); const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<ExerciseFilters>({}); const [filters, setFilters] = useState<ExerciseFilters>({});
@ -46,19 +53,31 @@ export function useExercises() {
setExercises(allExercises); setExercises(allExercises);
// Calculate stats // Calculate stats
const newStats = allExercises.reduce((acc: ExerciseStats, exercise: Exercise) => { const newStats = allExercises.reduce((acc: ExerciseStats, exercise: ExerciseDisplay) => {
// Increment total count
acc.totalCount++; acc.totalCount++;
acc.byCategory[exercise.category] = (acc.byCategory[exercise.category] || 0) + 1;
acc.byType[exercise.type] = (acc.byType[exercise.type] || 0) + 1; // Update category stats with type checking
if (exercise.category) {
acc.byCategory[exercise.category] = (acc.byCategory[exercise.category] || 0) + 1;
}
// Update type stats with type checking
if (exercise.type) {
acc.byType[exercise.type] = (acc.byType[exercise.type] || 0) + 1;
}
// Update equipment stats with type checking
if (exercise.equipment) { if (exercise.equipment) {
acc.byEquipment[exercise.equipment] = (acc.byEquipment[exercise.equipment] || 0) + 1; acc.byEquipment[exercise.equipment] = (acc.byEquipment[exercise.equipment] || 0) + 1;
} }
return acc; return acc;
}, { }, {
totalCount: 0, totalCount: 0,
byCategory: {} as Record<ExerciseCategory, number>, byCategory: {},
byType: {} as Record<ExerciseType, number>, byType: {},
byEquipment: {} as Record<Equipment, number>, byEquipment: {},
}); });
setStats(newStats); setStats(newStats);
@ -89,7 +108,7 @@ export function useExercises() {
} }
// Tags filter // Tags filter
if (filters.tags?.length && !filters.tags.some(tag => exercise.tags.includes(tag))) { if (filters.tags?.length && !exercise.tags.some((tag: string) => filters.tags?.includes(tag))) {
return false; return false;
} }
@ -103,8 +122,8 @@ export function useExercises() {
const query = filters.searchQuery.toLowerCase(); const query = filters.searchQuery.toLowerCase();
return ( return (
exercise.title.toLowerCase().includes(query) || exercise.title.toLowerCase().includes(query) ||
exercise.description?.toLowerCase().includes(query) || (exercise.description?.toLowerCase() || '').includes(query) ||
exercise.tags.some(tag => tag.toLowerCase().includes(query)) exercise.tags.some((tag: string) => tag.toLowerCase().includes(query))
); );
} }
@ -113,9 +132,16 @@ export function useExercises() {
}, [exercises, filters]); }, [exercises, filters]);
// Create a new exercise // Create a new exercise
const createExercise = useCallback(async (exercise: Omit<Exercise, 'id'>) => { const createExercise = useCallback(async (exercise: Omit<BaseExercise, 'id'>) => {
try { try {
const id = await libraryService.addExercise(exercise); // Create a display version of the exercise with source
const displayExercise: Omit<ExerciseDisplay, 'id'> = {
...exercise,
source: 'local', // Set default source for new exercises
isFavorite: false
};
const id = await libraryService.addExercise(displayExercise);
await loadExercises(); // Reload all exercises to update stats await loadExercises(); // Reload all exercises to update stats
return id; return id;
} catch (err) { } catch (err) {

View File

@ -1,6 +1,12 @@
// lib/mocks/exercises.ts // lib/mocks/exercises.ts
import { NostrEvent } from '@/types/nostr'; import { NostrEvent } from '@/types/nostr';
import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; import {
ExerciseDisplay,
ExerciseType,
ExerciseCategory,
Equipment,
BaseExercise
} from '@/types/exercise';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
// Mock exercise definitions that will become our initial POWR library // Mock exercise definitions that will become our initial POWR library
@ -215,7 +221,7 @@ export const mockExerciseEvents: NostrEvent[] = [
} }
]; ];
function getTagValue(tags: string[][], name: string): string | undefined { function getTagValue(tags: string[][], name: string): string | undefined {
const tag = tags.find((tag: string[]) => tag[0] === name); const tag = tags.find((tag: string[]) => tag[0] === name);
return tag ? tag[1] : undefined; return tag ? tag[1] : undefined;
} }
@ -226,8 +232,8 @@ function getTagValue(tags: string[][], name: string): string | undefined {
.map((tag: string[]) => tag[1]); .map((tag: string[]) => tag[1]);
} }
export function convertNostrToExercise(event: NostrEvent): Exercise { export function convertNostrToExercise(event: NostrEvent): ExerciseDisplay {
return { const baseExercise: BaseExercise = {
id: event.id || '', id: event.id || '',
title: getTagValue(event.tags, 'title') || '', title: getTagValue(event.tags, 'title') || '',
type: getTagValue(event.tags, 'equipment') === 'bodyweight' type: getTagValue(event.tags, 'equipment') === 'bodyweight'
@ -252,26 +258,33 @@ function getTagValue(tags: string[][], name: string): string | undefined {
availability: { availability: {
source: ['powr'] source: ['powr']
}, },
created_at: event.created_at * 1000, created_at: event.created_at * 1000
source: 'powr' };
// Convert to ExerciseDisplay
return {
...baseExercise,
source: 'powr',
isFavorite: false,
usageCount: 0
}; };
} }
// Export pre-converted exercises for easy testing // Export pre-converted exercises for easy testing
export const mockExercises = mockExerciseEvents.map(convertNostrToExercise); export const mockExercises = mockExerciseEvents.map(convertNostrToExercise);
// Helper to seed the database // Helper to seed the database
export async function seedExercises(exerciseService: any) { export async function seedExercises(exerciseService: any) {
try { try {
const existingCount = (await exerciseService.getAllExercises()).length; const existingCount = (await exerciseService.getAllExercises()).length;
if (existingCount === 0) { if (existingCount === 0) {
console.log('Seeding database with mock exercises...'); console.log('Seeding database with mock exercises...');
for (const exercise of mockExercises) { for (const exercise of mockExercises) {
await exerciseService.createExercise(exercise); await exerciseService.createExercise(exercise);
}
console.log('Successfully seeded database');
} }
console.log('Successfully seeded database'); } catch (error) {
console.error('Error seeding database:', error);
} }
} catch (error) { }
console.error('Error seeding database:', error);
}
}

View File

@ -1,11 +1,14 @@
// types/exercise.ts - handles everything about individual exercises // types/exercise.ts
/* import { NostrEventKind } from './events'; import { SyncableContent } from './shared';
*/
import { SyncableContent, StorageSource } from './shared';
// Exercise classification types /**
* Core Exercise Classifications
* These types define the fundamental ways we categorize exercises
*/
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight'; export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core'; export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
export type Equipment = export type Equipment =
| 'bodyweight' | 'bodyweight'
| 'barbell' | 'barbell'
@ -15,32 +18,28 @@ export type Equipment =
| 'cable' | 'cable'
| 'other'; | 'other';
export interface Exercise extends BaseExercise { /**
source: 'local' | 'powr' | 'nostr'; * Exercise Format Configuration
usageCount?: number; * Defines how an exercise should be tracked and measured
lastUsed?: Date; */
format_json?: string; // For database storage export interface ExerciseFormat {
format_units_json?: string; // For database storage weight?: boolean;
nostr_event_id?: string; // For Nostr integration reps?: boolean;
} rpe?: boolean;
set_type?: boolean;
// Base library content interface
export interface LibraryContent extends SyncableContent {
title: string;
type: 'exercise' | 'workout' | 'program';
description?: string;
author?: {
name: string;
pubkey?: string;
};
category?: ExerciseCategory;
equipment?: Equipment;
source: 'local' | 'powr' | 'nostr';
tags: string[];
isPublic?: boolean;
} }
// Basic exercise definition export interface ExerciseFormatUnits {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
}
/**
* Base Exercise Definition
* Contains the core properties that define an exercise
*/
export interface BaseExercise extends SyncableContent { export interface BaseExercise extends SyncableContent {
title: string; title: string;
type: ExerciseType; type: ExerciseType;
@ -49,21 +48,24 @@ export interface BaseExercise extends SyncableContent {
description?: string; description?: string;
instructions?: string[]; instructions?: string[];
tags: string[]; tags: string[];
format?: { format?: ExerciseFormat;
weight?: boolean; format_units?: ExerciseFormatUnits;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
};
} }
// Set types and formats /**
* Exercise UI Display
* Extends BaseExercise with UI-specific properties
*/
export interface ExerciseDisplay extends BaseExercise {
source: 'local' | 'powr' | 'nostr';
usageCount?: number;
lastUsed?: Date;
isFavorite?: boolean;
}
/**
* Set Types and Tracking
*/
export type SetType = 'warmup' | 'normal' | 'drop' | 'failure'; export type SetType = 'warmup' | 'normal' | 'drop' | 'failure';
export interface WorkoutSet { export interface WorkoutSet {
@ -77,7 +79,9 @@ export interface WorkoutSet {
timestamp?: number; timestamp?: number;
} }
// Exercise with workout-specific data /**
* Exercise with active workout data
*/
export interface WorkoutExercise extends BaseExercise { export interface WorkoutExercise extends BaseExercise {
sets: WorkoutSet[]; sets: WorkoutSet[];
totalWeight?: number; totalWeight?: number;
@ -87,7 +91,9 @@ export interface WorkoutExercise extends BaseExercise {
targetReps?: number; targetReps?: number;
} }
// Exercise template specific types /**
* Exercise Template with recommendations and progression
*/
export interface ExerciseTemplate extends BaseExercise { export interface ExerciseTemplate extends BaseExercise {
defaultSets?: { defaultSets?: {
type: SetType; type: SetType;
@ -110,7 +116,9 @@ export interface ExerciseTemplate extends BaseExercise {
}; };
} }
// Exercise history and progress tracking /**
* Exercise History Tracking
*/
export interface ExerciseHistory { export interface ExerciseHistory {
exerciseId: string; exerciseId: string;
entries: Array<{ entries: Array<{
@ -146,4 +154,19 @@ export function isWorkoutExercise(exercise: any): exercise is WorkoutExercise {
export function isExerciseTemplate(exercise: any): exercise is ExerciseTemplate { export function isExerciseTemplate(exercise: any): exercise is ExerciseTemplate {
return exercise && 'recommendations' in exercise; return exercise && 'recommendations' in exercise;
}
/**
* Converts a BaseExercise to ExerciseDisplay
*/
export function toExerciseDisplay(exercise: BaseExercise): ExerciseDisplay {
return {
...exercise, // Include all BaseExercise properties
source: exercise.availability.source.includes('nostr')
? 'nostr'
: exercise.availability.source.includes('powr')
? 'powr'
: 'local',
isFavorite: false
};
} }

View File

@ -1,50 +0,0 @@
// types/library.ts
interface TemplateExercise {
title: string;
targetSets: number;
targetReps: number;
}
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
export type TemplateCategory = 'Full Body' | 'Custom' | 'Push/Pull/Legs' | 'Upper/Lower' | 'Conditioning';
export type ContentSource = 'local' | 'powr' | 'nostr';
export interface Template {
id: string;
title: string;
type: TemplateType;
category: TemplateCategory;
exercises: Array<{
title: string;
targetSets: number;
targetReps: number;
}>;
description?: string;
tags: string[];
source: ContentSource;
isFavorite?: boolean;
lastUsed?: Date;
}
export interface FilterOptions {
equipment: string[];
tags: string[];
source: ContentSource[];
}
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
export type ExerciseEquipment = 'bodyweight' | 'barbell' | 'dumbbell' | 'kettlebell' | 'machine' | 'cable' | 'other';
export interface Exercise {
id: string;
title: string;
category: ExerciseCategory;
type?: ExerciseType;
equipment?: ExerciseEquipment;
description?: string;
tags: string[];
source: ContentSource;
usageCount?: number;
lastUsed?: Date;
}

214
types/templates.ts Normal file
View File

@ -0,0 +1,214 @@
// types/template.ts
import { BaseExercise, ExerciseCategory } from './exercise';
import { StorageSource, SyncableContent } from './shared';
import { generateId } from '@/utils/ids';
/**
* Template Classifications
*/
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
export type TemplateCategory =
| 'Full Body'
| 'Push/Pull/Legs'
| 'Upper/Lower'
| 'Custom'
| 'Cardio'
| 'CrossFit'
| 'Strength'
| 'Conditioning';
/**
* Exercise configurations within templates
*/
export interface TemplateExerciseDisplay {
title: string;
targetSets: number;
targetReps: number;
notes?: string;
}
export interface TemplateExerciseConfig {
exercise: BaseExercise;
targetSets: number;
targetReps: number;
weight?: number;
rpe?: number;
setType?: 'warmup' | 'normal' | 'drop' | 'failure';
restSeconds?: number;
notes?: string;
format?: {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
};
}
/**
* Template versioning and derivation tracking
*/
export interface TemplateSource {
id: string;
eventId?: string;
authorName?: string;
authorPubkey?: string;
version?: number;
}
/**
* Base template properties shared between UI and database
*/
export interface TemplateBase {
id: string;
title: string;
type: TemplateType;
category: TemplateCategory;
description?: string;
tags: string[];
metadata?: {
lastUsed?: number;
useCount?: number;
averageDuration?: number;
};
author?: {
name: string;
pubkey?: string;
};
}
/**
* UI Template - Used for display and interaction
*/
export interface Template extends TemplateBase {
exercises: TemplateExerciseDisplay[];
source: 'local' | 'powr' | 'nostr';
isFavorite: boolean; // Required for UI state
}
/**
* Full Template - Used for database storage and Nostr events
*/
export interface WorkoutTemplate extends TemplateBase, SyncableContent {
exercises: TemplateExerciseConfig[];
isPublic: boolean;
version: number;
// Template configuration
format?: {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight: 'kg' | 'lbs';
reps: 'count';
rpe: '0-10';
set_type: 'warmup|normal|drop|failure';
};
// Workout specific configuration
rounds?: number;
duration?: number;
interval?: number;
restBetweenRounds?: number;
// Template derivation
sourceTemplate?: TemplateSource;
derivatives?: {
count: number;
lastCreated: number;
};
// Nostr integration
nostrEventId?: string;
}
/**
* Helper Functions
*/
/**
* Gets a display string for the template source
*/
export function getSourceDisplay(template: WorkoutTemplate): string {
if (!template.sourceTemplate) {
return template.availability.source.includes('nostr')
? 'NOSTR'
: template.availability.source.includes('powr')
? 'POWR'
: 'Local Template';
}
const author = template.sourceTemplate.authorName || 'Unknown Author';
if (template.sourceTemplate.version) {
return `Modified from ${author} (v${template.sourceTemplate.version})`;
}
return `Original by ${author}`;
}
/**
* Converts a WorkoutTemplate to Template for UI display
*/
export function toTemplateDisplay(template: WorkoutTemplate): Template {
return {
id: template.id,
title: template.title,
type: template.type,
category: template.category,
description: template.description,
exercises: template.exercises.map(ex => ({
title: ex.exercise.title,
targetSets: ex.targetSets,
targetReps: ex.targetReps,
notes: ex.notes
})),
tags: template.tags,
source: template.availability.source.includes('nostr')
? 'nostr'
: template.availability.source.includes('powr')
? 'powr'
: 'local',
isFavorite: false,
metadata: template.metadata,
author: template.author
};
}
/**
* Converts a Template to WorkoutTemplate for storage/sync
*/
export function toWorkoutTemplate(template: Template): WorkoutTemplate {
return {
...template,
exercises: template.exercises.map(ex => ({
exercise: {
id: generateId(),
title: ex.title,
type: 'strength',
category: 'Push' as ExerciseCategory,
tags: [],
availability: {
source: ['local']
},
created_at: Date.now()
},
targetSets: ex.targetSets,
targetReps: ex.targetReps,
notes: ex.notes
})),
isPublic: false,
version: 1,
created_at: Date.now(),
availability: {
source: ['local']
}
};
}