From b4dc79cc879914fe7e09c1a05ae33fc430bfba50 Mon Sep 17 00:00:00 2001 From: DocNR Date: Tue, 25 Feb 2025 15:03:45 -0500 Subject: [PATCH] added active workout bar --- app/(tabs)/_layout.tsx | 158 ++++---- app/(tabs)/index.tsx | 155 +++++--- app/(workout)/create.tsx | 459 +++++++++++++++--------- components/workout/ActiveWorkoutBar.tsx | 175 +++++++++ components/workout/HomeWorkout.tsx | 4 +- stores/workoutStore.ts | 222 ++++++++---- 6 files changed, 814 insertions(+), 359 deletions(-) create mode 100644 components/workout/ActiveWorkoutBar.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 7738fb2..6d4df20 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( - - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - + + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + + {/* Render the ActiveWorkoutBar above the tab bar */} + + ); } \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f3eee4f..59a2d4d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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(null) - const [favoriteWorkouts, setFavoriteWorkouts] = useState([]) - const [isLoadingFavorites, setIsLoadingFavorites] = useState(true) + const [showActiveWorkoutModal, setShowActiveWorkoutModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [favoriteWorkouts, setFavoriteWorkouts] = useState([]); + 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 (
router.push('/(workout)/template-select')} + onStartBlank={handleQuickStart} + onSelectTemplate={handleSelectTemplate} /> {/* Favorites section */} @@ -205,11 +258,11 @@ export default function WorkoutScreen() { Active Workout - You have an active workout in progress. Would you like to finish it first? + You have an active workout in progress. Would you like to continue it or start a new workout? - setShowActiveWorkoutModal(false)}> + Start New @@ -219,5 +272,5 @@ export default function WorkoutScreen() { - ) + ); } \ No newline at end of file diff --git a/app/(workout)/create.tsx b/app/(workout)/create.tsx index e69ba40..4ecb43c 100644 --- a/app/(workout)/create.tsx +++ b/app/(workout)/create.tsx @@ -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 ( + + + {/* Timer Display */} + + + Rest Timer + + + {formatTime(restTimer.remaining * 1000)} + + + + {/* Controls */} + + + + + + + + ); + } + + const hasExercises = activeWorkout.exercises.length > 0; + return ( - {/* Swipe indicator */} + {/* Swipe indicator and back button */} {/* Header with Title and Finish Button */} - {/* Finish button in top right */} - + {/* Top row with minimize and finish buttons */} + + + @@ -182,177 +244,224 @@ export default function CreateWorkoutScreen() { - {/* Scrollable Exercises List */} + {/* Content Area */} - {activeWorkout.exercises.length > 0 ? ( - activeWorkout.exercises.map((exercise, exerciseIndex) => ( - - {/* Exercise Header */} - - - {exercise.title} - - - - - {/* Sets Info */} - - - {exercise.sets.filter(s => s.isCompleted).length} sets completed - - - - {/* Set Headers */} - - SET - PREV - KG - REPS - - - - {/* Exercise Sets */} - - {exercise.sets.map((set, setIndex) => { - const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null; - return ( - - {/* Set Number */} - - {setIndex + 1} - - - {/* Previous Set */} - - {previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'} - - + {hasExercises ? ( + // Exercise List when exercises exist + <> + {activeWorkout.exercises.map((exercise, exerciseIndex) => ( + + {/* Exercise Header */} + + + {exercise.title} + + + + + {/* Sets Info */} + + + {exercise.sets.filter(s => s.isCompleted).length} sets completed + + + + {/* Set Headers */} + + SET + PREV + KG + REPS + + + + {/* Exercise Sets */} + + {exercise.sets.map((set, setIndex) => { + const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null; + return ( + + {/* Set Number */} + + {setIndex + 1} + + + {/* Previous Set */} + + {previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'} + + {/* Weight Input */} - - - {set.weight} - - + )} + value={set.weight ? set.weight.toString() : ''} + onChangeText={(text) => { + const weight = text === '' ? 0 : parseFloat(text); + if (!isNaN(weight)) { + updateSet(exerciseIndex, setIndex, { weight }); + } + }} + keyboardType="numeric" + selectTextOnFocus + /> - + {/* Reps Input */} - - - {set.reps} - - + )} + 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 + /> - - {/* Complete Button */} - - - ); - })} - - - {/* Add Set Button */} + + {/* Complete Button */} + + + ); + })} + + + {/* Add Set Button */} + + + ))} + + {/* Cancel Button - only shown at the bottom when exercises exist */} + - - )) + + ) : ( - - - No exercises added. Add exercises to start your workout. + // Empty State with nice message and icon + + + + No exercises added + + Add exercises to start tracking your workout + + + {/* Add Exercises Button for empty state */} + + + {/* Cancel Button for empty state */} + )} - {/* Add Exercise FAB */} - - router.push('/(workout)/add-exercises')} - /> - - - {/* Cancel Workout Dialog */} - - - - Cancel Workout - - Are you sure you want to cancel this workout? All progress will be lost. - - - - setShowCancelDialog(false)} - > - Continue Workout - - { - await clearAutoSave(); - router.back(); - }} - > - Cancel Workout - - - - + {/* Add Exercise FAB - only shown when exercises exist */} + {hasExercises && ( + + router.push('/(workout)/add-exercises')} + /> + + )} + + {/* Cancel Workout Dialog */} + + + + Cancel Workout + + Are you sure you want to cancel this workout? All progress will be lost. + + + + setShowCancelDialog(false)}> + Continue Workout + + + Cancel Workout + + + + ); } diff --git a/components/workout/ActiveWorkoutBar.tsx b/components/workout/ActiveWorkoutBar.tsx new file mode 100644 index 0000000..96e045f --- /dev/null +++ b/components/workout/ActiveWorkoutBar.tsx @@ -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 ( + + + + + + + {activeWorkout.title} + + + {formatTime(elapsedTime)} + + {activeWorkout.exercises.length} exercise{activeWorkout.exercises.length !== 1 ? 's' : ''} + + + + + + ); +} + +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, + }, +}); \ No newline at end of file diff --git a/components/workout/HomeWorkout.tsx b/components/workout/HomeWorkout.tsx index 3f48687..02b3526 100644 --- a/components/workout/HomeWorkout.tsx +++ b/components/workout/HomeWorkout.tsx @@ -19,7 +19,7 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork