mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 01:02:09 +00:00
Library tab MVP finished
This commit is contained in:
parent
083367e872
commit
f26a59f569
17
CHANGELOG.md
17
CHANGELOG.md
@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Detailed SQLite error logging
|
||||
- Improved transaction management
|
||||
- Added proper error types and propagation
|
||||
- Template management features
|
||||
- Basic template creation interface
|
||||
- Favorite template functionality
|
||||
- Template categories and filtering
|
||||
- Quick-start template actions
|
||||
|
||||
### Changed
|
||||
- Improved exercise library interface
|
||||
@ -49,6 +54,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added proper SQLite error types
|
||||
- Enhanced transaction rollback handling
|
||||
- Added detailed debug logging
|
||||
- Updated type system for better data handling
|
||||
- Consolidated exercise and template types
|
||||
- Added proper type guards
|
||||
- Improved type safety in components
|
||||
- Enhanced template display UI
|
||||
- Added category pills for filtering
|
||||
- Improved spacing and layout
|
||||
- Better visual hierarchy for favorites
|
||||
|
||||
### Fixed
|
||||
- Exercise deletion functionality
|
||||
@ -57,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- TypeScript parameter typing in database services
|
||||
- Null value handling in database operations
|
||||
- Development seeding duplicate prevention
|
||||
- Template category spacing issues
|
||||
- Exercise list rendering on iOS
|
||||
- Database reset and reseeding behavior
|
||||
|
||||
### Technical Details
|
||||
1. Database Schema Enforcement:
|
||||
@ -87,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Exercise creation now enforces schema constraints
|
||||
- Input validation prevents invalid data entry
|
||||
- Enhanced error messages provide better debugging information
|
||||
- Template management requires updated type definitions
|
||||
|
||||
## [0.1.0] - 2024-02-09
|
||||
|
||||
|
@ -1,129 +1,78 @@
|
||||
// app/(tabs)/library/exercises.tsx
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExerciseCard } from '@/components/exercises/ExerciseCard';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Dumbbell } from 'lucide-react-native';
|
||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
|
||||
import { Dumbbell } from 'lucide-react-native';
|
||||
import { BaseExercise, Exercise } from '@/types/exercise';
|
||||
import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList';
|
||||
import { ExerciseDetails } from '@/components/exercises/ExerciseDetails';
|
||||
import { ExerciseDisplay, ExerciseType, BaseExercise } from '@/types/exercise';
|
||||
import { useExercises } from '@/lib/hooks/useExercises';
|
||||
|
||||
export default function ExercisesScreen() {
|
||||
const sectionListRef = useRef<SectionList>(null);
|
||||
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 {
|
||||
exercises,
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
createExercise,
|
||||
deleteExercise,
|
||||
refreshExercises
|
||||
refreshExercises,
|
||||
updateFilters,
|
||||
clearFilters
|
||||
} = useExercises();
|
||||
|
||||
// Organize exercises into sections
|
||||
const sections = React.useMemo(() => {
|
||||
const exercisesByLetter = exercises.reduce((acc, exercise) => {
|
||||
const firstLetter = exercise.title[0].toUpperCase();
|
||||
if (!acc[firstLetter]) {
|
||||
acc[firstLetter] = [];
|
||||
// Filter exercises based on search query
|
||||
React.useEffect(() => {
|
||||
if (searchQuery) {
|
||||
updateFilters({ searchQuery });
|
||||
} else {
|
||||
updateFilters({ searchQuery: undefined });
|
||||
}
|
||||
acc[firstLetter].push(exercise);
|
||||
return acc;
|
||||
}, {} as Record<string, Exercise[]>);
|
||||
}, [searchQuery, updateFilters]);
|
||||
|
||||
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);
|
||||
// Update type filter when activeFilter changes
|
||||
React.useEffect(() => {
|
||||
if (activeFilter) {
|
||||
updateFilters({ type: [activeFilter] });
|
||||
} else {
|
||||
clearFilters();
|
||||
}
|
||||
}, []);
|
||||
}, [activeFilter, updateFilters, clearFilters]);
|
||||
|
||||
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
|
||||
});
|
||||
const handleExercisePress = (exercise: ExerciseDisplay) => {
|
||||
setSelectedExercise(exercise);
|
||||
};
|
||||
|
||||
// Log for debugging
|
||||
if (__DEV__) {
|
||||
console.log('Scrolling to section:', {
|
||||
letter,
|
||||
sectionIndex,
|
||||
totalSections: sections.length
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sections]);
|
||||
const handleEdit = async () => {
|
||||
// TODO: Implement edit functionality
|
||||
setSelectedExercise(null);
|
||||
};
|
||||
|
||||
// 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'> = {
|
||||
const handleCreateExercise = async (exerciseData: BaseExercise) => {
|
||||
// Convert BaseExercise to include required source information
|
||||
const exerciseWithSource: Omit<BaseExercise, '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);
|
||||
await createExercise(exerciseWithSource);
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -135,7 +84,7 @@ export default function ExercisesScreen() {
|
||||
{error.message}
|
||||
</Text>
|
||||
<Button onPress={refreshExercises}>
|
||||
<Text>Retry</Text>
|
||||
<Text className="text-primary-foreground">Retry</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@ -143,122 +92,94 @@ export default function ExercisesScreen() {
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
{/* Stats Bar */}
|
||||
<View className="flex-row justify-between items-center p-4 bg-card border-b border-border">
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Total Exercises</Text>
|
||||
<Text className="text-2xl font-bold">{stats.totalCount}</Text>
|
||||
{/* Search bar */}
|
||||
<View className="px-4 py-3">
|
||||
<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>
|
||||
<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>
|
||||
<Text className="text-xs text-muted-foreground">Pull</Text>
|
||||
<Text className="text-base font-medium">{stats.byCategory['Pull'] || 0}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<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)}
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholder="Search"
|
||||
className="pl-9 bg-transparent h-10 flex-1"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
stickySectionHeadersEnabled
|
||||
initialNumToRender={10}
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
{/* 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)}
|
||||
>
|
||||
{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 className={activeFilter === null ? "text-primary-foreground" : ""}>
|
||||
All
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</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
|
||||
icon={Dumbbell}
|
||||
onPress={() => setShowNewExercise(true)}
|
||||
/>
|
||||
|
||||
{/* New exercise sheet */}
|
||||
<NewExerciseSheet
|
||||
isOpen={showNewExercise}
|
||||
onClose={() => setShowNewExercise(false)}
|
||||
onSubmit={handleAddExercise}
|
||||
onSubmit={handleCreateExercise}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
@ -1,12 +1,28 @@
|
||||
// app/(tabs)/library/templates.tsx
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { TemplateCard } from '@/components/templates/TemplateCard';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, Plus } from 'lucide-react-native';
|
||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
|
||||
import { Plus } from 'lucide-react-native';
|
||||
import { Template } from '@/types/library';
|
||||
import { TemplateCard } from '@/components/templates/TemplateCard';
|
||||
import { TemplateDetails } from '@/components/templates/TemplateDetails';
|
||||
import {
|
||||
Template,
|
||||
WorkoutTemplate,
|
||||
TemplateCategory,
|
||||
toWorkoutTemplate
|
||||
} from '@/types/templates';
|
||||
|
||||
const TEMPLATE_CATEGORIES: TemplateCategory[] = [
|
||||
'Full Body',
|
||||
'Push/Pull/Legs',
|
||||
'Upper/Lower',
|
||||
'Conditioning',
|
||||
'Custom'
|
||||
];
|
||||
|
||||
// Mock data - move to a separate file later
|
||||
const initialTemplates: Template[] = [
|
||||
@ -43,14 +59,16 @@ const initialTemplates: Template[] = [
|
||||
export default function TemplatesScreen() {
|
||||
const [showNewTemplate, setShowNewTemplate] = useState(false);
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<TemplateCategory | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<WorkoutTemplate | null>(null);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setTemplates(current => current.filter(t => t.id !== id));
|
||||
};
|
||||
|
||||
const handleTemplatePress = (template: Template) => {
|
||||
// TODO: Show template details
|
||||
console.log('Selected template:', template);
|
||||
setSelectedTemplate(toWorkoutTemplate(template));
|
||||
};
|
||||
|
||||
const handleStartWorkout = (template: Template) => {
|
||||
@ -73,17 +91,74 @@ export default function TemplatesScreen() {
|
||||
setShowNewTemplate(false);
|
||||
};
|
||||
|
||||
// Filter templates based on category and search
|
||||
const filteredTemplates = templates.filter(template => {
|
||||
const matchesCategory = !activeCategory || template.category === activeCategory;
|
||||
const matchesSearch = !searchQuery ||
|
||||
template.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
// Separate favorites and regular templates
|
||||
const favoriteTemplates = templates.filter(t => t.isFavorite);
|
||||
const regularTemplates = templates.filter(t => !t.isFavorite);
|
||||
const favoriteTemplates = filteredTemplates.filter(t => t.isFavorite);
|
||||
const regularTemplates = filteredTemplates.filter(t => !t.isFavorite);
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
{favoriteTemplates.length > 0 && (
|
||||
<View className="py-4">
|
||||
<Text className="text-lg font-semibold mb-4 px-4">
|
||||
<View>
|
||||
<Text className="text-lg font-semibold px-4 py-2">
|
||||
Favorites
|
||||
</Text>
|
||||
<View className="gap-3">
|
||||
@ -102,8 +177,8 @@ export default function TemplatesScreen() {
|
||||
)}
|
||||
|
||||
{/* All Templates Section */}
|
||||
<View className="py-4">
|
||||
<Text className="text-lg font-semibold mb-4 px-4">
|
||||
<View>
|
||||
<Text className="text-lg font-semibold px-4 py-2">
|
||||
All Templates
|
||||
</Text>
|
||||
{regularTemplates.length > 0 ? (
|
||||
@ -132,6 +207,17 @@ export default function TemplatesScreen() {
|
||||
<View className="h-20" />
|
||||
</ScrollView>
|
||||
|
||||
{/* Rest of the components (sheets & FAB) remain the same */}
|
||||
{selectedTemplate && (
|
||||
<TemplateDetails
|
||||
template={selectedTemplate}
|
||||
open={!!selectedTemplate}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedTemplate(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FloatingActionButton
|
||||
icon={Plus}
|
||||
onPress={() => setShowNewTemplate(true)}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
351
components/exercises/ExerciseDetails.tsx
Normal file
351
components/exercises/ExerciseDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
102
components/exercises/SimplifiedExerciseCard.tsx
Normal file
102
components/exercises/SimplifiedExerciseCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
225
components/exercises/SimplifiedExerciseList.tsx
Normal file
225
components/exercises/SimplifiedExerciseList.tsx
Normal 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;
|
@ -1,19 +1,25 @@
|
||||
// components/library/NewExerciseSheet.tsx
|
||||
import React from 'react';
|
||||
import { View, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { BaseExercise, ExerciseType, ExerciseCategory, Equipment, Exercise } from '@/types/exercise';
|
||||
import { StorageSource } from '@/types/shared';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import {
|
||||
BaseExercise,
|
||||
ExerciseType,
|
||||
ExerciseCategory,
|
||||
Equipment,
|
||||
ExerciseFormat,
|
||||
ExerciseFormatUnits
|
||||
} from '@/types/exercise';
|
||||
import { StorageSource } from '@/types/shared';
|
||||
|
||||
interface NewExerciseSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (exercise: Omit<Exercise, 'id'>) => void; // Changed from BaseExercise
|
||||
onSubmit: (exercise: BaseExercise) => void;
|
||||
}
|
||||
|
||||
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
|
||||
@ -29,7 +35,7 @@ const EQUIPMENT_OPTIONS: Equipment[] = [
|
||||
];
|
||||
|
||||
export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) {
|
||||
const [formData, setFormData] = React.useState({
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
type: 'strength' as ExerciseType,
|
||||
category: 'Push' as ExerciseCategory,
|
||||
@ -41,40 +47,39 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
reps: true,
|
||||
rpe: true,
|
||||
set_type: true
|
||||
},
|
||||
} as ExerciseFormat,
|
||||
format_units: {
|
||||
weight: 'kg' as const,
|
||||
reps: 'count' as const,
|
||||
rpe: '0-10' as const,
|
||||
set_type: 'warmup|normal|drop|failure' as const
|
||||
}
|
||||
weight: 'kg',
|
||||
reps: 'count',
|
||||
rpe: '0-10',
|
||||
set_type: 'warmup|normal|drop|failure'
|
||||
} as ExerciseFormatUnits
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.title || !formData.equipment) return;
|
||||
|
||||
// Transform the form data into an Exercise type
|
||||
const exerciseData: Omit<Exercise, 'id'> = {
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Create BaseExercise
|
||||
const exercise: BaseExercise = {
|
||||
id: generateId(),
|
||||
title: formData.title,
|
||||
type: formData.type,
|
||||
category: formData.category,
|
||||
equipment: formData.equipment,
|
||||
description: formData.description,
|
||||
tags: formData.tags,
|
||||
tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()],
|
||||
format: formData.format,
|
||||
format_units: formData.format_units,
|
||||
// Add required Exercise fields
|
||||
source: 'local',
|
||||
created_at: Date.now(),
|
||||
created_at: timestamp,
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
format_json: JSON.stringify(formData.format),
|
||||
format_units_json: JSON.stringify(formData.format_units)
|
||||
source: ['local' as StorageSource],
|
||||
lastSynced: undefined
|
||||
}
|
||||
};
|
||||
|
||||
onSubmit(exerciseData);
|
||||
onClose();
|
||||
onSubmit(exercise);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
@ -97,6 +102,8 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
set_type: 'warmup|normal|drop|failure'
|
||||
}
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
@ -105,17 +112,15 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
<SheetTitle>New Exercise</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1"
|
||||
>
|
||||
<ScrollView className="gap-4">
|
||||
<ScrollView className="flex-1">
|
||||
<View className="gap-4 py-4">
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Exercise Name</Text>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChangeText={(text: string) => setFormData(prev => ({ ...prev, title: text }))}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
|
||||
placeholder="e.g., Barbell Back Squat"
|
||||
className="text-foreground"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@ -125,10 +130,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
{EXERCISE_TYPES.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={formData.type === type ? 'purple' : 'outline'}
|
||||
variant={formData.type === type ? 'default' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, type }))}
|
||||
>
|
||||
<Text className={formData.type === type ? 'text-white' : ''}>
|
||||
<Text className={formData.type === type ? 'text-primary-foreground' : ''}>
|
||||
{type}
|
||||
</Text>
|
||||
</Button>
|
||||
@ -142,10 +147,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
{CATEGORIES.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={formData.category === category ? 'purple' : 'outline'}
|
||||
variant={formData.category === category ? 'default' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, category }))}
|
||||
>
|
||||
<Text className={formData.category === category ? 'text-white' : ''}>
|
||||
<Text className={formData.category === category ? 'text-primary-foreground' : ''}>
|
||||
{category}
|
||||
</Text>
|
||||
</Button>
|
||||
@ -159,10 +164,10 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
{EQUIPMENT_OPTIONS.map((eq) => (
|
||||
<Button
|
||||
key={eq}
|
||||
variant={formData.equipment === eq ? 'purple' : 'outline'}
|
||||
variant={formData.equipment === eq ? 'default' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
|
||||
>
|
||||
<Text className={formData.equipment === eq ? 'text-white' : ''}>
|
||||
<Text className={formData.equipment === eq ? 'text-primary-foreground' : ''}>
|
||||
{eq}
|
||||
</Text>
|
||||
</Button>
|
||||
@ -172,26 +177,25 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Description</Text>
|
||||
<Textarea
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChangeText={(text: string) => setFormData(prev => ({ ...prev, description: text }))}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
|
||||
placeholder="Exercise description..."
|
||||
numberOfLines={6}
|
||||
className="min-h-[120px]"
|
||||
style={{ maxHeight: 200 }}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant='purple'
|
||||
variant='default'
|
||||
onPress={handleSubmit}
|
||||
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>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
@ -1,13 +1,23 @@
|
||||
// components/library/NewTemplateSheet.tsx
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Template,
|
||||
TemplateType,
|
||||
TemplateCategory,
|
||||
TemplateExerciseDisplay
|
||||
} from '@/types/templates';
|
||||
import { ExerciseDisplay } from '@/types/exercise';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import { TemplateType, TemplateCategory } from '@/types/library';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { LibraryService } from '@/lib/db/services/LibraryService';
|
||||
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List } from 'lucide-react-native';
|
||||
|
||||
interface NewTemplateSheetProps {
|
||||
isOpen: boolean;
|
||||
@ -15,131 +25,629 @@ interface NewTemplateSheetProps {
|
||||
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[] = [
|
||||
'Full Body',
|
||||
'Upper/Lower',
|
||||
'Push/Pull/Legs',
|
||||
'Cardio',
|
||||
'CrossFit',
|
||||
'Strength',
|
||||
'Conditioning',
|
||||
'Custom'
|
||||
];
|
||||
// Step 0: Workout Type Selection
|
||||
interface WorkoutTypeStepProps {
|
||||
onSelectType: (type: TemplateType) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheetProps) {
|
||||
const [formData, setFormData] = React.useState({
|
||||
title: '',
|
||||
type: '' as TemplateType,
|
||||
category: '' as TemplateCategory,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
const template: Template = {
|
||||
id: generateId(),
|
||||
title: formData.title,
|
||||
type: formData.type,
|
||||
category: formData.category,
|
||||
description: formData.description,
|
||||
exercises: [],
|
||||
tags: [],
|
||||
source: 'local',
|
||||
isFavorite: false,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
onSubmit(template);
|
||||
onClose();
|
||||
setFormData({
|
||||
title: '',
|
||||
type: '' as TemplateType,
|
||||
category: '' as TemplateCategory,
|
||||
description: '',
|
||||
});
|
||||
};
|
||||
function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
|
||||
const workoutTypes = [
|
||||
{
|
||||
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 (
|
||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Template</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View className="gap-4">
|
||||
<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>
|
||||
<Text className="text-base font-medium mb-2">Template Name</Text>
|
||||
<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={formData.title}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
|
||||
value={title}
|
||||
onChangeText={onTitleChange}
|
||||
placeholder="e.g., Full Body Strength"
|
||||
className="text-foreground"
|
||||
/>
|
||||
</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>
|
||||
<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((category) => (
|
||||
{categories.map((cat) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={formData.category === category ? 'purple' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, category }))}
|
||||
key={cat}
|
||||
variant={category === cat ? 'default' : 'outline'}
|
||||
onPress={() => onCategoryChange(cat)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-medium",
|
||||
formData.category === category ? "text-white" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
<Text className={category === cat ? 'text-primary-foreground' : ''}>
|
||||
{cat}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Description</Text>
|
||||
<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
|
||||
value={formData.description}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
|
||||
placeholder="Template description..."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
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="purple"
|
||||
className="mt-4"
|
||||
onPress={handleSubmit}
|
||||
disabled={!formData.title || !formData.type || !formData.category}
|
||||
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
|
||||
onPress={() => handleToggleSelection(exercise.id)}
|
||||
size="sm"
|
||||
>
|
||||
<Text className="text-white font-semibold">Create Template</Text>
|
||||
<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) {
|
||||
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: '',
|
||||
description: '',
|
||||
category: 'Full Body',
|
||||
tags: ['strength']
|
||||
});
|
||||
|
||||
// Load exercises on mount
|
||||
useEffect(() => {
|
||||
const loadExercises = async () => {
|
||||
try {
|
||||
const data = await libraryService.getExercises();
|
||||
setExercises(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load exercises:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
loadExercises();
|
||||
}
|
||||
}, [isOpen, libraryService]);
|
||||
|
||||
// Reset state when sheet closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStep('type');
|
||||
setWorkoutType('strength');
|
||||
setSelectedExercises([]);
|
||||
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 (
|
||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
||||
<SheetHeader>
|
||||
<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>
|
||||
<SheetContent>
|
||||
{renderContent()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Template } from '@/types/library';
|
||||
import { Template, TemplateExerciseDisplay } from '@/types/templates';
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template;
|
||||
@ -47,10 +47,12 @@ export function TemplateCard({
|
||||
description,
|
||||
tags = [],
|
||||
source,
|
||||
lastUsed,
|
||||
metadata,
|
||||
isFavorite
|
||||
} = template;
|
||||
|
||||
const lastUsed = metadata?.lastUsed ? new Date(metadata.lastUsed) : undefined;
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete(id);
|
||||
setShowDeleteAlert(false);
|
||||
@ -95,7 +97,7 @@ export function TemplateCard({
|
||||
Exercises:
|
||||
</Text>
|
||||
<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">
|
||||
• {exercise.title} ({exercise.targetSets}×{exercise.targetReps})
|
||||
</Text>
|
||||
@ -117,7 +119,7 @@ export function TemplateCard({
|
||||
|
||||
{tags.length > 0 && (
|
||||
<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">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
@ -216,12 +218,15 @@ export function TemplateCard({
|
||||
<Text className="text-base">Type: {type}</Text>
|
||||
<Text className="text-base">Category: {category}</Text>
|
||||
<Text className="text-base">Source: {source}</Text>
|
||||
{metadata?.useCount && (
|
||||
<Text className="text-base">Times Used: {metadata.useCount}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Exercises</Text>
|
||||
<View className="gap-2">
|
||||
{exercises.map((exercise, index) => (
|
||||
{exercises.map((exercise: TemplateExerciseDisplay, index: number) => (
|
||||
<Text key={index} className="text-base">
|
||||
{exercise.title} ({exercise.targetSets}×{exercise.targetReps})
|
||||
</Text>
|
||||
@ -232,7 +237,7 @@ export function TemplateCard({
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Tags</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
{tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
|
373
components/templates/TemplateDetails.tsx
Normal file
373
components/templates/TemplateDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,343 +1,186 @@
|
||||
# POWR Library Tab PRD
|
||||
Updated: 2025-02-19
|
||||
|
||||
## Overview
|
||||
|
||||
### 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.
|
||||
|
||||
### Goals
|
||||
1. Provide organized access to exercises and workout templates
|
||||
2. Enable efficient content discovery and reuse
|
||||
3. Support clear content ownership and source tracking
|
||||
4. Maintain offline-first functionality
|
||||
5. Prepare for future Nostr integration
|
||||
### Goals Status
|
||||
1. ✅ Provide organized access to exercises and workout templates
|
||||
- Implemented main navigation and content organization
|
||||
- Search and filtering working
|
||||
- Basic content management in place
|
||||
|
||||
## Feature Requirements
|
||||
2. ✅ Enable efficient content discovery and reuse
|
||||
- Search functionality implemented
|
||||
- Category filtering working
|
||||
- Alphabetical organization with quick scroll
|
||||
|
||||
### Navigation Structure
|
||||
- Material Top Tabs navigation with three sections:
|
||||
- Templates (default tab)
|
||||
- Exercises
|
||||
- Programs (placeholder for future implementation)
|
||||
3. 🟡 Support clear content ownership and source tracking
|
||||
- Basic source badges implemented
|
||||
- Nostr attribution pending
|
||||
- Content history tracking in progress
|
||||
|
||||
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
|
||||
|
||||
#### Content Organization
|
||||
- Favorites section
|
||||
- Recently performed section
|
||||
- Alphabetical list of remaining templates
|
||||
- Clear source badges (Local/POWR/Nostr)
|
||||
- ✅ Basic template list with sections
|
||||
- ✅ Favorites section
|
||||
- ✅ Source badges (Local/POWR/Nostr)
|
||||
- 🟡 Recently performed section (partial)
|
||||
- ❌ Usage statistics
|
||||
|
||||
#### Template Item Display
|
||||
- Template title
|
||||
- Workout type (strength, circuit, EMOM, etc.)
|
||||
- Preview of included exercises (first 3)
|
||||
- Source badge
|
||||
- Favorite star button
|
||||
- Usage stats
|
||||
- ✅ Template title
|
||||
- ✅ Workout type indication
|
||||
- ✅ Exercise preview (first 3)
|
||||
- ✅ Source badges
|
||||
- ✅ Favorite functionality
|
||||
- 🟡 Usage stats (partial)
|
||||
|
||||
#### Search & Filtering
|
||||
- Persistent search bar with real-time filtering
|
||||
- Filter options:
|
||||
- Workout type
|
||||
- Equipment needed
|
||||
- Tags
|
||||
- ✅ Real-time search
|
||||
- ✅ Basic filtering options
|
||||
- 🟡 Advanced filters (partial)
|
||||
|
||||
### Exercises Tab
|
||||
|
||||
#### Content Organization
|
||||
- Recent section (10 most recent exercises)
|
||||
- Alphabetical list of all exercises
|
||||
- Tag-based categorization
|
||||
- Clear source badges
|
||||
- ✅ Alphabetical list with quick scroll
|
||||
- ✅ Categorization system
|
||||
- ✅ Source badges
|
||||
- ❌ Recent section
|
||||
- ❌ Usage tracking
|
||||
|
||||
#### Exercise Item Display
|
||||
- Exercise name
|
||||
- Category/tags
|
||||
- Equipment type
|
||||
- Source badge
|
||||
- Usage stats
|
||||
- ✅ Exercise name
|
||||
- ✅ Category/tags
|
||||
- ✅ Equipment type
|
||||
- ✅ Source badge
|
||||
- ❌ Usage stats
|
||||
|
||||
#### Search & Filtering
|
||||
- Persistent search bar with real-time filtering
|
||||
- Filter options:
|
||||
- Equipment
|
||||
- Tags
|
||||
- Source
|
||||
- ✅ Real-time search
|
||||
- ✅ Basic filters
|
||||
- 🟡 Advanced filtering options
|
||||
|
||||
### Programs Tab (Future)
|
||||
- Placeholder implementation
|
||||
- "Coming Soon" messaging
|
||||
- Basic description of future functionality
|
||||
### Technical Implementation
|
||||
|
||||
## 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
|
||||
- Basic info
|
||||
- Source badge (Local/POWR/Nostr)
|
||||
- Quick stats/preview
|
||||
- Favorite button (templates only)
|
||||
#### State Management
|
||||
- ✅ Basic state handling
|
||||
- ✅ Form state management
|
||||
- 🟡 Complex state interactions
|
||||
- ❌ Global state optimization
|
||||
|
||||
#### 2. Quick Preview (Hover/Long Press)
|
||||
- Extended preview info
|
||||
- Key stats
|
||||
- Quick actions
|
||||
## Next Development Priorities
|
||||
|
||||
#### 3. Bottom Sheet Details
|
||||
- Basic Information:
|
||||
- Full title and description
|
||||
- Category/tags
|
||||
- Equipment requirements
|
||||
### Phase 1: Core Enhancements
|
||||
1. Template Management
|
||||
- Implement history tracking
|
||||
- Add usage statistics
|
||||
- Enhance template details view
|
||||
|
||||
- Stats & History:
|
||||
- Personal records
|
||||
- Usage history
|
||||
- Performance trends
|
||||
2. Progressive Disclosure
|
||||
- Add long press preview
|
||||
- Improve bottom sheet details
|
||||
- Create full screen edit mode
|
||||
|
||||
- Source Information:
|
||||
- For local content:
|
||||
- Creation date
|
||||
- Last modified
|
||||
- For Nostr content:
|
||||
- Author information
|
||||
- Original post date
|
||||
- Relay source
|
||||
3. Exercise Management
|
||||
- Implement usage tracking
|
||||
- Add performance metrics
|
||||
- Enhance filtering system
|
||||
|
||||
- Action Buttons:
|
||||
- For local content:
|
||||
- Start Workout (templates)
|
||||
- Edit
|
||||
- Publish to Nostr
|
||||
- Delete
|
||||
- For Nostr content:
|
||||
- Start Workout (templates)
|
||||
- Delete from Library
|
||||
### Phase 2: Advanced Features
|
||||
1. Media Support
|
||||
- Image/video linking
|
||||
- Local caching
|
||||
- Placeholder system
|
||||
|
||||
#### 4. Full Details Modal
|
||||
- Comprehensive view
|
||||
- Complete history
|
||||
- Advanced options
|
||||
2. History & Stats
|
||||
- Usage tracking
|
||||
- Performance metrics
|
||||
- Trend visualization
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Data Storage
|
||||
- SQLite for local storage
|
||||
- Schema supporting:
|
||||
- Exercise templates
|
||||
- Workout templates
|
||||
- Usage history
|
||||
- Source tracking
|
||||
- Nostr metadata
|
||||
|
||||
### Content Management
|
||||
- No limit on custom exercises/templates
|
||||
- Tag character limit: 30 characters
|
||||
- Support for external media links (images/videos)
|
||||
- Local caching of Nostr content
|
||||
|
||||
### Media Content Handling
|
||||
- For Nostr content:
|
||||
- Store media URLs in metadata
|
||||
- Cache images locally when saved
|
||||
- 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
|
||||
3. Enhanced Filtering
|
||||
- Combined filters
|
||||
- Smart suggestions
|
||||
- Recent searches
|
||||
|
||||
### Phase 3: Nostr Integration
|
||||
1. Content syncing
|
||||
2. Publishing flow
|
||||
3. Author attribution
|
||||
4. Media handling
|
||||
1. Event Handling
|
||||
- Event processing
|
||||
- Content validation
|
||||
- Relay management
|
||||
|
||||
## Success Metrics
|
||||
2. Sync System
|
||||
- Content synchronization
|
||||
- Conflict resolution
|
||||
- Offline handling
|
||||
|
||||
### Performance
|
||||
- Search response: < 100ms
|
||||
- Scroll performance: 60fps
|
||||
- Image load time: < 500ms
|
||||
## MVP Assessment
|
||||
|
||||
### User Experience
|
||||
- Content discovery time
|
||||
- Search success rate
|
||||
- Template reuse rate
|
||||
- Exercise reference frequency
|
||||
### Current MVP Features (✅ Complete)
|
||||
1. Core Navigation
|
||||
- Tab structure
|
||||
- Content organization
|
||||
- Basic routing
|
||||
|
||||
### Technical
|
||||
- Offline reliability
|
||||
- Storage efficiency
|
||||
- Cache hit rate
|
||||
- Sync success rate
|
||||
2. Exercise Management
|
||||
- Create/edit/delete
|
||||
- Categorization
|
||||
- Search/filter
|
||||
|
||||
## Future Considerations
|
||||
3. Template Management
|
||||
- Template creation
|
||||
- Exercise inclusion
|
||||
- Favorites system
|
||||
|
||||
### Programs Tab Development
|
||||
- Program creation
|
||||
- Calendar integration
|
||||
- Progress tracking
|
||||
- Social sharing
|
||||
4. Data Foundation
|
||||
- SQLite integration
|
||||
- Basic CRUD
|
||||
- Schema structure
|
||||
|
||||
### Enhanced Social Features
|
||||
- Content recommendations
|
||||
- Author following
|
||||
- Usage analytics
|
||||
- Community features
|
||||
### MVP Technical Metrics
|
||||
- Search response: < 100ms ✅
|
||||
- Scroll performance: 60fps ✅
|
||||
- Database operations: < 50ms ✅
|
||||
|
||||
### Additional Enhancements
|
||||
- Advanced media support
|
||||
- Custom collections
|
||||
- Export/import functionality
|
||||
- Backup solutions
|
||||
### Known Limitations
|
||||
1. No media support in current version
|
||||
2. Limited performance tracking
|
||||
3. Basic filtering only
|
||||
4. No offline state handling
|
||||
5. No Nostr integration
|
||||
|
||||
2025-02-09 Update
|
||||
|
||||
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
|
||||
## 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.
|
@ -1,13 +1,19 @@
|
||||
// lib/db/services/ExerciseService.ts
|
||||
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';
|
||||
|
||||
export class ExerciseService {
|
||||
constructor(private db: SQLiteDatabase) {}
|
||||
|
||||
// Add this new method
|
||||
async getAllExercises(): Promise<Exercise[]> {
|
||||
async getAllExercises(): Promise<ExerciseDisplay[]> {
|
||||
try {
|
||||
const exercises = await this.db.getAllAsync<any>(`
|
||||
SELECT * FROM exercises ORDER BY created_at DESC
|
||||
@ -29,22 +35,55 @@ export class ExerciseService {
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
return exercises.map(exercise => ({
|
||||
return exercises.map(exercise => {
|
||||
const baseExercise: BaseExercise = {
|
||||
...exercise,
|
||||
tags: tagsByExercise[exercise.id] || [],
|
||||
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
|
||||
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
|
||||
availability: { source: [exercise.source] }
|
||||
}));
|
||||
};
|
||||
return toExerciseDisplay(baseExercise);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting all exercises:', 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(
|
||||
exercise: Omit<Exercise, 'id' | 'availability'>,
|
||||
exercise: Omit<BaseExercise, 'id'>,
|
||||
inTransaction: boolean = false
|
||||
): Promise<string> {
|
||||
const id = generateId();
|
||||
@ -68,7 +107,7 @@ export class ExerciseService {
|
||||
exercise.format_units ? JSON.stringify(exercise.format_units) : null,
|
||||
timestamp,
|
||||
timestamp,
|
||||
exercise.source || 'local'
|
||||
exercise.availability.source[0]
|
||||
]
|
||||
);
|
||||
|
||||
@ -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?: {
|
||||
types?: ExerciseType[];
|
||||
categories?: ExerciseCategory[];
|
||||
equipment?: Equipment[];
|
||||
tags?: string[];
|
||||
source?: 'local' | 'powr' | 'nostr';
|
||||
}): Promise<Exercise[]> {
|
||||
}): Promise<ExerciseDisplay[]> {
|
||||
try {
|
||||
let sql = `
|
||||
SELECT DISTINCT e.*
|
||||
@ -278,7 +195,7 @@ export class ExerciseService {
|
||||
sql += ' ORDER BY e.title ASC';
|
||||
|
||||
// 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
|
||||
const exerciseIds = exercises.map(e => e.id);
|
||||
@ -297,23 +214,27 @@ export class ExerciseService {
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
// Add tags to exercises
|
||||
return exercises.map(exercise => ({
|
||||
// Convert to ExerciseDisplay
|
||||
return exercises.map(exercise => {
|
||||
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: tagsByExercise[exercise.id] || []
|
||||
}));
|
||||
tags: tagsByExercise[exercise.id] || [],
|
||||
availability: { source: [exercise.source] }
|
||||
};
|
||||
return toExerciseDisplay(baseExercise);
|
||||
});
|
||||
}
|
||||
|
||||
return exercises;
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error searching exercises:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getRecentExercises(limit: number = 10): Promise<Exercise[]> {
|
||||
async getRecentExercises(limit: number = 10): Promise<ExerciseDisplay[]> {
|
||||
try {
|
||||
const sql = `
|
||||
SELECT e.*
|
||||
@ -322,7 +243,7 @@ export class ExerciseService {
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
const exercises = await this.db.getAllAsync<Exercise>(sql, [limit]);
|
||||
const exercises = await this.db.getAllAsync<any>(sql, [limit]);
|
||||
|
||||
// Get tags for these exercises
|
||||
const exerciseIds = exercises.map(e => e.id);
|
||||
@ -341,55 +262,111 @@ export class ExerciseService {
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
return exercises.map(exercise => ({
|
||||
return exercises.map(exercise => {
|
||||
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: tagsByExercise[exercise.id] || []
|
||||
}));
|
||||
tags: tagsByExercise[exercise.id] || [],
|
||||
availability: { source: [exercise.source] }
|
||||
};
|
||||
return toExerciseDisplay(baseExercise);
|
||||
});
|
||||
}
|
||||
|
||||
return exercises;
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error getting recent exercises:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getExerciseTags(): Promise<{ tag: string; count: number }[]> {
|
||||
try {
|
||||
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[] = [];
|
||||
async updateExercise(id: string, exercise: Partial<BaseExercise>): Promise<void> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
try {
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
for (const exercise of exercises) {
|
||||
const id = await this.createExercise(exercise);
|
||||
ids.push(id);
|
||||
// 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 (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) {
|
||||
console.error('Error bulk importing exercises:', error);
|
||||
console.error('Error updating exercise:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to sync with Nostr events
|
||||
async syncWithNostrEvent(eventId: string, exercise: Omit<Exercise, 'id'>): Promise<string> {
|
||||
async syncWithNostrEvent(eventId: string, exercise: Omit<BaseExercise, 'id'>): Promise<string> {
|
||||
try {
|
||||
// Check if we already have this exercise
|
||||
const existing = await this.db.getFirstAsync<{ id: string }>(
|
||||
@ -446,8 +423,3 @@ export class ExerciseService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
@ -1,7 +1,7 @@
|
||||
// lib/db/services/LibraryService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { DbService } from '../db-service';
|
||||
import { BaseExercise, Exercise } from '@/types/exercise';
|
||||
import { BaseExercise, ExerciseDisplay } from '@/types/exercise';
|
||||
import { StorageSource } from '@/types/shared';
|
||||
import { generateId } from '@/utils/ids';
|
||||
|
||||
@ -13,7 +13,7 @@ export class LibraryService {
|
||||
this.db = new DbService(database);
|
||||
}
|
||||
|
||||
async getExercises(): Promise<Exercise[]> {
|
||||
async getExercises(): Promise<ExerciseDisplay[]> {
|
||||
try {
|
||||
const result = await this.db.getAllAsync<{
|
||||
id: string;
|
||||
@ -41,19 +41,19 @@ export class LibraryService {
|
||||
return result.map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
type: row.type as Exercise['type'],
|
||||
category: row.category as Exercise['category'],
|
||||
equipment: row.equipment as Exercise['equipment'] || undefined,
|
||||
type: row.type as ExerciseDisplay['type'],
|
||||
category: row.category as ExerciseDisplay['category'],
|
||||
equipment: row.equipment as ExerciseDisplay['equipment'] || undefined,
|
||||
description: row.description || undefined,
|
||||
instructions: row.instructions ? row.instructions.split(',') : undefined,
|
||||
tags: row.tags ? row.tags.split(',') : [],
|
||||
created_at: row.created_at,
|
||||
source: row.source as Exercise['source'],
|
||||
source: row.source as ExerciseDisplay['source'],
|
||||
availability: {
|
||||
source: [row.source as StorageSource]
|
||||
},
|
||||
format_json: row.format_json || undefined,
|
||||
format_units_json: row.format_units_json || undefined
|
||||
format: row.format_json ? JSON.parse(row.format_json) : undefined,
|
||||
format_units: row.format_units_json ? JSON.parse(row.format_units_json) : undefined
|
||||
}));
|
||||
} catch (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 {
|
||||
const id = generateId();
|
||||
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, // updated_at
|
||||
exercise.source,
|
||||
exercise.format_json || null,
|
||||
exercise.format_units_json || null
|
||||
exercise.format ? JSON.stringify(exercise.format) : null,
|
||||
exercise.format_units ? JSON.stringify(exercise.format_units) : null
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -1,7 +1,14 @@
|
||||
// lib/hooks/useExercises.ts
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
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';
|
||||
|
||||
// Filtering types
|
||||
@ -10,29 +17,29 @@ export interface ExerciseFilters {
|
||||
category?: ExerciseCategory[];
|
||||
equipment?: Equipment[];
|
||||
tags?: string[];
|
||||
source?: Exercise['source'][];
|
||||
source?: ('local' | 'powr' | 'nostr')[];
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
interface ExerciseStats {
|
||||
totalCount: number;
|
||||
byCategory: Record<ExerciseCategory, number>;
|
||||
byType: Record<ExerciseType, number>;
|
||||
byEquipment: Record<Equipment, number>;
|
||||
byCategory: Partial<Record<ExerciseCategory, number>>;
|
||||
byType: Partial<Record<ExerciseType, number>>;
|
||||
byEquipment: Partial<Record<Equipment, number>>;
|
||||
}
|
||||
|
||||
const initialStats: ExerciseStats = {
|
||||
totalCount: 0,
|
||||
byCategory: {} as Record<ExerciseCategory, number>,
|
||||
byType: {} as Record<ExerciseType, number>,
|
||||
byEquipment: {} as Record<Equipment, number>,
|
||||
byCategory: {},
|
||||
byType: {},
|
||||
byEquipment: {},
|
||||
};
|
||||
|
||||
export function useExercises() {
|
||||
const db = useSQLiteContext();
|
||||
const libraryService = React.useMemo(() => new LibraryService(db), [db]);
|
||||
|
||||
const [exercises, setExercises] = useState<Exercise[]>([]);
|
||||
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<ExerciseFilters>({});
|
||||
@ -46,19 +53,31 @@ export function useExercises() {
|
||||
setExercises(allExercises);
|
||||
|
||||
// Calculate stats
|
||||
const newStats = allExercises.reduce((acc: ExerciseStats, exercise: Exercise) => {
|
||||
const newStats = allExercises.reduce((acc: ExerciseStats, exercise: ExerciseDisplay) => {
|
||||
// Increment total count
|
||||
acc.totalCount++;
|
||||
|
||||
// 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) {
|
||||
acc.byEquipment[exercise.equipment] = (acc.byEquipment[exercise.equipment] || 0) + 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {
|
||||
totalCount: 0,
|
||||
byCategory: {} as Record<ExerciseCategory, number>,
|
||||
byType: {} as Record<ExerciseType, number>,
|
||||
byEquipment: {} as Record<Equipment, number>,
|
||||
byCategory: {},
|
||||
byType: {},
|
||||
byEquipment: {},
|
||||
});
|
||||
|
||||
setStats(newStats);
|
||||
@ -89,7 +108,7 @@ export function useExercises() {
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -103,8 +122,8 @@ export function useExercises() {
|
||||
const query = filters.searchQuery.toLowerCase();
|
||||
return (
|
||||
exercise.title.toLowerCase().includes(query) ||
|
||||
exercise.description?.toLowerCase().includes(query) ||
|
||||
exercise.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
(exercise.description?.toLowerCase() || '').includes(query) ||
|
||||
exercise.tags.some((tag: string) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
@ -113,9 +132,16 @@ export function useExercises() {
|
||||
}, [exercises, filters]);
|
||||
|
||||
// Create a new exercise
|
||||
const createExercise = useCallback(async (exercise: Omit<Exercise, 'id'>) => {
|
||||
const createExercise = useCallback(async (exercise: Omit<BaseExercise, 'id'>) => {
|
||||
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
|
||||
return id;
|
||||
} catch (err) {
|
||||
|
@ -1,6 +1,12 @@
|
||||
// lib/mocks/exercises.ts
|
||||
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';
|
||||
|
||||
// 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);
|
||||
return tag ? tag[1] : undefined;
|
||||
}
|
||||
@ -226,8 +232,8 @@ function getTagValue(tags: string[][], name: string): string | undefined {
|
||||
.map((tag: string[]) => tag[1]);
|
||||
}
|
||||
|
||||
export function convertNostrToExercise(event: NostrEvent): Exercise {
|
||||
return {
|
||||
export function convertNostrToExercise(event: NostrEvent): ExerciseDisplay {
|
||||
const baseExercise: BaseExercise = {
|
||||
id: event.id || '',
|
||||
title: getTagValue(event.tags, 'title') || '',
|
||||
type: getTagValue(event.tags, 'equipment') === 'bodyweight'
|
||||
@ -252,16 +258,23 @@ function getTagValue(tags: string[][], name: string): string | undefined {
|
||||
availability: {
|
||||
source: ['powr']
|
||||
},
|
||||
created_at: event.created_at * 1000,
|
||||
source: 'powr'
|
||||
created_at: event.created_at * 1000
|
||||
};
|
||||
|
||||
// Convert to ExerciseDisplay
|
||||
return {
|
||||
...baseExercise,
|
||||
source: 'powr',
|
||||
isFavorite: false,
|
||||
usageCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Export pre-converted exercises for easy testing
|
||||
export const mockExercises = mockExerciseEvents.map(convertNostrToExercise);
|
||||
// Export pre-converted exercises for easy testing
|
||||
export const mockExercises = mockExerciseEvents.map(convertNostrToExercise);
|
||||
|
||||
// Helper to seed the database
|
||||
export async function seedExercises(exerciseService: any) {
|
||||
// Helper to seed the database
|
||||
export async function seedExercises(exerciseService: any) {
|
||||
try {
|
||||
const existingCount = (await exerciseService.getAllExercises()).length;
|
||||
if (existingCount === 0) {
|
||||
@ -274,4 +287,4 @@ export async function seedExercises(exerciseService: any) {
|
||||
} catch (error) {
|
||||
console.error('Error seeding database:', error);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
// types/exercise.ts - handles everything about individual exercises
|
||||
/* import { NostrEventKind } from './events';
|
||||
*/
|
||||
import { SyncableContent, StorageSource } from './shared';
|
||||
// types/exercise.ts
|
||||
import { SyncableContent } 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 ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
|
||||
|
||||
export type Equipment =
|
||||
| 'bodyweight'
|
||||
| 'barbell'
|
||||
@ -15,32 +18,28 @@ export type Equipment =
|
||||
| 'cable'
|
||||
| 'other';
|
||||
|
||||
export interface Exercise extends BaseExercise {
|
||||
source: 'local' | 'powr' | 'nostr';
|
||||
usageCount?: number;
|
||||
lastUsed?: Date;
|
||||
format_json?: string; // For database storage
|
||||
format_units_json?: string; // For database storage
|
||||
nostr_event_id?: string; // For Nostr integration
|
||||
}
|
||||
|
||||
// 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;
|
||||
/**
|
||||
* Exercise Format Configuration
|
||||
* Defines how an exercise should be tracked and measured
|
||||
*/
|
||||
export interface ExerciseFormat {
|
||||
weight?: boolean;
|
||||
reps?: boolean;
|
||||
rpe?: boolean;
|
||||
set_type?: 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 {
|
||||
title: string;
|
||||
type: ExerciseType;
|
||||
@ -49,21 +48,24 @@ export interface BaseExercise extends SyncableContent {
|
||||
description?: string;
|
||||
instructions?: string[];
|
||||
tags: 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';
|
||||
};
|
||||
format?: ExerciseFormat;
|
||||
format_units?: ExerciseFormatUnits;
|
||||
}
|
||||
|
||||
// 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 interface WorkoutSet {
|
||||
@ -77,7 +79,9 @@ export interface WorkoutSet {
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
// Exercise with workout-specific data
|
||||
/**
|
||||
* Exercise with active workout data
|
||||
*/
|
||||
export interface WorkoutExercise extends BaseExercise {
|
||||
sets: WorkoutSet[];
|
||||
totalWeight?: number;
|
||||
@ -87,7 +91,9 @@ export interface WorkoutExercise extends BaseExercise {
|
||||
targetReps?: number;
|
||||
}
|
||||
|
||||
// Exercise template specific types
|
||||
/**
|
||||
* Exercise Template with recommendations and progression
|
||||
*/
|
||||
export interface ExerciseTemplate extends BaseExercise {
|
||||
defaultSets?: {
|
||||
type: SetType;
|
||||
@ -110,7 +116,9 @@ export interface ExerciseTemplate extends BaseExercise {
|
||||
};
|
||||
}
|
||||
|
||||
// Exercise history and progress tracking
|
||||
/**
|
||||
* Exercise History Tracking
|
||||
*/
|
||||
export interface ExerciseHistory {
|
||||
exerciseId: string;
|
||||
entries: Array<{
|
||||
@ -147,3 +155,18 @@ export function isWorkoutExercise(exercise: any): exercise is WorkoutExercise {
|
||||
export function isExerciseTemplate(exercise: any): exercise is ExerciseTemplate {
|
||||
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
|
||||
};
|
||||
}
|
@ -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
214
types/templates.ts
Normal 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']
|
||||
}
|
||||
};
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user