add exercise to active workout and bug fixes

This commit is contained in:
DocNR 2025-03-05 14:37:38 -05:00
parent f870f2a0ca
commit 98a5b9ed09
9 changed files with 839 additions and 636 deletions

View File

@ -11,6 +11,7 @@ import { ExerciseDetails } from '@/components/exercises/ExerciseDetails';
import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise';
import { useExercises } from '@/lib/hooks/useExercises';
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
import { useWorkoutStore } from '@/stores/workoutStore';
// Default available filters
const availableFilters = {
@ -33,6 +34,8 @@ export default function ExercisesScreen() {
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
const [activeFilters, setActiveFilters] = useState(0);
const { isActive, isMinimized } = useWorkoutStore();
const shouldShowFAB = !isActive || !isMinimized;
const {
exercises,
@ -167,10 +170,12 @@ export default function ExercisesScreen() {
)}
{/* FAB for adding new exercise */}
<FloatingActionButton
icon={Dumbbell}
onPress={() => setShowNewExercise(true)}
/>
{shouldShowFAB && (
<FloatingActionButton
icon={Dumbbell}
onPress={() => setShowNewExercise(true)}
/>
)}
{/* New exercise sheet */}
<NewExerciseSheet

View File

@ -5,6 +5,7 @@ import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
import {
AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2,
Code, Search, ListFilter, Wifi, Zap, FileJson, X, Info
@ -79,8 +80,6 @@ export default function ProgramsScreen() {
const [eventKind, setEventKind] = useState(NostrEventKind.EXERCISE);
const [eventContent, setEventContent] = useState('');
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
const [privateKey, setPrivateKey] = useState('');
const [error, setError] = useState<string | null>(null);
// Use the NDK hooks
const { ndk, isLoading: ndkLoading } = useNDK();
@ -269,45 +268,9 @@ export default function ProgramsScreen() {
setIsLoginSheetOpen(true);
};
// Close login sheet
const handleCloseLogin = () => {
setIsLoginSheetOpen(false);
};
// Handle key generation
const handleGenerateKeys = async () => {
try {
const { nsec } = generateKeys();
setPrivateKey(nsec);
setError(null);
} catch (err) {
setError('Failed to generate keys');
console.error('Key generation error:', err);
}
};
// Handle login
const handleLogin = async () => {
if (!privateKey.trim()) {
setError('Please enter your private key or generate a new one');
return;
}
setError(null);
try {
const success = await login(privateKey);
if (success) {
setPrivateKey('');
handleCloseLogin();
} else {
setError('Failed to login with the provided key');
}
} catch (err) {
console.error('Login error:', err);
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
}
};
// Handle logout
const handleLogout = async () => {
@ -735,74 +698,11 @@ export default function ProgramsScreen() {
</Card>
{/* Login Modal */}
<Modal
visible={isLoginSheetOpen}
transparent={true}
animationType="slide"
onRequestClose={handleCloseLogin}
>
<View className="flex-1 justify-center items-center bg-black/50">
<View className="bg-background rounded-lg w-[90%] max-w-md p-4">
<View className="flex-row justify-between items-center mb-4">
<Text className="text-lg font-bold">Login with Nostr</Text>
<TouchableOpacity onPress={handleCloseLogin}>
<X size={24} />
</TouchableOpacity>
</View>
<View className="space-y-4">
<Text>Enter your Nostr private key (nsec)</Text>
<Input
placeholder="nsec1..."
value={privateKey}
onChangeText={setPrivateKey}
secureTextEntry
autoCapitalize="none"
/>
{error && (
<View className="p-3 bg-destructive/10 rounded-md border border-destructive">
<Text className="text-destructive">{error}</Text>
</View>
)}
<View className="flex-row space-x-2">
<Button
variant="outline"
onPress={handleGenerateKeys}
disabled={loading}
className="flex-1"
>
<Text>Generate New Keys</Text>
</Button>
<Button
onPress={handleLogin}
disabled={loading}
className="flex-1"
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-primary-foreground">Login</Text>
)}
</Button>
</View>
<View className="bg-secondary/30 p-3 rounded-md mt-4">
<View className="flex-row items-center mb-2">
<Info size={16} className="mr-2 text-muted-foreground" />
<Text className="font-semibold">What is a Nostr Key?</Text>
</View>
<Text className="text-sm text-muted-foreground">
Nostr is a decentralized protocol where your private key (nsec) is your identity and password.
Your private key is securely stored on your device and is never sent to any servers.
</Text>
</View>
</View>
</View>
</View>
</Modal>
<NostrLoginSheet
open={isLoginSheetOpen}
onClose={handleCloseLogin}
/>
{/* Create Event */}
<Card className="mb-4">
<CardHeader>

View File

@ -71,6 +71,8 @@ export default function TemplatesScreen() {
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
const [activeFilters, setActiveFilters] = useState(0);
const { isActive, isMinimized } = useWorkoutStore();
const shouldShowFAB = !isActive || !isMinimized;
const handleDelete = (id: string) => {
setTemplates(current => current.filter(t => t.id !== id));
@ -266,10 +268,12 @@ export default function TemplatesScreen() {
<View className="h-20" />
</ScrollView>
<FloatingActionButton
icon={Plus}
onPress={() => setShowNewTemplate(true)}
/>
{shouldShowFAB && (
<FloatingActionButton
icon={Plus}
onPress={() => setShowNewTemplate(true)}
/>
)}
<NewTemplateSheet
isOpen={showNewTemplate}

View File

@ -1,6 +1,6 @@
// app/(workout)/add-exercises.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView } from 'react-native';
import { View, ScrollView, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
@ -10,9 +10,10 @@ import { useWorkoutStore } from '@/stores/workoutStore';
import { useSQLiteContext } from 'expo-sqlite';
import { LibraryService } from '@/lib/db/services/LibraryService';
import { TabScreen } from '@/components/layout/TabScreen';
import { ChevronLeft } from 'lucide-react-native';
import { ChevronLeft, Search, Plus } from 'lucide-react-native';
import { BaseExercise } from '@/types/exercise';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
export default function AddExercisesScreen() {
const db = useSQLiteContext();
@ -20,6 +21,7 @@ export default function AddExercisesScreen() {
const [exercises, setExercises] = useState<BaseExercise[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [isNewExerciseSheetOpen, setIsNewExerciseSheetOpen] = useState(false);
const insets = useSafeAreaInsets();
const { addExercises } = useWorkoutStore();
@ -59,76 +61,128 @@ export default function AddExercisesScreen() {
router.back();
};
const handleNewExerciseSubmit = (exercise: BaseExercise) => {
// Add to exercises list
setExercises(prev => [exercise, ...prev]);
// Auto-select the new exercise
setSelectedIds(prev => [...prev, exercise.id]);
};
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
return (
<TabScreen>
<View style={{ flex: 1, paddingTop: insets.top }}>
{/* Standard header with back button */}
<View className="px-4 py-3 flex-row items-center border-b border-border">
<Button
variant="ghost"
size="icon"
onPress={() => router.back()}
>
<ChevronLeft className="text-foreground" />
</Button>
<Text className="text-xl font-semibold ml-2">Add Exercises</Text>
<View style={{ flex: 1, paddingTop: insets.top, backgroundColor: 'hsl(var(--background))' }}>
{/* Header with back button */}
<View className="px-4 py-4 flex-row items-center justify-between border-b border-border">
<View className="flex-row items-center">
<Button
variant="ghost"
size="icon"
onPress={() => router.back()}
className="mr-2"
>
<ChevronLeft className="text-foreground" size={22} />
</Button>
<Text className="text-xl font-semibold">Add Exercises</Text>
</View>
<View className="flex-row items-center">
<Text className="text-sm text-muted-foreground mr-3">
{selectedIds.length} selected
</Text>
<Button
variant="ghost"
size="icon"
onPress={() => setIsNewExerciseSheetOpen(true)}
>
<Plus size={22} color={purpleColor} />
</Button>
</View>
</View>
{/* Search input */}
<View className="px-4 pt-4 pb-2">
<Input
placeholder="Search exercises..."
value={search}
onChangeText={setSearch}
className="text-foreground"
/>
<View className="relative">
<View className="absolute left-3 h-full justify-center z-10">
<Search size={18} className="text-muted-foreground" />
</View>
<Input
placeholder="Search exercises..."
value={search}
onChangeText={setSearch}
className="pl-10 bg-muted/50 border-0"
/>
</View>
</View>
<ScrollView className="flex-1">
<View className="px-4">
<Text className="mb-4 text-muted-foreground">
Selected: {selectedIds.length} exercises
</Text>
<View className="p-4">
<View className="gap-3">
{filteredExercises.map(exercise => (
<Card key={exercise.id}>
<CardContent className="p-4">
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">{exercise.category}</Text>
{exercise.equipment && (
<Text className="text-xs text-muted-foreground mt-0.5">{exercise.equipment}</Text>
)}
</View>
<Button
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
onPress={() => handleToggleSelection(exercise.id)}
size="sm"
>
<Text className={selectedIds.includes(exercise.id) ? 'text-primary-foreground' : ''}>
{selectedIds.includes(exercise.id) ? 'Selected' : 'Add'}
</Text>
</Button>
</View>
</CardContent>
</Card>
))}
{filteredExercises.map(exercise => {
const isSelected = selectedIds.includes(exercise.id);
return (
<TouchableOpacity
key={exercise.id}
onPress={() => handleToggleSelection(exercise.id)}
activeOpacity={0.7}
>
<Card
style={isSelected ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View className="flex-row justify-between items-center">
<View className="flex-1">
<Text className="text-lg font-semibold">
{exercise.title}
</Text>
<View className="flex-row mt-1">
<Text className="text-sm text-muted-foreground">{exercise.category}</Text>
{exercise.equipment && (
<Text className="text-sm text-muted-foreground"> {exercise.equipment}</Text>
)}
</View>
</View>
</View>
</CardContent>
</Card>
</TouchableOpacity>
);
})}
{filteredExercises.length === 0 && (
<View className="items-center justify-center py-12">
<Text className="text-muted-foreground">No exercises found</Text>
</View>
)}
</View>
</View>
</ScrollView>
<View className="p-4 border-t border-border">
{/* Action button with proper safe area padding */}
<View className="px-4 pt-3 pb-3" style={{ paddingBottom: Math.max(insets.bottom, 16) }}>
<Button
className="w-full"
className="w-full py-4"
onPress={handleAddSelected}
disabled={selectedIds.length === 0}
style={{ backgroundColor: purpleColor }}
>
<Text className="text-primary-foreground">
Add {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
<Text className="text-white font-medium">
Add {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''} to Workout
</Text>
</Button>
</View>
{/* New Exercise Sheet */}
<NewExerciseSheet
isOpen={isNewExerciseSheetOpen}
onClose={() => setIsNewExerciseSheetOpen(false)}
onSubmit={handleNewExerciseSubmit}
/>
</View>
</TabScreen>
);

View File

@ -1,11 +1,10 @@
// app/(workout)/create.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView, StyleSheet } from 'react-native';
import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { router, useNavigation } from 'expo-router';
import { TabScreen } from '@/components/layout/TabScreen';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
@ -26,13 +25,7 @@ import { formatTime } from '@/utils/formatTime';
import { ParamListBase } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import SetInput from '@/components/workout/SetInput';
// Define styles outside of component
const styles = StyleSheet.create({
timerText: {
fontVariant: ['tabular-nums']
}
});
import { useColorScheme } from '@/lib/useColorScheme';
export default function CreateWorkoutScreen() {
const {
@ -55,6 +48,84 @@ export default function CreateWorkoutScreen() {
maximizeWorkout
} = useWorkoutStore.getState();
// Get theme colors
const { isDarkColorScheme } = useColorScheme();
// Create dynamic styles based on theme
const dynamicStyles = StyleSheet.create({
timerText: {
fontVariant: ['tabular-nums']
},
cardContainer: {
marginBottom: 24,
backgroundColor: isDarkColorScheme ? '#1F1F23' : 'white',
borderRadius: 8,
borderWidth: 1,
borderColor: isDarkColorScheme ? '#333' : '#eee',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 2,
},
cardHeader: {
padding: 16,
flexDirection: 'row',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: isDarkColorScheme ? '#333' : '#eee'
},
cardTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#8B5CF6' // Purple is used in both themes
},
setsInfo: {
paddingHorizontal: 16,
paddingVertical: 4
},
setsInfoText: {
fontSize: 14,
color: isDarkColorScheme ? '#999' : '#666'
},
headerRow: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 4,
borderTopWidth: 1,
borderTopColor: isDarkColorScheme ? '#333' : '#eee',
backgroundColor: isDarkColorScheme ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)'
},
headerCell: {
fontSize: 14,
fontWeight: '500',
color: isDarkColorScheme ? '#999' : '#666',
textAlign: 'center'
},
setNumberCell: {
width: 32
},
prevCell: {
width: 80
},
valueCell: {
flex: 1
},
spacer: {
width: 44
},
setsList: {
padding: 0
},
actionButton: {
borderTopWidth: 1,
borderTopColor: isDarkColorScheme ? '#333' : '#eee'
},
iconColor: {
color: isDarkColorScheme ? '#999' : '#666'
}
});
type CreateScreenNavigationProp = NativeStackNavigationProp<ParamListBase>;
const navigation = useNavigation<CreateScreenNavigationProp>();
@ -165,7 +236,9 @@ export default function CreateWorkoutScreen() {
variant="outline"
onPress={() => useWorkoutStore.getState().extendRest(30)}
>
<Plus className="mr-2 text-foreground" size={18} />
<View>
<Plus className="mr-2 text-foreground" size={18} />
</View>
<Text>Add 30s</Text>
</Button>
</View>
@ -190,7 +263,9 @@ export default function CreateWorkoutScreen() {
router.back();
}}
>
<ChevronLeft className="text-foreground" />
<View>
<ChevronLeft className="text-foreground" />
</View>
</Button>
<Text className="text-xl font-semibold ml-2">Back</Text>
</View>
@ -220,7 +295,7 @@ export default function CreateWorkoutScreen() {
{/* Timer Display */}
<View className="flex-row items-center px-4 pb-3 border-b border-border">
<Text style={styles.timerText} className={cn(
<Text style={dynamicStyles.timerText} className={cn(
"text-2xl font-mono",
status === 'paused' ? "text-muted-foreground" : "text-foreground"
)}>
@ -234,7 +309,9 @@ export default function CreateWorkoutScreen() {
className="ml-2"
onPress={pauseWorkout}
>
<Pause className="text-foreground" />
<View>
<Pause className="text-foreground" />
</View>
</Button>
) : (
<Button
@ -243,7 +320,9 @@ export default function CreateWorkoutScreen() {
className="ml-2"
onPress={resumeWorkout}
>
<Play className="text-foreground" />
<View>
<Play className="text-foreground" />
</View>
</Button>
)}
</View>
@ -262,45 +341,37 @@ export default function CreateWorkoutScreen() {
// Exercise List when exercises exist
<>
{activeWorkout.exercises.map((exercise, exerciseIndex) => (
<Card
key={exercise.id}
className="mb-6 overflow-hidden border border-border bg-card"
>
<View key={exercise.id} style={dynamicStyles.cardContainer}>
{/* Exercise Header */}
<View className="flex-row justify-between items-center px-4 py-1 border-b border-border">
<Text className="text-lg font-semibold text-[#8B5CF6]">
<View style={dynamicStyles.cardHeader}>
<Text style={dynamicStyles.cardTitle}>
{exercise.title}
</Text>
<Button
variant="ghost"
size="icon"
onPress={() => {
// Open exercise options menu
console.log('Open exercise options');
}}
>
<MoreHorizontal className="text-muted-foreground" size={20} />
</Button>
<TouchableOpacity onPress={() => console.log('Open exercise options')}>
<View>
<MoreHorizontal size={20} color={isDarkColorScheme ? "#999" : "#666"} />
</View>
</TouchableOpacity>
</View>
{/* Sets Info */}
<View className="px-4 py-1">
<Text className="text-sm text-muted-foreground">
<View style={dynamicStyles.setsInfo}>
<Text style={dynamicStyles.setsInfoText}>
{exercise.sets.filter(s => s.isCompleted).length} sets completed
</Text>
</View>
{/* Set Headers */}
<View className="flex-row px-4 py-1 border-t border-border bg-muted/30">
<Text className="w-8 text-sm font-medium text-muted-foreground text-center">SET</Text>
<Text className="w-20 text-sm font-medium text-muted-foreground text-center">PREV</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text>
<View style={{ width: 44 }} /> {/* Space for the checkmark/complete button */}
<View style={dynamicStyles.headerRow}>
<Text style={[dynamicStyles.headerCell, dynamicStyles.setNumberCell]}>SET</Text>
<Text style={[dynamicStyles.headerCell, dynamicStyles.prevCell]}>PREV</Text>
<Text style={[dynamicStyles.headerCell, dynamicStyles.valueCell]}>KG</Text>
<Text style={[dynamicStyles.headerCell, dynamicStyles.valueCell]}>REPS</Text>
<View style={dynamicStyles.spacer} />
</View>
{/* Exercise Sets */}
<CardContent className="p-0">
<View style={dynamicStyles.setsList}>
{exercise.sets.map((set, setIndex) => {
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : undefined;
@ -317,18 +388,22 @@ export default function CreateWorkoutScreen() {
/>
);
})}
</CardContent>
</View>
{/* Add Set Button */}
<Button
variant="ghost"
className="flex-row justify-center items-center py-2 border-t border-border"
onPress={() => handleAddSet(exerciseIndex)}
>
<Plus size={18} className="text-foreground mr-2" />
<Text className="text-foreground">Add Set</Text>
</Button>
</Card>
<View style={dynamicStyles.actionButton}>
<Button
variant="ghost"
className="flex-row justify-center items-center py-2"
onPress={() => handleAddSet(exerciseIndex)}
>
<View>
<Plus size={18} className="text-foreground mr-2" />
</View>
<Text className="text-foreground">Add Set</Text>
</Button>
</View>
</View>
))}
{/* Add Exercises Button */}
@ -352,7 +427,9 @@ export default function CreateWorkoutScreen() {
) : (
// Empty State with nice message and icon
<View className="flex-1 justify-center items-center px-4">
<Dumbbell className="text-muted-foreground mb-6" size={80} />
<View>
<Dumbbell className="text-muted-foreground mb-6" size={80} />
</View>
<Text className="text-xl font-semibold text-center mb-2">
No exercises added
</Text>

View File

@ -1,6 +1,6 @@
// components/library/NewExerciseSheet.tsx
import React, { useState } from 'react';
import { View, ScrollView } from 'react-native';
import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard } from 'react-native';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@ -106,96 +106,124 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
onClose();
};
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
return (
<Sheet isOpen={isOpen} onClose={onClose}>
<SheetHeader>
<SheetTitle>New Exercise</SheetTitle>
</SheetHeader>
<SheetContent>
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<View>
<Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={{ flex: 1 }}>
<SheetHeader>
<SheetTitle>Create New Exercise</SheetTitle>
</SheetHeader>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<View className="gap-5 py-5">
<View>
<Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/>
{!formData.title && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Type</Text>
<View className="flex-row flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
<View>
<Text className="text-base font-medium mb-2">Type</Text>
<View className="flex-row flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
style={formData.type === type ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.type === type ? 'text-white' : ''}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
style={formData.category === category ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.category === category ? 'text-white' : ''}>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Equipment</Text>
<View className="flex-row flex-wrap gap-2">
{EQUIPMENT_OPTIONS.map((eq) => (
<Button
key={eq}
variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
style={formData.equipment === eq ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.equipment === eq ? 'text-white' : ''}>
{eq.charAt(0).toUpperCase() + eq.slice(1)}
</Text>
</Button>
))}
</View>
{!formData.equipment && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..."
multiline
numberOfLines={4}
textAlignVertical="top"
className="min-h-24 py-2"
/>
</View>
<Button
className="mt-6 py-5"
variant='default'
onPress={handleSubmit}
disabled={!formData.title || !formData.equipment}
style={{ backgroundColor: purpleColor }}
>
<Text className={formData.type === type ? 'text-primary-foreground' : ''}>
{type}
</Text>
<Text className="text-white font-semibold">Create Exercise</Text>
</Button>
))}
</View>
</View>
</ScrollView>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
>
<Text className={formData.category === category ? 'text-primary-foreground' : ''}>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Equipment</Text>
<View className="flex-row flex-wrap gap-2">
{EQUIPMENT_OPTIONS.map((eq) => (
<Button
key={eq}
variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
>
<Text className={formData.equipment === eq ? 'text-primary-foreground' : ''}>
{eq}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..."
multiline
numberOfLines={4}
/>
</View>
<Button
className="mt-4"
variant='default'
onPress={handleSubmit}
disabled={!formData.title || !formData.equipment}
>
<Text className="text-primary-foreground font-semibold">Create Exercise</Text>
</Button>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</SheetContent>
</Sheet>
);

View File

@ -17,7 +17,7 @@ import { ExerciseDisplay } from '@/types/exercise';
import { generateId } from '@/utils/ids';
import { useSQLiteContext } from 'expo-sqlite';
import { LibraryService } from '@/lib/db/services/LibraryService';
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List } from 'lucide-react-native';
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List, Search } from 'lucide-react-native';
interface NewTemplateSheetProps {
isOpen: boolean;
@ -28,6 +28,9 @@ interface NewTemplateSheetProps {
// Steps in template creation
type CreationStep = 'type' | 'info' | 'exercises' | 'config' | 'review';
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
// Step 0: Workout Type Selection
interface WorkoutTypeStepProps {
onSelectType: (type: TemplateType) => void;
@ -78,9 +81,10 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
<TouchableOpacity
onPress={() => onSelectType(workout.type)}
className="flex-row justify-between items-center"
activeOpacity={0.7}
>
<View className="flex-row items-center gap-3">
<workout.icon size={24} className="text-foreground" />
<workout.icon size={24} color={purpleColor} />
<View className="flex-1">
<Text className="text-lg font-semibold">{workout.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">
@ -89,7 +93,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
</View>
</View>
<View className="pl-2 pr-1">
<ChevronRight className="text-muted-foreground" size={20} />
<ChevronRight color={purpleColor} size={20} />
</View>
</TouchableOpacity>
) : (
@ -114,7 +118,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
))}
</View>
<Button variant="outline" onPress={onCancel} className="mt-4">
<Button variant="outline" onPress={onCancel} className="mt-4 py-4">
<Text>Cancel</Text>
</Button>
</View>
@ -157,6 +161,11 @@ function BasicInfoStep({
placeholder="e.g., Full Body Strength"
className="text-foreground"
/>
{!title && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
@ -166,7 +175,8 @@ function BasicInfoStep({
onChangeText={onDescriptionChange}
placeholder="Describe this workout..."
numberOfLines={4}
className="bg-input placeholder:text-muted-foreground"
className="bg-input placeholder:text-muted-foreground min-h-24"
textAlignVertical="top"
/>
</View>
@ -178,8 +188,9 @@ function BasicInfoStep({
key={cat}
variant={category === cat ? 'default' : 'outline'}
onPress={() => onCategoryChange(cat)}
style={category === cat ? { backgroundColor: purpleColor } : {}}
>
<Text className={category === cat ? 'text-primary-foreground' : ''}>
<Text className={category === cat ? 'text-white' : ''}>
{cat}
</Text>
</Button>
@ -191,8 +202,12 @@ function BasicInfoStep({
<Button variant="outline" onPress={onCancel}>
<Text>Cancel</Text>
</Button>
<Button onPress={onNext} disabled={!title}>
<Text className="text-primary-foreground">Next</Text>
<Button
onPress={onNext}
disabled={!title}
style={!title ? {} : { backgroundColor: purpleColor }}
>
<Text className={!title ? '' : 'text-white'}>Next</Text>
</Button>
</View>
</View>
@ -236,12 +251,17 @@ function ExerciseSelectionStep({
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-2">
<Input
placeholder="Search exercises..."
value={search}
onChangeText={setSearch}
className="text-foreground"
/>
<View className="relative">
<View className="absolute left-3 h-full justify-center z-10">
<Search size={18} className="text-muted-foreground" />
</View>
<Input
placeholder="Search exercises..."
value={search}
onChangeText={setSearch}
className="pl-10 bg-muted/50 border-0"
/>
</View>
</View>
<ScrollView className="flex-1">
@ -252,27 +272,43 @@ function ExerciseSelectionStep({
<View className="gap-3">
{filteredExercises.map(exercise => (
<View key={exercise.id} className="p-4 bg-card border border-border rounded-md">
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">{exercise.category}</Text>
{exercise.equipment && (
<Text className="text-xs text-muted-foreground mt-0.5">{exercise.equipment}</Text>
)}
<TouchableOpacity
key={exercise.id}
onPress={() => handleToggleSelection(exercise.id)}
activeOpacity={0.7}
>
<View
className="p-4 bg-card border border-border rounded-md"
style={selectedIds.includes(exercise.id) ? { borderColor: purpleColor, borderWidth: 1.5 } : {}}
>
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="text-lg font-semibold">{exercise.title}</Text>
<Text className="text-sm text-muted-foreground mt-1">{exercise.category}</Text>
{exercise.equipment && (
<Text className="text-xs text-muted-foreground mt-0.5">{exercise.equipment}</Text>
)}
</View>
<Button
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
onPress={() => handleToggleSelection(exercise.id)}
size="sm"
style={selectedIds.includes(exercise.id) ? { backgroundColor: purpleColor } : {}}
>
<Text className={selectedIds.includes(exercise.id) ? 'text-white' : ''}>
{selectedIds.includes(exercise.id) ? 'Selected' : 'Add'}
</Text>
</Button>
</View>
<Button
variant={selectedIds.includes(exercise.id) ? 'default' : 'outline'}
onPress={() => handleToggleSelection(exercise.id)}
size="sm"
>
<Text className={selectedIds.includes(exercise.id) ? 'text-primary-foreground' : ''}>
{selectedIds.includes(exercise.id) ? 'Selected' : 'Add'}
</Text>
</Button>
</View>
</View>
</TouchableOpacity>
))}
{filteredExercises.length === 0 && (
<View className="items-center justify-center py-12">
<Text className="text-muted-foreground">No exercises found</Text>
</View>
)}
</View>
</View>
</ScrollView>
@ -284,8 +320,9 @@ function ExerciseSelectionStep({
<Button
onPress={handleContinue}
disabled={selectedIds.length === 0}
style={selectedIds.length === 0 ? {} : { backgroundColor: purpleColor }}
>
<Text className="text-primary-foreground">
<Text className={selectedIds.length === 0 ? '' : 'text-white'}>
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
</Text>
</Button>
@ -313,7 +350,7 @@ function ExerciseConfigStep({
return (
<View className="flex-1">
<ScrollView className="flex-1">
<View className="px-4 gap-3">
<View className="px-4 gap-3 py-4">
{exercises.map((exercise, index) => (
<View key={exercise.id} className="p-4 bg-card border border-border rounded-md">
<Text className="text-lg font-semibold mb-2">{exercise.title}</Text>
@ -357,8 +394,11 @@ function ExerciseConfigStep({
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button onPress={onNext}>
<Text className="text-primary-foreground">Review Template</Text>
<Button
onPress={onNext}
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white">Review Template</Text>
</Button>
</View>
</View>
@ -427,8 +467,11 @@ function ReviewStep({
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button onPress={onSubmit}>
<Text className="text-primary-foreground">Create Template</Text>
<Button
onPress={onSubmit}
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white">Create Template</Text>
</Button>
</View>
</View>

View File

@ -1,11 +1,12 @@
// components/sheets/NostrLoginSheet.tsx
import React, { useState } from 'react';
import { Modal, View, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
import { Info, X } from 'lucide-react-native';
import { View, ActivityIndicator, Modal, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { X, Info } from 'lucide-react-native';
import { useNDKAuth } from '@/lib/hooks/useNDK';
import { useColorScheme } from '@/lib/useColorScheme';
interface NostrLoginSheetProps {
open: boolean;
@ -16,6 +17,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
const [privateKey, setPrivateKey] = useState('');
const [error, setError] = useState<string | null>(null);
const { login, generateKeys, isLoading } = useNDKAuth();
const { isDarkColorScheme } = useColorScheme();
// Handle key generation
const handleGenerateKeys = async () => {
@ -52,8 +54,6 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
}
};
if (!open) return null;
return (
<Modal
visible={open}
@ -61,120 +61,70 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
animationType="slide"
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.header}>
<Text style={styles.title}>Login with Nostr</Text>
<TouchableOpacity onPress={onClose}>
<View className="flex-1 justify-center items-center bg-black/70">
<View className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[90%] max-w-md p-6 shadow-xl`}>
<View className="flex-row justify-between items-center mb-6">
<Text className="text-xl font-bold">Login with Nostr</Text>
<TouchableOpacity onPress={onClose} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<ScrollView style={styles.scrollView}>
<View style={styles.content}>
<Text className="mb-2 text-base">Enter your Nostr private key (nsec)</Text>
<Input
placeholder="nsec1..."
value={privateKey}
onChangeText={setPrivateKey}
secureTextEntry
autoCapitalize="none"
className="mb-4"
/>
{error && (
<View className="mb-4 p-3 bg-destructive/10 rounded-md border border-destructive">
<Text className="text-destructive">{error}</Text>
</View>
)}
<View className="flex-row space-x-2 mb-6">
<Button
variant="outline"
onPress={handleGenerateKeys}
disabled={isLoading}
className="flex-1"
>
<Text>Generate New Keys</Text>
</Button>
<Button
onPress={handleLogin}
disabled={isLoading}
className="flex-1"
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" style={styles.loader} />
) : (
<Text>Login</Text>
)}
</Button>
</View>
<View className="bg-secondary/30 p-3 rounded-md">
<View className="flex-row items-center mb-2">
<Info size={16} className="mr-3 text-muted-foreground" />
<Text className="font-semibold">What is a Nostr Key?</Text>
</View>
<Text className="text-sm text-muted-foreground mb-2">
Nostr is a decentralized protocol where your private key (nsec) is your identity and password.
</Text>
<Text className="text-sm text-muted-foreground">
Your private key is securely stored on your device and is never sent to any servers.
</Text>
</View>
<View className="space-y-4">
<Text className="text-base">Enter your Nostr private key (nsec)</Text>
<Input
placeholder="nsec1..."
value={privateKey}
onChangeText={setPrivateKey}
secureTextEntry
autoCapitalize="none"
className="mb-2"
style={{ paddingVertical: 12 }}
/>
{error && (
<View className="p-4 mb-2 bg-destructive/10 rounded-md border border-destructive">
<Text className="text-destructive">{error}</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
)}
<View className="flex-row gap-4 mt-4 mb-2">
<Button
variant="outline"
onPress={handleGenerateKeys}
disabled={isLoading}
className="flex-1 py-3"
>
<Text>Generate Key</Text>
</Button>
<Button
onPress={handleLogin}
disabled={isLoading}
className="flex-1 py-3"
style={{ backgroundColor: 'hsl(261, 90%, 66%)' }}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-white font-medium">Login</Text>
)}
</Button>
</View>
<View className={`${isDarkColorScheme ? 'bg-background/50' : 'bg-secondary/30'} p-4 rounded-md mt-4 border border-border`}>
<View className="flex-row items-center mb-2">
<Info size={18} className="mr-2 text-muted-foreground" />
<Text className="font-semibold text-base">What is a Nostr Key?</Text>
</View>
<Text className="text-sm text-muted-foreground">
Nostr is a decentralized protocol where your private key (nsec) is your identity and password.
Your private key is securely stored on your device and is never sent to any servers.
</Text>
</View>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
modalContent: {
width: '90%',
maxWidth: 500,
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 15,
},
title: {
fontSize: 18,
fontWeight: 'bold',
},
container: {
maxHeight: '80%',
},
scrollView: {
flex: 1,
},
content: {
padding: 10,
},
loader: {
marginRight: 8,
}
});
}

View File

@ -8,6 +8,7 @@ POWR needs to integrate social features that leverage the Nostr protocol while m
### Functional Requirements
- Custom Nostr event types for exercises, workout templates, and workout records
- Social sharing of workout content via NIP-19 references
- Content management including deletion requests
- Comment system on exercises, templates, and workout records
- Reactions and likes on shared content
- App discovery through NIP-89 handlers
@ -27,7 +28,7 @@ POWR needs to integrate social features that leverage the Nostr protocol while m
## Design Decisions
### 1. Custom Event Kinds vs. Standard Kinds
**Approach**: Use custom event kinds (33401, 33402, 33403) for exercises, templates, and workout records rather than generic kind 1 events.
**Approach**: Use custom event kinds (33401, 33402, 1301) for exercises, templates, and workout records rather than generic kind 1 events.
**Rationale**:
- Custom kinds enable clear data separation and easier filtering
@ -86,6 +87,21 @@ POWR needs to integrate social features that leverage the Nostr protocol while m
- Dependency on external wallet implementations
- Requires careful error handling for payment flows
### 5. Content Publishing and Deletion Workflow
**Approach**: Implement a three-tier approach to content sharing with NIP-09 deletion requests.
**Rationale**:
- Gives users control over content visibility
- Maintains local-first philosophy
- Provides clear separation between private and public data
- Follows Nostr standards for content management
- Enables social sharing while maintaining specialized data format
**Trade-offs**:
- Deletion on Nostr is not guaranteed across all relays
- Additional UI complexity to explain publishing/deletion states
- Need to track content state across local storage and relays
## Technical Design
### Core Components
@ -124,9 +140,9 @@ interface WorkoutTemplate extends NostrEvent {
]
}
// Workout Record Event (Kind 33403)
// Workout Record Event (Kind 1301)
interface WorkoutRecord extends NostrEvent {
kind: 33403;
kind: 1301;
content: string; // Workout notes
tags: [
["d", string], // Unique identifier
@ -144,6 +160,34 @@ interface WorkoutRecord extends NostrEvent {
]
}
// Social Share (Kind 1)
interface SocialShare extends NostrEvent {
kind: 1;
content: string; // Social post text
tags: [
// Quote reference to the exercise, template or workout
["q", string, string, string], // event-id, relay-url, pubkey
// Mention author's pubkey
["p", string], // pubkey of the event creator
// App handler registration (NIP-89)
["client", string, string, string] // Name, 31990 reference, relay-url
]
}
// Deletion Request (Kind 5) - NIP-09
interface DeletionRequest extends NostrEvent {
kind: 5;
content: string; // Reason for deletion (optional)
tags: [
// Event reference(s) to delete
["e", string], // event-id(s) to delete
// Or addressable event reference
["a", string], // "<kind>:<pubkey>:<d-identifier>"
// Kind of the event being deleted
["k", string] // kind number as string
]
}
// Comment (Kind 1111 - as per NIP-22)
interface WorkoutComment extends NostrEvent {
kind: 1111;
@ -151,7 +195,7 @@ interface WorkoutComment extends NostrEvent {
tags: [
// Root reference (exercise, template, or record)
["e", string, string, string], // id, relay, marker "root"
["K", string], // Root kind (33401, 33402, or 33403)
["K", string], // Root kind (33401, 33402, or 1301)
["P", string, string], // Root pubkey, relay
// Parent comment (for replies)
@ -178,7 +222,7 @@ interface AppHandler extends NostrEvent {
tags: [
["k", "33401", "exercise-template"],
["k", "33402", "workout-template"],
["k", "33403", "workout-record"],
["k", "1301", "workout-record"],
["web", string], // App URL
["name", string], // App name
["description", string] // App description
@ -234,23 +278,49 @@ class SocialService {
async reactToEvent(
event: NostrEvent,
reaction: "+" | "🔥" | "👍"
): Promise<NostrEvent> {
const reactionEvent = {
kind: 7,
content: reaction,
tags: [
["e", event.id, "<relay-url>"],
["p", event.pubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
// Sign and publish the reaction
return await this.ndk.publish(reactionEvent);
}
): Promise<NostrEvent>;
// Request deletion of event
async requestDeletion(
eventId: string,
eventKind: number,
reason?: string
): Promise<NostrEvent>;
// Request deletion of addressable event
async requestAddressableDeletion(
kind: number,
pubkey: string,
dTag: string,
reason?: string
): Promise<NostrEvent>;
}
```
### Content Publishing Workflow
```mermaid
graph TD
A[Create Content] --> B{Publish to Relays?}
B -->|No| C[Local Storage Only]
B -->|Yes| D[Save to Local Storage]
D --> E[Publish to Relays]
E --> F{Share Socially?}
F -->|No| G[Done - Content on Relays]
F -->|Yes| H[Create kind:1 Social Post]
H --> I[Reference Original Event]
I --> J[Done - Content Shared]
K[Delete Content] --> L{Delete from Relays?}
L -->|No| M[Delete from Local Only]
L -->|Yes| N[Create kind:5 Deletion Request]
N --> O[Publish Deletion Request]
O --> P{Delete Locally?}
P -->|No| Q[Done - Deletion Requested]
P -->|Yes| R[Delete from Local Storage]
R --> S[Done - Content Deleted]
```
### Data Flow Diagram
```mermaid
@ -258,6 +328,7 @@ graph TD
subgraph User
A[Create Content] --> B[Local Storage]
G[View Content] --> F[UI Components]
T[Request Deletion] --> U[Deletion Manager]
end
subgraph LocalStorage
@ -268,6 +339,7 @@ graph TD
subgraph NostrNetwork
D -->|Publish| E[Relays]
E -->|Subscribe| F
U -->|Publish| E
end
subgraph SocialInteractions
@ -302,7 +374,7 @@ const templatesWithExerciseQuery = {
// Find workout records for a specific template
const workoutRecordsQuery = {
kinds: [33403],
kinds: [1301],
"#template": [`33402:${pubkey}:${templateId}`]
};
@ -310,13 +382,13 @@ const workoutRecordsQuery = {
const commentsQuery = {
kinds: [1111],
"#e": [workoutEventId],
"#K": ["33403"] // Root kind filter
"#K": ["1301"] // Root kind filter
};
// Find social posts (kind 1) that reference our workout events
const socialReferencesQuery = {
kinds: [1],
"#e": [workoutEventId]
"#q": [workoutEventId]
};
// Get reactions to a workout record
@ -325,100 +397,241 @@ const reactionsQuery = {
"#e": [workoutEventId]
};
// Find popular templates based on usage count
async function findPopularTemplates() {
// First get all templates
const templates = await ndk.fetchEvents({
kinds: [33402],
limit: 100
// Find deletion requests for an event
const deletionRequestQuery = {
kinds: [5],
"#e": [eventId]
};
// Find deletion requests for an addressable event
const addressableDeletionRequestQuery = {
kinds: [5],
"#a": [`${kind}:${pubkey}:${dTag}`]
};
```
## Event Publishing and Deletion Implementation
### Publishing Workflow
POWR implements a three-tier approach to content publishing:
1. **Local Only**
- Content is saved only to the device's local storage
- No Nostr events are published
- Content is completely private to the user
2. **Publish to Relays**
- Content is saved locally and published to user-selected relays
- Published as appropriate Nostr events (33401, 33402, 1301)
- Content becomes discoverable by compatible apps via NIP-89
- Local copy is marked as "published to relays"
3. **Social Sharing**
- Content is published to relays as in step 2
- Additionally, a kind:1 social post is created
- The social post quotes the specialized content
- Makes content visible in standard Nostr social clients
- Links back to the specialized content via NIP-19 references
### Deletion Workflow
POWR implements NIP-09 for deletion requests:
1. **Local Deletion**
- Content is removed from local storage only
- No effect on previously published relay content
- User maintains control over local data independent of relay status
2. **Relay Deletion Request**
- Creates a kind:5 deletion request event
- References the content to be deleted
- Includes the kind of content being deleted
- Published to relays that had the original content
- Original content may remain in local storage if desired
3. **Complete Deletion**
- Combination of local deletion and relay deletion request
- Content is removed locally and requested for deletion from relays
- Any social shares remain unless specifically deleted
### Example Implementation
```typescript
// Publishing Content
async function publishExerciseTemplate(exercise) {
// Save locally first
const localId = await localDb.saveExercise(exercise);
// If user wants to publish to relays
if (exercise.publishToRelays) {
// Create Nostr event
const event = {
kind: 33401,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", localId],
["title", exercise.title],
["format", ...Object.keys(exercise.format)],
["format_units", ...formatUnitsToArray(exercise.format_units)],
["equipment", exercise.equipment],
...exercise.tags.map(tag => ["t", tag])
],
content: exercise.description || ""
};
// Sign and publish
event.id = getEventHash(event);
event.sig = signEvent(event, userPrivkey);
await publishToRelays(event);
// Update local record to reflect published status
await localDb.markAsPublished(localId, event.id);
// If user wants to share socially
if (exercise.shareAsSocialPost) {
await createSocialShare(event, exercise.socialShareText || "Check out this exercise!");
}
return { localId, eventId: event.id };
}
return { localId };
}
// Requesting Deletion
async function requestDeletion(eventId, eventKind, options = {}) {
const { deleteLocally = false, reason = "" } = options;
// Create deletion request
const deletionRequest = {
kind: 5,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["e", eventId],
["k", eventKind.toString()]
],
content: reason
};
// Sign and publish
deletionRequest.id = getEventHash(deletionRequest);
deletionRequest.sig = signEvent(deletionRequest, userPrivkey);
await publishToRelays(deletionRequest);
// Update local storage
await localDb.markAsDeletedFromRelays(eventId);
// Delete locally if requested
if (deleteLocally) {
await localDb.deleteContentLocally(eventId);
}
return deletionRequest;
}
// Request deletion of addressable event
async function requestAddressableDeletion(kind, pubkey, dTag, options = {}) {
const { deleteLocally = false, reason = "" } = options;
const deletionRequest = {
kind: 5,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["a", `${kind}:${pubkey}:${dTag}`],
["k", kind.toString()]
],
content: reason
};
// Sign and publish
deletionRequest.id = getEventHash(deletionRequest);
deletionRequest.sig = signEvent(deletionRequest, userPrivkey);
await publishToRelays(deletionRequest);
// Update local storage
await localDb.markAddressableEventAsDeletedFromRelays(kind, pubkey, dTag);
// Delete locally if requested
if (deleteLocally) {
await localDb.deleteAddressableContentLocally(kind, pubkey, dTag);
}
return deletionRequest;
}
// Check for deletion requests when viewing content
async function checkDeletionStatus(eventId) {
const deletionRequests = await ndk.fetchEvents({
kinds: [5],
"#e": [eventId]
});
// Then count associated workout records for each
const templateCounts = await Promise.all(
templates.map(async (template) => {
const dTag = template.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) return { template, count: 0 };
const records = await ndk.fetchEvents({
kinds: [33403],
"#template": [`33402:${template.pubkey}:${dTag}`]
});
return {
template,
count: records.length
};
})
);
for (const request of deletionRequests) {
// Verify the deletion request is from the original author
if (request.pubkey === event.pubkey) {
return { isDeleted: true, request };
}
}
// Sort by usage count
return templateCounts.sort((a, b) => b.count - a.count);
return { isDeleted: false };
}
```
### Integration Points
## User Interface Design
#### Nostr Protocol Integration
- NDK for Nostr event management and relay communication
- NIP-19 for nevent URL encoding/decoding
- NIP-22 for comment threading
- NIP-25 for reactions and likes
- NIP-47 for Nostr Wallet Connect
- NIP-57 for zaps
- NIP-89 for app handler registration
### Content Status Indicators
#### Application Integration
- **Library Screen**:
- Displays social metrics on exercise/template cards (usage count, likes, comments)
- Filters for trending/popular content
- Visual indicators for content source (local, POWR, Nostr)
The UI should clearly indicate the status of fitness content:
- **Detail Screens**:
- Shows comments and reactions on exercises, templates, and workout records
- Displays creator information with follow option
- Presents usage statistics and popularity metrics
- Provides zap/tip options for content creators
1. **Local Only**
- Visual indicator showing content is only on device
- Options to publish to relays or share socially
- **Profile Screen**:
- Displays user's created workouts, templates, and exercises
- Shows workout history and statistics visualization
- Presents achievements, PRs, and milestone tracking
- Includes user's social activity (comments, reactions)
- Provides analytics dashboard of workout progress and consistency
2. **Published to Relays**
- Indicator showing content is published
- Display relay publishing status
- Option to create social share
- **Settings Screen**:
- Nostr Wallet Connect management
- Relay configuration and connection management
- Social preferences (public/private sharing defaults)
- Notification settings for social interactions
- Mute and content filtering options
- Profile visibility and privacy controls
3. **Socially Shared**
- Indicator showing content has been shared socially
- Link to view social post
- Stats on social engagement (comments, reactions)
- **Share Sheet**:
- Social sharing interface for workout records and achievements
- Options for including stats, images, or workout summaries
- Relay selection for content publishing
- Privacy option to share publicly or to specific relays only
4. **Deletion Requested**
- Indicator showing deletion has been requested
- Option to delete locally if not already done
- Explanation that deletion from all relays cannot be guaranteed
- **Comment UI**:
- Thread-based comment creation and display
- Reply functionality with proper nesting
- Reaction options with count displays
- Comment filtering and sorting options
### Deletion Interface
#### External Dependencies
- SQLite for local storage
- NDK (Nostr Development Kit) for Nostr integration
- NWC libraries for wallet connectivity
- Lightning payment providers
The UI for deletion should be clear and informative:
1. **Deletion Options**
- "Delete Locally" - Removes from device only
- "Request Deletion from Relays" - Issues NIP-09 deletion request
- "Delete Completely" - Both local and relay deletion
2. **Confirmation Dialog**
- Clear explanation of deletion scope
- Warning that relay deletion is not guaranteed
- Option to provide reason for deletion (for relay requests)
3. **Deletion Status**
- Visual indicator for content with deletion requests
- Option to view deletion request details
- Ability to check status across relays
## Implementation Plan
### Phase 1: Core Nostr Event Structure
1. Implement custom event kinds (33401, 33402, 33403)
2. Create event validation and processing functions
3. Build local-first storage with Nostr event structure
4. Develop basic event publishing to relays
1. Implement custom event kinds (33401, 33402, 1301)
2. Create local storage schema with publishing status tracking
3. Build basic event publishing to relays
4. Implement NIP-09 deletion requests
### Phase 2: Social Interaction Foundation
1. Implement NIP-22 comments system
@ -445,74 +658,26 @@ async function findPopularTemplates() {
### Unit Tests
- Event validation and processing tests
- Deletion request handling tests
- Comment threading logic tests
- Wallet connection management tests
- Relay communication tests
- Social share URL generation tests
### Integration Tests
- End-to-end comment flow testing
- Reaction and like functionality testing
- End-to-end publishing flow testing
- Deletion request workflow testing
- Comment and reaction functionality testing
- Template usage tracking tests
- Social sharing workflow tests
- Zap flow testing
- Cross-client compatibility testing
### User Testing
- Usability of social sharing flows
- Clarity of comment interfaces
- Usability of publishing and deletion workflows
- Clarity of content status indicators
- Wallet connection experience
- Performance on different devices and connection speeds
## Observability
### Logging
- Social event publishing attempts and results
- Relay connection status
- Comment submission success/failure
- Wallet connection events
- Payment attempts and results
### Metrics
- Template popularity (usage counts)
- Comment engagement rates
- Social sharing frequency
- Zaps received/sent
- Relay response times
- Offline content creation stats
## Future Considerations
### Potential Enhancements
- Group fitness challenges with bounties
- Subscription model for premium content
- Coaching marketplace with Lightning payments
- Team workout coordination
- Custom fitness community creation
- AI-powered workout recommendations based on social data
### Known Limitations
- Reliance on external Lightning wallets
- Comment moderation limited to client-side filtering
- Content discovery dependent on relay availability
- Limited backward compatibility with generic Nostr clients
## Dependencies
### Runtime Dependencies
- NDK (Nostr Development Kit)
- SQLite database
- Nostr relay connections
- Lightning network (for zaps)
- NWC-compatible wallets
### Development Dependencies
- TypeScript
- React Native
- Expo
- Jest for testing
- NativeWind for styling
## Security Considerations
- Never store or request user private keys
- Secure management of NWC connection secrets
@ -521,61 +686,38 @@ async function findPopularTemplates() {
- User control over content visibility
- Protection against spam and abuse
### Privacy Control Mechanisms
The application implements several layers of privacy controls:
1. **Publication Controls**:
- Per-content privacy settings (public, followers-only, private)
- Relay selection for each published event
- Option to keep all workout data local-only
2. **Content Visibility**:
- Anonymous workout publishing (remove identifying data)
- Selective stat sharing (choose which metrics to publish)
- Time-delayed publishing (share workouts after a delay)
3. **Technical Mechanisms**:
- Local-first storage ensures all data is usable offline
- Content encryption for sensitive information (using NIP-44)
- Private relay support for limited audience sharing
- Event expiration tags for temporary content
4. **User Interface**:
- Clear visual indicators for public vs. private content
- Confirmation dialogs before publishing to relays
- Privacy setting presets (public account, private account, mixed)
- Granular permission controls for different content types
## Rollout Strategy
### Development Phase
1. Implement custom event kinds and validation
2. Create UI components for social interactions
2. Create UI components for content publishing status
3. Develop local-first storage with Nostr sync
4. Build and test commenting system
4. Build and test deletion request functionality
5. Implement wallet connection interface
6. Add documentation for Nostr integration
### Beta Testing
1. Release to limited test group
2. Monitor relay performance and sync issues
3. Gather feedback on social interaction flows
3. Gather feedback on publishing and deletion flows
4. Test cross-client compatibility
5. Evaluate Lightning payment reliability
### Production Deployment
1. Deploy app handler registration
2. Roll out social features progressively
2. Roll out features progressively
3. Monitor engagement and performance metrics
4. Provide guides for social feature usage
4. Provide guides for feature usage
5. Establish relay connection recommendations
6. Create nostr:// URI scheme handlers
## References
- [Nostr NIPs Repository](https://github.com/nostr-protocol/nips)
- [NIP-09 Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md)
- [NIP-10 Text Notes and Threads](https://github.com/nostr-protocol/nips/blob/master/10.md)
- [NIP-19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
- [NIP-22 Comment](https://github.com/nostr-protocol/nips/blob/master/22.md)
- [NIP-89 Recommended Application Handlers](https://github.com/nostr-protocol/nips/blob/master/89.md)
- [NDK Documentation](https://github.com/nostr-dev-kit/ndk)
- [POWR Workout NIP Draft](nostr-exercise-nip.md)
- [NIP-47 Wallet Connect](https://github.com/nostr-protocol/nips/blob/master/47.md)
- [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md)
- [NIP-89 App Handlers](https://github.com/nostr-protocol/nips/blob/master/89.md)
- [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md)