mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-05 08:42:05 +00:00
workout screen WIP
This commit is contained in:
parent
05d3c02523
commit
17cb416777
@ -1,29 +1,223 @@
|
|||||||
// app/(tabs)/index.tsx (Workout tab)
|
// app/(tabs)/index.tsx
|
||||||
import { View } from 'react-native';
|
import { useState, useEffect } from 'react'
|
||||||
import { Text } from '@/components/ui/text';
|
import { ScrollView, View } from 'react-native'
|
||||||
import { TabScreen } from '@/components/layout/TabScreen';
|
import { router } from 'expo-router'
|
||||||
import Header from '@/components/Header';
|
import {
|
||||||
import { Plus } from 'lucide-react-native';
|
AlertDialog,
|
||||||
import { Button } from '@/components/ui/button';
|
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() {
|
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 (
|
return (
|
||||||
<TabScreen>
|
<TabScreen>
|
||||||
<Header
|
<Header title="Workout" />
|
||||||
title="Workout"
|
|
||||||
rightElement={
|
<ScrollView
|
||||||
<Button
|
className="flex-1 px-4"
|
||||||
variant="ghost"
|
showsVerticalScrollIndicator={false}
|
||||||
size="icon"
|
contentContainerStyle={{ paddingBottom: 20 }}
|
||||||
onPress={() => console.log('New workout')}
|
>
|
||||||
>
|
<HomeWorkout
|
||||||
<Plus className="text-foreground" />
|
onStartBlank={handleQuickStart} // Use the new handler here
|
||||||
</Button>
|
onSelectTemplate={() => router.push('/(workout)/template-select')}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
<View className="flex-1 items-center justify-center">
|
{/* Favorites section */}
|
||||||
<Text>Workout Screen</Text>
|
<Card className="mt-6">
|
||||||
</View>
|
<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>
|
</TabScreen>
|
||||||
);
|
)
|
||||||
}
|
}
|
@ -1,15 +1,50 @@
|
|||||||
// app/(workout)/_layout.tsx
|
// 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() {
|
export default function WorkoutLayout() {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
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
|
<Stack.Screen
|
||||||
name="new-exercise"
|
name="create"
|
||||||
options={{
|
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>
|
</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": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@babel/core": "^7.26.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
"@types/react-native": "^0.72.8",
|
"@types/react-native": "^0.72.8",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
|
"expo-haptics": "^14.0.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -9045,6 +9047,13 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.4",
|
"version": "22.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
|
||||||
@ -11998,6 +12007,16 @@
|
|||||||
"react": "*"
|
"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": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "14.0.2",
|
"version": "14.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.2.tgz",
|
||||||
|
@ -92,9 +92,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@babel/core": "^7.26.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
"@types/react-native": "^0.72.8",
|
"@types/react-native": "^0.72.8",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
|
"expo-haptics": "^14.0.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"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 {
|
export interface WorkoutExercise extends BaseExercise {
|
||||||
sets: WorkoutSet[];
|
sets: WorkoutSet[];
|
||||||
totalWeight?: number;
|
|
||||||
notes?: string;
|
|
||||||
restTime?: number; // Rest time in seconds
|
|
||||||
targetSets?: number;
|
targetSets?: number;
|
||||||
targetReps?: number;
|
targetReps?: number;
|
||||||
|
notes?: string;
|
||||||
|
restTime?: number;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
lastUpdated?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
// types/template.ts
|
// types/templates.ts
|
||||||
import { BaseExercise, ExerciseCategory } from './exercise';
|
import { BaseExercise, Equipment, ExerciseCategory, SetType } from './exercise';
|
||||||
import { StorageSource, SyncableContent } from './shared';
|
import { StorageSource, SyncableContent } from './shared';
|
||||||
import { generateId } from '@/utils/ids';
|
import { generateId } from '@/utils/ids';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template Classifications
|
* Template Classifications
|
||||||
|
* Aligned with NIP-33402
|
||||||
*/
|
*/
|
||||||
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
|
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
|
||||||
|
|
||||||
@ -34,9 +35,11 @@ export interface TemplateExerciseConfig {
|
|||||||
targetReps: number;
|
targetReps: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
setType?: 'warmup' | 'normal' | 'drop' | 'failure';
|
setType?: SetType;
|
||||||
restSeconds?: number;
|
restSeconds?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
// Format configuration from NIP-33401
|
||||||
format?: {
|
format?: {
|
||||||
weight?: boolean;
|
weight?: boolean;
|
||||||
reps?: boolean;
|
reps?: boolean;
|
||||||
@ -49,6 +52,14 @@ export interface TemplateExerciseConfig {
|
|||||||
rpe?: '0-10';
|
rpe?: '0-10';
|
||||||
set_type?: 'warmup|normal|drop|failure';
|
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;
|
type: TemplateType;
|
||||||
category: TemplateCategory;
|
category: TemplateCategory;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|
||||||
|
// Workout structure
|
||||||
|
rounds?: number;
|
||||||
|
duration?: number;
|
||||||
|
interval?: number;
|
||||||
|
restBetweenRounds?: number;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
metadata?: {
|
metadata?: {
|
||||||
lastUsed?: number;
|
lastUsed?: number;
|
||||||
useCount?: number;
|
useCount?: number;
|
||||||
averageDuration?: number;
|
averageDuration?: number;
|
||||||
|
completionRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
author?: {
|
author?: {
|
||||||
name: string;
|
name: string;
|
||||||
pubkey?: string;
|
pubkey?: string;
|
||||||
@ -114,12 +136,6 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
|
|||||||
set_type: 'warmup|normal|drop|failure';
|
set_type: 'warmup|normal|drop|failure';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Workout specific configuration
|
|
||||||
rounds?: number;
|
|
||||||
duration?: number;
|
|
||||||
interval?: number;
|
|
||||||
restBetweenRounds?: number;
|
|
||||||
|
|
||||||
// Template derivation
|
// Template derivation
|
||||||
sourceTemplate?: TemplateSource;
|
sourceTemplate?: TemplateSource;
|
||||||
derivatives?: {
|
derivatives?: {
|
||||||
@ -129,6 +145,7 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
|
|||||||
|
|
||||||
// Nostr integration
|
// Nostr integration
|
||||||
nostrEventId?: string;
|
nostrEventId?: string;
|
||||||
|
relayUrls?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,7 +211,20 @@ export function toWorkoutTemplate(template: Template): WorkoutTemplate {
|
|||||||
title: ex.title,
|
title: ex.title,
|
||||||
type: 'strength',
|
type: 'strength',
|
||||||
category: 'Push' as ExerciseCategory,
|
category: 'Push' as ExerciseCategory,
|
||||||
|
equipment: 'barbell' as Equipment,
|
||||||
tags: [],
|
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: {
|
availability: {
|
||||||
source: ['local']
|
source: ['local']
|
||||||
},
|
},
|
||||||
@ -211,4 +241,31 @@ export function toWorkoutTemplate(template: Template): WorkoutTemplate {
|
|||||||
source: ['local']
|
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