mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 18:31:03 +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
|
# Changelog - March 6, 2025
|
||||||
|
|
||||||
## Added
|
## 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
|
- NDK mobile integration for Nostr functionality
|
||||||
- Added event publishing and subscription capabilities
|
- Added event publishing and subscription capabilities
|
||||||
- Implemented proper type safety for NDK interactions
|
- 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
|
- Component interoperability with NDK mobile
|
||||||
|
|
||||||
## Improved
|
## 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
|
- Refactored code for better type safety
|
||||||
- Enhanced error handling with proper type checking
|
- Enhanced error handling with proper type checking
|
||||||
- Improved Nostr event creation workflow with NDK
|
- 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
|
<Button
|
||||||
variant="purple"
|
variant="purple"
|
||||||
className="px-4"
|
className="px-4"
|
||||||
onPress={() => completeWorkout()}
|
onPress={() => router.push('/(workout)/complete')}
|
||||||
disabled={!hasExercises}
|
disabled={!hasExercises}
|
||||||
>
|
>
|
||||||
<Text className="text-white font-medium">Finish</Text>
|
<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"
|
keyboardType="decimal-pad"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
placeholderTextColor={mutedForegroundColor}
|
placeholderTextColor={mutedForegroundColor}
|
||||||
returnKeyType="next"
|
|
||||||
selectTextOnFocus
|
selectTextOnFocus
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -149,7 +148,6 @@ export default function SetInput({
|
|||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
placeholderTextColor={mutedForegroundColor}
|
placeholderTextColor={mutedForegroundColor}
|
||||||
returnKeyType="done"
|
|
||||||
selectTextOnFocus
|
selectTextOnFocus
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</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
|
// lib/initNDK.ts
|
||||||
import 'react-native-get-random-values'; // This must be the first import
|
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';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
// Use the same default relays you have in your current implementation
|
// Use the same default relays you have in your current implementation
|
||||||
@ -29,8 +29,129 @@ export async function initializeNDK() {
|
|||||||
// Initialize cache adapter
|
// Initialize cache adapter
|
||||||
await cacheAdapter.initialize();
|
await cacheAdapter.initialize();
|
||||||
|
|
||||||
// Connect to relays
|
// Setup relay status tracking
|
||||||
await ndk.connect();
|
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,
|
RestTimer,
|
||||||
WorkoutSet,
|
WorkoutSet,
|
||||||
WorkoutSummary,
|
WorkoutSummary,
|
||||||
WorkoutExercise
|
WorkoutExercise,
|
||||||
|
WorkoutCompletionOptions
|
||||||
} from '@/types/workout';
|
} from '@/types/workout';
|
||||||
import type {
|
import type {
|
||||||
WorkoutTemplate,
|
WorkoutTemplate,
|
||||||
@ -20,7 +21,12 @@ import type {
|
|||||||
import type { BaseExercise } from '@/types/exercise';
|
import type { BaseExercise } from '@/types/exercise';
|
||||||
import { openDatabaseSync } from 'expo-sqlite';
|
import { openDatabaseSync } from 'expo-sqlite';
|
||||||
import { FavoritesService } from '@/lib/db/services/FavoritesService';
|
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
|
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
|
||||||
|
|
||||||
@ -73,6 +79,7 @@ interface ExtendedWorkoutActions extends WorkoutActions {
|
|||||||
completeWorkout: () => void;
|
completeWorkout: () => void;
|
||||||
cancelWorkout: () => void;
|
cancelWorkout: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
publishEvent: (event: NostrEvent) => Promise<any>;
|
||||||
|
|
||||||
// Exercise and Set Management from original implementation
|
// Exercise and Set Management from original implementation
|
||||||
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
|
updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
|
||||||
@ -179,13 +186,21 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
|||||||
set({ status: 'active' });
|
set({ status: 'active' });
|
||||||
},
|
},
|
||||||
|
|
||||||
completeWorkout: async () => {
|
// Update completeWorkout in workoutStore.ts
|
||||||
|
completeWorkout: async (options?: WorkoutCompletionOptions) => {
|
||||||
const { activeWorkout } = get();
|
const { activeWorkout } = get();
|
||||||
if (!activeWorkout) return;
|
if (!activeWorkout) return;
|
||||||
|
|
||||||
// Stop the workout timer
|
// Stop the workout timer
|
||||||
get().stopWorkoutTimer();
|
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 = {
|
const completedWorkout = {
|
||||||
...activeWorkout,
|
...activeWorkout,
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
@ -193,19 +208,128 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
|||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save final workout state
|
try {
|
||||||
await saveWorkout(completedWorkout);
|
// Save workout locally regardless of storage option
|
||||||
|
await saveWorkout(completedWorkout);
|
||||||
|
|
||||||
// Calculate and save summary statistics
|
// Calculate and save summary statistics
|
||||||
const summary = calculateWorkoutSummary(completedWorkout);
|
const summary = calculateWorkoutSummary(completedWorkout);
|
||||||
await saveSummary(summary);
|
await saveSummary(summary);
|
||||||
|
|
||||||
set({
|
// Handle Nostr publishing if selected and user is authenticated
|
||||||
status: 'completed',
|
if (options.storageType !== 'local_only') {
|
||||||
activeWorkout: completedWorkout,
|
try {
|
||||||
isActive: false,
|
const { ndk, isAuthenticated } = useNDKStore.getState();
|
||||||
isMinimized: false
|
|
||||||
});
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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 () => {
|
cancelWorkout: async () => {
|
||||||
|
@ -75,6 +75,22 @@ export interface Workout extends SyncableContent {
|
|||||||
nostrEventId?: string;
|
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
|
* Personal Records
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user