mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-05 16:52:07 +00:00
added active workout bar
This commit is contained in:
parent
665751961e
commit
b4dc79cc87
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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">
|
<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" />
|
||||||
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user