workout completion flow WIP

This commit is contained in:
DocNR 2025-03-07 10:09:55 -05:00
parent b61381b865
commit 25d40b3a12
12 changed files with 1808 additions and 21 deletions

View File

@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
# Changelog - March 6, 2025
## Added
- Comprehensive workout completion flow
- Implemented three-tier storage approach (Local Only, Publish Complete, Publish Limited)
- Added support for template modifications with options to keep original, update, or save as new
- Created celebration screen with confetti animation
- Integrated social sharing capabilities for Nostr
- Built detailed workout summary with achievement tracking
- Added workout statistics including duration, volume, and set completion
- Implemented privacy-focused publishing options
- Added template attribution and modification tracking
- NDK mobile integration for Nostr functionality
- Added event publishing and subscription capabilities
- Implemented proper type safety for NDK interactions
@ -37,6 +46,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Component interoperability with NDK mobile
## Improved
- Enhanced relay connection management
- Added timeout-based connection attempts
- Implemented better connection status tracking
- Added relay connectivity verification before publishing
- Improved error handling for publishing failures
- Workout completion UI
- Added scrollable interfaces for better content accessibility
- Enhanced visual feedback for selected options
- Improved button placement and visual hierarchy
- Added clearer visual indicators for selected storage options
- Refactored code for better type safety
- Enhanced error handling with proper type checking
- Improved Nostr event creation workflow with NDK

169
app/(workout)/complete.tsx Normal file
View File

@ -0,0 +1,169 @@
// app/(workout)/complete.tsx - revised version
import React from 'react';
import { View, Modal, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import { Text } from '@/components/ui/text';
import { X } from 'lucide-react-native';
import { useWorkoutStore } from '@/stores/workoutStore';
import { WorkoutCompletionFlow } from '@/components/workout/WorkoutCompletionFlow';
import { WorkoutCompletionOptions } from '@/types/workout';
import { useColorScheme } from '@/lib/useColorScheme';
export default function CompleteWorkoutScreen() {
const { resumeWorkout } = useWorkoutStore.getState();
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();
// 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');
};
// Handle cancellation
const handleCancel = () => {
resumeWorkout();
router.back();
};
return (
<Modal
visible={true}
transparent={true}
animationType="slide"
onRequestClose={handleCancel}
>
<View className="flex-1 justify-center items-center bg-black/70">
<View
className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
style={{ maxHeight: 700 }}
>
{/* Header */}
<View className="flex-row justify-between items-center p-4 border-b border-border">
<Text className="text-xl font-bold text-foreground">Complete Workout</Text>
<TouchableOpacity onPress={handleCancel} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
{/* Content */}
<View className="flex-1 p-4">
<WorkoutCompletionFlow
onComplete={handleComplete}
onCancel={handleCancel}
/>
</View>
</View>
</View>
</Modal>
);
}

View File

@ -273,7 +273,7 @@ export default function CreateWorkoutScreen() {
<Button
variant="purple"
className="px-4"
onPress={() => completeWorkout()}
onPress={() => router.push('/(workout)/complete')}
disabled={!hasExercises}
>
<Text className="text-white font-medium">Finish</Text>

221
components/Confetti.tsx Normal file
View File

@ -0,0 +1,221 @@
// components/Confetti.tsx - enhanced version
import React, { useEffect, useRef } from 'react';
import { View, Dimensions, Platform } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
withSequence,
withDelay,
cancelAnimation,
runOnJS,
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
// Adjust number of confetti based on device performance capabilities
const CONFETTI_SIZE = 15;
const NUMBER_OF_CONFETTI = Platform.OS === 'web' ? 80 : 60; // Reduce for mobile
const ANIMATION_DURATION = 5000; // 5 seconds total
const CONFETTI_COLORS = [
'#3B82F6', // blue
'#10B981', // green
'#F59E0B', // yellow
'#EF4444', // red
'#8B5CF6', // purple (match app's purple)
'#EC4899', // pink
'#F97316', // orange
'#06B6D4', // cyan
];
interface ConfettiProps {
onComplete?: () => void;
zIndex?: number; // Allow customizing z-index
density?: 'low' | 'medium' | 'high'; // Control confetti density
}
const Confetti: React.FC<ConfettiProps> = ({
onComplete,
zIndex = 1000,
density = 'medium'
}) => {
// Track animations for cleanup
const animationsRef = useRef<Animated.SharedValue<number>[]>([]);
// Adjust number of confetti based on density
const confettiCount = density === 'low' ?
Math.floor(NUMBER_OF_CONFETTI * 0.6) :
density === 'high' ?
Math.floor(NUMBER_OF_CONFETTI * 1.3) :
NUMBER_OF_CONFETTI;
// Call onComplete callback
const handleComplete = () => {
if (onComplete) {
onComplete();
}
};
// Cleanup animations on unmount
useEffect(() => {
return () => {
animationsRef.current.forEach(anim => {
cancelAnimation(anim);
});
};
}, []);
const confettiPieces = Array.from({ length: confettiCount }).map((_, index) => {
// Randomize starting position within a narrower area for more focal explosion
const startX = SCREEN_WIDTH / 2 + (Math.random() - 0.5) * SCREEN_WIDTH * 0.3;
const startY = SCREEN_HEIGHT * 0.55; // Start more centrally
const translateY = useSharedValue(startY);
const translateX = useSharedValue(startX);
const rotate = useSharedValue(0);
const scale = useSharedValue(0);
const opacity = useSharedValue(1);
// Track animation values for cleanup
animationsRef.current.push(translateY, translateX, rotate, scale, opacity);
// Varied random positions
const angle = (Math.random() * Math.PI * 2); // Random angle in radians
const distance = Math.random() * Math.max(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.5;
const randomEndX = startX + Math.cos(angle) * distance;
// More upward movement for most confetti
const upwardBias = Math.random() * 0.8 + 0.1; // 0.1 to 0.9
const randomEndY = startY - (SCREEN_HEIGHT * upwardBias);
const randomRotation = Math.random() * 1000 - 500; // -500 to 500 degrees
const randomDelay = Math.random() * 500; // 0 to 500ms
const color = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
// Add gravity effect for more natural falling
const gravityEffect = Math.random() * 300 + 100; // Different "weights"
useEffect(() => {
// Dramatic scale up
scale.value = withSequence(
withDelay(randomDelay, withSpring(1.2, { damping: 3 })),
withDelay(ANIMATION_DURATION - 1000, withTiming(0, { duration: 1000 }))
);
// Natural movement with gravity
translateY.value = withSequence(
// Initial burst upward
withDelay(
randomDelay,
withSpring(randomEndY, {
velocity: -100,
damping: 10,
stiffness: 80
})
),
// Then fall with gravity
withTiming(randomEndY + gravityEffect, {
duration: ANIMATION_DURATION - randomDelay - 1000
})
);
translateX.value = withSequence(
withDelay(
randomDelay,
withSpring(randomEndX, {
velocity: 50,
damping: 15,
stiffness: 40
})
)
);
// Continuous rotation
rotate.value = withSequence(
withDelay(
randomDelay,
withTiming(randomRotation, {
duration: ANIMATION_DURATION
})
)
);
// Slower fade out
opacity.value = withDelay(
ANIMATION_DURATION - 1000,
withTiming(0, {
duration: 1000,
})
);
// Trigger completion callback after all confetti are done
if (index === confettiCount - 1) {
setTimeout(() => {
runOnJS(handleComplete)();
}, ANIMATION_DURATION + 500); // Add a small buffer to ensure all pieces are done
}
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotate.value}deg` },
{ scale: scale.value }
],
opacity: opacity.value,
}));
// More varied shapes
const shapeType = index % 4; // 4 different shapes
const shape = {
width: shapeType === 1 ? CONFETTI_SIZE * 2 : CONFETTI_SIZE,
height: shapeType === 1 ? CONFETTI_SIZE : shapeType === 2 ? CONFETTI_SIZE * 2 : CONFETTI_SIZE,
borderRadius: shapeType === 3 ? CONFETTI_SIZE / 2 : 2,
};
return (
<Animated.View
key={index}
style={[
{
position: 'absolute',
width: shape.width,
height: shape.height,
backgroundColor: color,
borderRadius: shape.borderRadius,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.2,
shadowRadius: 1.5,
elevation: 2,
},
animatedStyle,
]}
/>
);
});
return (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: zIndex,
pointerEvents: 'none', // Allow interaction with elements behind confetti
}}
pointerEvents="none"
>
{confettiPieces}
</View>
);
};
export default Confetti;

View File

@ -127,7 +127,6 @@ export default function SetInput({
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={mutedForegroundColor}
returnKeyType="next"
selectTextOnFocus
/>
</TouchableOpacity>
@ -149,7 +148,6 @@ export default function SetInput({
keyboardType="number-pad"
placeholder="0"
placeholderTextColor={mutedForegroundColor}
returnKeyType="done"
selectTextOnFocus
/>
</TouchableOpacity>

View File

@ -0,0 +1,708 @@
// components/workout/WorkoutCompletionFlow.tsx - streamlined with celebration
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, ScrollView } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { useWorkoutStore } from '@/stores/workoutStore';
import { useNDK, useNDKCurrentUser } from '@/lib/hooks/useNDK';
import { WorkoutCompletionOptions } from '@/types/workout';
import {
Shield,
Lock,
FileText,
Clock,
Dumbbell,
Trophy,
Share,
Cog
} from 'lucide-react-native';
import { TemplateService } from '@/lib/db/services/TemplateService';
import Confetti from '@/components/Confetti';
// Storage options component
function StorageOptionsTab({
options,
setOptions,
onNext
}: {
options: WorkoutCompletionOptions,
setOptions: (options: WorkoutCompletionOptions) => void,
onNext: () => void
}) {
const { isAuthenticated } = useNDKCurrentUser();
// Handle storage option selection
const handleStorageOptionSelect = (value: 'local_only' | 'publish_complete' | 'publish_limited') => {
setOptions({
...options,
storageType: value
});
};
// Handle template action selection
const handleTemplateAction = (value: 'keep_original' | 'update_existing' | 'save_as_new') => {
setOptions({
...options,
templateAction: value
});
};
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
// 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 {
if (activeWorkout && activeWorkout.templateId) {
hasTemplateChanges = TemplateService.hasTemplateChanges(activeWorkout);
}
} catch (error) {
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">
<Text className="text-lg font-semibold text-foreground">Storage Options</Text>
<Text className="text-muted-foreground">
Choose how you want to store your workout data
</Text>
{/* Local only option */}
<TouchableOpacity
onPress={() => handleStorageOptionSelect('local_only')}
activeOpacity={0.7}
>
<Card
style={options.storageType === 'local_only' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-muted items-center justify-center">
<Lock size={20} className="text-muted-foreground" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground">Local Only</Text>
<Text className="text-sm text-muted-foreground">
Keep workout data private on this device
</Text>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Publish complete option */}
{isAuthenticated && (
<TouchableOpacity
onPress={() => handleStorageOptionSelect('publish_complete')}
activeOpacity={0.7}
>
<Card
style={options.storageType === 'publish_complete' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-muted items-center justify-center">
<FileText size={20} className="text-muted-foreground" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground">Publish Complete</Text>
<Text className="text-sm text-muted-foreground">
Publish full workout data to Nostr network
</Text>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
)}
{/* Limited metrics option */}
{isAuthenticated && (
<TouchableOpacity
onPress={() => handleStorageOptionSelect('publish_limited')}
activeOpacity={0.7}
>
<Card
style={options.storageType === 'publish_limited' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-muted items-center justify-center">
<Shield size={20} className="text-muted-foreground" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground">Limited Metrics</Text>
<Text className="text-sm text-muted-foreground">
Publish workout with limited metrics for privacy
</Text>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
)}
{/* Template options section - only if needed */}
{hasTemplateChanges && (
<>
<Separator className="my-2" />
<Text className="text-lg font-semibold text-foreground">Template Options</Text>
<Text className="text-muted-foreground">
Your workout includes modifications to the original template
</Text>
{/* Keep original option */}
<TouchableOpacity
onPress={() => handleTemplateAction('keep_original')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'keep_original' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Keep Original</Text>
<Text className="text-sm text-muted-foreground">
Don't update the template
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Update existing option */}
<TouchableOpacity
onPress={() => handleTemplateAction('update_existing')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'update_existing' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Update Template</Text>
<Text className="text-sm text-muted-foreground">
Save these changes to the original template
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Save as new option */}
<TouchableOpacity
onPress={() => handleTemplateAction('save_as_new')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'save_as_new' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Save as New</Text>
<Text className="text-sm text-muted-foreground">
Create a new template from this workout
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Template name input if save as new is selected */}
{options.templateAction === 'save_as_new' && (
<View className="mt-2 mb-4">
<Text className="text-sm mb-2">New template name:</Text>
<Input
placeholder="My Custom Template"
value={options.newTemplateName || activeWorkout?.title || ''}
onChangeText={(text) => setOptions({
...options,
newTemplateName: text
})}
/>
</View>
)}
</>
)}
{/* Next button */}
<Button
onPress={onNext}
className="w-full mb-6"
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white font-medium">
Next
</Text>
</Button>
</View>
</ScrollView>
);
}
// Summary component
function SummaryTab({
options,
onBack,
onFinish
}: {
options: WorkoutCompletionOptions,
onBack: () => void,
onFinish: () => void
}) {
const { activeWorkout } = useWorkoutStore();
// Helper functions
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return hours > 0
? `${hours}h ${minutes % 60}m`
: `${minutes}m ${seconds % 60}s`;
};
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;
};
const getTotalSets = (): number => {
return activeWorkout?.exercises.reduce(
(total, exercise) => total + exercise.sets.length,
0
) || 0;
};
const getCompletedSets = (): number => {
return activeWorkout?.exercises.reduce(
(total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length,
0
) || 0;
};
// Calculate volume
const getTotalVolume = (): number => {
return activeWorkout?.exercises.reduce(
(total, exercise) =>
total + exercise.sets.reduce(
(setTotal, set) => setTotal + ((set.weight || 0) * (set.reps || 0)),
0
),
0
) || 0;
};
// Mock PRs - in a real app, you'd compare with previous workouts
const mockPRs = [
{ exercise: "Bench Press", value: "80kg × 8 reps", previous: "75kg × 8 reps" },
{ exercise: "Squat", value: "120kg × 5 reps", previous: "110kg × 5 reps" }
];
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
return (
<ScrollView className="flex-1 px-2">
<View className="py-4 gap-6">
<Text className="text-lg font-semibold text-foreground">Workout Summary</Text>
<View className="gap-4">
{/* Duration card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-2">
<Clock size={18} className="text-muted-foreground" />
<Text className="font-medium text-foreground">Duration</Text>
</View>
<Text className="text-xl font-semibold text-foreground">
{formatDuration(getDuration())}
</Text>
</CardContent>
</Card>
{/* Exercise stats card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-2">
<Dumbbell size={18} className="text-muted-foreground" />
<Text className="font-medium text-foreground">Exercises</Text>
</View>
<View className="gap-3">
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Total Exercises:
</Text>
<Text className="text-base font-medium text-foreground">
{activeWorkout?.exercises.length || 0}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Sets Completed:
</Text>
<Text className="text-base font-medium text-foreground">
{getCompletedSets()} / {getTotalSets()}
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Total Volume:
</Text>
<Text className="text-base font-medium text-foreground">
{getTotalVolume()} kg
</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-base text-muted-foreground">
Estimated Calories:
</Text>
<Text className="text-base font-medium text-foreground">
{Math.round(getDuration() / 1000 / 60 * 5)} kcal
</Text>
</View>
</View>
</CardContent>
</Card>
{/* PRs Card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-3">
<Trophy size={18} className="text-amber-500" />
<Text className="font-medium text-foreground">Personal Records</Text>
</View>
{mockPRs.length > 0 ? (
<View className="gap-3">
{mockPRs.map((pr, index) => (
<View key={index} className="gap-1">
<Text className="font-medium">{pr.exercise}</Text>
<View className="flex-row justify-between">
<Text className="text-sm text-muted-foreground">New PR:</Text>
<Text className="text-sm font-medium text-amber-500">{pr.value}</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-sm text-muted-foreground">Previous:</Text>
<Text className="text-sm text-muted-foreground">{pr.previous}</Text>
</View>
{index < mockPRs.length - 1 && <Separator className="my-2" />}
</View>
))}
</View>
) : (
<Text className="text-muted-foreground">No personal records set in this workout</Text>
)}
</CardContent>
</Card>
{/* Selected storage option card */}
<Card>
<CardContent className="p-4">
<View className="flex-row items-center gap-3 mb-2">
<Cog size={18} className="text-muted-foreground" />
<Text className="font-medium text-foreground">Selected Options</Text>
</View>
<View className="flex-row items-center">
<Text className="text-base text-muted-foreground flex-1">
Storage:
</Text>
<Badge
variant={options.storageType === 'local_only' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>
{options.storageType === 'local_only'
? 'Local Only'
: options.storageType === 'publish_complete'
? 'Full Metrics'
: 'Limited Metrics'
}
</Text>
</Badge>
</View>
</CardContent>
</Card>
</View>
{/* Navigation buttons */}
<View className="flex-row gap-3 mt-4 mb-6">
<Button
variant="outline"
className="flex-1"
onPress={onBack}
>
<Text>Back</Text>
</Button>
<Button
className="flex-1"
style={{ backgroundColor: purpleColor }}
onPress={onFinish}
>
<Text className="text-white font-medium">
Finish Workout
</Text>
</Button>
</View>
</View>
</ScrollView>
);
}
// 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('');
// 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
);
// 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)} />
)}
<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>
</>
)}
{/* 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>
</View>
</ScrollView>
);
}
export function WorkoutCompletionFlow({
onComplete,
onCancel
}: {
onComplete: (options: WorkoutCompletionOptions) => void;
onCancel: () => void;
}) {
const { stopWorkoutTimer } = useWorkoutStore();
// States
const [options, setOptions] = useState<WorkoutCompletionOptions>({
storageType: 'local_only',
shareOnSocial: false,
templateAction: 'keep_original',
});
const [step, setStep] = useState<'options' | 'summary' | 'celebration'>('options');
// Navigate through steps
const handleNext = () => {
setStep('summary');
};
const handleBack = () => {
setStep('options');
};
const handleFinish = () => {
// Stop workout timer when finishing
stopWorkoutTimer();
// Move to celebration screen
setStep('celebration');
};
const handleComplete = (finalOptions: WorkoutCompletionOptions) => {
// Call the completion function with the selected options
onComplete(finalOptions);
};
const renderStep = () => {
switch (step) {
case 'options':
return (
<StorageOptionsTab
options={options}
setOptions={setOptions}
onNext={handleNext}
/>
);
case 'summary':
return (
<SummaryTab
options={options}
onBack={handleBack}
onFinish={handleFinish}
/>
);
case 'celebration':
return (
<CelebrationTab
options={options}
onComplete={handleComplete}
/>
);
default:
return null;
}
};
return (
<View className="flex-1">
{renderStep()}
</View>
);
}

View File

@ -0,0 +1,275 @@
# Workout Completion Flow Design Document
## Problem Statement
Users need a clear, privacy-respecting process for completing workouts, with options to save locally and/or publish to Nostr, update templates based on changes made during the workout, and optionally share their accomplishments socially. The current implementation lacks a structured flow for these decisions and doesn't address privacy concerns around workout metrics.
## Requirements
### Functional Requirements
- Allow users to complete workouts and save data locally
- Provide options to publish workouts to Nostr with complete or limited data
- Enable social sharing of workout accomplishments
- Support template updates based on workout modifications
- Maintain proper attribution for templates
- Support offline completion with queued publishing
- Present clear workout summary and celebration screens
### Non-Functional Requirements
- Privacy: Control over what workout metrics are published
- Performance: Completion flow should respond within 500ms
- Reliability: Work offline with 100% data retention
- Usability: Max 3 steps to complete a workout
- Security: Secure handling of Nostr keys and signing
- Consistency: Match Nostr protocol specifications (NIP-4e)
## Design Decisions
### 1. Three-Tier Storage Approach
Implement a tiered approach to workout data storage and sharing: Local Only, Publish to Nostr (Complete/Limited), and Social Sharing.
**Rationale**:
- Provides users with clear control over their data privacy
- Aligns with the Nostr protocol's decentralized nature
- Balances social engagement with privacy concerns
- Enables participation regardless of privacy preferences
**Trade-offs**:
- Additional complexity in the UI
- More complex data handling logic
- Potential confusion around data visibility
### 2. Template Update Handling
When users modify a workout during execution, offer options to: Keep Original Template, Update Existing Template, or Save as New Template.
**Rationale**:
- Supports natural evolution of workout templates
- Maintains history and attribution
- Prevents accidental template modifications
- Enables template personalization
**Trade-offs**:
- Additional decision point for users
- Version tracking complexity
- Potential template proliferation
### 3. Conflict Resolution Strategy
Implement a "Last Write Wins with Notification" approach for template conflicts, with options to keep local changes, accept remote changes, or create a fork.
**Rationale**:
- Simple to implement and understand
- Provides user awareness of conflicts
- Maintains user control over conflict resolution
- Avoids blocking workout completion flow
**Trade-offs**:
- May occasionally result in lost updates
- Requires additional UI for conflict resolution
- Can create multiple versions of templates
## Technical Design
### Core Components
```typescript
// Workout Completion Options
interface WorkoutCompletionOptions {
storageType: 'local_only' | 'publish_complete' | 'publish_limited';
shareOnSocial: boolean;
socialMessage?: string;
templateAction: 'keep_original' | 'update_existing' | 'save_as_new';
newTemplateName?: string;
}
// Nostr Event Creation
interface NostrEventCreator {
createWorkoutRecord(
workout: Workout,
options: WorkoutCompletionOptions
): NostrEvent;
createSocialShare(
workoutRecord: NostrEvent,
message: string
): NostrEvent;
updateTemplate(
originalTemplate: WorkoutTemplate,
modifiedWorkout: Workout
): NostrEvent;
}
// Publishing Queue
interface PublishingQueue {
queueEvent(event: NostrEvent): Promise<void>;
processQueue(): Promise<void>;
getQueueStatus(): { pending: number, failed: number };
}
// Conflict Resolution
interface ConflictResolver {
detectConflicts(localTemplate: WorkoutTemplate, remoteTemplate: WorkoutTemplate): boolean;
resolveConflict(
localTemplate: WorkoutTemplate,
remoteTemplate: WorkoutTemplate,
resolution: 'use_local' | 'use_remote' | 'create_fork'
): WorkoutTemplate;
}
```
### Workout Completion Flow
```typescript
async function completeWorkout(
workout: Workout,
options: WorkoutCompletionOptions
): Promise<CompletionResult> {
// 1. Save complete workout data locally
await saveWorkoutLocally(workout);
// 2. Handle template updates if needed
if (workout.templateId && workout.hasChanges) {
await handleTemplateUpdate(workout, options.templateAction, options.newTemplateName);
}
// 3. Publish to Nostr if selected
let workoutEvent: NostrEvent | null = null;
if (options.storageType !== 'local_only') {
const isLimited = options.storageType === 'publish_limited';
workoutEvent = await publishWorkoutToNostr(workout, isLimited);
}
// 4. Create social share if selected
if (options.shareOnSocial && workoutEvent) {
await createSocialShare(workoutEvent, options.socialMessage || '');
}
// 5. Return completion status
return {
success: true,
localId: workout.id,
nostrEventId: workoutEvent?.id,
pendingSync: !navigator.onLine
};
}
```
## Implementation Plan
### Phase 1: Core Completion Flow
1. Implement workout completion confirmation dialog
2. Create completion options screen with storage choices
3. Build local storage functionality with workout summary
4. Add workout celebration screen with achievements
5. Implement template difference detection
### Phase 2: Nostr Integration
1. Implement workout record (kind 1301) publishing
2. Add support for limited metrics publishing
3. Create template update/versioning system
4. Implement social sharing via kind 1 posts
5. Add offline queue with sync status indicators
### Phase 3: Refinement and Enhancement
1. Add conflict detection and resolution
2. Implement template attribution preservation
3. Create version history browsing
4. Add advanced privacy controls
5. Implement achievement recognition system
## Testing Strategy
### Unit Tests
- Template difference detection
- Nostr event generation (complete and limited)
- Social post creation
- Conflict detection
- Privacy filtering logic
### Integration Tests
- End-to-end workout completion flow
- Offline completion and sync
- Template update scenarios
- Cross-device template conflict resolution
- Social sharing with quoted content
### User Testing
- Template modification scenarios
- Privacy control understanding
- Conflict resolution UX
- Workout completion satisfaction
## Observability
### Logging
- Workout completion events
- Publishing attempts and results
- Template update operations
- Conflict detection and resolution
- Offline queue processing
### Metrics
- Completion rates
- Publishing success rates
- Social sharing frequency
- Template update frequency
- Offline queue size and processing time
## Future Considerations
### Potential Enhancements
- Collaborative template editing
- Richer social sharing with images/graphics
- Template popularity and trending metrics
- Coach/trainee permission model
- Interactive workout summary visualizations
### Known Limitations
- Limited to Nostr protocol constraints
- No guaranteed deletion of published content
- Template conflicts require manual resolution
- No cross-device real-time sync
- Limited to supported NIP implementations
## Dependencies
### Runtime Dependencies
- Nostr NDK for event handling
- SQLite for local storage
- Expo SecureStore for key management
- Connectivity detection for offline mode
### Development Dependencies
- TypeScript for type safety
- React Native testing tools
- Mock Nostr relay for testing
- UI/UX prototyping tools
## Security Considerations
- Private keys never exposed to application code
- Local workout data encrypted at rest
- Clear indication of what data is being published
- Template attribution verification
- Rate limiting for publishing operations
## Rollout Strategy
### Development Phase
1. Implement core completion flow with local storage
2. Add Nostr publishing with complete/limited options
3. Implement template handling and conflict resolution
4. Add social sharing capabilities
5. Implement comprehensive testing suite
### Production Deployment
1. Release to limited beta testing group
2. Monitor completion flow metrics and error rates
3. Gather feedback on privacy controls and template handling
4. Implement refinements based on user feedback
5. Roll out to all users with clear documentation
## References
- [NIP-4e: Workout Events](https://github.com/nostr-protocol/nips/blob/4e-draft/4e.md)
- [POWR Social Features Design Document](https://github.com/docNR/powr/blob/main/docs/design/SocialDesignDocument.md)
- [Nostr NDK Documentation](https://github.com/nostr-dev-kit/ndk)
- [Offline-First Application Architecture](https://blog.flutter.io/offline-first-application-architecture-a2c4b2c61c8b)
- [React Native Performance Optimization](https://reactnative.dev/docs/performance)

View File

@ -0,0 +1,86 @@
// lib/services/NostrWorkoutService.ts - updated
import { Workout } from '@/types/workout';
import { NostrEvent } from '@/types/nostr';
/**
* Service for creating and handling Nostr workout events
*/
export class NostrWorkoutService {
/**
* Creates a complete Nostr workout event with all details
*/
static createCompleteWorkoutEvent(workout: Workout): NostrEvent {
return {
kind: 1301, // As per NIP-4e spec
content: workout.notes || '',
tags: [
['d', workout.id],
['title', workout.title],
['type', workout.type],
['start', Math.floor(workout.startTime / 1000).toString()],
['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''],
['completed', 'true'],
// Add all exercise data with complete metrics
...workout.exercises.flatMap(exercise =>
exercise.sets.map(set => [
'exercise',
`33401:${exercise.id}`,
set.weight?.toString() || '',
set.reps?.toString() || '',
set.rpe?.toString() || '',
set.type
])
),
// Include template reference if workout was based on template
...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []),
// Add any tags from the workout
...(workout.tags ? workout.tags.map(tag => ['t', tag]) : [])
],
created_at: Math.floor(Date.now() / 1000)
};
}
/**
* Creates a limited Nostr workout event with reduced metrics for privacy
*/
static createLimitedWorkoutEvent(workout: Workout): NostrEvent {
return {
kind: 1301,
content: workout.notes || '',
tags: [
['d', workout.id],
['title', workout.title],
['type', workout.type],
['start', Math.floor(workout.startTime / 1000).toString()],
['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''],
['completed', 'true'],
// Add limited exercise data - just exercise names without metrics
...workout.exercises.map(exercise => [
'exercise',
`33401:${exercise.id}`
]),
...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []),
...(workout.tags ? workout.tags.map(tag => ['t', tag]) : [])
],
created_at: Math.floor(Date.now() / 1000)
};
}
/**
* Creates a social share event that quotes the workout record
*/
static createSocialShareEvent(workoutEventId: string, message: string): NostrEvent {
return {
kind: 1, // Standard note
content: message,
tags: [
// Quote the workout event
['q', workoutEventId],
// Add hash tags for discovery
['t', 'workout'],
['t', 'fitness']
],
created_at: Math.floor(Date.now() / 1000)
};
}
}

View File

@ -0,0 +1,50 @@
// lib/db/services/TemplateService.ts
import { Workout } from '@/types/workout';
import { WorkoutTemplate } from '@/types/templates';
import { generateId } from '@/utils/ids';
/**
* Service for managing workout templates
*/
export class TemplateService {
/**
* Updates an existing template based on workout changes
*/
static async updateExistingTemplate(workout: Workout): Promise<boolean> {
try {
// This would require actual implementation with DB access
// For now, this is a placeholder
console.log('Updating template from workout:', workout.id);
// Future: Implement with your DB service
return true;
} catch (error) {
console.error('Error updating template:', error);
return false;
}
}
/**
* Saves a workout as a new template
*/
static async saveAsNewTemplate(workout: Workout, name: string): Promise<string | null> {
try {
// This would require actual implementation with DB access
// For now, this is a placeholder
console.log('Creating new template from workout:', workout.id, 'with name:', name);
// Future: Implement with your DB service
return generateId();
} catch (error) {
console.error('Error creating template:', error);
return null;
}
}
/**
* Detects if a workout has changes compared to its original template
*/
static hasTemplateChanges(workout: Workout): boolean {
// This would require comparing with the original template
// For now, assume changes if there's a templateId
return !!workout.templateId;
}
}

View File

@ -1,6 +1,6 @@
// lib/initNDK.ts
import 'react-native-get-random-values'; // This must be the first import
import NDK, { NDKCacheAdapterSqlite } from '@nostr-dev-kit/ndk-mobile';
import NDK, { NDKCacheAdapterSqlite, NDKEvent, NDKRelay } from '@nostr-dev-kit/ndk-mobile';
import * as SecureStore from 'expo-secure-store';
// Use the same default relays you have in your current implementation
@ -29,8 +29,129 @@ export async function initializeNDK() {
// Initialize cache adapter
await cacheAdapter.initialize();
// Connect to relays
await ndk.connect();
// Setup relay status tracking
const relayStatus: Record<string, 'connected' | 'connecting' | 'disconnected' | 'error'> = {};
DEFAULT_RELAYS.forEach(url => {
relayStatus[url] = 'connecting';
});
return { ndk };
// Set up listeners before connecting
DEFAULT_RELAYS.forEach(url => {
const relay = ndk.pool.getRelay(url);
if (relay) {
// Connection success
relay.on('connect', () => {
console.log(`[NDK] Relay connected: ${url}`);
relayStatus[url] = 'connected';
});
// Connection closed
relay.on('disconnect', () => {
console.log(`[NDK] Relay disconnected: ${url}`);
relayStatus[url] = 'disconnected';
});
// For errors, use the notice event which is used for errors in NDK
relay.on('notice', (notice: string) => {
console.error(`[NDK] Relay notice/error for ${url}:`, notice);
relayStatus[url] = 'error';
});
}
});
// Function to check relay connection status
const checkRelayConnections = () => {
const connected = Object.entries(relayStatus)
.filter(([_, status]) => status === 'connected')
.map(([url]) => url);
console.log(`[NDK] Connected relays: ${connected.length}/${DEFAULT_RELAYS.length}`);
return {
connectedCount: connected.length,
connectedRelays: connected
};
};
try {
// Connect to relays with a timeout
console.log('[NDK] Connecting to relays...');
// Create a promise that resolves when connected to at least one relay
const connectionPromise = new Promise<void>((resolve, reject) => {
// Function to check if we have at least one connection
const checkConnection = () => {
const { connectedCount } = checkRelayConnections();
if (connectedCount > 0) {
console.log('[NDK] Successfully connected to at least one relay');
resolve();
}
};
// Check immediately after connecting
ndk.pool.on('relay:connect', checkConnection);
// Set a timeout for connection
setTimeout(() => {
const { connectedCount } = checkRelayConnections();
if (connectedCount === 0) {
console.warn('[NDK] Connection timeout - no relays connected');
// Don't reject, as we can still work offline
resolve();
}
}, 5000);
});
// Initiate the connection
await ndk.connect();
// Wait for either connection or timeout
await connectionPromise;
// Final connection check
const { connectedCount, connectedRelays } = checkRelayConnections();
return {
ndk,
relayStatus,
connectedRelayCount: connectedCount,
connectedRelays
};
} catch (error) {
console.error('[NDK] Error during connection:', error);
// Still return the NDK instance so the app can work offline
return {
ndk,
relayStatus,
connectedRelayCount: 0,
connectedRelays: []
};
}
}
// Helper function to test publishing to relays
export async function testRelayPublishing(ndk: NDK): Promise<boolean> {
try {
console.log('[NDK] Testing relay publishing...');
// Create a simple test event - use NDKEvent constructor instead of getEvent()
const testEvent = new NDKEvent(ndk);
testEvent.kind = 1;
testEvent.content = 'Test message from POWR app';
testEvent.tags = [['t', 'test']];
// Try to sign and publish with timeout
const publishPromise = Promise.race([
testEvent.publish(),
new Promise<boolean>((_, reject) =>
setTimeout(() => reject(new Error('Publish timeout')), 5000)
)
]);
await publishPromise;
console.log('[NDK] Test publish successful');
return true;
} catch (error) {
console.error('[NDK] Test publish failed:', error);
return false;
}
}

View File

@ -10,7 +10,8 @@ import type {
RestTimer,
WorkoutSet,
WorkoutSummary,
WorkoutExercise
WorkoutExercise,
WorkoutCompletionOptions
} from '@/types/workout';
import type {
WorkoutTemplate,
@ -20,7 +21,12 @@ import type {
import type { BaseExercise } from '@/types/exercise';
import { openDatabaseSync } from 'expo-sqlite';
import { FavoritesService } from '@/lib/db/services/FavoritesService';
import { router } from 'expo-router';
import { useNDKStore } from '@/lib/stores/ndk';
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
import { TemplateService } from '@/lib//db/services/TemplateService';
import { NostrEvent } from '@/types/nostr';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
@ -73,6 +79,7 @@ interface ExtendedWorkoutActions extends WorkoutActions {
completeWorkout: () => void;
cancelWorkout: () => void;
reset: () => void;
publishEvent: (event: NostrEvent) => Promise<any>;
// Exercise and Set Management from original implementation
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
@ -179,13 +186,21 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
set({ status: 'active' });
},
completeWorkout: async () => {
// Update completeWorkout in workoutStore.ts
completeWorkout: async (options?: WorkoutCompletionOptions) => {
const { activeWorkout } = get();
if (!activeWorkout) return;
// Stop the workout timer
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,
@ -193,19 +208,128 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
lastUpdated: Date.now()
};
// Save final workout state
await saveWorkout(completedWorkout);
try {
// Save workout locally regardless of storage option
await saveWorkout(completedWorkout);
// Calculate and save summary statistics
const summary = calculateWorkoutSummary(completedWorkout);
await saveSummary(summary);
// Handle Nostr publishing if selected and user is authenticated
if (options.storageType !== 'local_only') {
try {
const { ndk, isAuthenticated } = useNDKStore.getState();
if (ndk && isAuthenticated) {
// Create appropriate Nostr event data
const eventData = options.storageType === 'publish_complete'
? NostrWorkoutService.createCompleteWorkoutEvent(completedWorkout)
: NostrWorkoutService.createLimitedWorkoutEvent(completedWorkout);
// Use NDK to publish
try {
// Create a new event
const event = new NDKEvent(ndk as any);
// Set the properties
event.kind = eventData.kind;
event.content = eventData.content;
event.tags = eventData.tags || [];
event.created_at = eventData.created_at;
// Sign and publish
await event.sign();
await event.publish();
console.log('Successfully published workout event');
// Handle social share if selected
if (options.shareOnSocial && options.socialMessage) {
const socialEventData = NostrWorkoutService.createSocialShareEvent(
event.id,
options.socialMessage
);
// Create an NDK event for the social share
const socialEvent = new NDKEvent(ndk as any);
socialEvent.kind = socialEventData.kind;
socialEvent.content = socialEventData.content;
socialEvent.tags = socialEventData.tags || [];
socialEvent.created_at = socialEventData.created_at;
// Sign and publish
await socialEvent.sign();
await socialEvent.publish();
console.log('Successfully published social share');
}
} catch (publishError) {
console.error('Error publishing to Nostr:', publishError);
}
}
} catch (error) {
console.error('Error preparing Nostr events:', error);
// Continue anyway to preserve local data
}
}
// Handle template updates if needed
if (completedWorkout.templateId && options.templateAction !== 'keep_original') {
try {
if (options.templateAction === 'update_existing') {
await TemplateService.updateExistingTemplate(completedWorkout);
} else if (options.templateAction === 'save_as_new' && options.newTemplateName) {
await TemplateService.saveAsNewTemplate(
completedWorkout,
options.newTemplateName
);
}
} catch (error) {
console.error('Error updating template:', error);
// Continue anyway to preserve workout data
}
}
// Calculate and save summary statistics
const summary = calculateWorkoutSummary(completedWorkout);
await saveSummary(summary);
// Finally update the app state
set({
status: 'completed',
activeWorkout: completedWorkout,
isActive: false,
isMinimized: false
});
} catch (error) {
console.error('Error completing workout:', error);
// Consider showing an error message to the user
}
},
set({
status: 'completed',
activeWorkout: completedWorkout,
isActive: false,
isMinimized: false
});
publishEvent: async (event: NostrEvent) => {
try {
const { ndk, isAuthenticated } = useNDKStore.getState();
if (!ndk || !isAuthenticated) {
throw new Error('Not authenticated or NDK not initialized');
}
// Create a new NDK event
const ndkEvent = new NDKEvent(ndk as any);
// Copy event properties
ndkEvent.kind = event.kind;
ndkEvent.content = event.content;
ndkEvent.tags = event.tags || [];
ndkEvent.created_at = event.created_at;
// Sign and publish
await ndkEvent.sign();
await ndkEvent.publish();
return ndkEvent;
} catch (error) {
console.error('Failed to publish event:', error);
throw error;
}
},
cancelWorkout: async () => {

View File

@ -75,6 +75,22 @@ export interface Workout extends SyncableContent {
nostrEventId?: string;
}
/**
* Options for completing a workout
*/
export interface WorkoutCompletionOptions {
// Storage option
storageType: 'local_only' | 'publish_complete' | 'publish_limited';
// Social sharing option
shareOnSocial: boolean;
socialMessage?: string;
// Template update options
templateAction: 'keep_original' | 'update_existing' | 'save_as_new';
newTemplateName?: string;
}
/**
* Personal Records
*/