From 3feb366a781d692e4739a30c5bc64eb6a2582270 Mon Sep 17 00:00:00 2001 From: DocNR Date: Fri, 7 Mar 2025 12:38:21 -0500 Subject: [PATCH] bug fix workout completion flow --- app/(workout)/complete.tsx | 153 +++------- app/(workout)/create.tsx | 26 +- components/workout/WorkoutAlertDialog.tsx | 67 +++++ components/workout/WorkoutCompletionFlow.tsx | 299 ++++++++++--------- stores/workoutStore.ts | 81 +++-- 5 files changed, 340 insertions(+), 286 deletions(-) create mode 100644 components/workout/WorkoutAlertDialog.tsx diff --git a/app/(workout)/complete.tsx b/app/(workout)/complete.tsx index 4e261b1..090aa12 100644 --- a/app/(workout)/complete.tsx +++ b/app/(workout)/complete.tsx @@ -1,5 +1,5 @@ -// app/(workout)/complete.tsx - revised version -import React from 'react'; +// app/(workout)/complete.tsx +import React, { useEffect } from 'react'; import { View, Modal, TouchableOpacity } from 'react-native'; import { router } from 'expo-router'; import { Text } from '@/components/ui/text'; @@ -9,132 +9,47 @@ import { WorkoutCompletionFlow } from '@/components/workout/WorkoutCompletionFlo import { WorkoutCompletionOptions } from '@/types/workout'; import { useColorScheme } from '@/lib/useColorScheme'; +/** + * Workout Completion Screen + * + * This modal screen is presented when a user chooses to complete their workout. + * It serves as a container for the multi-step completion flow, handling: + * - Modal presentation and dismissal + * - Delegating completion logic to the WorkoutCompletionFlow component + * + * The screen acts primarily as a UI wrapper, with the core completion logic + * handled by the WorkoutStore and the step-by-step flow managed by the + * WorkoutCompletionFlow component. + * + * Note: Workout timing is already stopped at this point, as the end time + * was set when the user confirmed finishing in the create screen. + */ export default function CompleteWorkoutScreen() { - const { resumeWorkout } = useWorkoutStore.getState(); + const { resumeWorkout, activeWorkout } = useWorkoutStore(); const { isDarkColorScheme } = useColorScheme(); -// Handle complete with options -const handleComplete = async (options: WorkoutCompletionOptions) => { - // Get a fresh reference to completeWorkout and other functions - const { completeWorkout, activeWorkout } = useWorkoutStore.getState(); + // Check if we have a workout to complete + if (!activeWorkout) { + // If there's no active workout, redirect back to the home screen + router.replace('/(tabs)'); + return null; + } + + // Handle complete with options + const handleComplete = async (options: WorkoutCompletionOptions) => { + // Get a fresh reference to completeWorkout + const { completeWorkout } = useWorkoutStore.getState(); - // 1. Complete the workout locally first - await completeWorkout(); - - // 2. If publishing to Nostr is selected, create and publish workout event - let workoutEventId = null; - if (options.storageType !== 'local_only' && activeWorkout) { - try { - const ndkStore = require('@/lib/stores/ndk').useNDKStore.getState(); - - // Create workout tags based on NIP-4e - const workoutTags = [ - ['d', activeWorkout.id], // Unique identifier - ['title', activeWorkout.title], - ['type', activeWorkout.type], - ['start', Math.floor(activeWorkout.startTime / 1000).toString()], - ['end', Math.floor(Date.now() / 1000).toString()], - ['completed', 'true'] - ]; - - // Add exercise tags - activeWorkout.exercises.forEach(exercise => { - // Add exercise tags following NIP-4e format - exercise.sets.forEach(set => { - if (set.isCompleted) { - workoutTags.push([ - 'exercise', - `33401:${exercise.id}`, - '', // relay URL can be empty for now - set.weight?.toString() || '', - set.reps?.toString() || '', - set.rpe?.toString() || '', - set.type || 'normal' - ]); - } - }); - }); - - // Add template reference if workout was based on a template - if (activeWorkout.templateId) { - workoutTags.push(['template', `33402:${activeWorkout.templateId}`, '']); - } - - // Add hashtags - workoutTags.push(['t', 'workout'], ['t', 'fitness']); - - // Attempt to publish the workout event - console.log("Publishing workout event with tags:", workoutTags); - const workoutEvent = await ndkStore.publishEvent( - 1301, // Use kind 1301 for workout records - activeWorkout.notes || "Completed workout", // Content is workout notes - workoutTags - ); - - if (workoutEvent) { - workoutEventId = workoutEvent.id; - console.log("Successfully published workout event:", workoutEventId); - } - } catch (error) { - console.error("Error publishing workout event:", error); - } - } - - // 3. If social sharing is enabled, create a reference to the workout event - if (options.shareOnSocial && options.socialMessage && workoutEventId) { - try { - const ndkStore = require('@/lib/stores/ndk').useNDKStore.getState(); - - // Create social post tags - const socialTags = [ - ['t', 'workout'], - ['t', 'fitness'], - ['t', 'powr'], - ['client', 'POWR'] - ]; - - // Get current user pubkey - const currentUserPubkey = ndkStore.currentUser?.pubkey; - - // Add quote reference to the workout event using 'q' tag - if (workoutEventId) { - // Format: ["q", "", "", ""] - socialTags.push(['q', workoutEventId, '', currentUserPubkey || '']); - } - - // Publish social post - await ndkStore.publishEvent(1, options.socialMessage, socialTags); - console.log("Successfully published social post quoting workout"); - } catch (error) { - console.error("Error publishing social post:", error); - } - } - - // 4. Handle template updates if needed - if (activeWorkout?.templateId && options.templateAction !== 'keep_original') { - try { - const TemplateService = require('@/lib/db/services/TemplateService').TemplateService; - - if (options.templateAction === 'update_existing') { - await TemplateService.updateExistingTemplate(activeWorkout); - } else if (options.templateAction === 'save_as_new' && options.newTemplateName) { - await TemplateService.saveAsNewTemplate(activeWorkout, options.newTemplateName); - } - } catch (error) { - console.error('Error handling template action:', error); - } - } - - // Navigate to home or history page - router.replace('/(tabs)/history'); + // Complete the workout with the provided options + await completeWorkout(options); }; - // Handle cancellation + // Handle cancellation (go back to workout) const handleCancel = () => { - resumeWorkout(); + // Go back to the workout screen router.back(); }; - + return ( router.push('/(workout)/complete')} + onPress={() => setShowFinishDialog(true)} disabled={!hasExercises} > Finish @@ -459,6 +461,28 @@ export default function CreateWorkoutScreen() { + {/* Finish Workout Dialog */} + { + setShowFinishDialog(false); + // Set the end time before navigating + useWorkoutStore.setState(state => ({ + activeWorkout: state.activeWorkout ? { + ...state.activeWorkout, + endTime: Date.now(), + lastUpdated: Date.now() + } : null + })); + // Navigate to completion screen + router.push('/(workout)/complete'); + }} + title="Complete Workout?" + description="Are you sure you want to finish this workout? This will end your current session." + confirmText="Complete Workout" + /> + {/* Cancel Workout Dialog */} diff --git a/components/workout/WorkoutAlertDialog.tsx b/components/workout/WorkoutAlertDialog.tsx new file mode 100644 index 0000000..954d6d5 --- /dev/null +++ b/components/workout/WorkoutAlertDialog.tsx @@ -0,0 +1,67 @@ +// components/workout/WorkoutAlertDialog.tsx +import React from 'react'; +import { Text } from '@/components/ui/text'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface WorkoutAlertDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + title?: string; + description?: string; + confirmText?: string; + cancelText?: string; +} + +/** + * A reusable alert dialog component for workout-related confirmations + * Includes styling specific to workout flows including emoji and purple accent + */ +export function WorkoutAlertDialog({ + open, + onOpenChange, + onConfirm, + title = "Complete Workout?", + description = "Are you sure you want to finish this workout?", + confirmText = "Complete", + cancelText = "Cancel" +}: WorkoutAlertDialogProps) { + return ( + + + + + + 💪 {title} + + + + + {description} + + + + + onOpenChange(false)}> + {cancelText} + + + {confirmText} + + + + + ); +} \ No newline at end of file diff --git a/components/workout/WorkoutCompletionFlow.tsx b/components/workout/WorkoutCompletionFlow.tsx index b82da05..01decff 100644 --- a/components/workout/WorkoutCompletionFlow.tsx +++ b/components/workout/WorkoutCompletionFlow.tsx @@ -1,4 +1,4 @@ -// components/workout/WorkoutCompletionFlow.tsx - streamlined with celebration +// components/workout/WorkoutCompletionFlow.tsx import React, { useState, useEffect } from 'react'; import { View, TouchableOpacity, ScrollView } from 'react-native'; import { Text } from '@/components/ui/text'; @@ -17,12 +17,30 @@ import { Clock, Dumbbell, Trophy, - Share, Cog } from 'lucide-react-native'; import { TemplateService } from '@/lib/db/services/TemplateService'; import Confetti from '@/components/Confetti'; +/** + * Workout Completion Flow Component + * + * This component manages the multi-step process of completing a workout: + * 1. Storage Options - How the workout data should be stored (local/published) + * 2. Summary - Displaying workout statistics before finalizing + * 3. Celebration - Confirming completion and providing sharing options + * + * Key features: + * - Step-based navigation with back/next functionality + * - Nostr sharing options for completed workouts + * - Template management options if workout was based on a template + * + * This component handles UI state and user input, but delegates the actual + * workout completion to the parent component via the onComplete callback. + * The Nostr publishing happens in the WorkoutStore after receiving the + * selected options from this flow. + */ + // Storage options component function StorageOptionsTab({ options, @@ -57,10 +75,6 @@ function StorageOptionsTab({ // Check if the workout is based on a template const { activeWorkout } = useWorkoutStore(); - // Add debug logging - console.log('Active workout:', activeWorkout?.id); - console.log('Template ID:', activeWorkout?.templateId); - // Use a try-catch block for more resilience let hasTemplateChanges = false; try { @@ -71,9 +85,6 @@ function StorageOptionsTab({ console.error('Error checking template changes:', error); } - console.log('Has template changes:', hasTemplateChanges); - console.log('Template action:', options.templateAction); - return ( @@ -298,15 +309,10 @@ function SummaryTab({ : `${minutes}m ${seconds % 60}s`; }; + // Get workout duration using timestamps const getDuration = (): number => { - if (!activeWorkout) return 0; - - if (activeWorkout.endTime && activeWorkout.startTime) { - return activeWorkout.endTime - activeWorkout.startTime; - } - - // If no end time, use current time - return Date.now() - activeWorkout.startTime; + if (!activeWorkout || !activeWorkout.endTime) return 0; + return activeWorkout.endTime - activeWorkout.startTime; }; const getTotalSets = (): number => { @@ -494,141 +500,141 @@ function SummaryTab({ // Celebration component with share option function CelebrationTab({ - options, - onComplete - }: { - options: WorkoutCompletionOptions, - onComplete: (options: WorkoutCompletionOptions) => void - }) { - const { isAuthenticated } = useNDKCurrentUser(); - const { activeWorkout } = useWorkoutStore(); // Move hook usage to component level - const [showConfetti, setShowConfetti] = useState(true); - const [shareMessage, setShareMessage] = useState(''); + options, + onComplete +}: { + options: WorkoutCompletionOptions, + onComplete: (options: WorkoutCompletionOptions) => void +}) { + const { isAuthenticated } = useNDKCurrentUser(); + const { activeWorkout } = useWorkoutStore(); + const [showConfetti, setShowConfetti] = useState(true); + const [shareMessage, setShareMessage] = useState(''); + + // Purple color used throughout the app + const purpleColor = 'hsl(261, 90%, 66%)'; + + // Generate default share message + useEffect(() => { + // Create default message based on workout data + let message = "Just completed a workout! 💪"; - // Purple color used throughout the app - const purpleColor = 'hsl(261, 90%, 66%)'; - - // Generate default share message - useEffect(() => { - // Create default message based on workout data - let message = "Just completed a workout! 💪"; + if (activeWorkout) { + const exerciseCount = activeWorkout.exercises.length; + const completedSets = activeWorkout.exercises.reduce( + (total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length, 0 + ); - if (activeWorkout) { - const exerciseCount = activeWorkout.exercises.length; - const completedSets = activeWorkout.exercises.reduce( - (total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length, 0 - ); - - // Add workout details - message = `Just completed a workout with ${exerciseCount} exercises and ${completedSets} sets! 💪`; - - // Add mock PR info - if (Math.random() > 0.5) { - message += " Hit some new PRs today! 🏆"; - } + // Add workout details + message = `Just completed a workout with ${exerciseCount} exercises and ${completedSets} sets! 💪`; + + // Add mock PR info + if (Math.random() > 0.5) { + message += " Hit some new PRs today! 🏆"; } - - setShareMessage(message); - }, [activeWorkout]); + } - const handleShare = () => { - // This will trigger a kind 1 note creation via the onComplete handler - onComplete({ - ...options, - shareOnSocial: true, - socialMessage: shareMessage - }); - }; - - const handleSkip = () => { - // Complete the workout without sharing - onComplete({ - ...options, - shareOnSocial: false - }); - }; - - return ( - - - {showConfetti && ( - setShowConfetti(false)} /> + setShareMessage(message); + }, [activeWorkout]); + + const handleShare = () => { + // This will trigger a kind 1 note creation via the onComplete handler + onComplete({ + ...options, + shareOnSocial: true, + socialMessage: shareMessage + }); + }; + + const handleSkip = () => { + // Complete the workout without sharing + onComplete({ + ...options, + shareOnSocial: false + }); + }; + + return ( + + + {showConfetti && ( + setShowConfetti(false)} /> + )} + + + {/* Trophy and heading */} + + + + Workout Complete! + + + Great job on finishing your workout! + + + + {/* Show sharing options for Nostr if appropriate */} + {isAuthenticated && options.storageType !== 'local_only' && ( + <> + + + + + Share Your Achievement + + + + Share your workout with your followers on Nostr + + + + + + + + + )} - - {/* Trophy and heading */} - - - - Workout Complete! + {/* If local-only or not authenticated */} + {(options.storageType === 'local_only' || !isAuthenticated) && ( + - - - - - )} - - {/* If local-only or not authenticated */} - {(options.storageType === 'local_only' || !isAuthenticated) && ( - - )} - + + )} - - ); - } + + + ); +} export function WorkoutCompletionFlow({ onComplete, @@ -637,8 +643,6 @@ export function WorkoutCompletionFlow({ onComplete: (options: WorkoutCompletionOptions) => void; onCancel: () => void; }) { - const { stopWorkoutTimer } = useWorkoutStore(); - // States const [options, setOptions] = useState({ storageType: 'local_only', @@ -658,9 +662,6 @@ export function WorkoutCompletionFlow({ }; const handleFinish = () => { - // Stop workout timer when finishing - stopWorkoutTimer(); - // Move to celebration screen setStep('celebration'); }; diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts index d5d3008..8c82e7d 100644 --- a/stores/workoutStore.ts +++ b/stores/workoutStore.ts @@ -1,5 +1,20 @@ // stores/workoutStore.ts +/** + * Workout Store + * + * This store manages the state for active workouts including: + * - Starting, pausing, and completing workouts + * - Managing exercise sets and completion status + * - Handling workout timing and duration tracking + * - Publishing workout data to Nostr when requested + * - Tracking favorite templates + * + * The store uses a timestamp-based approach for duration tracking, + * capturing start and end times to accurately represent workout duration + * even when accounting for time spent in completion flow. + */ + import { create } from 'zustand'; import { createSelectors } from '@/utils/createSelectors'; import { generateId } from '@/utils/ids'; @@ -76,8 +91,8 @@ interface ExtendedWorkoutActions extends WorkoutActions { startWorkout: (workout: Partial) => void; pauseWorkout: () => void; resumeWorkout: () => void; - completeWorkout: () => void; - cancelWorkout: () => void; + completeWorkout: (options?: WorkoutCompletionOptions) => Promise; + cancelWorkout: () => Promise; reset: () => void; publishEvent: (event: NostrEvent) => Promise; @@ -142,6 +157,9 @@ const useWorkoutStoreBase = create = {}) => { + // First stop any existing timer to avoid duplicate timers + get().stopWorkoutTimer(); + const workout: Workout = { id: generateId('local'), title: workoutData.title || 'Quick Workout', @@ -174,6 +192,9 @@ const useWorkoutStoreBase = create { const { activeWorkout } = get(); if (!activeWorkout) return; - - // Stop the workout timer + + // Ensure workout timer is stopped get().stopWorkoutTimer(); - + // If no options were provided, show the completion flow if (!options) { // Navigate to the completion flow screen router.push('/(workout)/complete'); return; } - + const completedWorkout = { ...activeWorkout, isCompleted: true, - endTime: Date.now(), lastUpdated: Date.now() - }; + }; try { // Save workout locally regardless of storage option @@ -291,13 +313,20 @@ const useWorkoutStoreBase = create { // Clear any existing timer first to prevent duplicates if (workoutTimerInterval) { @@ -555,6 +584,7 @@ const useWorkoutStoreBase = create { + // Get fresh state reference to avoid stale closures const { status } = useWorkoutStoreBase.getState(); if (status === 'active') { useWorkoutStoreBase.getState().tick(1000); @@ -720,13 +750,28 @@ const useWorkoutStoreBase = create { const { activeWorkout } = get(); if (!activeWorkout) return; - - await get().completeWorkout(); + + // Set the end time right when entering completion flow + set({ + activeWorkout: { + ...activeWorkout, + endTime: Date.now(), + lastUpdated: Date.now() + } + }); + + // Make sure to stop the timer before navigating + get().stopWorkoutTimer(); + + // Navigate to completion screen + router.push('/(workout)/complete'); }, clearAutoSave: async () => { // TODO: Implement clearing autosave from storage - get().stopWorkoutTimer(); // Make sure to stop the timer + + // Make sure to stop the timer + get().stopWorkoutTimer(); // Preserve favorites when resetting const favoriteIds = get().favoriteIds; @@ -749,7 +794,8 @@ const useWorkoutStoreBase = create { - get().stopWorkoutTimer(); // Make sure to stop the timer + // Make sure to stop the timer + get().stopWorkoutTimer(); // Preserve favorites when resetting const favoriteIds = get().favoriteIds; @@ -792,8 +838,9 @@ async function getTemplate(templateId: string): Promise async function saveWorkout(workout: Workout): Promise { try { + // Make sure we're capturing the duration properly in what's saved + console.log('Saving workout with endTime:', workout.endTime); // TODO: Implement actual save logic using our database service - console.log('Saving workout:', workout); } catch (error) { console.error('Error saving workout:', error); }