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

View File

@ -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>
)
);
}

View File

@ -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>
);
}

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">
<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" />

View File

@ -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');
}
});
}