added active workout bar

This commit is contained in:
DocNR 2025-02-25 15:03:45 -05:00
parent 665751961e
commit b4dc79cc87
6 changed files with 814 additions and 359 deletions

View File

@ -1,78 +1,100 @@
// app/(tabs)/_layout.tsx // app/(tabs)/_layout.tsx
import React from 'react'; import React, { useEffect } from 'react';
import { Platform } from 'react-native'; import { Platform, View } from 'react-native';
import { Tabs } from 'expo-router'; import { Tabs, useNavigation } from 'expo-router';
import { useTheme } from '@react-navigation/native'; 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 type { CustomTheme } from '@/lib/theme';
import ActiveWorkoutBar from '@/components/workout/ActiveWorkoutBar';
import { useWorkoutStore } from '@/stores/workoutStore';
export default function TabLayout() { export default function TabLayout() {
const theme = useTheme() as CustomTheme; 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 ( return (
<Tabs <View style={{ flex: 1 }}>
screenOptions={{ <Tabs
headerShown: false, screenOptions={{
tabBarStyle: { headerShown: false,
backgroundColor: theme.colors.background, tabBarStyle: {
borderTopColor: theme.colors.border, backgroundColor: theme.colors.background,
borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1, borderTopColor: theme.colors.border,
elevation: 0, borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
shadowOpacity: 0, elevation: 0,
}, shadowOpacity: 0,
tabBarActiveTintColor: theme.colors.tabActive, },
tabBarInactiveTintColor: theme.colors.tabInactive, tabBarActiveTintColor: theme.colors.tabActive,
tabBarShowLabel: true, tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: { tabBarShowLabel: true,
fontSize: 12, tabBarLabelStyle: {
marginBottom: Platform.OS === 'ios' ? 0 : 4, fontSize: 12,
}, marginBottom: Platform.OS === 'ios' ? 0 : 4,
}}> },
<Tabs.Screen }}>
name="profile" <Tabs.Screen
options={{ name="profile"
title: 'Profile', options={{
tabBarIcon: ({ color, size }) => ( title: 'Profile',
<User size={size} color={color} /> tabBarIcon: ({ color, size }) => (
), <User size={size} color={color} />
}} ),
/> }}
<Tabs.Screen />
name="library" <Tabs.Screen
options={{ name="library"
title: 'Library', options={{
tabBarIcon: ({ color, size }) => ( title: 'Library',
<Library size={size} color={color} /> tabBarIcon: ({ color, size }) => (
), <Library size={size} color={color} />
}} ),
/> }}
<Tabs.Screen />
name="index" <Tabs.Screen
options={{ name="index"
title: 'Workout', options={{
tabBarIcon: ({ color, size }) => ( title: 'Workout',
<Dumbbell size={size} color={color} /> tabBarIcon: ({ color, size }) => (
), <Dumbbell size={size} color={color} />
}} ),
/> }}
<Tabs.Screen />
name="social" <Tabs.Screen
options={{ name="social"
title: 'Social', options={{
tabBarIcon: ({ color, size }) => ( title: 'Social',
<Users size={size} color={color} /> tabBarIcon: ({ color, size }) => (
), <Users size={size} color={color} />
}} ),
/> }}
<Tabs.Screen />
name="history" <Tabs.Screen
options={{ name="history"
title: 'History', options={{
tabBarIcon: ({ color, size }) => ( title: 'History',
<History size={size} color={color} /> tabBarIcon: ({ color, size }) => (
), <History size={size} color={color} />
}} ),
/> }}
</Tabs> />
</Tabs>
{/* Render the ActiveWorkoutBar above the tab bar */}
<ActiveWorkoutBar />
</View>
); );
} }

View File

@ -37,12 +37,18 @@ interface FavoriteTemplateData {
source: 'local' | 'powr' | 'nostr'; 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() { export default function WorkoutScreen() {
const { startWorkout } = useWorkoutStore.getState(); const { startWorkout } = useWorkoutStore.getState();
const [showActiveWorkoutModal, setShowActiveWorkoutModal] = useState(false) const [showActiveWorkoutModal, setShowActiveWorkoutModal] = useState(false);
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null) const [pendingAction, setPendingAction] = useState<PendingWorkoutAction | null>(null);
const [favoriteWorkouts, setFavoriteWorkouts] = useState<FavoriteTemplateData[]>([]) const [favoriteWorkouts, setFavoriteWorkouts] = useState<FavoriteTemplateData[]>([]);
const [isLoadingFavorites, setIsLoadingFavorites] = useState(true) const [isLoadingFavorites, setIsLoadingFavorites] = useState(true);
const { const {
getFavorites, getFavorites,
@ -51,16 +57,16 @@ export default function WorkoutScreen() {
checkFavoriteStatus, checkFavoriteStatus,
isActive, isActive,
endWorkout endWorkout
} = useWorkoutStore() } = useWorkoutStore();
useEffect(() => { useEffect(() => {
loadFavorites() loadFavorites();
}, []) }, []);
const loadFavorites = async () => { const loadFavorites = async () => {
setIsLoadingFavorites(true) setIsLoadingFavorites(true);
try { try {
const favorites = await getFavorites() const favorites = await getFavorites();
const workoutTemplates = favorites const workoutTemplates = favorites
.filter(f => f.content && f.content.id && checkFavoriteStatus(f.content.id)) .filter(f => f.content && f.content.id && checkFavoriteStatus(f.content.id))
@ -87,57 +93,51 @@ export default function WorkoutScreen() {
} as FavoriteTemplateData; } as FavoriteTemplateData;
}); });
setFavoriteWorkouts(workoutTemplates) setFavoriteWorkouts(workoutTemplates);
} catch (error) { } catch (error) {
console.error('Error loading favorites:', error) console.error('Error loading favorites:', error);
} finally { } finally {
setIsLoadingFavorites(false) setIsLoadingFavorites(false);
} }
} };
// Handle starting a template-based workout
const handleStartWorkout = async (templateId: string) => { const handleStartWorkout = async (templateId: string) => {
if (isActive) { if (isActive) {
setPendingTemplateId(templateId) // Save what the user wants to do for later
setShowActiveWorkoutModal(true) setPendingAction({ type: 'template', templateId });
return setShowActiveWorkoutModal(true);
return;
} }
try { try {
await startWorkoutFromTemplate(templateId) await startWorkoutFromTemplate(templateId);
router.push('/(workout)/create') router.push('/(workout)/create');
} catch (error) { } catch (error) {
console.error('Error starting workout:', error) console.error('Error starting workout:', error);
} }
} };
const handleStartNew = async () => { // Handle selecting a template
if (!pendingTemplateId) return const handleSelectTemplate = () => {
if (isActive) {
setPendingAction({ type: 'template-select' });
setShowActiveWorkoutModal(true);
return;
}
const templateToStart = pendingTemplateId router.push('/(workout)/template-select');
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)
}
}
// Handle quick start
const handleQuickStart = () => { 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 // Initialize a new workout with a random funny title
startWorkout({ startWorkout({
title: getRandomWorkoutTitle(), title: getRandomWorkoutTitle(),
@ -148,18 +148,71 @@ export default function WorkoutScreen() {
router.push('/(workout)/create'); 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 ( return (
<TabScreen> <TabScreen>
<Header title="Workout" /> <Header title="Workout" />
<ScrollView <ScrollView
className="flex-1 px-4" className="flex-1 px-4 pt-4"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20 }} contentContainerStyle={{ paddingBottom: 20 }}
> >
<HomeWorkout <HomeWorkout
onStartBlank={handleQuickStart} // Use the new handler here onStartBlank={handleQuickStart}
onSelectTemplate={() => router.push('/(workout)/template-select')} onSelectTemplate={handleSelectTemplate}
/> />
{/* Favorites section */} {/* Favorites section */}
@ -205,11 +258,11 @@ export default function WorkoutScreen() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Active Workout</AlertDialogTitle> <AlertDialogTitle>Active Workout</AlertDialogTitle>
<AlertDialogDescription> <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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<View className="flex-row justify-end gap-3"> <View className="flex-row justify-end gap-3">
<AlertDialogCancel onPress={() => setShowActiveWorkoutModal(false)}> <AlertDialogCancel onPress={handleStartNew}>
<Text>Start New</Text> <Text>Start New</Text>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction onPress={handleContinueExisting}> <AlertDialogAction onPress={handleContinueExisting}>
@ -219,5 +272,5 @@ export default function WorkoutScreen() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</TabScreen> </TabScreen>
) );
} }

View File

@ -1,6 +1,6 @@
// app/(workout)/create.tsx // app/(workout)/create.tsx
import React, { useState, useEffect } from 'react'; 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 { router } from 'expo-router';
import { TabScreen } from '@/components/layout/TabScreen'; import { TabScreen } from '@/components/layout/TabScreen';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
@ -12,16 +12,18 @@ import {
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle AlertDialogTitle,
AlertDialogCancel
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { useWorkoutStore } from '@/stores/workoutStore'; 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import EditableText from '@/components/EditableText'; import EditableText from '@/components/EditableText';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
import { WorkoutSet } from '@/types/workout'; import { WorkoutSet } from '@/types/workout';
import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { formatTime } from '@/utils/formatTime';
export default function CreateWorkoutScreen() { export default function CreateWorkoutScreen() {
const { const {
@ -29,7 +31,8 @@ export default function CreateWorkoutScreen() {
activeWorkout, activeWorkout,
elapsedTime, elapsedTime,
restTimer, restTimer,
clearAutoSave clearAutoSave,
isMinimized
} = useWorkoutStore(); } = useWorkoutStore();
const { const {
@ -37,36 +40,38 @@ export default function CreateWorkoutScreen() {
resumeWorkout, resumeWorkout,
completeWorkout, completeWorkout,
updateWorkoutTitle, updateWorkoutTitle,
updateSet updateSet,
cancelWorkout,
minimizeWorkout,
maximizeWorkout
} = useWorkoutStore.getState(); } = 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 [showCancelDialog, setShowCancelDialog] = useState(false);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
// Format time as mm:ss in monospace font // Handler for confirming workout cancellation
const formatTime = (ms: number) => { const confirmCancelWorkout = async () => {
const totalSeconds = Math.floor(ms / 1000); setShowCancelDialog(false);
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') { // If cancelWorkout exists in the store, use it
timerInterval = setInterval(() => { if (typeof cancelWorkout === 'function') {
useWorkoutStore.getState().tick(1000); await cancelWorkout();
}, 1000); } else {
// Otherwise use the clearAutoSave function
await clearAutoSave();
} }
return () => { router.back();
if (timerInterval) { };
clearInterval(timerInterval);
}
};
}, [status]);
// Handler for adding a new set to an exercise // Handler for adding a new set to an exercise
const handleAddSet = (exerciseIndex: number) => { 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 // Show empty state when no workout is active
if (!activeWorkout) { if (!activeWorkout) {
return ( 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 ( return (
<TabScreen> <TabScreen>
<View style={{ flex: 1, paddingTop: insets.top }}> <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-full items-center py-2">
<View className="w-10 h-1 rounded-full bg-muted-foreground/30" /> <View className="w-10 h-1 rounded-full bg-muted-foreground/30" />
</View> </View>
{/* Header with Title and Finish Button */} {/* Header with Title and Finish Button */}
<View className="px-4 py-3 border-b border-border"> <View className="px-4 py-3 border-b border-border">
{/* Finish button in top right */} {/* Top row with minimize and finish buttons */}
<View className="flex-row justify-end mb-2"> <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 <Button
variant="purple" variant="purple"
className="px-4" className="px-4"
onPress={() => completeWorkout()} onPress={() => completeWorkout()}
disabled={!hasExercises}
> >
<Text className="text-white font-medium">Finish</Text> <Text className="text-white font-medium">Finish</Text>
</Button> </Button>
@ -182,177 +244,224 @@ export default function CreateWorkoutScreen() {
</View> </View>
</View> </View>
{/* Scrollable Exercises List */} {/* Content Area */}
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: insets.bottom + 20, paddingBottom: insets.bottom + 20,
paddingTop: 16 paddingTop: 16,
...(hasExercises ? {} : { flex: 1 })
}} }}
> >
{activeWorkout.exercises.length > 0 ? ( {hasExercises ? (
activeWorkout.exercises.map((exercise, exerciseIndex) => ( // Exercise List when exercises exist
<Card <>
key={exercise.id} {activeWorkout.exercises.map((exercise, exerciseIndex) => (
className="mb-6 overflow-hidden border border-border bg-card" <Card
> key={exercise.id}
{/* Exercise Header */} className="mb-6 overflow-hidden border border-border bg-card"
<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 Header */}
{exercise.title} <View className="flex-row justify-between items-center px-4 py-3 border-b border-border">
</Text> <Text className="text-lg font-semibold text-purple">
<Button {exercise.title}
variant="ghost" </Text>
size="icon" <Button
onPress={() => { variant="ghost"
// Open exercise options menu size="icon"
console.log('Open exercise options'); onPress={() => {
}} // Open exercise options menu
> console.log('Open exercise options');
<MoreHorizontal className="text-muted-foreground" size={20} /> }}
</Button> >
</View> <MoreHorizontal className="text-muted-foreground" size={20} />
</Button>
{/* Sets Info */} </View>
<View className="px-4 py-2">
<Text className="text-sm text-muted-foreground"> {/* Sets Info */}
{exercise.sets.filter(s => s.isCompleted).length} sets completed <View className="px-4 py-2">
</Text> <Text className="text-sm text-muted-foreground">
</View> {exercise.sets.filter(s => s.isCompleted).length} sets completed
</Text>
{/* Set Headers */} </View>
<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> {/* Set Headers */}
<Text className="w-20 text-sm font-medium text-muted-foreground">PREV</Text> <View className="flex-row px-4 py-2 border-t border-border bg-muted/30">
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text> <Text className="w-16 text-sm font-medium text-muted-foreground">SET</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text> <Text className="w-20 text-sm font-medium text-muted-foreground">PREV</Text>
<View style={{ width: 44 }} /> <Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text>
</View> <Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text>
<View style={{ width: 44 }} />
{/* Exercise Sets */} </View>
<CardContent className="p-0">
{exercise.sets.map((set, setIndex) => { {/* Exercise Sets */}
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null; <CardContent className="p-0">
return ( {exercise.sets.map((set, setIndex) => {
<View const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null;
key={set.id} return (
className={cn( <View
"flex-row items-center px-4 py-3 border-t border-border", key={set.id}
set.isCompleted && "bg-primary/5" 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} {/* Set Number */}
</Text> <Text className="w-16 text-base font-medium text-foreground">
{setIndex + 1}
{/* Previous Set */} </Text>
<Text className="w-20 text-sm text-muted-foreground">
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'} {/* Previous Set */}
</Text> <Text className="w-20 text-sm text-muted-foreground">
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
</Text>
{/* Weight Input */} {/* Weight Input */}
<View className="flex-1 px-2"> <View className="flex-1 px-2">
<View className={cn( <TextInput
"bg-secondary h-10 rounded-md px-3 justify-center", className={cn(
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
set.isCompleted && "bg-primary/10" set.isCompleted && "bg-primary/10"
)}> )}
<Text className="text-center text-foreground"> value={set.weight ? set.weight.toString() : ''}
{set.weight} onChangeText={(text) => {
</Text> const weight = text === '' ? 0 : parseFloat(text);
</View> if (!isNaN(weight)) {
updateSet(exerciseIndex, setIndex, { weight });
}
}}
keyboardType="numeric"
selectTextOnFocus
/>
</View> </View>
{/* Reps Input */} {/* Reps Input */}
<View className="flex-1 px-2"> <View className="flex-1 px-2">
<View className={cn( <TextInput
"bg-secondary h-10 rounded-md px-3 justify-center", className={cn(
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
set.isCompleted && "bg-primary/10" set.isCompleted && "bg-primary/10"
)}> )}
<Text className="text-center text-foreground"> value={set.reps ? set.reps.toString() : ''}
{set.reps} onChangeText={(text) => {
</Text> const reps = text === '' ? 0 : parseInt(text, 10);
</View> if (!isNaN(reps)) {
updateSet(exerciseIndex, setIndex, { reps });
}
}}
keyboardType="numeric"
selectTextOnFocus
/>
</View> </View>
{/* Complete Button */} {/* Complete Button */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="w-11 h-11" className="w-11 h-11"
onPress={() => handleCompleteSet(exerciseIndex, setIndex)} onPress={() => handleCompleteSet(exerciseIndex, setIndex)}
> >
<CheckCircle2 <CheckCircle2
className={set.isCompleted ? "text-purple" : "text-muted-foreground"} className={set.isCompleted ? "text-purple" : "text-muted-foreground"}
fill={set.isCompleted ? "currentColor" : "none"} fill={set.isCompleted ? "currentColor" : "none"}
size={22} size={22}
/> />
</Button> </Button>
</View> </View>
); );
})} })}
</CardContent> </CardContent>
{/* Add Set Button */} {/* 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 <Button
variant="ghost" variant="outline"
className="flex-row justify-center items-center py-3 border-t border-border" className="w-full"
onPress={() => handleAddSet(exerciseIndex)} onPress={() => setShowCancelDialog(true)}
> >
<Plus size={18} className="text-foreground mr-2" /> <Text className="text-foreground">Cancel Workout</Text>
<Text className="text-foreground">Add Set</Text>
</Button> </Button>
</Card> </View>
)) </>
) : ( ) : (
<View className="flex-1 items-center justify-center py-20"> // Empty State with nice message and icon
<Text className="text-lg text-muted-foreground text-center"> <View className="flex-1 justify-center items-center px-4">
No exercises added. Add exercises to start your workout. <Dumbbell className="text-muted-foreground mb-6" size={80} />
<Text className="text-xl font-semibold text-center mb-2">
No exercises added
</Text> </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> </View>
)} )}
</ScrollView> </ScrollView>
{/* Add Exercise FAB */} {/* Add Exercise FAB - only shown when exercises exist */}
<View style={{ {hasExercises && (
position: 'absolute', <View style={{
right: 16, position: 'absolute',
bottom: insets.bottom + 16 right: 16,
}}> bottom: insets.bottom + 16
<FloatingActionButton }}>
icon={Plus} <FloatingActionButton
onPress={() => router.push('/(workout)/add-exercises')} 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>
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> </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> </TabScreen>
); );
} }

View 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,
},
});

View File

@ -19,7 +19,7 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork
<CardContent className="flex-col gap-4"> <CardContent className="flex-col gap-4">
<Button <Button
size="lg" size="lg"
className="w-full items-center justify-center gap-2" className="w-full flex-row items-center justify-center gap-2"
onPress={onStartBlank} onPress={onStartBlank}
> >
<Play className="h-5 w-5" /> <Play className="h-5 w-5" />
@ -29,7 +29,7 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
className="w-full items-center justify-center gap-2" className="w-full flex-row items-center justify-center gap-2"
onPress={onSelectTemplate} onPress={onSelectTemplate}
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />

View File

@ -17,10 +17,14 @@ import type {
TemplateType, TemplateType,
TemplateExerciseConfig TemplateExerciseConfig
} from '@/types/templates'; } 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 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 { interface FavoriteItem {
id: string; id: string;
content: WorkoutTemplate; content: WorkoutTemplate;
@ -29,32 +33,33 @@ interface FavoriteItem {
interface ExtendedWorkoutState extends WorkoutState { interface ExtendedWorkoutState extends WorkoutState {
isActive: boolean; isActive: boolean;
isMinimized: boolean;
favorites: FavoriteItem[]; favorites: FavoriteItem[];
} }
interface WorkoutActions { interface WorkoutActions {
// Core Workout Flow // Core Workout Flow
startWorkout: (workout: Partial<Workout>) => void; startWorkout: (workout: Partial<Workout>) => void;
pauseWorkout: () => void; pauseWorkout: () => void;
resumeWorkout: () => void; resumeWorkout: () => void;
completeWorkout: () => void; completeWorkout: () => void;
cancelWorkout: () => void; cancelWorkout: () => void;
reset: () => void; reset: () => void;
// Exercise and Set Management // Exercise and Set Management
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void; updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
completeSet: (exerciseIndex: number, setIndex: number) => void; completeSet: (exerciseIndex: number, setIndex: number) => void;
nextExercise: () => void; nextExercise: () => void;
previousExercise: () => void; previousExercise: () => void;
// Rest Timer // Rest Timer
startRest: (duration: number) => void; startRest: (duration: number) => void;
stopRest: () => void; stopRest: () => void;
extendRest: (additionalSeconds: number) => void; extendRest: (additionalSeconds: number) => void;
// Timer Actions // Timer Actions
tick: (elapsed: number) => void; tick: (elapsed: number) => void;
} }
interface ExtendedWorkoutActions extends WorkoutActions { interface ExtendedWorkoutActions extends WorkoutActions {
// Core Workout Flow from original implementation // Core Workout Flow from original implementation
@ -93,6 +98,14 @@ interface ExtendedWorkoutActions extends WorkoutActions {
endWorkout: () => Promise<void>; endWorkout: () => Promise<void>;
clearAutoSave: () => Promise<void>; clearAutoSave: () => Promise<void>;
updateWorkoutTitle: (title: string) => void; updateWorkoutTitle: (title: string) => void;
// Minimized state actions
minimizeWorkout: () => void;
maximizeWorkout: () => void;
// Workout timer management
startWorkoutTimer: () => void;
stopWorkoutTimer: () => void;
} }
const initialState: ExtendedWorkoutState = { const initialState: ExtendedWorkoutState = {
@ -107,11 +120,12 @@ const initialState: ExtendedWorkoutState = {
remaining: 0 remaining: 0
}, },
isActive: false, isActive: false,
isMinimized: false,
favorites: [] favorites: []
}; };
const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
...initialState, ...initialState,
// Core Workout Flow // Core Workout Flow
startWorkout: (workoutData: Partial<Workout> = {}) => { startWorkout: (workoutData: Partial<Workout> = {}) => {
@ -135,8 +149,12 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
activeWorkout: workout, activeWorkout: workout,
currentExerciseIndex: 0, currentExerciseIndex: 0,
elapsedTime: 0, elapsedTime: 0,
isActive: true isActive: true,
isMinimized: false
}); });
// Start the workout timer
get().startWorkoutTimer();
}, },
pauseWorkout: () => { pauseWorkout: () => {
@ -159,6 +177,9 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
const { activeWorkout } = get(); const { activeWorkout } = get();
if (!activeWorkout) return; if (!activeWorkout) return;
// Stop the workout timer
get().stopWorkoutTimer();
const completedWorkout = { const completedWorkout = {
...activeWorkout, ...activeWorkout,
isCompleted: true, isCompleted: true,
@ -175,23 +196,43 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
set({ set({
status: 'completed', status: 'completed',
activeWorkout: completedWorkout activeWorkout: completedWorkout,
isActive: false,
isMinimized: false
}); });
}, },
cancelWorkout: () => { cancelWorkout: async () => {
const { activeWorkout } = get(); const { activeWorkout } = get();
if (!activeWorkout) return; if (!activeWorkout) return;
// Save cancelled state for recovery if needed // Stop the workout timer
saveWorkout({ get().stopWorkoutTimer();
// Prepare canceled workout with proper metadata
const canceledWorkout = {
...activeWorkout, ...activeWorkout,
isCompleted: false, isCompleted: false,
endTime: Date.now(), 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 // Exercise and Set Management
@ -232,10 +273,14 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
const sets = [...exercise.sets]; const sets = [...exercise.sets];
const now = Date.now(); const now = Date.now();
// Toggle completion status
const isCurrentlyCompleted = sets[setIndex].isCompleted;
sets[setIndex] = { sets[setIndex] = {
...sets[setIndex], ...sets[setIndex],
isCompleted: true, isCompleted: !isCurrentlyCompleted,
completedAt: now, completedAt: !isCurrentlyCompleted ? now : undefined,
lastUpdated: now lastUpdated: now
}; };
@ -345,7 +390,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
const { status, restTimer } = get(); const { status, restTimer } = get();
if (status === 'active') { if (status === 'active') {
set((state: WorkoutState) => ({ set((state: ExtendedWorkoutState) => ({
elapsedTime: state.elapsedTime + elapsed 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 // Template Management
startWorkoutFromTemplate: async (templateId: string) => { startWorkoutFromTemplate: async (templateId: string) => {
@ -385,13 +457,13 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
source: ['local'] source: ['local']
}, },
created_at: Date.now(), created_at: Date.now(),
sets: Array(templateExercise.targetSets || 3).fill({ sets: Array(templateExercise.targetSets || 3).fill(0).map(() => ({
id: generateId('local'), id: generateId('local'),
type: 'normal', type: 'normal',
weight: 0, weight: 0,
reps: templateExercise.targetReps || 0, reps: templateExercise.targetReps || 0,
isCompleted: false isCompleted: false
}), })),
isCompleted: false, isCompleted: false,
notes: templateExercise.notes || '' notes: templateExercise.notes || ''
})); }));
@ -405,18 +477,18 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
}); });
}, },
updateWorkoutTitle: (title: string) => { updateWorkoutTitle: (title: string) => {
const { activeWorkout } = get(); const { activeWorkout } = get();
if (!activeWorkout) return; if (!activeWorkout) return;
set({ set({
activeWorkout: { activeWorkout: {
...activeWorkout, ...activeWorkout,
title, title,
lastUpdated: Date.now() lastUpdated: Date.now()
} }
}); });
}, },
// Favorite Management // Favorite Management
getFavorites: async () => { getFavorites: async () => {
@ -448,30 +520,42 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
if (!activeWorkout) return; if (!activeWorkout) return;
await get().completeWorkout(); await get().completeWorkout();
set({ isActive: false });
}, },
clearAutoSave: async () => { clearAutoSave: async () => {
// TODO: Implement clearing autosave from storage // TODO: Implement clearing autosave from storage
get().stopWorkoutTimer(); // Make sure to stop the timer
set(initialState); 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 // Helper functions
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> { async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
// This is a placeholder - you'll need to implement actual template fetching // This is a placeholder - you'll need to implement actual template fetching
// from your database/storage service // from your database/storage service
try { try {
// Example implementation: // Example implementation:
// return await db.getTemplate(templateId); // return await db.getTemplate(templateId);
return null; return null;
} catch (error) { } catch (error) {
console.error('Error fetching template:', error); console.error('Error fetching template:', error);
return null; return null;
}
} }
}
async function saveWorkout(workout: Workout): Promise<void> { async function saveWorkout(workout: Workout): Promise<void> {
try { try {
@ -533,4 +617,16 @@ async function saveSummary(summary: WorkoutSummary) {
} }
// Create auto-generated selectors // 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');
}
});
}