bug fix workout completion flow

This commit is contained in:
DocNR 2025-03-07 12:38:21 -05:00
parent 25d40b3a12
commit 3feb366a78
5 changed files with 340 additions and 286 deletions

View File

@ -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", "<event-id>", "<relay-url>", "<author-pubkey>"]
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 (
<Modal
visible={true}

View File

@ -26,6 +26,7 @@ import { ParamListBase } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import SetInput from '@/components/workout/SetInput';
import { useColorScheme } from '@/lib/useColorScheme';
import { WorkoutAlertDialog } from '@/components/workout/WorkoutAlertDialog';
export default function CreateWorkoutScreen() {
const {
@ -152,6 +153,7 @@ export default function CreateWorkoutScreen() {
return unsubscribe;
}, [navigation, activeWorkout, isMinimized, minimizeWorkout]);
const [showFinishDialog, setShowFinishDialog] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const insets = useSafeAreaInsets();
@ -273,7 +275,7 @@ export default function CreateWorkoutScreen() {
<Button
variant="purple"
className="px-4"
onPress={() => router.push('/(workout)/complete')}
onPress={() => setShowFinishDialog(true)}
disabled={!hasExercises}
>
<Text className="text-white font-medium">Finish</Text>
@ -459,6 +461,28 @@ export default function CreateWorkoutScreen() {
</ScrollView>
</View>
{/* Finish Workout Dialog */}
<WorkoutAlertDialog
open={showFinishDialog}
onOpenChange={setShowFinishDialog}
onConfirm={() => {
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 */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>

View File

@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Text className="text-xl">
💪 {title}
</Text>
</AlertDialogTitle>
<AlertDialogDescription>
<Text>
{description}
</Text>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onPress={() => onOpenChange(false)}>
<Text>{cancelText}</Text>
</AlertDialogCancel>
<AlertDialogAction
onPress={onConfirm}
className="bg-purple-500"
>
<Text className="text-white">{confirmText}</Text>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -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 (
<ScrollView className="flex-1 px-2">
<View className="py-4 gap-6">
@ -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 (
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View className="flex-1 items-center justify-center px-4 py-8">
{showConfetti && (
<Confetti onComplete={() => 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 (
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View className="flex-1 items-center justify-center px-4 py-8">
{showConfetti && (
<Confetti onComplete={() => setShowConfetti(false)} />
)}
<View className="w-full max-w-md">
{/* Trophy and heading */}
<View className="items-center mb-8">
<Trophy size={60} color="#F59E0B" />
<Text className="text-2xl font-bold text-center mt-4 mb-2">
Workout Complete!
</Text>
<Text className="text-center text-muted-foreground">
Great job on finishing your workout!
</Text>
</View>
{/* Show sharing options for Nostr if appropriate */}
{isAuthenticated && options.storageType !== 'local_only' && (
<>
<Separator className="my-4" />
<View className="mb-4">
<Text className="text-lg font-semibold mb-3">
Share Your Achievement
</Text>
<Text className="text-muted-foreground mb-3">
Share your workout with your followers on Nostr
</Text>
<Input
multiline
numberOfLines={4}
value={shareMessage}
onChangeText={setShareMessage}
className="min-h-[120px] p-3 mb-4"
/>
<Button
className="w-full mb-3"
style={{ backgroundColor: purpleColor }}
onPress={handleShare}
>
<Text className="text-white font-medium">
Share to Nostr
</Text>
</Button>
<Button
variant="outline"
className="w-full"
onPress={handleSkip}
>
<Text>
Skip Sharing
</Text>
</Button>
</View>
</>
)}
<View className="w-full max-w-md">
{/* Trophy and heading */}
<View className="items-center mb-8">
<Trophy size={60} color="#F59E0B" />
<Text className="text-2xl font-bold text-center mt-4 mb-2">
Workout Complete!
{/* If local-only or not authenticated */}
{(options.storageType === 'local_only' || !isAuthenticated) && (
<Button
className="w-full mt-4"
style={{ backgroundColor: purpleColor }}
onPress={handleSkip}
>
<Text className="text-white font-medium">
Continue
</Text>
<Text className="text-center text-muted-foreground">
Great job on finishing your workout!
</Text>
</View>
{/* Show sharing options for Nostr if appropriate */}
{isAuthenticated && options.storageType !== 'local_only' && (
<>
<Separator className="my-4" />
<View className="mb-4">
<Text className="text-lg font-semibold mb-3">
Share Your Achievement
</Text>
<Text className="text-muted-foreground mb-3">
Share your workout with your followers on Nostr
</Text>
<Input
multiline
numberOfLines={4}
value={shareMessage}
onChangeText={setShareMessage}
className="min-h-[120px] p-3 mb-4"
/>
<Button
className="w-full mb-3"
style={{ backgroundColor: purpleColor }}
onPress={handleShare}
>
<Text className="text-white font-medium">
Share to Nostr
</Text>
</Button>
<Button
variant="outline"
className="w-full"
onPress={handleSkip}
>
<Text>
Skip Sharing
</Text>
</Button>
</View>
</>
)}
{/* If local-only or not authenticated */}
{(options.storageType === 'local_only' || !isAuthenticated) && (
<Button
className="w-full mt-4"
style={{ backgroundColor: purpleColor }}
onPress={handleSkip}
>
<Text className="text-white font-medium">
Continue
</Text>
</Button>
)}
</View>
</Button>
)}
</View>
</ScrollView>
);
}
</View>
</ScrollView>
);
}
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<WorkoutCompletionOptions>({
storageType: 'local_only',
@ -658,9 +662,6 @@ export function WorkoutCompletionFlow({
};
const handleFinish = () => {
// Stop workout timer when finishing
stopWorkoutTimer();
// Move to celebration screen
setStep('celebration');
};

View File

@ -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<Workout>) => void;
pauseWorkout: () => void;
resumeWorkout: () => void;
completeWorkout: () => void;
cancelWorkout: () => void;
completeWorkout: (options?: WorkoutCompletionOptions) => Promise<void>;
cancelWorkout: () => Promise<void>;
reset: () => void;
publishEvent: (event: NostrEvent) => Promise<any>;
@ -142,6 +157,9 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
// Core Workout Flow
startWorkout: (workoutData: Partial<Workout> = {}) => {
// 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<ExtendedWorkoutState & ExtendedWorkoutActions
const { status, activeWorkout } = get();
if (status !== 'active' || !activeWorkout) return;
// Stop the timer interval when pausing
get().stopWorkoutTimer();
set({ status: 'paused' });
// Auto-save when pausing
saveWorkout(activeWorkout);
@ -184,29 +205,30 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
if (status !== 'paused' || !activeWorkout) return;
set({ status: 'active' });
// Restart the timer when resuming
get().startWorkoutTimer();
},
// Update completeWorkout in workoutStore.ts
completeWorkout: async (options?: WorkoutCompletionOptions) => {
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<ExtendedWorkoutState & ExtendedWorkoutActions
}
}
// Make sure workout timer is stopped again (just to be extra safe)
get().stopWorkoutTimer();
// Finally update the app state
set({
status: 'completed',
activeWorkout: completedWorkout,
activeWorkout: null, // Set to null to fully clear the workout
isActive: false,
isMinimized: false
});
// Ensure we fully reset the state
get().reset();
} catch (error) {
console.error('Error completing workout:', error);
// Consider showing an error message to the user
@ -336,7 +365,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
const { activeWorkout } = get();
if (!activeWorkout) return;
// Stop the workout timer
// Ensure workout timer is stopped
get().stopWorkoutTimer();
// Prepare canceled workout with proper metadata
@ -545,7 +574,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
}
},
// Workout timer management - new functions
// Workout timer management - improved for reliability
startWorkoutTimer: () => {
// Clear any existing timer first to prevent duplicates
if (workoutTimerInterval) {
@ -555,6 +584,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
// Start a new timer that continues to run even when components unmount
workoutTimerInterval = setInterval(() => {
// 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<ExtendedWorkoutState & ExtendedWorkoutActions
endWorkout: async () => {
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<ExtendedWorkoutState & ExtendedWorkoutActions
},
reset: () => {
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<WorkoutTemplate | null>
async function saveWorkout(workout: Workout): Promise<void> {
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);
}