mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 10:51:19 +00:00
workout completion flow WIP
This commit is contained in:
parent
b61381b865
commit
25d40b3a12
19
CHANGELOG.md
19
CHANGELOG.md
@ -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
169
app/(workout)/complete.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
221
components/Confetti.tsx
Normal 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;
|
@ -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>
|
||||
|
708
components/workout/WorkoutCompletionFlow.tsx
Normal file
708
components/workout/WorkoutCompletionFlow.tsx
Normal 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>
|
||||
);
|
||||
}
|
275
docs/design/WorkoutCompletion.md
Normal file
275
docs/design/WorkoutCompletion.md
Normal 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)
|
86
lib/db/services/NostrWorkoutService.ts
Normal file
86
lib/db/services/NostrWorkoutService.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
50
lib/db/services/TemplateService.ts
Normal file
50
lib/db/services/TemplateService.ts
Normal 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;
|
||||
}
|
||||
}
|
129
lib/initNDK.ts
129
lib/initNDK.ts
@ -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;
|
||||
}
|
||||
}
|
@ -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 () => {
|
||||
|
@ -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
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user