mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-03 15:52:06 +00:00
workout screen WIP
This commit is contained in:
parent
05d3c02523
commit
17cb416777
@ -1,29 +1,223 @@
|
||||
// app/(tabs)/index.tsx (Workout tab)
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import Header from '@/components/Header';
|
||||
import { Plus } from 'lucide-react-native';
|
||||
import { Button } from '@/components/ui/button';
|
||||
// app/(tabs)/index.tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ScrollView, View } from 'react-native'
|
||||
import { router } from 'expo-router'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { TabScreen } from '@/components/layout/TabScreen'
|
||||
import Header from '@/components/Header'
|
||||
import HomeWorkout from '@/components/workout/HomeWorkout'
|
||||
import FavoriteTemplate from '@/components/workout/FavoriteTemplate'
|
||||
import { useWorkoutStore } from '@/stores/workoutStore'
|
||||
import type { WorkoutTemplate } from '@/types/templates'
|
||||
import { Text } from '@/components/ui/text'
|
||||
import { getRandomWorkoutTitle } from '@/utils/workoutTitles'
|
||||
|
||||
interface FavoriteTemplateData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
exercises: Array<{
|
||||
title: string;
|
||||
sets: number;
|
||||
reps: number;
|
||||
}>;
|
||||
exerciseCount: number;
|
||||
duration?: number;
|
||||
isFavorited: boolean;
|
||||
lastUsed?: number;
|
||||
source: 'local' | 'powr' | 'nostr';
|
||||
}
|
||||
|
||||
export default function WorkoutScreen() {
|
||||
const { startWorkout } = useWorkoutStore.getState();
|
||||
const [showActiveWorkoutModal, setShowActiveWorkoutModal] = useState(false)
|
||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null)
|
||||
const [favoriteWorkouts, setFavoriteWorkouts] = useState<FavoriteTemplateData[]>([])
|
||||
const [isLoadingFavorites, setIsLoadingFavorites] = useState(true)
|
||||
|
||||
const {
|
||||
getFavorites,
|
||||
startWorkoutFromTemplate,
|
||||
removeFavorite,
|
||||
checkFavoriteStatus,
|
||||
isActive,
|
||||
endWorkout
|
||||
} = useWorkoutStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites()
|
||||
}, [])
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setIsLoadingFavorites(true)
|
||||
try {
|
||||
const favorites = await getFavorites()
|
||||
|
||||
const workoutTemplates = favorites
|
||||
.filter(f => f.content && f.content.id && checkFavoriteStatus(f.content.id))
|
||||
.map(f => {
|
||||
const content = f.content;
|
||||
return {
|
||||
id: content.id,
|
||||
title: content.title || 'Untitled Workout',
|
||||
description: content.description || '',
|
||||
exercises: content.exercises.map(ex => ({
|
||||
title: ex.exercise.title,
|
||||
sets: ex.targetSets,
|
||||
reps: ex.targetReps
|
||||
})),
|
||||
exerciseCount: content.exercises.length,
|
||||
duration: content.metadata?.averageDuration,
|
||||
isFavorited: true,
|
||||
lastUsed: content.metadata?.lastUsed,
|
||||
source: content.availability.source.includes('nostr')
|
||||
? 'nostr'
|
||||
: content.availability.source.includes('powr')
|
||||
? 'powr'
|
||||
: 'local'
|
||||
} as FavoriteTemplateData;
|
||||
});
|
||||
|
||||
setFavoriteWorkouts(workoutTemplates)
|
||||
} catch (error) {
|
||||
console.error('Error loading favorites:', error)
|
||||
} finally {
|
||||
setIsLoadingFavorites(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartWorkout = async (templateId: string) => {
|
||||
if (isActive) {
|
||||
setPendingTemplateId(templateId)
|
||||
setShowActiveWorkoutModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await startWorkoutFromTemplate(templateId)
|
||||
router.push('/(workout)/create')
|
||||
} catch (error) {
|
||||
console.error('Error starting workout:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartNew = async () => {
|
||||
if (!pendingTemplateId) return
|
||||
|
||||
const templateToStart = pendingTemplateId
|
||||
setShowActiveWorkoutModal(false)
|
||||
setPendingTemplateId(null)
|
||||
|
||||
await endWorkout()
|
||||
await startWorkoutFromTemplate(templateToStart)
|
||||
router.push('/(workout)/create')
|
||||
}
|
||||
|
||||
const handleContinueExisting = () => {
|
||||
setShowActiveWorkoutModal(false)
|
||||
setPendingTemplateId(null)
|
||||
router.push('/(workout)/create')
|
||||
}
|
||||
|
||||
const handleFavoritePress = async (templateId: string) => {
|
||||
try {
|
||||
await removeFavorite(templateId)
|
||||
await loadFavorites()
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickStart = () => {
|
||||
// Initialize a new workout with a random funny title
|
||||
startWorkout({
|
||||
title: getRandomWorkoutTitle(),
|
||||
type: 'strength',
|
||||
exercises: []
|
||||
});
|
||||
|
||||
router.push('/(workout)/create');
|
||||
};
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header
|
||||
title="Workout"
|
||||
rightElement={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => console.log('New workout')}
|
||||
>
|
||||
<Plus className="text-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Workout Screen</Text>
|
||||
</View>
|
||||
<Header title="Workout" />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1 px-4"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<HomeWorkout
|
||||
onStartBlank={handleQuickStart} // Use the new handler here
|
||||
onSelectTemplate={() => router.push('/(workout)/template-select')}
|
||||
/>
|
||||
|
||||
{/* Favorites section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Favorites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingFavorites ? (
|
||||
<View className="p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Loading favorites...
|
||||
</Text>
|
||||
</View>
|
||||
) : favoriteWorkouts.length === 0 ? (
|
||||
<View className="p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Star workouts from your library to see them here
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="gap-4">
|
||||
{favoriteWorkouts.map(template => (
|
||||
<FavoriteTemplate
|
||||
key={template.id}
|
||||
title={template.title}
|
||||
exercises={template.exercises}
|
||||
duration={template.duration}
|
||||
exerciseCount={template.exerciseCount}
|
||||
isFavorited={true}
|
||||
onPress={() => handleStartWorkout(template.id)}
|
||||
onFavoritePress={() => handleFavoritePress(template.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
<AlertDialog open={showActiveWorkoutModal}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Active Workout</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an active workout in progress. Would you like to finish it first?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<View className="flex-row justify-end gap-3">
|
||||
<AlertDialogCancel onPress={() => setShowActiveWorkoutModal(false)}>
|
||||
<Text>Start New</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleContinueExisting}>
|
||||
<Text>Continue Workout</Text>
|
||||
</AlertDialogAction>
|
||||
</View>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TabScreen>
|
||||
);
|
||||
)
|
||||
}
|
@ -1,15 +1,50 @@
|
||||
// app/(workout)/_layout.tsx
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react'
|
||||
import { Stack } from 'expo-router'
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
export default function WorkoutLayout() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.background
|
||||
},
|
||||
presentation: 'modal', // Make all screens in this group modal by default
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true, // Allow gesture to dismiss
|
||||
gestureDirection: 'vertical', // Swipe down to dismiss
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="new-exercise"
|
||||
name="create"
|
||||
options={{
|
||||
title: 'New Exercise'
|
||||
// Modal presentation for create screen
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'vertical',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="template-select"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="add-exercises"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
)
|
||||
}
|
133
app/(workout)/add-exercises.tsx
Normal file
133
app/(workout)/add-exercises.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
// app/(workout)/add-exercises.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { LibraryService } from '@/lib/db/services/LibraryService';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { BaseExercise } from '@/types/exercise';
|
||||
|
||||
export default function AddExercisesScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const [libraryService] = useState(() => new LibraryService(db));
|
||||
const [exercises, setExercises] = useState<BaseExercise[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { addExercises } = useWorkoutStore();
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
loadExercises();
|
||||
}, [libraryService]);
|
||||
|
||||
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 handleAddSelected = () => {
|
||||
const selectedExercises = exercises.filter(e => selectedIds.includes(e.id));
|
||||
addExercises(selectedExercises);
|
||||
|
||||
// Just go back - this will dismiss the modal and return to create screen
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<View className="flex-1 pt-12">
|
||||
{/* Close button in the top right */}
|
||||
<View className="absolute top-12 right-4 z-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<X className="text-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="px-4 pt-4 pb-2">
|
||||
<Text className="text-xl font-semibold mb-4">Add Exercises</Text>
|
||||
<Input
|
||||
placeholder="Search exercises..."
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
className="text-foreground"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1">
|
||||
<View className="px-4">
|
||||
<Text className="mb-4 text-muted-foreground">
|
||||
Selected: {selectedIds.length} exercises
|
||||
</Text>
|
||||
|
||||
<View className="gap-3">
|
||||
{filteredExercises.map(exercise => (
|
||||
<Card key={exercise.id}>
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-semibold">{exercise.title}</Text>
|
||||
<Text className="text-sm text-muted-foreground mt-1">{exercise.category}</Text>
|
||||
{exercise.equipment && (
|
||||
<Text className="text-xs text-muted-foreground mt-0.5">{exercise.equipment}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
|
||||
onPress={() => handleToggleSelection(exercise.id)}
|
||||
size="sm"
|
||||
>
|
||||
<Text className={selectedIds.includes(exercise.id) ? 'text-primary-foreground' : ''}>
|
||||
{selectedIds.includes(exercise.id) ? 'Selected' : 'Add'}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="p-4 border-t border-border">
|
||||
<Button
|
||||
className="w-full"
|
||||
onPress={handleAddSelected}
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
<Text className="text-primary-foreground">
|
||||
Add {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
364
app/(workout)/create.tsx
Normal file
364
app/(workout)/create.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
// app/(workout)/create.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, StyleSheet } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { Plus, Pause, Play, MoreHorizontal, CheckCircle2 } from 'lucide-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import EditableText from '@/components/EditableText';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import { WorkoutSet } from '@/types/workout';
|
||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||
|
||||
export default function CreateWorkoutScreen() {
|
||||
const {
|
||||
status,
|
||||
activeWorkout,
|
||||
elapsedTime,
|
||||
restTimer,
|
||||
clearAutoSave
|
||||
} = useWorkoutStore();
|
||||
|
||||
const {
|
||||
pauseWorkout,
|
||||
resumeWorkout,
|
||||
completeWorkout,
|
||||
updateWorkoutTitle,
|
||||
updateSet
|
||||
} = useWorkoutStore.getState();
|
||||
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Format time as mm:ss in monospace font
|
||||
const formatTime = (ms: number) => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Timer update effect
|
||||
useEffect(() => {
|
||||
let timerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (status === 'active') {
|
||||
timerInterval = setInterval(() => {
|
||||
useWorkoutStore.getState().tick(1000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
// Handler for adding a new set to an exercise
|
||||
const handleAddSet = (exerciseIndex: number) => {
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const exercise = activeWorkout.exercises[exerciseIndex];
|
||||
const lastSet = exercise.sets[exercise.sets.length - 1];
|
||||
|
||||
const newSet: WorkoutSet = {
|
||||
id: generateId('local'),
|
||||
weight: lastSet?.weight || 0,
|
||||
reps: lastSet?.reps || 0,
|
||||
type: 'normal',
|
||||
isCompleted: false
|
||||
};
|
||||
|
||||
updateSet(exerciseIndex, exercise.sets.length, newSet);
|
||||
};
|
||||
|
||||
// Handler for completing a set
|
||||
const handleCompleteSet = (exerciseIndex: number, setIndex: number) => {
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const exercise = activeWorkout.exercises[exerciseIndex];
|
||||
const set = exercise.sets[setIndex];
|
||||
|
||||
updateSet(exerciseIndex, setIndex, {
|
||||
...set,
|
||||
isCompleted: !set.isCompleted
|
||||
});
|
||||
};
|
||||
|
||||
// Show empty state when no workout is active
|
||||
if (!activeWorkout) {
|
||||
return (
|
||||
<TabScreen>
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<Text className="text-xl font-semibold text-foreground text-center mb-4">
|
||||
No active workout
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text className="text-primary-foreground">Go Back</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
{/* Swipe indicator */}
|
||||
<View className="w-full items-center py-2">
|
||||
<View className="w-10 h-1 rounded-full bg-muted-foreground/30" />
|
||||
</View>
|
||||
|
||||
{/* Header with Title and Finish Button */}
|
||||
<View className="px-4 py-3 border-b border-border">
|
||||
{/* Finish button in top right */}
|
||||
<View className="flex-row justify-end mb-2">
|
||||
<Button
|
||||
variant="purple"
|
||||
className="px-4"
|
||||
onPress={() => completeWorkout()}
|
||||
>
|
||||
<Text className="text-white font-medium">Finish</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Full-width workout title */}
|
||||
<View className="mb-3">
|
||||
<EditableText
|
||||
value={activeWorkout.title}
|
||||
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
|
||||
placeholder="Workout Title"
|
||||
textStyle={{
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Timer Display */}
|
||||
<View className="flex-row items-center">
|
||||
<Text style={styles.timerText} className={cn(
|
||||
"text-2xl font-mono",
|
||||
status === 'paused' ? "text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{formatTime(elapsedTime)}
|
||||
</Text>
|
||||
|
||||
{status === 'active' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onPress={pauseWorkout}
|
||||
>
|
||||
<Pause className="text-foreground" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onPress={resumeWorkout}
|
||||
>
|
||||
<Play className="text-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Scrollable Exercises List */}
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: insets.bottom + 20,
|
||||
paddingTop: 16
|
||||
}}
|
||||
>
|
||||
{activeWorkout.exercises.length > 0 ? (
|
||||
activeWorkout.exercises.map((exercise, exerciseIndex) => (
|
||||
<Card
|
||||
key={exercise.id}
|
||||
className="mb-6 overflow-hidden border border-border bg-card"
|
||||
>
|
||||
{/* Exercise Header */}
|
||||
<View className="flex-row justify-between items-center px-4 py-3 border-b border-border">
|
||||
<Text className="text-lg font-semibold text-purple">
|
||||
{exercise.title}
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => {
|
||||
// Open exercise options menu
|
||||
console.log('Open exercise options');
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="text-muted-foreground" size={20} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Sets Info */}
|
||||
<View className="px-4 py-2">
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{exercise.sets.filter(s => s.isCompleted).length} sets completed
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Set Headers */}
|
||||
<View className="flex-row px-4 py-2 border-t border-border bg-muted/30">
|
||||
<Text className="w-16 text-sm font-medium text-muted-foreground">SET</Text>
|
||||
<Text className="w-20 text-sm font-medium text-muted-foreground">PREV</Text>
|
||||
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text>
|
||||
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text>
|
||||
<View style={{ width: 44 }} />
|
||||
</View>
|
||||
|
||||
{/* Exercise Sets */}
|
||||
<CardContent className="p-0">
|
||||
{exercise.sets.map((set, setIndex) => {
|
||||
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null;
|
||||
return (
|
||||
<View
|
||||
key={set.id}
|
||||
className={cn(
|
||||
"flex-row items-center px-4 py-3 border-t border-border",
|
||||
set.isCompleted && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* Set Number */}
|
||||
<Text className="w-16 text-base font-medium text-foreground">
|
||||
{setIndex + 1}
|
||||
</Text>
|
||||
|
||||
{/* Previous Set */}
|
||||
<Text className="w-20 text-sm text-muted-foreground">
|
||||
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
|
||||
</Text>
|
||||
|
||||
{/* Weight Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<View className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 justify-center",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}>
|
||||
<Text className="text-center text-foreground">
|
||||
{set.weight}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Reps Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<View className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 justify-center",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}>
|
||||
<Text className="text-center text-foreground">
|
||||
{set.reps}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Complete Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-11 h-11"
|
||||
onPress={() => handleCompleteSet(exerciseIndex, setIndex)}
|
||||
>
|
||||
<CheckCircle2
|
||||
className={set.isCompleted ? "text-purple" : "text-muted-foreground"}
|
||||
fill={set.isCompleted ? "currentColor" : "none"}
|
||||
size={22}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
|
||||
{/* Add Set Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-row justify-center items-center py-3 border-t border-border"
|
||||
onPress={() => handleAddSet(exerciseIndex)}
|
||||
>
|
||||
<Plus size={18} className="text-foreground mr-2" />
|
||||
<Text className="text-foreground">Add Set</Text>
|
||||
</Button>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="text-lg text-muted-foreground text-center">
|
||||
No exercises added. Add exercises to start your workout.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Add Exercise FAB */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: insets.bottom + 16
|
||||
}}>
|
||||
<FloatingActionButton
|
||||
icon={Plus}
|
||||
onPress={() => router.push('/(workout)/add-exercises')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Cancel Workout Dialog */}
|
||||
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Workout</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this workout? All progress will be lost.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<View className="flex-row justify-end gap-3">
|
||||
<AlertDialogAction
|
||||
onPress={() => setShowCancelDialog(false)}
|
||||
>
|
||||
<Text>Continue Workout</Text>
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction
|
||||
onPress={async () => {
|
||||
await clearAutoSave();
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<Text>Cancel Workout</Text>
|
||||
</AlertDialogAction>
|
||||
</View>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
timerText: {
|
||||
fontVariant: ['tabular-nums']
|
||||
}
|
||||
});
|
109
app/(workout)/template-select.tsx
Normal file
109
app/(workout)/template-select.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
// app/(workout)/template-select.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import type { TemplateType } from '@/types/templates';
|
||||
|
||||
// Temporary mock data - replace with actual template data
|
||||
const MOCK_TEMPLATES = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Full Body Strength',
|
||||
type: 'strength',
|
||||
category: 'Strength',
|
||||
exercises: [
|
||||
{ title: 'Squat', sets: 3, reps: 8 },
|
||||
{ title: 'Bench Press', sets: 3, reps: 8 },
|
||||
{ title: 'Deadlift', sets: 3, reps: 8 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Upper Body Push',
|
||||
type: 'strength',
|
||||
category: 'Push/Pull/Legs',
|
||||
exercises: [
|
||||
{ title: 'Bench Press', sets: 4, reps: 8 },
|
||||
{ title: 'Shoulder Press', sets: 3, reps: 10 },
|
||||
{ title: 'Tricep Extensions', sets: 3, reps: 12 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function TemplateSelectScreen() {
|
||||
const router = useRouter();
|
||||
const startWorkout = useWorkoutStore.use.startWorkout();
|
||||
|
||||
const handleSelectTemplate = (template: typeof MOCK_TEMPLATES[0]) => {
|
||||
startWorkout({
|
||||
title: template.title,
|
||||
type: template.type as TemplateType, // Cast to proper type
|
||||
exercises: template.exercises.map(ex => ({
|
||||
id: generateId('local'),
|
||||
title: ex.title,
|
||||
type: 'strength',
|
||||
category: 'Push',
|
||||
equipment: 'barbell',
|
||||
tags: [],
|
||||
format: {
|
||||
weight: true,
|
||||
reps: true,
|
||||
rpe: true,
|
||||
set_type: true
|
||||
},
|
||||
format_units: {
|
||||
weight: 'kg',
|
||||
reps: 'count',
|
||||
rpe: '0-10',
|
||||
set_type: 'warmup|normal|drop|failure'
|
||||
},
|
||||
sets: Array(ex.sets).fill({
|
||||
id: generateId('local'),
|
||||
type: 'normal',
|
||||
weight: 0,
|
||||
reps: ex.reps,
|
||||
isCompleted: false
|
||||
}),
|
||||
isCompleted: false,
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
created_at: Date.now()
|
||||
}))
|
||||
});
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<Text className="text-lg font-semibold mb-4">Recent Templates</Text>
|
||||
|
||||
{MOCK_TEMPLATES.map(template => (
|
||||
<Card key={template.id} className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold">{template.title}</Text>
|
||||
<Text className="text-sm text-muted-foreground mb-2">{template.category}</Text>
|
||||
|
||||
{/* Exercise Preview */}
|
||||
<View className="mb-4">
|
||||
{template.exercises.map((exercise, index) => (
|
||||
<Text key={index} className="text-sm text-muted-foreground">
|
||||
{exercise.title} - {exercise.sets}×{exercise.reps}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button onPress={() => handleSelectTemplate(template)}>
|
||||
<Text>Start Workout</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
116
components/EditableText.tsx
Normal file
116
components/EditableText.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
// components/EditableText.tsx
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
TextStyle
|
||||
} from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Check, Edit2 } from 'lucide-react-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useColorScheme } from '@/lib/useColorScheme';
|
||||
|
||||
interface EditableTextProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
inputStyle?: StyleProp<TextStyle>;
|
||||
placeholder?: string;
|
||||
placeholderTextColor?: string;
|
||||
}
|
||||
|
||||
export default function EditableText({
|
||||
value,
|
||||
onChangeText,
|
||||
style,
|
||||
textStyle,
|
||||
inputStyle,
|
||||
placeholder,
|
||||
placeholderTextColor
|
||||
}: EditableTextProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState(value);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const { isDarkColorScheme } = useColorScheme();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (tempValue.trim()) {
|
||||
onChangeText(tempValue);
|
||||
} else {
|
||||
setTempValue(value);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{isEditing ? (
|
||||
<View className="flex-row items-center bg-secondary rounded-lg px-3 py-2">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={tempValue}
|
||||
onChangeText={setTempValue}
|
||||
onBlur={handleSubmit}
|
||||
onSubmitEditing={handleSubmit}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: isDarkColorScheme ? '#FFFFFF' : '#000000' },
|
||||
textStyle,
|
||||
inputStyle
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={placeholderTextColor || isDarkColorScheme ? '#9CA3AF' : '#6B7280'}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit}
|
||||
className="p-2 ml-2"
|
||||
>
|
||||
<Check className="text-primary" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsEditing(true)}
|
||||
className="flex-col p-2 rounded-lg"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-lg font-semibold text-foreground",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
style={textStyle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<View className="mt-1">
|
||||
<View className="flex-row items-center self-start px-1.5 py-1 rounded bg-muted/20">
|
||||
<Edit2 size={14} className="text-muted-foreground" />
|
||||
<Text className="text-xs text-muted-foreground ml-1">Edit</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
padding: 0,
|
||||
},
|
||||
});
|
149
components/workout/ExerciseTracker.tsx
Normal file
149
components/workout/ExerciseTracker.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
// components/workout/ExerciseTracker.tsx
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight, Plus, TimerReset, Dumbbell } from 'lucide-react-native';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import SetInput from '@/components/workout/SetInput';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import type { WorkoutSet } from '@/types/workout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
export default function ExerciseTracker() {
|
||||
const router = useRouter();
|
||||
const activeWorkout = useWorkoutStore.use.activeWorkout();
|
||||
const currentExerciseIndex = useWorkoutStore.use.currentExerciseIndex();
|
||||
const { nextExercise, previousExercise, startRest, updateSet } = useWorkoutStore.getState();
|
||||
|
||||
// Handle adding a new set - define callback before any conditional returns
|
||||
const handleAddSet = useCallback(() => {
|
||||
if (!activeWorkout?.exercises[currentExerciseIndex]) return;
|
||||
|
||||
const currentExercise = activeWorkout.exercises[currentExerciseIndex];
|
||||
const lastSet = currentExercise.sets[currentExercise.sets.length - 1];
|
||||
const newSet: WorkoutSet = {
|
||||
id: generateId('local'),
|
||||
weight: lastSet?.weight || 0,
|
||||
reps: lastSet?.reps || 0,
|
||||
type: 'normal',
|
||||
isCompleted: false
|
||||
};
|
||||
|
||||
updateSet(currentExerciseIndex, currentExercise.sets.length, newSet);
|
||||
}, [activeWorkout, currentExerciseIndex, updateSet]);
|
||||
|
||||
// Empty state check after hooks
|
||||
if (!activeWorkout?.exercises || activeWorkout.exercises.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<View className="items-center opacity-60">
|
||||
<View className="w-24 h-24 rounded-full bg-primary/10 items-center justify-center mb-6">
|
||||
<Dumbbell size={48} className="text-primary" />
|
||||
</View>
|
||||
<Text className="text-xl font-semibold mb-2">No exercises added</Text>
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Tap the + button to add exercises to your workout
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare derivative state after hooks
|
||||
const currentExercise = activeWorkout.exercises[currentExerciseIndex];
|
||||
const hasNextExercise = currentExerciseIndex < activeWorkout.exercises.length - 1;
|
||||
const hasPreviousExercise = currentExerciseIndex > 0;
|
||||
|
||||
if (!currentExercise) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{/* Exercise Navigation */}
|
||||
<View className="flex-row items-center justify-between px-4 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => previousExercise()}
|
||||
disabled={!hasPreviousExercise}
|
||||
>
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
|
||||
<View className="flex-1 px-4">
|
||||
<Text className="text-lg font-semibold text-center">
|
||||
{currentExercise.title}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground text-center">
|
||||
{currentExercise.equipment} • {currentExercise.category}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => nextExercise()}
|
||||
disabled={!hasNextExercise}
|
||||
>
|
||||
<ChevronRight className="text-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Sets List */}
|
||||
<ScrollView className="flex-1 px-4 py-2">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Header Row */}
|
||||
<View className={cn(
|
||||
"flex-row items-center px-4 py-2 border-b border-border",
|
||||
"bg-muted/50"
|
||||
)}>
|
||||
<Text className="w-8 text-sm font-medium text-muted-foreground">Set</Text>
|
||||
<Text className="w-20 text-sm text-center text-muted-foreground">Prev</Text>
|
||||
<Text className="flex-1 text-sm text-center text-muted-foreground">kg</Text>
|
||||
<Text className="flex-1 text-sm text-center text-muted-foreground">Reps</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
{/* Sets */}
|
||||
{currentExercise.sets.map((set: WorkoutSet, index: number) => (
|
||||
<SetInput
|
||||
key={set.id}
|
||||
exerciseIndex={currentExerciseIndex}
|
||||
setIndex={index}
|
||||
setNumber={index + 1}
|
||||
weight={set.weight}
|
||||
reps={set.reps}
|
||||
isCompleted={set.isCompleted}
|
||||
previousSet={index > 0 ? currentExercise.sets[index - 1] : undefined}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<View className="flex-row justify-center gap-2 p-4 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onPress={() => startRest(90)}
|
||||
>
|
||||
<TimerReset className="mr-2 text-foreground" />
|
||||
<Text>Rest Timer</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onPress={handleAddSet}
|
||||
>
|
||||
<Plus className="mr-2 text-foreground" />
|
||||
<Text>Add Set</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
91
components/workout/FavoriteTemplate.tsx
Normal file
91
components/workout/FavoriteTemplate.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
// components/workout/FavoriteTemplate.tsx
|
||||
import React 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 { Star, Clock, Dumbbell } from 'lucide-react-native';
|
||||
import type { GestureResponderEvent } from 'react-native';
|
||||
|
||||
interface FavoriteTemplateProps {
|
||||
title: string;
|
||||
exercises: Array<{
|
||||
title: string;
|
||||
sets: number;
|
||||
reps: number;
|
||||
}>;
|
||||
duration?: number;
|
||||
exerciseCount: number;
|
||||
isFavorited?: boolean;
|
||||
onPress?: () => void;
|
||||
onFavoritePress?: () => void;
|
||||
}
|
||||
|
||||
export default function FavoriteTemplate({
|
||||
title,
|
||||
exercises,
|
||||
duration,
|
||||
exerciseCount,
|
||||
isFavorited = false,
|
||||
onPress,
|
||||
onFavoritePress
|
||||
}: FavoriteTemplateProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-semibold text-card-foreground mb-1">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm text-muted-foreground mb-2">
|
||||
{exercises.slice(0, 3).map(ex =>
|
||||
`${ex.title} (${ex.sets}×${ex.reps})`
|
||||
).join(', ')}
|
||||
{exercises.length > 3 && '...'}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-4">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Dumbbell size={16} className="text-muted-foreground" />
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{exerciseCount} exercises
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{duration && (
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Clock size={16} className="text-muted-foreground" />
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{duration} min
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={(e: GestureResponderEvent) => {
|
||||
e.stopPropagation();
|
||||
onFavoritePress?.();
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
size={20}
|
||||
className={isFavorited ? "text-primary" : "text-muted-foreground"}
|
||||
fill={isFavorited ? "currentColor" : "none"}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
41
components/workout/HomeWorkout.tsx
Normal file
41
components/workout/HomeWorkout.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
// components/workout/HomeWorkout.tsx
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play, Plus } from 'lucide-react-native'
|
||||
import { Text } from '@/components/ui/text' // Import Text from our UI components
|
||||
|
||||
interface HomeWorkoutProps {
|
||||
onStartBlank?: () => void;
|
||||
onSelectTemplate?: () => void;
|
||||
}
|
||||
|
||||
export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWorkoutProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Start a Workout</CardTitle>
|
||||
<CardDescription>Begin a new workout or choose from your templates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-col gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full items-center justify-center gap-2"
|
||||
onPress={onStartBlank}
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
<Text className="text-primary-foreground">Quick Start</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full items-center justify-center gap-2"
|
||||
onPress={onSelectTemplate}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<Text>Use Template</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
113
components/workout/RestTimer.tsx
Normal file
113
components/workout/RestTimer.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
// components/workout/RestTimer.tsx
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Square } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const HAPTIC_THRESHOLDS = [30, 10, 5]; // Seconds remaining for haptic feedback
|
||||
|
||||
export default function RestTimer() {
|
||||
// Use selectors for reactive state
|
||||
const restTimer = useWorkoutStore.use.restTimer();
|
||||
const activeWorkout = useWorkoutStore.use.activeWorkout();
|
||||
const currentExerciseIndex = useWorkoutStore.use.currentExerciseIndex();
|
||||
|
||||
// Get actions from store
|
||||
const { stopRest, startRest, tick } = useWorkoutStore.getState();
|
||||
|
||||
const handleAddTime = useCallback(() => {
|
||||
if (!restTimer.isActive) return;
|
||||
startRest(restTimer.duration + 30); // Add 30 seconds
|
||||
}, [restTimer.isActive, restTimer.duration]);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (restTimer.isActive && restTimer.remaining > 0) {
|
||||
interval = setInterval(() => {
|
||||
// Update the remaining time every second
|
||||
tick(1);
|
||||
|
||||
// Haptic feedback at thresholds
|
||||
if (HAPTIC_THRESHOLDS.includes(restTimer.remaining)) {
|
||||
Haptics.notificationAsync(
|
||||
restTimer.remaining <= 5
|
||||
? Haptics.NotificationFeedbackType.Warning
|
||||
: Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-stop timer when it reaches 0
|
||||
if (restTimer.remaining <= 0) {
|
||||
stopRest();
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [restTimer.isActive, restTimer.remaining]);
|
||||
|
||||
// Get the next exercise if any
|
||||
const nextExercise = activeWorkout && currentExerciseIndex < activeWorkout.exercises.length - 1
|
||||
? activeWorkout.exercises[currentExerciseIndex + 1]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-background/80">
|
||||
{/* Timer Display */}
|
||||
<View className="items-center mb-8">
|
||||
<Text className="text-4xl font-bold text-foreground mb-2">
|
||||
Rest Timer
|
||||
</Text>
|
||||
<Text className="text-6xl font-bold text-primary">
|
||||
{formatTime(restTimer.remaining)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Controls */}
|
||||
<View className="flex-row gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onPress={stopRest}
|
||||
>
|
||||
<Square className="mr-2 text-foreground" />
|
||||
<Text>Skip</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onPress={handleAddTime}
|
||||
>
|
||||
<Plus className="mr-2 text-foreground" />
|
||||
<Text>Add 30s</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Next Exercise Preview */}
|
||||
{nextExercise && (
|
||||
<View className="mt-8 items-center">
|
||||
<Text className="text-sm text-muted-foreground mb-1">
|
||||
Next Exercise
|
||||
</Text>
|
||||
<Text className="text-lg font-semibold text-foreground">
|
||||
{nextExercise.title}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
164
components/workout/SetInput.tsx
Normal file
164
components/workout/SetInput.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
// components/workout/SetInput.tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import type { WorkoutSet } from '@/types/workout';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
interface SetInputProps {
|
||||
exerciseIndex: number;
|
||||
setIndex: number;
|
||||
setNumber: number;
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
isCompleted?: boolean;
|
||||
previousSet?: WorkoutSet;
|
||||
}
|
||||
|
||||
export default function SetInput({
|
||||
exerciseIndex,
|
||||
setIndex,
|
||||
setNumber,
|
||||
weight = 0,
|
||||
reps = 0,
|
||||
isCompleted = false,
|
||||
previousSet
|
||||
}: SetInputProps) {
|
||||
// Local state for controlled inputs
|
||||
const [weightValue, setWeightValue] = useState(weight.toString());
|
||||
const [repsValue, setRepsValue] = useState(reps.toString());
|
||||
|
||||
// Get actions from store
|
||||
const { updateSet, completeSet } = useWorkoutStore.getState();
|
||||
|
||||
// Debounced update functions to prevent too many state updates
|
||||
const debouncedUpdateWeight = useCallback(
|
||||
debounce((value: number) => {
|
||||
updateSet(exerciseIndex, setIndex, { weight: value });
|
||||
}, 500),
|
||||
[exerciseIndex, setIndex]
|
||||
);
|
||||
|
||||
const debouncedUpdateReps = useCallback(
|
||||
debounce((value: number) => {
|
||||
updateSet(exerciseIndex, setIndex, { reps: value });
|
||||
}, 500),
|
||||
[exerciseIndex, setIndex]
|
||||
);
|
||||
|
||||
const handleWeightChange = (value: string) => {
|
||||
if (value === '' || /^\d*\.?\d*$/.test(value)) {
|
||||
setWeightValue(value);
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
debouncedUpdateWeight(numValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepsChange = (value: string) => {
|
||||
if (value === '' || /^\d*$/.test(value)) {
|
||||
setRepsValue(value);
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
debouncedUpdateReps(numValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteSet = useCallback(() => {
|
||||
completeSet(exerciseIndex, setIndex);
|
||||
}, [exerciseIndex, setIndex]);
|
||||
|
||||
const handleCopyPreviousWeight = useCallback(() => {
|
||||
if (previousSet?.weight) {
|
||||
handleWeightChange(previousSet.weight.toString());
|
||||
}
|
||||
}, [previousSet]);
|
||||
|
||||
const handleCopyPreviousReps = useCallback(() => {
|
||||
if (previousSet?.reps) {
|
||||
handleRepsChange(previousSet.reps.toString());
|
||||
}
|
||||
}, [previousSet]);
|
||||
|
||||
return (
|
||||
<View className={cn(
|
||||
"flex-row items-center px-4 py-2 border-b border-border",
|
||||
isCompleted && "bg-primary/5"
|
||||
)}>
|
||||
{/* Set Number */}
|
||||
<Text className="w-8 text-sm font-medium text-muted-foreground">
|
||||
{setNumber}
|
||||
</Text>
|
||||
|
||||
{/* Previous Set */}
|
||||
<Text className="w-20 text-sm text-center text-muted-foreground">
|
||||
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
|
||||
</Text>
|
||||
|
||||
{/* Weight Input */}
|
||||
<TouchableOpacity
|
||||
className="flex-1 mx-1"
|
||||
activeOpacity={0.7}
|
||||
onLongPress={handleCopyPreviousWeight}
|
||||
>
|
||||
<TextInput
|
||||
className={cn(
|
||||
"h-10 px-3 rounded-md text-center text-foreground",
|
||||
"bg-secondary border border-border",
|
||||
isCompleted && "bg-primary/10 border-primary/20"
|
||||
)}
|
||||
value={weightValue === '0' ? '' : weightValue}
|
||||
onChangeText={handleWeightChange}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor="text-muted-foreground"
|
||||
returnKeyType="next"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Reps Input */}
|
||||
<TouchableOpacity
|
||||
className="flex-1 mx-1"
|
||||
activeOpacity={0.7}
|
||||
onLongPress={handleCopyPreviousReps}
|
||||
>
|
||||
<TextInput
|
||||
className={cn(
|
||||
"h-10 px-3 rounded-md text-center text-foreground",
|
||||
"bg-secondary border border-border",
|
||||
isCompleted && "bg-primary/10 border-primary/20"
|
||||
)}
|
||||
value={repsValue === '0' ? '' : repsValue}
|
||||
onChangeText={handleRepsChange}
|
||||
keyboardType="number-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor="text-muted-foreground"
|
||||
returnKeyType="done"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Complete Button */}
|
||||
<Button
|
||||
variant={isCompleted ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="w-10 h-10"
|
||||
onPress={handleCompleteSet}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isCompleted ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
103
components/workout/WorkoutHeader.tsx
Normal file
103
components/workout/WorkoutHeader.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
// components/workout/WorkoutHeader.tsx
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pause, Play, Square, ChevronLeft } from 'lucide-react-native';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { formatTime } from '@/utils/formatTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'expo-router';
|
||||
import EditableText from '@/components/EditableText';
|
||||
|
||||
interface WorkoutHeaderProps {
|
||||
title?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export default function WorkoutHeader({ title, onBack }: WorkoutHeaderProps) {
|
||||
const router = useRouter();
|
||||
const status = useWorkoutStore.use.status();
|
||||
const activeWorkout = useWorkoutStore.use.activeWorkout();
|
||||
const elapsedTime = useWorkoutStore.use.elapsedTime();
|
||||
const { pauseWorkout, resumeWorkout, completeWorkout, updateWorkoutTitle } = useWorkoutStore.getState();
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkout) return null;
|
||||
|
||||
return (
|
||||
<View className={cn(
|
||||
"px-4 py-2 border-b border-border",
|
||||
status === 'paused' && "bg-muted/50"
|
||||
)}>
|
||||
{/* Header Row */}
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={handleBack}
|
||||
>
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
|
||||
<View className="flex-1 px-4">
|
||||
<EditableText
|
||||
value={activeWorkout.title}
|
||||
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
|
||||
style={{ alignItems: 'center' }}
|
||||
placeholder="Workout Title"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-2">
|
||||
{status === 'active' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={pauseWorkout}
|
||||
>
|
||||
<Pause className="text-foreground" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={resumeWorkout}
|
||||
>
|
||||
<Play className="text-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onPress={completeWorkout}
|
||||
>
|
||||
<Square className="text-destructive-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Row */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className={cn(
|
||||
"text-2xl font-bold",
|
||||
status === 'paused' ? "text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{formatTime(elapsedTime)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm text-muted-foreground capitalize">
|
||||
{activeWorkout.type}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
19
package-lock.json
generated
19
package-lock.json
generated
@ -78,9 +78,11 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"expo-haptics": "^14.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@ -9045,6 +9047,13 @@
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
|
||||
@ -11998,6 +12007,16 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-14.0.1.tgz",
|
||||
"integrity": "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-keep-awake": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.2.tgz",
|
||||
|
@ -92,9 +92,11 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"expo-haptics": "^14.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"private": true
|
||||
|
536
stores/workoutStore.ts
Normal file
536
stores/workoutStore.ts
Normal file
@ -0,0 +1,536 @@
|
||||
// stores/workoutStore.ts
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createSelectors } from '@/utils/createSelectors';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import type {
|
||||
Workout,
|
||||
WorkoutState,
|
||||
WorkoutAction,
|
||||
RestTimer,
|
||||
WorkoutSet,
|
||||
WorkoutSummary,
|
||||
WorkoutExercise
|
||||
} from '@/types/workout';
|
||||
import type {
|
||||
WorkoutTemplate,
|
||||
TemplateType,
|
||||
TemplateExerciseConfig
|
||||
} from '@/types/templates';
|
||||
import type { BaseExercise } from '@/types/exercise'; // Add this import
|
||||
|
||||
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
interface FavoriteItem {
|
||||
id: string;
|
||||
content: WorkoutTemplate;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
interface ExtendedWorkoutState extends WorkoutState {
|
||||
isActive: boolean;
|
||||
favorites: FavoriteItem[];
|
||||
}
|
||||
|
||||
interface WorkoutActions {
|
||||
// Core Workout Flow
|
||||
startWorkout: (workout: Partial<Workout>) => void;
|
||||
pauseWorkout: () => void;
|
||||
resumeWorkout: () => void;
|
||||
completeWorkout: () => void;
|
||||
cancelWorkout: () => void;
|
||||
reset: () => void;
|
||||
|
||||
// Exercise and Set Management
|
||||
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
|
||||
completeSet: (exerciseIndex: number, setIndex: number) => void;
|
||||
nextExercise: () => void;
|
||||
previousExercise: () => void;
|
||||
|
||||
// Rest Timer
|
||||
startRest: (duration: number) => void;
|
||||
stopRest: () => void;
|
||||
extendRest: (additionalSeconds: number) => void;
|
||||
|
||||
// Timer Actions
|
||||
tick: (elapsed: number) => void;
|
||||
}
|
||||
|
||||
interface ExtendedWorkoutActions extends WorkoutActions {
|
||||
// Core Workout Flow from original implementation
|
||||
startWorkout: (workout: Partial<Workout>) => void;
|
||||
pauseWorkout: () => void;
|
||||
resumeWorkout: () => void;
|
||||
completeWorkout: () => void;
|
||||
cancelWorkout: () => void;
|
||||
reset: () => void;
|
||||
|
||||
// Exercise and Set Management from original implementation
|
||||
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
|
||||
completeSet: (exerciseIndex: number, setIndex: number) => void;
|
||||
nextExercise: () => void;
|
||||
previousExercise: () => void;
|
||||
addExercises: (exercises: BaseExercise[]) => void;
|
||||
|
||||
// Rest Timer from original implementation
|
||||
startRest: (duration: number) => void;
|
||||
stopRest: () => void;
|
||||
extendRest: (additionalSeconds: number) => void;
|
||||
|
||||
// Timer Actions from original implementation
|
||||
tick: (elapsed: number) => void;
|
||||
|
||||
// New favorite management
|
||||
getFavorites: () => Promise<FavoriteItem[]>;
|
||||
addFavorite: (template: WorkoutTemplate) => Promise<void>;
|
||||
removeFavorite: (templateId: string) => Promise<void>;
|
||||
checkFavoriteStatus: (templateId: string) => boolean;
|
||||
|
||||
// New template management
|
||||
startWorkoutFromTemplate: (templateId: string) => Promise<void>;
|
||||
|
||||
// Additional workout actions
|
||||
endWorkout: () => Promise<void>;
|
||||
clearAutoSave: () => Promise<void>;
|
||||
updateWorkoutTitle: (title: string) => void;
|
||||
}
|
||||
|
||||
const initialState: ExtendedWorkoutState = {
|
||||
status: 'idle',
|
||||
activeWorkout: null,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
elapsedTime: 0,
|
||||
restTimer: {
|
||||
isActive: false,
|
||||
duration: 0,
|
||||
remaining: 0
|
||||
},
|
||||
isActive: false,
|
||||
favorites: []
|
||||
};
|
||||
|
||||
const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Core Workout Flow
|
||||
startWorkout: (workoutData: Partial<Workout> = {}) => {
|
||||
const workout: Workout = {
|
||||
id: generateId('local'),
|
||||
title: workoutData.title || 'Quick Workout',
|
||||
type: workoutData.type || 'strength',
|
||||
exercises: workoutData.exercises || [], // Start with empty exercises array
|
||||
startTime: Date.now(),
|
||||
isCompleted: false,
|
||||
created_at: Date.now(),
|
||||
lastUpdated: Date.now(),
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
...workoutData
|
||||
};
|
||||
|
||||
set({
|
||||
status: 'active',
|
||||
activeWorkout: workout,
|
||||
currentExerciseIndex: 0,
|
||||
elapsedTime: 0,
|
||||
isActive: true
|
||||
});
|
||||
},
|
||||
|
||||
pauseWorkout: () => {
|
||||
const { status, activeWorkout } = get();
|
||||
if (status !== 'active' || !activeWorkout) return;
|
||||
|
||||
set({ status: 'paused' });
|
||||
// Auto-save when pausing
|
||||
saveWorkout(activeWorkout);
|
||||
},
|
||||
|
||||
resumeWorkout: () => {
|
||||
const { status, activeWorkout } = get();
|
||||
if (status !== 'paused' || !activeWorkout) return;
|
||||
|
||||
set({ status: 'active' });
|
||||
},
|
||||
|
||||
completeWorkout: async () => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const completedWorkout = {
|
||||
...activeWorkout,
|
||||
isCompleted: true,
|
||||
endTime: Date.now(),
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
|
||||
// Save final workout state
|
||||
await saveWorkout(completedWorkout);
|
||||
|
||||
// Calculate and save summary statistics
|
||||
const summary = calculateWorkoutSummary(completedWorkout);
|
||||
await saveSummary(summary);
|
||||
|
||||
set({
|
||||
status: 'completed',
|
||||
activeWorkout: completedWorkout
|
||||
});
|
||||
},
|
||||
|
||||
cancelWorkout: () => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
// Save cancelled state for recovery if needed
|
||||
saveWorkout({
|
||||
...activeWorkout,
|
||||
isCompleted: false,
|
||||
endTime: Date.now(),
|
||||
lastUpdated: Date.now()
|
||||
});
|
||||
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Exercise and Set Management
|
||||
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const exercises = [...activeWorkout.exercises];
|
||||
const exercise = { ...exercises[exerciseIndex] };
|
||||
const sets = [...exercise.sets];
|
||||
|
||||
const now = Date.now();
|
||||
sets[setIndex] = {
|
||||
...sets[setIndex],
|
||||
...data,
|
||||
lastUpdated: now
|
||||
};
|
||||
|
||||
exercise.sets = sets;
|
||||
exercise.lastUpdated = now;
|
||||
exercises[exerciseIndex] = exercise;
|
||||
|
||||
set({
|
||||
activeWorkout: {
|
||||
...activeWorkout,
|
||||
exercises,
|
||||
lastUpdated: now
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
completeSet: (exerciseIndex: number, setIndex: number) => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const exercises = [...activeWorkout.exercises];
|
||||
const exercise = { ...exercises[exerciseIndex] };
|
||||
const sets = [...exercise.sets];
|
||||
|
||||
const now = Date.now();
|
||||
sets[setIndex] = {
|
||||
...sets[setIndex],
|
||||
isCompleted: true,
|
||||
completedAt: now,
|
||||
lastUpdated: now
|
||||
};
|
||||
|
||||
exercise.sets = sets;
|
||||
exercise.lastUpdated = now;
|
||||
exercises[exerciseIndex] = exercise;
|
||||
|
||||
set({
|
||||
activeWorkout: {
|
||||
...activeWorkout,
|
||||
exercises,
|
||||
lastUpdated: now
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
nextExercise: () => {
|
||||
const { activeWorkout, currentExerciseIndex } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const nextIndex = Math.min(
|
||||
currentExerciseIndex + 1,
|
||||
activeWorkout.exercises.length - 1
|
||||
);
|
||||
|
||||
set({
|
||||
currentExerciseIndex: nextIndex,
|
||||
currentSetIndex: 0
|
||||
});
|
||||
},
|
||||
|
||||
previousExercise: () => {
|
||||
const { currentExerciseIndex } = get();
|
||||
|
||||
set({
|
||||
currentExerciseIndex: Math.max(currentExerciseIndex - 1, 0),
|
||||
currentSetIndex: 0
|
||||
});
|
||||
},
|
||||
|
||||
addExercises: (exercises: BaseExercise[]) => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const now = Date.now();
|
||||
const newExercises: WorkoutExercise[] = exercises.map(ex => ({
|
||||
id: generateId('local'),
|
||||
title: ex.title,
|
||||
type: ex.type,
|
||||
category: ex.category,
|
||||
equipment: ex.equipment,
|
||||
tags: ex.tags || [],
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
created_at: now,
|
||||
lastUpdated: now,
|
||||
sets: [
|
||||
{
|
||||
id: generateId('local'),
|
||||
type: 'normal',
|
||||
weight: 0,
|
||||
reps: 0,
|
||||
isCompleted: false
|
||||
}
|
||||
],
|
||||
isCompleted: false
|
||||
}));
|
||||
|
||||
set({
|
||||
activeWorkout: {
|
||||
...activeWorkout,
|
||||
exercises: [...activeWorkout.exercises, ...newExercises],
|
||||
lastUpdated: now
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Rest Timer
|
||||
startRest: (duration: number) => set({
|
||||
restTimer: {
|
||||
isActive: true,
|
||||
duration,
|
||||
remaining: duration
|
||||
}
|
||||
}),
|
||||
|
||||
stopRest: () => set({
|
||||
restTimer: initialState.restTimer
|
||||
}),
|
||||
|
||||
extendRest: (additionalSeconds: number) => {
|
||||
const { restTimer } = get();
|
||||
if (!restTimer.isActive) return;
|
||||
|
||||
set({
|
||||
restTimer: {
|
||||
...restTimer,
|
||||
duration: restTimer.duration + additionalSeconds,
|
||||
remaining: restTimer.remaining + additionalSeconds
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Timer Actions
|
||||
tick: (elapsed: number) => {
|
||||
const { status, restTimer } = get();
|
||||
|
||||
if (status === 'active') {
|
||||
set((state: WorkoutState) => ({
|
||||
elapsedTime: state.elapsedTime + elapsed
|
||||
}));
|
||||
|
||||
// Update rest timer if active
|
||||
if (restTimer.isActive) {
|
||||
const remaining = Math.max(0, restTimer.remaining - elapsed/1000);
|
||||
|
||||
if (remaining === 0) {
|
||||
set({ restTimer: initialState.restTimer });
|
||||
} else {
|
||||
set({
|
||||
restTimer: {
|
||||
...restTimer,
|
||||
remaining
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Template Management
|
||||
startWorkoutFromTemplate: async (templateId: string) => {
|
||||
// Get template from your template store/service
|
||||
const template = await getTemplate(templateId);
|
||||
if (!template) return;
|
||||
|
||||
// Convert template exercises to workout exercises
|
||||
const exercises: WorkoutExercise[] = template.exercises.map(templateExercise => ({
|
||||
id: generateId('local'),
|
||||
title: templateExercise.exercise.title,
|
||||
type: templateExercise.exercise.type,
|
||||
category: templateExercise.exercise.category,
|
||||
equipment: templateExercise.exercise.equipment,
|
||||
tags: templateExercise.exercise.tags || [],
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
created_at: Date.now(),
|
||||
sets: Array(templateExercise.targetSets || 3).fill({
|
||||
id: generateId('local'),
|
||||
type: 'normal',
|
||||
weight: 0,
|
||||
reps: templateExercise.targetReps || 0,
|
||||
isCompleted: false
|
||||
}),
|
||||
isCompleted: false,
|
||||
notes: templateExercise.notes || ''
|
||||
}));
|
||||
|
||||
// Start workout with template data
|
||||
get().startWorkout({
|
||||
title: template.title,
|
||||
type: template.type || 'strength',
|
||||
exercises,
|
||||
templateId: template.id
|
||||
});
|
||||
},
|
||||
|
||||
updateWorkoutTitle: (title: string) => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
set({
|
||||
activeWorkout: {
|
||||
...activeWorkout,
|
||||
title,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Favorite Management
|
||||
getFavorites: async () => {
|
||||
const { favorites } = get();
|
||||
return favorites;
|
||||
},
|
||||
|
||||
addFavorite: async (template: WorkoutTemplate) => {
|
||||
const favorites = [...get().favorites];
|
||||
favorites.push({
|
||||
id: template.id,
|
||||
content: template,
|
||||
addedAt: Date.now()
|
||||
});
|
||||
set({ favorites });
|
||||
},
|
||||
|
||||
removeFavorite: async (templateId: string) => {
|
||||
const favorites = get().favorites.filter(f => f.id !== templateId);
|
||||
set({ favorites });
|
||||
},
|
||||
|
||||
checkFavoriteStatus: (templateId: string) => {
|
||||
return get().favorites.some(f => f.id === templateId);
|
||||
},
|
||||
|
||||
endWorkout: async () => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
await get().completeWorkout();
|
||||
set({ isActive: false });
|
||||
},
|
||||
|
||||
clearAutoSave: async () => {
|
||||
// TODO: Implement clearing autosave from storage
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
reset: () => set(initialState)
|
||||
}));
|
||||
|
||||
// Helper functions
|
||||
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
|
||||
// This is a placeholder - you'll need to implement actual template fetching
|
||||
// from your database/storage service
|
||||
try {
|
||||
// Example implementation:
|
||||
// return await db.getTemplate(templateId);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching template:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkout(workout: Workout): Promise<void> {
|
||||
try {
|
||||
// TODO: Implement actual save logic using our database service
|
||||
console.log('Saving workout:', workout);
|
||||
} catch (error) {
|
||||
console.error('Error saving workout:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateWorkoutSummary(workout: Workout): WorkoutSummary {
|
||||
return {
|
||||
id: generateId('local'),
|
||||
title: workout.title,
|
||||
type: workout.type,
|
||||
duration: workout.endTime ? workout.endTime - workout.startTime : 0,
|
||||
startTime: workout.startTime,
|
||||
endTime: workout.endTime || Date.now(),
|
||||
exerciseCount: workout.exercises.length,
|
||||
completedExercises: workout.exercises.filter(e => e.isCompleted).length,
|
||||
totalVolume: calculateTotalVolume(workout),
|
||||
totalReps: calculateTotalReps(workout),
|
||||
averageRpe: calculateAverageRpe(workout),
|
||||
exerciseSummaries: [],
|
||||
personalRecords: []
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTotalVolume(workout: Workout): number {
|
||||
return workout.exercises.reduce((total, exercise) => {
|
||||
return total + exercise.sets.reduce((setTotal, set) => {
|
||||
return setTotal + (set.weight || 0) * (set.reps || 0);
|
||||
}, 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function calculateTotalReps(workout: Workout): number {
|
||||
return workout.exercises.reduce((total, exercise) => {
|
||||
return total + exercise.sets.reduce((setTotal, set) => {
|
||||
return setTotal + (set.reps || 0);
|
||||
}, 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function calculateAverageRpe(workout: Workout): number {
|
||||
const rpeSets = workout.exercises.reduce((sets, exercise) => {
|
||||
return sets.concat(exercise.sets.filter(set => set.rpe !== undefined));
|
||||
}, [] as WorkoutSet[]);
|
||||
|
||||
if (rpeSets.length === 0) return 0;
|
||||
|
||||
const totalRpe = rpeSets.reduce((total, set) => total + (set.rpe || 0), 0);
|
||||
return totalRpe / rpeSets.length;
|
||||
}
|
||||
|
||||
async function saveSummary(summary: WorkoutSummary) {
|
||||
// TODO: Implement summary saving
|
||||
console.log('Saving summary:', summary);
|
||||
}
|
||||
|
||||
// Create auto-generated selectors
|
||||
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
|
@ -84,11 +84,12 @@ export interface WorkoutSet {
|
||||
*/
|
||||
export interface WorkoutExercise extends BaseExercise {
|
||||
sets: WorkoutSet[];
|
||||
totalWeight?: number;
|
||||
notes?: string;
|
||||
restTime?: number; // Rest time in seconds
|
||||
targetSets?: number;
|
||||
targetReps?: number;
|
||||
notes?: string;
|
||||
restTime?: number;
|
||||
isCompleted?: boolean;
|
||||
lastUpdated?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,10 +1,11 @@
|
||||
// types/template.ts
|
||||
import { BaseExercise, ExerciseCategory } from './exercise';
|
||||
// types/templates.ts
|
||||
import { BaseExercise, Equipment, ExerciseCategory, SetType } from './exercise';
|
||||
import { StorageSource, SyncableContent } from './shared';
|
||||
import { generateId } from '@/utils/ids';
|
||||
|
||||
/**
|
||||
* Template Classifications
|
||||
* Aligned with NIP-33402
|
||||
*/
|
||||
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
|
||||
|
||||
@ -34,9 +35,11 @@ export interface TemplateExerciseConfig {
|
||||
targetReps: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
setType?: 'warmup' | 'normal' | 'drop' | 'failure';
|
||||
setType?: SetType;
|
||||
restSeconds?: number;
|
||||
notes?: string;
|
||||
|
||||
// Format configuration from NIP-33401
|
||||
format?: {
|
||||
weight?: boolean;
|
||||
reps?: boolean;
|
||||
@ -49,6 +52,14 @@ export interface TemplateExerciseConfig {
|
||||
rpe?: '0-10';
|
||||
set_type?: 'warmup|normal|drop|failure';
|
||||
};
|
||||
|
||||
// For timed workouts
|
||||
duration?: number;
|
||||
interval?: number;
|
||||
|
||||
// For circuit/EMOM
|
||||
position?: number;
|
||||
roundRest?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -71,12 +82,23 @@ export interface TemplateBase {
|
||||
type: TemplateType;
|
||||
category: TemplateCategory;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
|
||||
// Workout structure
|
||||
rounds?: number;
|
||||
duration?: number;
|
||||
interval?: number;
|
||||
restBetweenRounds?: number;
|
||||
|
||||
// Metadata
|
||||
metadata?: {
|
||||
lastUsed?: number;
|
||||
useCount?: number;
|
||||
averageDuration?: number;
|
||||
completionRate?: number;
|
||||
};
|
||||
|
||||
author?: {
|
||||
name: string;
|
||||
pubkey?: string;
|
||||
@ -114,12 +136,6 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
|
||||
set_type: 'warmup|normal|drop|failure';
|
||||
};
|
||||
|
||||
// Workout specific configuration
|
||||
rounds?: number;
|
||||
duration?: number;
|
||||
interval?: number;
|
||||
restBetweenRounds?: number;
|
||||
|
||||
// Template derivation
|
||||
sourceTemplate?: TemplateSource;
|
||||
derivatives?: {
|
||||
@ -129,6 +145,7 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
|
||||
|
||||
// Nostr integration
|
||||
nostrEventId?: string;
|
||||
relayUrls?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -194,7 +211,20 @@ export function toWorkoutTemplate(template: Template): WorkoutTemplate {
|
||||
title: ex.title,
|
||||
type: 'strength',
|
||||
category: 'Push' as ExerciseCategory,
|
||||
equipment: 'barbell' as Equipment,
|
||||
tags: [],
|
||||
format: {
|
||||
weight: true,
|
||||
reps: true,
|
||||
rpe: true,
|
||||
set_type: true
|
||||
},
|
||||
format_units: {
|
||||
weight: 'kg',
|
||||
reps: 'count',
|
||||
rpe: '0-10',
|
||||
set_type: 'warmup|normal|drop|failure'
|
||||
},
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
@ -211,4 +241,31 @@ export function toWorkoutTemplate(template: Template): WorkoutTemplate {
|
||||
source: ['local']
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Nostr event from a template (NIP-33402)
|
||||
*/
|
||||
export function createNostrTemplateEvent(template: WorkoutTemplate) {
|
||||
return {
|
||||
kind: 33402,
|
||||
content: template.description || '',
|
||||
tags: [
|
||||
['d', template.id],
|
||||
['title', template.title],
|
||||
['type', template.type],
|
||||
...(template.rounds ? [['rounds', template.rounds.toString()]] : []),
|
||||
...(template.duration ? [['duration', template.duration.toString()]] : []),
|
||||
...(template.interval ? [['interval', template.interval.toString()]] : []),
|
||||
...template.exercises.map(ex => [
|
||||
'exercise',
|
||||
`33401:${ex.exercise.id}`,
|
||||
ex.targetSets.toString(),
|
||||
ex.targetReps.toString(),
|
||||
ex.setType || 'normal'
|
||||
]),
|
||||
...template.tags.map(tag => ['t', tag])
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
}
|
242
types/workout.ts
Normal file
242
types/workout.ts
Normal file
@ -0,0 +1,242 @@
|
||||
// types/workout.ts
|
||||
import type { WorkoutTemplate, TemplateType } from './templates';
|
||||
import type { BaseExercise } from './exercise';
|
||||
import type { SyncableContent } from './shared';
|
||||
import type { NostrEvent } from './nostr';
|
||||
|
||||
/**
|
||||
* Core workout status types
|
||||
*/
|
||||
export type WorkoutStatus = 'idle' | 'active' | 'paused' | 'completed';
|
||||
|
||||
/**
|
||||
* Individual workout set
|
||||
*/
|
||||
export interface WorkoutSet {
|
||||
id: string;
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
rpe?: number;
|
||||
type: 'warmup' | 'normal' | 'drop' | 'failure';
|
||||
isCompleted: boolean;
|
||||
notes?: string;
|
||||
timestamp?: number;
|
||||
lastUpdated?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise within a workout
|
||||
*/
|
||||
export interface WorkoutExercise extends BaseExercise {
|
||||
sets: WorkoutSet[];
|
||||
targetSets?: number;
|
||||
targetReps?: number;
|
||||
notes?: string;
|
||||
restTime?: number; // Rest time in seconds
|
||||
isCompleted?: boolean;
|
||||
lastUpdated?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active workout tracking
|
||||
*/
|
||||
export interface Workout extends SyncableContent {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TemplateType;
|
||||
exercises: WorkoutExercise[];
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
notes?: string;
|
||||
lastUpdated?: number;
|
||||
tags?: string[];
|
||||
|
||||
// Template reference if workout was started from template
|
||||
templateId?: string;
|
||||
|
||||
// Workout configuration
|
||||
rounds?: number;
|
||||
duration?: number; // Total duration in seconds
|
||||
interval?: number; // For EMOM/interval workouts
|
||||
restBetweenRounds?: number;
|
||||
|
||||
// Workout metrics
|
||||
totalVolume?: number;
|
||||
totalReps?: number;
|
||||
averageRpe?: number;
|
||||
|
||||
// Completion tracking
|
||||
isCompleted: boolean;
|
||||
roundsCompleted?: number;
|
||||
exercisesCompleted?: number;
|
||||
|
||||
// For Nostr integration
|
||||
nostrEventId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal Records
|
||||
*/
|
||||
export interface PersonalRecord {
|
||||
id: string;
|
||||
exerciseId: string;
|
||||
metric: 'weight' | 'reps' | 'volume' | 'time';
|
||||
value: number;
|
||||
workoutId: string;
|
||||
achievedAt: number;
|
||||
|
||||
// Context about the PR
|
||||
exercise: {
|
||||
title: string;
|
||||
equipment?: string;
|
||||
};
|
||||
previousValue?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workout Summary Statistics
|
||||
*/
|
||||
export interface WorkoutSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TemplateType;
|
||||
duration: number; // Total time in milliseconds
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
|
||||
// Overall stats
|
||||
exerciseCount: number;
|
||||
completedExercises: number;
|
||||
totalVolume: number;
|
||||
totalReps: number;
|
||||
averageRpe?: number;
|
||||
|
||||
// Exercise-specific summaries
|
||||
exerciseSummaries: Array<{
|
||||
exerciseId: string;
|
||||
title: string;
|
||||
setCount: number;
|
||||
completedSets: number;
|
||||
volume: number;
|
||||
peakWeight?: number;
|
||||
totalReps: number;
|
||||
averageRpe?: number;
|
||||
}>;
|
||||
|
||||
// Achievements
|
||||
personalRecords: PersonalRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rest Timer State
|
||||
*/
|
||||
export interface RestTimer {
|
||||
isActive: boolean;
|
||||
duration: number; // Total rest duration in seconds
|
||||
remaining: number; // Remaining time in seconds
|
||||
exerciseId?: string; // Associated exercise if any
|
||||
setIndex?: number; // Associated set if any
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Workout State
|
||||
*/
|
||||
export interface WorkoutState {
|
||||
status: WorkoutStatus;
|
||||
activeWorkout: Workout | null;
|
||||
currentExerciseIndex: number;
|
||||
currentSetIndex: number;
|
||||
elapsedTime: number; // Total workout time in milliseconds
|
||||
lastSaved?: number; // Timestamp of last save
|
||||
restTimer: RestTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workout Actions
|
||||
*/
|
||||
export type WorkoutAction =
|
||||
| { type: 'START_WORKOUT'; payload: Partial<Workout> }
|
||||
| { type: 'PAUSE_WORKOUT' }
|
||||
| { type: 'RESUME_WORKOUT' }
|
||||
| { type: 'COMPLETE_WORKOUT' }
|
||||
| { type: 'UPDATE_SET'; payload: { exerciseIndex: number; setIndex: number; data: Partial<WorkoutSet> } }
|
||||
| { type: 'NEXT_EXERCISE' }
|
||||
| { type: 'PREVIOUS_EXERCISE' }
|
||||
| { type: 'START_REST'; payload: number }
|
||||
| { type: 'STOP_REST' }
|
||||
| { type: 'TICK'; payload: number }
|
||||
| { type: 'RESET' };
|
||||
|
||||
/**
|
||||
* Helper functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a template to an active workout
|
||||
*/
|
||||
export function templateToWorkout(template: WorkoutTemplate): Workout {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
title: template.title,
|
||||
type: template.type,
|
||||
exercises: template.exercises.map(ex => ({
|
||||
...ex.exercise,
|
||||
sets: Array(ex.targetSets).fill({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'normal',
|
||||
reps: ex.targetReps,
|
||||
isCompleted: false
|
||||
}),
|
||||
targetSets: ex.targetSets,
|
||||
targetReps: ex.targetReps,
|
||||
notes: ex.notes
|
||||
})),
|
||||
templateId: template.id,
|
||||
startTime: Date.now(),
|
||||
isCompleted: false,
|
||||
rounds: template.rounds,
|
||||
duration: template.duration,
|
||||
interval: template.interval,
|
||||
restBetweenRounds: template.restBetweenRounds,
|
||||
created_at: Date.now(),
|
||||
availability: {
|
||||
source: ['local']
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Nostr workout record event
|
||||
*/
|
||||
export function createNostrWorkoutEvent(workout: Workout): NostrEvent {
|
||||
const exerciseTags = workout.exercises.flatMap(exercise =>
|
||||
exercise.sets.map(set => [
|
||||
'exercise',
|
||||
`33401:${exercise.id}`,
|
||||
set.weight?.toString() || '',
|
||||
set.reps?.toString() || '',
|
||||
set.rpe?.toString() || '',
|
||||
set.type
|
||||
])
|
||||
);
|
||||
|
||||
const workoutTags = workout.tags ? workout.tags.map(tag => ['t', tag]) : [];
|
||||
|
||||
return {
|
||||
kind: 33403,
|
||||
content: workout.notes || '',
|
||||
tags: [
|
||||
['d', workout.id],
|
||||
['title', workout.title],
|
||||
['type', workout.type],
|
||||
['start', Math.floor(workout.startTime / 1000).toString()],
|
||||
['end', Math.floor(workout.endTime! / 1000).toString()],
|
||||
['completed', workout.isCompleted.toString()],
|
||||
...exerciseTags,
|
||||
...workoutTags
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
}
|
17
utils/createSelectors.ts
Normal file
17
utils/createSelectors.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// utils/createSelectors.ts
|
||||
import { StoreApi, UseBoundStore } from 'zustand';
|
||||
|
||||
type WithSelectors<S> = S extends { getState: () => infer T }
|
||||
? S & { use: { [K in keyof T]: () => T[K] } }
|
||||
: never;
|
||||
|
||||
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
|
||||
_store: S,
|
||||
) => {
|
||||
let store = _store as WithSelectors<typeof _store>;
|
||||
store.use = {};
|
||||
for (let k of Object.keys(store.getState())) {
|
||||
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
|
||||
}
|
||||
return store;
|
||||
};
|
7
utils/formatTime.ts
Normal file
7
utils/formatTime.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// utils/formatTime.ts
|
||||
export function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
49
utils/workout.ts
Normal file
49
utils/workout.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// utils/workout.ts
|
||||
import { generateId } from '@/utils/ids';
|
||||
import type {
|
||||
Workout,
|
||||
WorkoutSet,
|
||||
WorkoutExercise
|
||||
} from '@/types/workout';
|
||||
import type { WorkoutTemplate } from '@/types/templates';
|
||||
|
||||
export function convertTemplateToWorkout(template: WorkoutTemplate) {
|
||||
// Convert template exercises to workout exercises with empty sets
|
||||
const exercises: WorkoutExercise[] = template.exercises.map((templateExercise) => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: generateId('local'),
|
||||
title: templateExercise.exercise.title,
|
||||
type: templateExercise.exercise.type,
|
||||
category: templateExercise.exercise.category,
|
||||
equipment: templateExercise.exercise.equipment,
|
||||
tags: templateExercise.exercise.tags || [],
|
||||
availability: {
|
||||
source: ['local']
|
||||
},
|
||||
created_at: now,
|
||||
// Create the specified number of sets from template
|
||||
sets: Array.from({ length: templateExercise.targetSets }, (): WorkoutSet => ({
|
||||
id: generateId('local'),
|
||||
weight: 0, // Start empty, but could use last workout weight
|
||||
reps: templateExercise.targetReps, // Use target reps from template
|
||||
type: 'normal',
|
||||
isCompleted: false
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId('local'),
|
||||
title: template.title,
|
||||
type: template.type,
|
||||
exercises,
|
||||
description: template.description,
|
||||
startTime: Date.now(),
|
||||
isCompleted: false,
|
||||
created_at: Date.now(),
|
||||
availability: {
|
||||
source: ['local']
|
||||
}
|
||||
};
|
||||
}
|
123
utils/workoutTitles.ts
Normal file
123
utils/workoutTitles.ts
Normal file
@ -0,0 +1,123 @@
|
||||
export const WORKOUT_TITLES = [
|
||||
// Action Movies
|
||||
'Die Hard Reps',
|
||||
'The Fast and The Furious: Deadlift Drift',
|
||||
'Mission: Impossible - Strength Protocol',
|
||||
'Rambo: The Strength Warrior',
|
||||
'Terminator: Reps Rising',
|
||||
'Mad Max: Fury Reps',
|
||||
'The Equalizer Set',
|
||||
'John Wick: Power Moves',
|
||||
'The Bourne Identity Workout',
|
||||
'The Rocketeer Push',
|
||||
'Lethal Weapon Reps',
|
||||
'Fast & Furious: Reps Drift',
|
||||
'The Expendables Strength',
|
||||
'Edge of Tomorrow Reps',
|
||||
'Commando Strength Challenge',
|
||||
'Speed: Rep Rush',
|
||||
'Taken: The Workout',
|
||||
'Heat: Reps Edition',
|
||||
'The A-Team Lifts',
|
||||
'Bad Boys for Life Squats',
|
||||
|
||||
// Thriller Movies
|
||||
'The Dark Knight Lifting',
|
||||
'Inception Reps',
|
||||
'The Sixth Sense Workout',
|
||||
'Shutter Island Strength',
|
||||
'Gone Girl Gains',
|
||||
'The Prestige Pulls',
|
||||
'Se7en: The Seven Reps',
|
||||
'The Departed Squats',
|
||||
'Zodiac Killer Lifts',
|
||||
'Fight Club Reps',
|
||||
'Prisoners Power Pulls',
|
||||
'The Girl with the Dragon Tattoo Strength',
|
||||
'Panic Room Workout',
|
||||
'Nightcrawler Circuit',
|
||||
'Memento Lifting',
|
||||
'The Insider Workout',
|
||||
'Insomnia Reps',
|
||||
'Source Code Strength',
|
||||
'The Talented Mr. Ripley Lifts',
|
||||
'Shutter Island Push',
|
||||
|
||||
// Western Movies
|
||||
'The Good, The Bad, and The Swole',
|
||||
'True Grit Gains',
|
||||
'Django Unchained Reps',
|
||||
'The Magnificent Seven Strength',
|
||||
'Unforgiven Squats',
|
||||
'Tombstone Power Pulls',
|
||||
'The Outlaw Josie Wales Lifting',
|
||||
'Butch Cassidy and the Sundance Rep',
|
||||
'For a Few Dollars More Strength',
|
||||
'Once Upon a Time in the West Squats',
|
||||
'High Noon Lifts',
|
||||
'The Wild Bunch Power Push',
|
||||
'Silverado Strength Challenge',
|
||||
'The Assassination of Jesse James Reps',
|
||||
'The Hateful Eight Lifting',
|
||||
'3:10 to Yuma Power Pulls',
|
||||
'The Searchers Strength',
|
||||
'A Fistful of Dollars Lifts',
|
||||
'No Country for Old Men Squats',
|
||||
'Rango Power Reps',
|
||||
|
||||
// Horror Movies
|
||||
'The Texas Chainsaw Massacre Reps',
|
||||
'Nightmare on Elm Street Strength',
|
||||
'Halloween Squats',
|
||||
'Friday the 13th Reps',
|
||||
'The Shining Strength Challenge',
|
||||
'It Chapter One: The Clown Reps',
|
||||
'The Exorcist Deadlift',
|
||||
'Scream Workout',
|
||||
'Psycho Lifting',
|
||||
'The Ring Circuit',
|
||||
'The Cabin in the Woods Lifts',
|
||||
'A Nightmare on Elm Street: Power Push',
|
||||
'Get Out Strength',
|
||||
'The Silence of the Lambs Reps',
|
||||
'Hereditary Push',
|
||||
'The Witch Lifting',
|
||||
'It Follows Squats',
|
||||
'Poltergeist Power Pulls',
|
||||
'The Conjuring Strength',
|
||||
'Midsommar Lifting',
|
||||
'The Babadook Reps',
|
||||
|
||||
// Hybrid Action/Horror Movies
|
||||
'Aliens Lifting',
|
||||
'Predator Strength Challenge',
|
||||
'Terminator 2: Judgment Day Reps',
|
||||
'Resident Evil Power Pulls',
|
||||
'The Matrix: Reps Reloaded',
|
||||
'World War Z Workout',
|
||||
'28 Days Later Strength',
|
||||
'The Mist Power Push',
|
||||
'The Road Reps',
|
||||
'I Am Legend Lifts',
|
||||
'The Walking Dead Strength Challenge',
|
||||
'The Thing Squats',
|
||||
'Dawn of the Dead Reps',
|
||||
'Event Horizon Push',
|
||||
'Daybreakers Power Lifts',
|
||||
'Land of the Dead Lifting',
|
||||
'The Strangers Reps',
|
||||
'Escape from New York Strength',
|
||||
'The Purge Circuit',
|
||||
'Zombie Land Lifting'
|
||||
];
|
||||
|
||||
export function getRandomWorkoutTitle(): string {
|
||||
const index = Math.floor(Math.random() * WORKOUT_TITLES.length);
|
||||
return WORKOUT_TITLES[index];
|
||||
}
|
||||
|
||||
// Get a random title from a specific genre
|
||||
export function getRandomWorkoutTitleByGenre(genre: string): string {
|
||||
// Implementation for future genre-specific selection
|
||||
return getRandomWorkoutTitle();
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user