mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-04 00:02:06 +00:00
added active workout bar
This commit is contained in:
parent
665751961e
commit
b4dc79cc87
@ -1,78 +1,100 @@
|
||||
// app/(tabs)/_layout.tsx
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { Tabs } from 'expo-router';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Platform, View } from 'react-native';
|
||||
import { Tabs, useNavigation } from 'expo-router';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Dumbbell, Library, Users, History, User, } from 'lucide-react-native';
|
||||
import { Dumbbell, Library, Users, History, User } from 'lucide-react-native';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
import ActiveWorkoutBar from '@/components/workout/ActiveWorkoutBar';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = useTheme() as CustomTheme;
|
||||
const navigation = useNavigation();
|
||||
const { isActive, isMinimized } = useWorkoutStore();
|
||||
const { minimizeWorkout } = useWorkoutStore.getState();
|
||||
|
||||
// Auto-minimize workout when navigating between tabs
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('state', (e) => {
|
||||
// If workout is active but not minimized, minimize it when changing tabs
|
||||
if (isActive && !isMinimized) {
|
||||
minimizeWorkout();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, isActive, isMinimized, minimizeWorkout]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopColor: theme.colors.border,
|
||||
borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.tabActive,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
marginBottom: Platform.OS === 'ios' ? 0 : 4,
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<User size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="library"
|
||||
options={{
|
||||
title: 'Library',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Library size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Workout',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Dumbbell size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="social"
|
||||
options={{
|
||||
title: 'Social',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Users size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'History',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<History size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopColor: theme.colors.border,
|
||||
borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.tabActive,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
marginBottom: Platform.OS === 'ios' ? 0 : 4,
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<User size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="library"
|
||||
options={{
|
||||
title: 'Library',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Library size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Workout',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Dumbbell size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="social"
|
||||
options={{
|
||||
title: 'Social',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Users size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'History',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<History size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Render the ActiveWorkoutBar above the tab bar */}
|
||||
<ActiveWorkoutBar />
|
||||
</View>
|
||||
);
|
||||
}
|
@ -37,12 +37,18 @@ interface FavoriteTemplateData {
|
||||
source: 'local' | 'powr' | 'nostr';
|
||||
}
|
||||
|
||||
// Type for tracking pending workout actions
|
||||
type PendingWorkoutAction =
|
||||
| { type: 'quick-start' }
|
||||
| { type: 'template', templateId: string }
|
||||
| { type: 'template-select' };
|
||||
|
||||
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 [showActiveWorkoutModal, setShowActiveWorkoutModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<PendingWorkoutAction | null>(null);
|
||||
const [favoriteWorkouts, setFavoriteWorkouts] = useState<FavoriteTemplateData[]>([]);
|
||||
const [isLoadingFavorites, setIsLoadingFavorites] = useState(true);
|
||||
|
||||
const {
|
||||
getFavorites,
|
||||
@ -51,16 +57,16 @@ export default function WorkoutScreen() {
|
||||
checkFavoriteStatus,
|
||||
isActive,
|
||||
endWorkout
|
||||
} = useWorkoutStore()
|
||||
} = useWorkoutStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites()
|
||||
}, [])
|
||||
loadFavorites();
|
||||
}, []);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setIsLoadingFavorites(true)
|
||||
setIsLoadingFavorites(true);
|
||||
try {
|
||||
const favorites = await getFavorites()
|
||||
const favorites = await getFavorites();
|
||||
|
||||
const workoutTemplates = favorites
|
||||
.filter(f => f.content && f.content.id && checkFavoriteStatus(f.content.id))
|
||||
@ -87,57 +93,51 @@ export default function WorkoutScreen() {
|
||||
} as FavoriteTemplateData;
|
||||
});
|
||||
|
||||
setFavoriteWorkouts(workoutTemplates)
|
||||
setFavoriteWorkouts(workoutTemplates);
|
||||
} catch (error) {
|
||||
console.error('Error loading favorites:', error)
|
||||
console.error('Error loading favorites:', error);
|
||||
} finally {
|
||||
setIsLoadingFavorites(false)
|
||||
setIsLoadingFavorites(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle starting a template-based workout
|
||||
const handleStartWorkout = async (templateId: string) => {
|
||||
if (isActive) {
|
||||
setPendingTemplateId(templateId)
|
||||
setShowActiveWorkoutModal(true)
|
||||
return
|
||||
// Save what the user wants to do for later
|
||||
setPendingAction({ type: 'template', templateId });
|
||||
setShowActiveWorkoutModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startWorkoutFromTemplate(templateId)
|
||||
router.push('/(workout)/create')
|
||||
await startWorkoutFromTemplate(templateId);
|
||||
router.push('/(workout)/create');
|
||||
} catch (error) {
|
||||
console.error('Error starting workout:', error)
|
||||
console.error('Error starting workout:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartNew = async () => {
|
||||
if (!pendingTemplateId) return
|
||||
// Handle selecting a template
|
||||
const handleSelectTemplate = () => {
|
||||
if (isActive) {
|
||||
setPendingAction({ type: 'template-select' });
|
||||
setShowActiveWorkoutModal(true);
|
||||
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)
|
||||
}
|
||||
}
|
||||
router.push('/(workout)/template-select');
|
||||
};
|
||||
|
||||
// Handle quick start
|
||||
const handleQuickStart = () => {
|
||||
// Check if there's already an active workout
|
||||
if (isActive) {
|
||||
setPendingAction({ type: 'quick-start' });
|
||||
setShowActiveWorkoutModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize a new workout with a random funny title
|
||||
startWorkout({
|
||||
title: getRandomWorkoutTitle(),
|
||||
@ -148,18 +148,71 @@ export default function WorkoutScreen() {
|
||||
router.push('/(workout)/create');
|
||||
};
|
||||
|
||||
// Handle starting a new workout (after ending the current one)
|
||||
const handleStartNew = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
setShowActiveWorkoutModal(false);
|
||||
|
||||
// End the current workout first
|
||||
await endWorkout();
|
||||
|
||||
// Now handle the pending action
|
||||
switch (pendingAction.type) {
|
||||
case 'quick-start':
|
||||
// Start a new quick workout
|
||||
startWorkout({
|
||||
title: getRandomWorkoutTitle(),
|
||||
type: 'strength',
|
||||
exercises: []
|
||||
});
|
||||
router.push('/(workout)/create');
|
||||
break;
|
||||
|
||||
case 'template':
|
||||
// Start a workout from the selected template
|
||||
await startWorkoutFromTemplate(pendingAction.templateId);
|
||||
router.push('/(workout)/create');
|
||||
break;
|
||||
|
||||
case 'template-select':
|
||||
// Navigate to template selection
|
||||
router.push('/(workout)/template-select');
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear the pending action
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// Handle continuing the existing workout
|
||||
const handleContinueExisting = () => {
|
||||
setShowActiveWorkoutModal(false);
|
||||
setPendingAction(null);
|
||||
router.push('/(workout)/create');
|
||||
};
|
||||
|
||||
const handleFavoritePress = async (templateId: string) => {
|
||||
try {
|
||||
await removeFavorite(templateId);
|
||||
await loadFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header title="Workout" />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1 px-4"
|
||||
className="flex-1 px-4 pt-4"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<HomeWorkout
|
||||
onStartBlank={handleQuickStart} // Use the new handler here
|
||||
onSelectTemplate={() => router.push('/(workout)/template-select')}
|
||||
onStartBlank={handleQuickStart}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
/>
|
||||
|
||||
{/* Favorites section */}
|
||||
@ -205,11 +258,11 @@ export default function WorkoutScreen() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Active Workout</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an active workout in progress. Would you like to finish it first?
|
||||
<Text>You have an active workout in progress. Would you like to continue it or start a new workout?</Text>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<View className="flex-row justify-end gap-3">
|
||||
<AlertDialogCancel onPress={() => setShowActiveWorkoutModal(false)}>
|
||||
<AlertDialogCancel onPress={handleStartNew}>
|
||||
<Text>Start New</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleContinueExisting}>
|
||||
@ -219,5 +272,5 @@ export default function WorkoutScreen() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TabScreen>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
// app/(workout)/create.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, StyleSheet } from 'react-native';
|
||||
import { View, ScrollView, StyleSheet, TextInput } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import { Text } from '@/components/ui/text';
|
||||
@ -12,16 +12,18 @@ import {
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
AlertDialogTitle,
|
||||
AlertDialogCancel
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { Plus, Pause, Play, MoreHorizontal, CheckCircle2 } from 'lucide-react-native';
|
||||
import { ArrowLeft, Plus, Pause, Play, MoreHorizontal, CheckCircle2, Dumbbell } 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';
|
||||
import { formatTime } from '@/utils/formatTime';
|
||||
|
||||
export default function CreateWorkoutScreen() {
|
||||
const {
|
||||
@ -29,7 +31,8 @@ export default function CreateWorkoutScreen() {
|
||||
activeWorkout,
|
||||
elapsedTime,
|
||||
restTimer,
|
||||
clearAutoSave
|
||||
clearAutoSave,
|
||||
isMinimized
|
||||
} = useWorkoutStore();
|
||||
|
||||
const {
|
||||
@ -37,36 +40,38 @@ export default function CreateWorkoutScreen() {
|
||||
resumeWorkout,
|
||||
completeWorkout,
|
||||
updateWorkoutTitle,
|
||||
updateSet
|
||||
updateSet,
|
||||
cancelWorkout,
|
||||
minimizeWorkout,
|
||||
maximizeWorkout
|
||||
} = useWorkoutStore.getState();
|
||||
|
||||
// Check if we're coming from minimized state when component mounts
|
||||
useEffect(() => {
|
||||
if (isMinimized) {
|
||||
maximizeWorkout();
|
||||
}
|
||||
|
||||
// No need to set up a timer here as it's now managed by the store
|
||||
}, [isMinimized]);
|
||||
|
||||
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;
|
||||
// Handler for confirming workout cancellation
|
||||
const confirmCancelWorkout = async () => {
|
||||
setShowCancelDialog(false);
|
||||
|
||||
if (status === 'active') {
|
||||
timerInterval = setInterval(() => {
|
||||
useWorkoutStore.getState().tick(1000);
|
||||
}, 1000);
|
||||
// If cancelWorkout exists in the store, use it
|
||||
if (typeof cancelWorkout === 'function') {
|
||||
await cancelWorkout();
|
||||
} else {
|
||||
// Otherwise use the clearAutoSave function
|
||||
await clearAutoSave();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
router.back();
|
||||
};
|
||||
|
||||
// Handler for adding a new set to an exercise
|
||||
const handleAddSet = (exerciseIndex: number) => {
|
||||
@ -99,6 +104,12 @@ export default function CreateWorkoutScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
// Handler for minimizing workout and going back
|
||||
const handleMinimize = () => {
|
||||
minimizeWorkout();
|
||||
router.back();
|
||||
};
|
||||
|
||||
// Show empty state when no workout is active
|
||||
if (!activeWorkout) {
|
||||
return (
|
||||
@ -117,22 +128,73 @@ export default function CreateWorkoutScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show rest timer overlay when active
|
||||
if (restTimer.isActive) {
|
||||
return (
|
||||
<TabScreen>
|
||||
<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 * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Controls */}
|
||||
<View className="flex-row gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onPress={() => useWorkoutStore.getState().stopRest()}
|
||||
>
|
||||
<Text>Skip</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onPress={() => useWorkoutStore.getState().extendRest(30)}
|
||||
>
|
||||
<Plus className="mr-2 text-foreground" size={18} />
|
||||
<Text>Add 30s</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const hasExercises = activeWorkout.exercises.length > 0;
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
{/* Swipe indicator */}
|
||||
{/* Swipe indicator and back button */}
|
||||
<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">
|
||||
{/* Top row with minimize and finish buttons */}
|
||||
<View className="flex-row justify-between items-center mb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-row items-center"
|
||||
onPress={handleMinimize}
|
||||
>
|
||||
<ArrowLeft className="mr-1 text-foreground" size={18} />
|
||||
<Text className="text-foreground">Minimize</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="purple"
|
||||
className="px-4"
|
||||
onPress={() => completeWorkout()}
|
||||
disabled={!hasExercises}
|
||||
>
|
||||
<Text className="text-white font-medium">Finish</Text>
|
||||
</Button>
|
||||
@ -182,177 +244,224 @@ export default function CreateWorkoutScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Scrollable Exercises List */}
|
||||
{/* Content Area */}
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: insets.bottom + 20,
|
||||
paddingTop: 16
|
||||
paddingTop: 16,
|
||||
...(hasExercises ? {} : { flex: 1 })
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{hasExercises ? (
|
||||
// Exercise List when exercises exist
|
||||
<>
|
||||
{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",
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}>
|
||||
<Text className="text-center text-foreground">
|
||||
{set.weight}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
value={set.weight ? set.weight.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const weight = text === '' ? 0 : parseFloat(text);
|
||||
if (!isNaN(weight)) {
|
||||
updateSet(exerciseIndex, setIndex, { weight });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Reps Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<View className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 justify-center",
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}>
|
||||
<Text className="text-center text-foreground">
|
||||
{set.reps}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
value={set.reps ? set.reps.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const reps = text === '' ? 0 : parseInt(text, 10);
|
||||
if (!isNaN(reps)) {
|
||||
updateSet(exerciseIndex, setIndex, { reps });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</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 */}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{/* Cancel Button - only shown at the bottom when exercises exist */}
|
||||
<View className="mt-4 mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-row justify-center items-center py-3 border-t border-border"
|
||||
onPress={() => handleAddSet(exerciseIndex)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onPress={() => setShowCancelDialog(true)}
|
||||
>
|
||||
<Plus size={18} className="text-foreground mr-2" />
|
||||
<Text className="text-foreground">Add Set</Text>
|
||||
<Text className="text-foreground">Cancel Workout</Text>
|
||||
</Button>
|
||||
</Card>
|
||||
))
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<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.
|
||||
// Empty State with nice message and icon
|
||||
<View className="flex-1 justify-center items-center px-4">
|
||||
<Dumbbell className="text-muted-foreground mb-6" size={80} />
|
||||
<Text className="text-xl font-semibold text-center mb-2">
|
||||
No exercises added
|
||||
</Text>
|
||||
<Text className="text-base text-muted-foreground text-center mb-8">
|
||||
Add exercises to start tracking your workout
|
||||
</Text>
|
||||
|
||||
{/* Add Exercises Button for empty state */}
|
||||
<Button
|
||||
variant="purple"
|
||||
className="w-full mb-4"
|
||||
onPress={() => router.push('/(workout)/add-exercises')}
|
||||
>
|
||||
<Text className="text-white font-medium">Add Exercises</Text>
|
||||
</Button>
|
||||
|
||||
{/* Cancel Button for empty state */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onPress={() => setShowCancelDialog(true)}
|
||||
>
|
||||
<Text className="text-foreground">Cancel Workout</Text>
|
||||
</Button>
|
||||
</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>
|
||||
{/* Add Exercise FAB - only shown when exercises exist */}
|
||||
{hasExercises && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: insets.bottom + 16
|
||||
}}>
|
||||
<FloatingActionButton
|
||||
icon={Plus}
|
||||
onPress={() => router.push('/(workout)/add-exercises')}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Cancel Workout Dialog */}
|
||||
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Workout</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Text>Are you sure you want to cancel this workout? All progress will be lost.</Text>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<View className="flex-row justify-end gap-3">
|
||||
<AlertDialogCancel onPress={() => setShowCancelDialog(false)}>
|
||||
<Text>Continue Workout</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={confirmCancelWorkout}>
|
||||
<Text>Cancel Workout</Text>
|
||||
</AlertDialogAction>
|
||||
</View>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
175
components/workout/ActiveWorkoutBar.tsx
Normal file
175
components/workout/ActiveWorkoutBar.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
// components/workout/ActiveWorkoutBar.tsx
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
Easing
|
||||
} from 'react-native-reanimated';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Activity } from 'lucide-react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { formatTime } from '@/utils/formatTime';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
|
||||
export default function ActiveWorkoutBar() {
|
||||
// Use Zustand store
|
||||
const {
|
||||
activeWorkout,
|
||||
isActive,
|
||||
isMinimized,
|
||||
status,
|
||||
elapsedTime
|
||||
} = useWorkoutStore();
|
||||
|
||||
const { maximizeWorkout } = useWorkoutStore.getState();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme() as CustomTheme;
|
||||
|
||||
// Animation values
|
||||
const glowOpacity = useSharedValue(0.5);
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const glowStyle = useAnimatedStyle(() => ({
|
||||
opacity: glowOpacity.value,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
// Only run animations if the bar should be visible
|
||||
if (isActive && isMinimized && activeWorkout && activeWorkout.exercises.length > 0) {
|
||||
// Pulse animation
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.02, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
// Glow animation
|
||||
glowOpacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.8, { duration: 1000 }),
|
||||
withTiming(0.5, { duration: 1000 })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}
|
||||
}, [isActive, isMinimized, activeWorkout]);
|
||||
|
||||
const handlePress = () => {
|
||||
maximizeWorkout();
|
||||
router.push('/(workout)/create');
|
||||
};
|
||||
|
||||
// Don't render anything if there's no active workout or if it's not minimized
|
||||
if (!isActive || !isMinimized || !activeWorkout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{ bottom: insets.bottom + 60 },
|
||||
animatedStyle
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.touchable, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glow,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
glowStyle
|
||||
]}
|
||||
/>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.leftContent}>
|
||||
<Activity size={16} color="white" />
|
||||
<Text style={styles.title} numberOfLines={1}>{activeWorkout.title}</Text>
|
||||
</View>
|
||||
<View style={styles.rightContent}>
|
||||
<Text style={styles.time}>{formatTime(elapsedTime)}</Text>
|
||||
<Text style={styles.exerciseCount}>
|
||||
{activeWorkout.exercises.length} exercise{activeWorkout.exercises.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: 40,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
elevation: Platform.OS === 'android' ? 1 : 0,
|
||||
},
|
||||
touchable: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
glow: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 0.5,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 16,
|
||||
height: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
leftContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
},
|
||||
rightContent: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
title: {
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
},
|
||||
time: {
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: Platform.select({
|
||||
ios: 'Courier',
|
||||
android: 'monospace'
|
||||
}),
|
||||
marginBottom: 2,
|
||||
},
|
||||
exerciseCount: {
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
@ -19,7 +19,7 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork
|
||||
<CardContent className="flex-col gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full items-center justify-center gap-2"
|
||||
className="w-full flex-row items-center justify-center gap-2"
|
||||
onPress={onStartBlank}
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
@ -29,7 +29,7 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full items-center justify-center gap-2"
|
||||
className="w-full flex-row items-center justify-center gap-2"
|
||||
onPress={onSelectTemplate}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
|
@ -17,10 +17,14 @@ import type {
|
||||
TemplateType,
|
||||
TemplateExerciseConfig
|
||||
} from '@/types/templates';
|
||||
import type { BaseExercise } from '@/types/exercise'; // Add this import
|
||||
import type { BaseExercise } from '@/types/exercise';
|
||||
|
||||
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
// Define a module-level timer reference for the workout timer
|
||||
// This ensures it persists even when components unmount
|
||||
let workoutTimerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
interface FavoriteItem {
|
||||
id: string;
|
||||
content: WorkoutTemplate;
|
||||
@ -29,32 +33,33 @@ interface FavoriteItem {
|
||||
|
||||
interface ExtendedWorkoutState extends WorkoutState {
|
||||
isActive: boolean;
|
||||
isMinimized: 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;
|
||||
}
|
||||
// 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
|
||||
@ -93,6 +98,14 @@ interface ExtendedWorkoutActions extends WorkoutActions {
|
||||
endWorkout: () => Promise<void>;
|
||||
clearAutoSave: () => Promise<void>;
|
||||
updateWorkoutTitle: (title: string) => void;
|
||||
|
||||
// Minimized state actions
|
||||
minimizeWorkout: () => void;
|
||||
maximizeWorkout: () => void;
|
||||
|
||||
// Workout timer management
|
||||
startWorkoutTimer: () => void;
|
||||
stopWorkoutTimer: () => void;
|
||||
}
|
||||
|
||||
const initialState: ExtendedWorkoutState = {
|
||||
@ -107,11 +120,12 @@ const initialState: ExtendedWorkoutState = {
|
||||
remaining: 0
|
||||
},
|
||||
isActive: false,
|
||||
isMinimized: false,
|
||||
favorites: []
|
||||
};
|
||||
|
||||
const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
|
||||
...initialState,
|
||||
...initialState,
|
||||
|
||||
// Core Workout Flow
|
||||
startWorkout: (workoutData: Partial<Workout> = {}) => {
|
||||
@ -135,8 +149,12 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
activeWorkout: workout,
|
||||
currentExerciseIndex: 0,
|
||||
elapsedTime: 0,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
isMinimized: false
|
||||
});
|
||||
|
||||
// Start the workout timer
|
||||
get().startWorkoutTimer();
|
||||
},
|
||||
|
||||
pauseWorkout: () => {
|
||||
@ -159,6 +177,9 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
// Stop the workout timer
|
||||
get().stopWorkoutTimer();
|
||||
|
||||
const completedWorkout = {
|
||||
...activeWorkout,
|
||||
isCompleted: true,
|
||||
@ -175,23 +196,43 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
|
||||
set({
|
||||
status: 'completed',
|
||||
activeWorkout: completedWorkout
|
||||
activeWorkout: completedWorkout,
|
||||
isActive: false,
|
||||
isMinimized: false
|
||||
});
|
||||
},
|
||||
|
||||
cancelWorkout: () => {
|
||||
cancelWorkout: async () => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
// Save cancelled state for recovery if needed
|
||||
saveWorkout({
|
||||
|
||||
// Stop the workout timer
|
||||
get().stopWorkoutTimer();
|
||||
|
||||
// Prepare canceled workout with proper metadata
|
||||
const canceledWorkout = {
|
||||
...activeWorkout,
|
||||
isCompleted: false,
|
||||
endTime: Date.now(),
|
||||
lastUpdated: Date.now()
|
||||
lastUpdated: Date.now(),
|
||||
status: 'canceled'
|
||||
};
|
||||
|
||||
// Log the cancellation if needed
|
||||
console.log('Workout canceled:', canceledWorkout.id);
|
||||
|
||||
// Save the canceled state for analytics or recovery purposes
|
||||
await saveWorkout(canceledWorkout);
|
||||
|
||||
// Clear any auto-saves
|
||||
// This would be the place to implement storage cleanup if needed
|
||||
await get().clearAutoSave();
|
||||
|
||||
// Reset to initial state
|
||||
set({
|
||||
...initialState,
|
||||
favorites: get().favorites // Preserve favorites when resetting
|
||||
});
|
||||
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Exercise and Set Management
|
||||
@ -232,10 +273,14 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
const sets = [...exercise.sets];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Toggle completion status
|
||||
const isCurrentlyCompleted = sets[setIndex].isCompleted;
|
||||
|
||||
sets[setIndex] = {
|
||||
...sets[setIndex],
|
||||
isCompleted: true,
|
||||
completedAt: now,
|
||||
isCompleted: !isCurrentlyCompleted,
|
||||
completedAt: !isCurrentlyCompleted ? now : undefined,
|
||||
lastUpdated: now
|
||||
};
|
||||
|
||||
@ -345,7 +390,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
const { status, restTimer } = get();
|
||||
|
||||
if (status === 'active') {
|
||||
set((state: WorkoutState) => ({
|
||||
set((state: ExtendedWorkoutState) => ({
|
||||
elapsedTime: state.elapsedTime + elapsed
|
||||
}));
|
||||
|
||||
@ -366,6 +411,33 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Workout timer management - new functions
|
||||
startWorkoutTimer: () => {
|
||||
// Clear any existing timer first to prevent duplicates
|
||||
if (workoutTimerInterval) {
|
||||
clearInterval(workoutTimerInterval);
|
||||
workoutTimerInterval = null;
|
||||
}
|
||||
|
||||
// Start a new timer that continues to run even when components unmount
|
||||
workoutTimerInterval = setInterval(() => {
|
||||
const { status } = useWorkoutStoreBase.getState();
|
||||
if (status === 'active') {
|
||||
useWorkoutStoreBase.getState().tick(1000);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
console.log('Workout timer started');
|
||||
},
|
||||
|
||||
stopWorkoutTimer: () => {
|
||||
if (workoutTimerInterval) {
|
||||
clearInterval(workoutTimerInterval);
|
||||
workoutTimerInterval = null;
|
||||
console.log('Workout timer stopped');
|
||||
}
|
||||
},
|
||||
|
||||
// Template Management
|
||||
startWorkoutFromTemplate: async (templateId: string) => {
|
||||
@ -385,13 +457,13 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
source: ['local']
|
||||
},
|
||||
created_at: Date.now(),
|
||||
sets: Array(templateExercise.targetSets || 3).fill({
|
||||
sets: Array(templateExercise.targetSets || 3).fill(0).map(() => ({
|
||||
id: generateId('local'),
|
||||
type: 'normal',
|
||||
weight: 0,
|
||||
reps: templateExercise.targetReps || 0,
|
||||
isCompleted: false
|
||||
}),
|
||||
})),
|
||||
isCompleted: false,
|
||||
notes: templateExercise.notes || ''
|
||||
}));
|
||||
@ -405,18 +477,18 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
});
|
||||
},
|
||||
|
||||
updateWorkoutTitle: (title: string) => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
set({
|
||||
activeWorkout: {
|
||||
...activeWorkout,
|
||||
title,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
});
|
||||
},
|
||||
updateWorkoutTitle: (title: string) => {
|
||||
const { activeWorkout } = get();
|
||||
if (!activeWorkout) return;
|
||||
|
||||
set({
|
||||
activeWorkout: {
|
||||
...activeWorkout,
|
||||
title,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Favorite Management
|
||||
getFavorites: async () => {
|
||||
@ -448,30 +520,42 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
if (!activeWorkout) return;
|
||||
|
||||
await get().completeWorkout();
|
||||
set({ isActive: false });
|
||||
},
|
||||
|
||||
clearAutoSave: async () => {
|
||||
// TODO: Implement clearing autosave from storage
|
||||
get().stopWorkoutTimer(); // Make sure to stop the timer
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// New actions for minimized state
|
||||
minimizeWorkout: () => {
|
||||
set({ isMinimized: true });
|
||||
},
|
||||
|
||||
maximizeWorkout: () => {
|
||||
set({ isMinimized: false });
|
||||
},
|
||||
|
||||
reset: () => set(initialState)
|
||||
reset: () => {
|
||||
get().stopWorkoutTimer(); // Make sure to stop the timer
|
||||
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;
|
||||
}
|
||||
// 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 {
|
||||
@ -533,4 +617,16 @@ async function saveSummary(summary: WorkoutSummary) {
|
||||
}
|
||||
|
||||
// Create auto-generated selectors
|
||||
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
|
||||
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
|
||||
|
||||
// Clean up interval on hot reload in development
|
||||
if (typeof module !== 'undefined' && 'hot' in module) {
|
||||
// @ts-ignore - 'hot' exists at runtime but TypeScript doesn't know about it
|
||||
module.hot?.dispose(() => {
|
||||
if (workoutTimerInterval) {
|
||||
clearInterval(workoutTimerInterval);
|
||||
workoutTimerInterval = null;
|
||||
console.log('Workout timer cleared on hot reload');
|
||||
}
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user