mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
update to exercise tab UI
This commit is contained in:
parent
7fd62bce37
commit
083367e872
@ -1,35 +1,32 @@
|
||||
// app/(tabs)/library/exercises.tsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, SectionList, TouchableOpacity, SectionListData } from 'react-native';
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { View, SectionList, TouchableOpacity, ViewToken } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExerciseCard } from '@/components/exercises/ExerciseCard';
|
||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
|
||||
import { Dumbbell } from 'lucide-react-native';
|
||||
import { Exercise, BaseExercise } from '@/types/exercise';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { ExerciseService } from '@/lib/db/services/ExerciseService';
|
||||
|
||||
interface ExerciseSection {
|
||||
title: string;
|
||||
data: Exercise[];
|
||||
}
|
||||
import { BaseExercise, Exercise } from '@/types/exercise';
|
||||
import { useExercises } from '@/lib/hooks/useExercises';
|
||||
|
||||
export default function ExercisesScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const exerciseService = React.useMemo(() => new ExerciseService(db), [db]);
|
||||
const sectionListRef = useRef<SectionList>(null);
|
||||
|
||||
const [exercises, setExercises] = useState<Exercise[]>([]);
|
||||
const [sections, setSections] = useState<ExerciseSection[]>([]);
|
||||
const [showNewExercise, setShowNewExercise] = useState(false);
|
||||
const [currentSection, setCurrentSection] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadExercises();
|
||||
}, []);
|
||||
const {
|
||||
exercises,
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
createExercise,
|
||||
deleteExercise,
|
||||
refreshExercises
|
||||
} = useExercises();
|
||||
|
||||
useEffect(() => {
|
||||
// Organize exercises into sections when exercises array changes
|
||||
// Organize exercises into sections
|
||||
const sections = React.useMemo(() => {
|
||||
const exercisesByLetter = exercises.reduce((acc, exercise) => {
|
||||
const firstLetter = exercise.title[0].toUpperCase();
|
||||
if (!acc[firstLetter]) {
|
||||
@ -39,34 +36,69 @@ export default function ExercisesScreen() {
|
||||
return acc;
|
||||
}, {} as Record<string, Exercise[]>);
|
||||
|
||||
// Create sections array sorted alphabetically
|
||||
const newSections = Object.entries(exercisesByLetter)
|
||||
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));
|
||||
|
||||
setSections(newSections);
|
||||
}, [exercises]);
|
||||
|
||||
const loadExercises = async () => {
|
||||
try {
|
||||
const loadedExercises = await exerciseService.getAllExercises();
|
||||
setExercises(loadedExercises);
|
||||
} catch (error) {
|
||||
console.error('Error loading exercises:', error);
|
||||
const handleViewableItemsChanged = useCallback(({
|
||||
viewableItems
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const firstSection = viewableItems.find(item => item.section)?.section?.title;
|
||||
if (firstSection) {
|
||||
setCurrentSection(firstSection);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddExercise = async (exerciseData: BaseExercise) => {
|
||||
try {
|
||||
await exerciseService.createExercise({
|
||||
...exerciseData,
|
||||
created_at: Date.now(),
|
||||
source: 'local'
|
||||
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
|
||||
});
|
||||
await loadExercises();
|
||||
|
||||
// Log for debugging
|
||||
if (__DEV__) {
|
||||
console.log('Scrolling to section:', {
|
||||
letter,
|
||||
sectionIndex,
|
||||
totalSections: sections.length
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sections]);
|
||||
|
||||
// Add getItemLayout to optimize scrolling
|
||||
const getItemLayout = useCallback((data: any, index: number) => ({
|
||||
length: 100, // Approximate height of each item
|
||||
offset: 100 * index,
|
||||
index,
|
||||
}), []);
|
||||
|
||||
|
||||
const handleAddExercise = async (exerciseData: Omit<BaseExercise, 'id' | 'availability' | 'created_at'>) => {
|
||||
try {
|
||||
const newExercise: Omit<Exercise, 'id'> = {
|
||||
...exerciseData,
|
||||
source: 'local',
|
||||
created_at: Date.now(),
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
format_json: exerciseData.format ? JSON.stringify(exerciseData.format) : undefined,
|
||||
format_units_json: exerciseData.format_units ? JSON.stringify(exerciseData.format_units) : undefined
|
||||
};
|
||||
|
||||
await createExercise(newExercise);
|
||||
setShowNewExercise(false);
|
||||
} catch (error) {
|
||||
console.error('Error adding exercise:', error);
|
||||
@ -75,8 +107,7 @@ export default function ExercisesScreen() {
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await exerciseService.deleteExercise(id);
|
||||
await loadExercises();
|
||||
await deleteExercise(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting exercise:', error);
|
||||
}
|
||||
@ -86,65 +117,139 @@ export default function ExercisesScreen() {
|
||||
console.log('Selected exercise:', exerciseId);
|
||||
};
|
||||
|
||||
const scrollToSection = (letter: string) => {
|
||||
const sectionIndex = sections.findIndex(section => section.title === letter);
|
||||
if (sectionIndex !== -1) {
|
||||
sectionListRef.current?.scrollToLocation({
|
||||
sectionIndex,
|
||||
itemIndex: 0,
|
||||
animated: true,
|
||||
viewOffset: 20
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-4 bg-background">
|
||||
<Text className="text-destructive text-center mb-4">
|
||||
{error.message}
|
||||
</Text>
|
||||
<Button onPress={refreshExercises}>
|
||||
<Text>Retry</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<View className="absolute right-0 top-0 bottom-0 w-6 z-10 justify-center bg-transparent">
|
||||
{alphabet.map((letter) => (
|
||||
<TouchableOpacity
|
||||
key={letter}
|
||||
onPress={() => scrollToSection(letter)}
|
||||
className="py-0.5"
|
||||
>
|
||||
{/* 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>
|
||||
</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)}
|
||||
/>
|
||||
</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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alphabet.map((letter) => (
|
||||
<Text
|
||||
className={`text-xs text-center ${
|
||||
availableLetters.has(letter)
|
||||
? 'text-primary font-medium'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
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>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SectionList
|
||||
ref={sectionListRef}
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View className="py-2 px-4 bg-background/80">
|
||||
<Text className="text-lg font-semibold text-foreground">{section.title}</Text>
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item }) => (
|
||||
<View className="px-4 py-1">
|
||||
<ExerciseCard
|
||||
{...item}
|
||||
onPress={() => handleExercisePress(item.id)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
stickySectionHeadersEnabled
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<FloatingActionButton
|
||||
icon={Dumbbell}
|
||||
onPress={() => setShowNewExercise(true)}
|
||||
|
@ -1,113 +1,82 @@
|
||||
// components/exercises/ExerciseCard.tsx
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, Platform } from 'react-native';
|
||||
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 { Trash2, Star } from 'lucide-react-native';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
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: (id: string) => void;
|
||||
onDelete: () => void;
|
||||
onFavorite?: () => void;
|
||||
}
|
||||
|
||||
export function ExerciseCard({
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
category,
|
||||
equipment,
|
||||
description,
|
||||
tags = [],
|
||||
source = 'local',
|
||||
usageCount,
|
||||
lastUsed,
|
||||
source,
|
||||
instructions = [],
|
||||
onPress,
|
||||
onDelete,
|
||||
onFavorite
|
||||
}: ExerciseCardProps) {
|
||||
const [showSheet, setShowSheet] = React.useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete(id);
|
||||
const handleDelete = () => {
|
||||
onDelete();
|
||||
setShowDeleteAlert(false);
|
||||
if (showSheet) {
|
||||
setShowSheet(false); // Close detail sheet if open
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity onPress={() => setShowSheet(true)} activeOpacity={0.7}>
|
||||
<Card className="mx-4">
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center gap-2 mb-1">
|
||||
<Text className="text-lg font-semibold text-card-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge
|
||||
variant={source === 'local' ? 'outline' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{category}
|
||||
</Text>
|
||||
{equipment && (
|
||||
<Text className="text-sm text-muted-foreground mt-0.5">
|
||||
{equipment}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 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 mt-2 native:pr-12">
|
||||
<Text className="text-sm text-muted-foreground mb-2 native:pr-12" numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(usageCount || lastUsed) && (
|
||||
<View className="flex-row gap-4 mt-2">
|
||||
{usageCount && (
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Used {usageCount} times
|
||||
</Text>
|
||||
)}
|
||||
{lastUsed && (
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Last used: {lastUsed.toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-2 mt-2">
|
||||
<View className="flex-row flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
@ -115,52 +84,46 @@ export function ExerciseCard({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-1 native:absolute native:right-0 native:top-0 native:p-2">
|
||||
{/* Action Buttons */}
|
||||
<View className="flex-row gap-1">
|
||||
{onFavorite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={onFavorite}
|
||||
className="native:h-10 native:w-10"
|
||||
>
|
||||
<Star className="text-muted-foreground" size={20} />
|
||||
<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="native:h-10 native:w-10 native:bg-muted/50 items-center justify-center"
|
||||
>
|
||||
<Trash2
|
||||
size={20}
|
||||
color={Platform.select({
|
||||
ios: undefined,
|
||||
android: '#dc2626'
|
||||
})}
|
||||
className="text-destructive"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Trash2 className="text-destructive" size={18} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Text>Delete Exercise</Text>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete Exercise</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Text>Are you sure you want to delete {title}? This action cannot be undone.</Text>
|
||||
Are you sure you want to delete {title}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Text>Cancel</Text>
|
||||
<View className="flex-row justify-end gap-3 mt-4">
|
||||
<AlertDialogCancel asChild>
|
||||
<Button variant="outline">
|
||||
<Text>Cancel</Text>
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleConfirmDelete}>
|
||||
<Text>Delete</Text>
|
||||
<AlertDialogAction asChild>
|
||||
<Button variant="destructive" onPress={handleDelete}>
|
||||
<Text className="text-white">Delete</Text>
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</View>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</View>
|
||||
@ -169,43 +132,43 @@ export function ExerciseCard({
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Sheet isOpen={showSheet} onClose={() => setShowSheet(false)}>
|
||||
{/* Details Sheet */}
|
||||
<Sheet isOpen={showDetails} onClose={() => setShowDetails(false)}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Text className="text-xl font-bold">{title}</Text>
|
||||
</SheetTitle>
|
||||
<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 leading-relaxed">{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>
|
||||
{(usageCount || lastUsed) && (
|
||||
|
||||
{instructions.length > 0 && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Statistics</Text>
|
||||
<Text className="text-base font-semibold mb-2">Instructions</Text>
|
||||
<View className="gap-2">
|
||||
{usageCount && (
|
||||
<Text className="text-base">Used {usageCount} times</Text>
|
||||
)}
|
||||
{lastUsed && (
|
||||
<Text className="text-base">
|
||||
Last used: {lastUsed.toLocaleDateString()}
|
||||
{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>
|
||||
|
77
components/exercises/ExerciseList.tsx
Normal file
77
components/exercises/ExerciseList.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
// 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;
|
@ -5,7 +5,7 @@ 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 } from '@/types/exercise';
|
||||
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';
|
||||
@ -13,7 +13,7 @@ import { generateId } from '@/utils/ids';
|
||||
interface NewExerciseSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (exercise: BaseExercise) => void;
|
||||
onSubmit: (exercise: Omit<Exercise, 'id'>) => void; // Changed from BaseExercise
|
||||
}
|
||||
|
||||
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
|
||||
@ -53,16 +53,27 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
|
||||
const handleSubmit = () => {
|
||||
if (!formData.title || !formData.equipment) return;
|
||||
|
||||
const exercise = {
|
||||
...formData,
|
||||
id: generateId('local'),
|
||||
// Transform the form data into an Exercise type
|
||||
const exerciseData: Omit<Exercise, 'id'> = {
|
||||
title: formData.title,
|
||||
type: formData.type,
|
||||
category: formData.category,
|
||||
equipment: formData.equipment,
|
||||
description: formData.description,
|
||||
tags: formData.tags,
|
||||
format: formData.format,
|
||||
format_units: formData.format_units,
|
||||
// Add required Exercise fields
|
||||
source: 'local',
|
||||
created_at: Date.now(),
|
||||
availability: {
|
||||
source: ['local' as StorageSource]
|
||||
}
|
||||
} as BaseExercise;
|
||||
|
||||
onSubmit(exercise);
|
||||
source: ['local']
|
||||
},
|
||||
format_json: JSON.stringify(formData.format),
|
||||
format_units_json: JSON.stringify(formData.format_units)
|
||||
};
|
||||
|
||||
onSubmit(exerciseData);
|
||||
onClose();
|
||||
|
||||
// Reset form
|
||||
|
295
docs/design/template-creation-design.md
Normal file
295
docs/design/template-creation-design.md
Normal file
@ -0,0 +1,295 @@
|
||||
# Workout Template Creation Design Document
|
||||
|
||||
## Problem Statement
|
||||
Users need a guided, step-by-step interface to create strength workout templates. The system should focus on the core strength training features while maintaining an architecture that can be extended to support other workout types in the future.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements (MVP)
|
||||
- Create strength workout templates
|
||||
- Configure basic template metadata
|
||||
- Select exercises from library
|
||||
- Configure sets/reps/rest for each exercise
|
||||
- Preview and edit template before saving
|
||||
- Save templates to local storage
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Sub-500ms response time for UI interactions
|
||||
- Consistent shadcn/ui theming
|
||||
- Mobile-first responsive design
|
||||
- Offline-first data storage
|
||||
- Error state handling
|
||||
- Loading state management
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Multi-Step Flow vs Single Form
|
||||
Chosen: Multi-step wizard pattern
|
||||
|
||||
Rationale:
|
||||
- Reduces cognitive load
|
||||
- Clear progress indication
|
||||
- Easier error handling
|
||||
- Better mobile experience
|
||||
- Simpler to extend for future workout types
|
||||
|
||||
### 2. Exercise Selection Interface
|
||||
Chosen: Searchable list with quick-add functionality
|
||||
|
||||
Rationale:
|
||||
- Faster template creation
|
||||
- Reduces context switching
|
||||
- Maintains flow state
|
||||
- Supports future search/filter features
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Flow Diagram
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[FAB Click] --> B[Basic Info Sheet]
|
||||
B --> C[Exercise Selection]
|
||||
C --> D[Exercise Config]
|
||||
D --> E[Template Review]
|
||||
E --> F[Save Template]
|
||||
|
||||
C -.-> C1[Search Exercises]
|
||||
C -.-> C2[Filter Exercises]
|
||||
|
||||
D -.-> D1[Configure Sets]
|
||||
D -.-> D2[Configure Rest]
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
-- Workout Templates (Nostr 33402 compatible)
|
||||
CREATE TABLE templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'strength' CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')),
|
||||
target_muscle_groups TEXT, -- JSON array
|
||||
format_json TEXT, -- Template-wide parameters
|
||||
format_units_json TEXT, -- Template-wide unit preferences
|
||||
duration INTEGER, -- For future timed workouts (seconds)
|
||||
rounds INTEGER, -- For future circuit/EMOM workouts
|
||||
rest_between_rounds INTEGER, -- For future circuit workouts (seconds)
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
nostr_event_id TEXT, -- For future Nostr sync
|
||||
tags TEXT, -- JSON array for categorization
|
||||
FOREIGN KEY(nostr_event_id) REFERENCES nostr_events(id)
|
||||
);
|
||||
|
||||
-- Template Exercises (Nostr exercise references)
|
||||
CREATE TABLE template_exercises (
|
||||
template_id TEXT NOT NULL,
|
||||
exercise_id TEXT NOT NULL,
|
||||
order_index INTEGER NOT NULL,
|
||||
sets INTEGER NOT NULL,
|
||||
reps INTEGER NOT NULL,
|
||||
weight REAL, -- In kg, null for bodyweight
|
||||
rpe INTEGER CHECK(rpe BETWEEN 1 AND 10),
|
||||
set_type TEXT CHECK(set_type IN ('warmup', 'normal', 'drop', 'failure')),
|
||||
rest_seconds INTEGER,
|
||||
notes TEXT,
|
||||
format_json TEXT, -- Exercise-specific parameters
|
||||
format_units_json TEXT, -- Exercise-specific unit preferences
|
||||
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id)
|
||||
);
|
||||
```
|
||||
|
||||
### Core Types
|
||||
```typescript
|
||||
interface StrengthTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'strength';
|
||||
targetMuscleGroups: string[];
|
||||
exercises: TemplateExercise[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
source: 'local' | 'powr' | 'nostr';
|
||||
format?: {
|
||||
// Template-wide format settings
|
||||
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';
|
||||
};
|
||||
}
|
||||
|
||||
interface TemplateExercise {
|
||||
exerciseId: string;
|
||||
orderIndex: number;
|
||||
sets: number;
|
||||
reps: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
setType: 'warmup' | 'normal' | 'drop' | 'failure';
|
||||
restSeconds?: number;
|
||||
notes?: string;
|
||||
format?: {
|
||||
// Exercise-specific format overrides
|
||||
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';
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Component Specifications
|
||||
|
||||
#### 1. Template Creation Sheet
|
||||
```typescript
|
||||
interface TemplateCreationSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: (template: StrengthTemplate) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Basic Info Form
|
||||
```typescript
|
||||
interface BasicInfoForm {
|
||||
title: string;
|
||||
description?: string;
|
||||
targetMuscleGroups: string[];
|
||||
}
|
||||
|
||||
const TARGET_MUSCLE_GROUPS = [
|
||||
'Full Body',
|
||||
'Upper Body',
|
||||
'Lower Body',
|
||||
'Push',
|
||||
'Pull',
|
||||
'Legs'
|
||||
];
|
||||
```
|
||||
|
||||
#### 3. Exercise Configuration Form
|
||||
```typescript
|
||||
interface ExerciseConfigFormProps {
|
||||
exercise: Exercise;
|
||||
onConfigComplete: (config: TemplateExercise) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Template Preview
|
||||
```typescript
|
||||
interface TemplatePreviewProps {
|
||||
template: StrengthTemplate;
|
||||
onEdit: (exerciseId: string) => void;
|
||||
onReorder: (newOrder: string[]) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### UI States
|
||||
|
||||
#### Loading States
|
||||
- Skeleton loaders for exercise list
|
||||
- Disabled buttons during operations
|
||||
- Progress indicators for saves
|
||||
|
||||
#### Error States
|
||||
- Validation error messages
|
||||
- Database operation errors
|
||||
- Required field notifications
|
||||
|
||||
#### Success States
|
||||
- Save confirmations
|
||||
- Creation completion
|
||||
- Navigation prompts
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Template Creation
|
||||
1. Database schema updates
|
||||
- Add templates table
|
||||
- Add template_exercises table
|
||||
- Add indexes for querying
|
||||
|
||||
2. Basic template creation flow
|
||||
- Template info form
|
||||
- Exercise selection
|
||||
- Basic set/rep configuration
|
||||
- Template preview and save
|
||||
|
||||
### Phase 2: Enhanced Features
|
||||
1. Exercise reordering
|
||||
2. Rest period configuration
|
||||
3. Template duplication
|
||||
4. Template sharing
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Form validation logic
|
||||
- State management
|
||||
- Data transformations
|
||||
- Component rendering
|
||||
|
||||
### Integration Tests
|
||||
- Full template creation flow
|
||||
- Data persistence
|
||||
- Error handling
|
||||
- State management
|
||||
|
||||
### End-to-End Tests
|
||||
- Complete template creation
|
||||
- Exercise selection
|
||||
- Template modification
|
||||
- Database operations
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Planned Enhancements
|
||||
- Circuit workout support
|
||||
- EMOM workout support
|
||||
- AMRAP workout support
|
||||
- Complex progression schemes
|
||||
- Template variations
|
||||
|
||||
### Potential Features
|
||||
- Template sharing via Nostr
|
||||
- Exercise substitutions
|
||||
- Previous workout data import
|
||||
- AI-assisted template creation
|
||||
- Advanced progression schemes
|
||||
|
||||
### Technical Debt Prevention
|
||||
- Maintain Nostr compatibility
|
||||
- Keep workout type abstraction
|
||||
- Plan for unit conversions
|
||||
- Consider sync conflicts
|
||||
- Design for offline-first
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Performance
|
||||
- Template creation < 30 seconds
|
||||
- Exercise addition < 2 seconds
|
||||
- UI response < 100ms
|
||||
- Save operation < 500ms
|
||||
|
||||
### User Experience
|
||||
- Template reuse rate
|
||||
- Exercise variation
|
||||
- Completion rate
|
||||
- Error rate
|
136
lib/db/services/LibraryService.ts
Normal file
136
lib/db/services/LibraryService.ts
Normal file
@ -0,0 +1,136 @@
|
||||
// lib/db/services/LibraryService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { DbService } from '../db-service';
|
||||
import { BaseExercise, Exercise } from '@/types/exercise';
|
||||
import { StorageSource } from '@/types/shared';
|
||||
import { generateId } from '@/utils/ids';
|
||||
|
||||
export class LibraryService {
|
||||
private readonly db: DbService;
|
||||
private readonly DEBUG = __DEV__;
|
||||
|
||||
constructor(database: SQLiteDatabase) {
|
||||
this.db = new DbService(database);
|
||||
}
|
||||
|
||||
async getExercises(): Promise<Exercise[]> {
|
||||
try {
|
||||
const result = await this.db.getAllAsync<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
category: string;
|
||||
equipment: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
tags: string | null;
|
||||
created_at: number;
|
||||
source: string;
|
||||
format_json: string | null;
|
||||
format_units_json: string | null;
|
||||
}>(
|
||||
`SELECT
|
||||
e.*,
|
||||
GROUP_CONCAT(DISTINCT t.tag) as tags
|
||||
FROM exercises e
|
||||
LEFT JOIN exercise_tags t ON e.id = t.exercise_id
|
||||
GROUP BY e.id
|
||||
ORDER BY e.title`
|
||||
);
|
||||
|
||||
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,
|
||||
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'],
|
||||
availability: {
|
||||
source: [row.source as StorageSource]
|
||||
},
|
||||
format_json: row.format_json || undefined,
|
||||
format_units_json: row.format_units_json || undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error getting exercises:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addExercise(exercise: Omit<Exercise, 'id'>): Promise<string> {
|
||||
try {
|
||||
const id = generateId();
|
||||
const timestamp = Date.now(); // Use same timestamp for both created_at and updated_at initially
|
||||
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
// Insert main exercise data
|
||||
await this.db.runAsync(
|
||||
`INSERT INTO exercises (
|
||||
id, title, type, category, equipment, description,
|
||||
created_at, updated_at, source, format_json, format_units_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
exercise.title,
|
||||
exercise.type,
|
||||
exercise.category,
|
||||
exercise.equipment || null,
|
||||
exercise.description || null,
|
||||
timestamp, // created_at
|
||||
timestamp, // updated_at
|
||||
exercise.source,
|
||||
exercise.format_json || null,
|
||||
exercise.format_units_json || null
|
||||
]
|
||||
);
|
||||
|
||||
// Insert tags
|
||||
if (exercise.tags?.length) {
|
||||
for (const tag of exercise.tags) {
|
||||
await this.db.runAsync(
|
||||
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
|
||||
[id, tag]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert instructions
|
||||
if (exercise.instructions?.length) {
|
||||
for (const [index, instruction] of exercise.instructions.entries()) {
|
||||
await this.db.runAsync(
|
||||
`INSERT INTO exercise_instructions (
|
||||
exercise_id, instruction, display_order
|
||||
) VALUES (?, ?, ?)`,
|
||||
[id, instruction, index]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Error adding exercise:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteExercise(id: string): Promise<void> {
|
||||
try {
|
||||
const result = await this.db.runAsync(
|
||||
'DELETE FROM exercises WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!result.changes) {
|
||||
throw new Error('Exercise not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting exercise:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
168
lib/hooks/useExercises.tsx
Normal file
168
lib/hooks/useExercises.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
// 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 { LibraryService } from '../db/services/LibraryService';
|
||||
|
||||
// Filtering types
|
||||
export interface ExerciseFilters {
|
||||
type?: ExerciseType[];
|
||||
category?: ExerciseCategory[];
|
||||
equipment?: Equipment[];
|
||||
tags?: string[];
|
||||
source?: Exercise['source'][];
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
interface ExerciseStats {
|
||||
totalCount: number;
|
||||
byCategory: Record<ExerciseCategory, number>;
|
||||
byType: Record<ExerciseType, number>;
|
||||
byEquipment: Record<Equipment, number>;
|
||||
}
|
||||
|
||||
const initialStats: ExerciseStats = {
|
||||
totalCount: 0,
|
||||
byCategory: {} as Record<ExerciseCategory, number>,
|
||||
byType: {} as Record<ExerciseType, number>,
|
||||
byEquipment: {} as Record<Equipment, number>,
|
||||
};
|
||||
|
||||
export function useExercises() {
|
||||
const db = useSQLiteContext();
|
||||
const libraryService = React.useMemo(() => new LibraryService(db), [db]);
|
||||
|
||||
const [exercises, setExercises] = useState<Exercise[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<ExerciseFilters>({});
|
||||
const [stats, setStats] = useState<ExerciseStats>(initialStats);
|
||||
|
||||
// Load all exercises from the database
|
||||
const loadExercises = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const allExercises = await libraryService.getExercises();
|
||||
setExercises(allExercises);
|
||||
|
||||
// Calculate stats
|
||||
const newStats = allExercises.reduce((acc: ExerciseStats, exercise: Exercise) => {
|
||||
acc.totalCount++;
|
||||
acc.byCategory[exercise.category] = (acc.byCategory[exercise.category] || 0) + 1;
|
||||
acc.byType[exercise.type] = (acc.byType[exercise.type] || 0) + 1;
|
||||
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>,
|
||||
});
|
||||
|
||||
setStats(newStats);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to load exercises'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [libraryService]);
|
||||
|
||||
// Filter exercises based on current filters
|
||||
const getFilteredExercises = useCallback(() => {
|
||||
return exercises.filter(exercise => {
|
||||
// Type filter
|
||||
if (filters.type?.length && !filters.type.includes(exercise.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (filters.category?.length && !filters.category.includes(exercise.category)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Equipment filter
|
||||
if (filters.equipment?.length && exercise.equipment && !filters.equipment.includes(exercise.equipment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (filters.tags?.length && !filters.tags.some(tag => exercise.tags.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Source filter
|
||||
if (filters.source?.length && !filters.source.includes(exercise.source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search query
|
||||
if (filters.searchQuery) {
|
||||
const query = filters.searchQuery.toLowerCase();
|
||||
return (
|
||||
exercise.title.toLowerCase().includes(query) ||
|
||||
exercise.description?.toLowerCase().includes(query) ||
|
||||
exercise.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [exercises, filters]);
|
||||
|
||||
// Create a new exercise
|
||||
const createExercise = useCallback(async (exercise: Omit<Exercise, 'id'>) => {
|
||||
try {
|
||||
const id = await libraryService.addExercise(exercise);
|
||||
await loadExercises(); // Reload all exercises to update stats
|
||||
return id;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to create exercise'));
|
||||
throw err;
|
||||
}
|
||||
}, [libraryService, loadExercises]);
|
||||
|
||||
// Delete an exercise
|
||||
const deleteExercise = useCallback(async (id: string) => {
|
||||
try {
|
||||
await libraryService.deleteExercise(id);
|
||||
await loadExercises(); // Reload to update stats
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to delete exercise'));
|
||||
throw err;
|
||||
}
|
||||
}, [libraryService, loadExercises]);
|
||||
|
||||
// Update filters
|
||||
const updateFilters = useCallback((newFilters: Partial<ExerciseFilters>) => {
|
||||
setFilters(current => ({
|
||||
...current,
|
||||
...newFilters
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({});
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadExercises();
|
||||
}, [loadExercises]);
|
||||
|
||||
return {
|
||||
exercises: getFilteredExercises(),
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
filters,
|
||||
updateFilters,
|
||||
clearFilters,
|
||||
createExercise,
|
||||
deleteExercise,
|
||||
refreshExercises: loadExercises
|
||||
};
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
// types/exercise.ts - handles everything about individual exercises
|
||||
/* import { NostrEventKind } from './events';
|
||||
*/
|
||||
import { SyncableContent } from './shared';
|
||||
import { SyncableContent, StorageSource } from './shared';
|
||||
|
||||
// Exercise classification types
|
||||
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
|
||||
|
Loading…
x
Reference in New Issue
Block a user