1
0
mirror of https://github.com/DocNR/POWR.git synced 2025-05-21 01:12:07 +00:00

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

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

@ -5,6 +5,7 @@ import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
import { import {
AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2, AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2,
Code, Search, ListFilter, Wifi, Zap, FileJson, X, Info Code, Search, ListFilter, Wifi, Zap, FileJson, X, Info
@ -79,8 +80,6 @@ export default function ProgramsScreen() {
const [eventKind, setEventKind] = useState(NostrEventKind.EXERCISE); const [eventKind, setEventKind] = useState(NostrEventKind.EXERCISE);
const [eventContent, setEventContent] = useState(''); const [eventContent, setEventContent] = useState('');
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
const [privateKey, setPrivateKey] = useState('');
const [error, setError] = useState<string | null>(null);
// Use the NDK hooks // Use the NDK hooks
const { ndk, isLoading: ndkLoading } = useNDK(); const { ndk, isLoading: ndkLoading } = useNDK();
@ -269,45 +268,9 @@ export default function ProgramsScreen() {
setIsLoginSheetOpen(true); setIsLoginSheetOpen(true);
}; };
// Close login sheet
const handleCloseLogin = () => { const handleCloseLogin = () => {
setIsLoginSheetOpen(false); 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 // Handle logout
const handleLogout = async () => { const handleLogout = async () => {
@ -735,74 +698,11 @@ export default function ProgramsScreen() {
</Card> </Card>
{/* Login Modal */} {/* Login Modal */}
<Modal <NostrLoginSheet
visible={isLoginSheetOpen} open={isLoginSheetOpen}
transparent={true} onClose={handleCloseLogin}
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>
{/* Create Event */} {/* Create Event */}
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>

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

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

@ -1,11 +1,10 @@
// app/(workout)/create.tsx // app/(workout)/create.tsx
import React, { useState, useEffect } from 'react'; 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 { router, useNavigation } from 'expo-router';
import { TabScreen } from '@/components/layout/TabScreen'; import { TabScreen } from '@/components/layout/TabScreen';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -26,13 +25,7 @@ import { formatTime } from '@/utils/formatTime';
import { ParamListBase } from '@react-navigation/native'; import { ParamListBase } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import SetInput from '@/components/workout/SetInput'; import SetInput from '@/components/workout/SetInput';
import { useColorScheme } from '@/lib/useColorScheme';
// Define styles outside of component
const styles = StyleSheet.create({
timerText: {
fontVariant: ['tabular-nums']
}
});
export default function CreateWorkoutScreen() { export default function CreateWorkoutScreen() {
const { const {
@ -55,6 +48,84 @@ export default function CreateWorkoutScreen() {
maximizeWorkout maximizeWorkout
} = useWorkoutStore.getState(); } = 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>; type CreateScreenNavigationProp = NativeStackNavigationProp<ParamListBase>;
const navigation = useNavigation<CreateScreenNavigationProp>(); const navigation = useNavigation<CreateScreenNavigationProp>();
@ -165,7 +236,9 @@ export default function CreateWorkoutScreen() {
variant="outline" variant="outline"
onPress={() => useWorkoutStore.getState().extendRest(30)} 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> <Text>Add 30s</Text>
</Button> </Button>
</View> </View>
@ -190,7 +263,9 @@ export default function CreateWorkoutScreen() {
router.back(); router.back();
}} }}
> >
<ChevronLeft className="text-foreground" /> <View>
<ChevronLeft className="text-foreground" />
</View>
</Button> </Button>
<Text className="text-xl font-semibold ml-2">Back</Text> <Text className="text-xl font-semibold ml-2">Back</Text>
</View> </View>
@ -220,7 +295,7 @@ export default function CreateWorkoutScreen() {
{/* Timer Display */} {/* Timer Display */}
<View className="flex-row items-center px-4 pb-3 border-b border-border"> <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", "text-2xl font-mono",
status === 'paused' ? "text-muted-foreground" : "text-foreground" status === 'paused' ? "text-muted-foreground" : "text-foreground"
)}> )}>
@ -234,7 +309,9 @@ export default function CreateWorkoutScreen() {
className="ml-2" className="ml-2"
onPress={pauseWorkout} onPress={pauseWorkout}
> >
<Pause className="text-foreground" /> <View>
<Pause className="text-foreground" />
</View>
</Button> </Button>
) : ( ) : (
<Button <Button
@ -243,7 +320,9 @@ export default function CreateWorkoutScreen() {
className="ml-2" className="ml-2"
onPress={resumeWorkout} onPress={resumeWorkout}
> >
<Play className="text-foreground" /> <View>
<Play className="text-foreground" />
</View>
</Button> </Button>
)} )}
</View> </View>
@ -262,45 +341,37 @@ export default function CreateWorkoutScreen() {
// Exercise List when exercises exist // Exercise List when exercises exist
<> <>
{activeWorkout.exercises.map((exercise, exerciseIndex) => ( {activeWorkout.exercises.map((exercise, exerciseIndex) => (
<Card <View key={exercise.id} style={dynamicStyles.cardContainer}>
key={exercise.id}
className="mb-6 overflow-hidden border border-border bg-card"
>
{/* Exercise Header */} {/* Exercise Header */}
<View className="flex-row justify-between items-center px-4 py-1 border-b border-border"> <View style={dynamicStyles.cardHeader}>
<Text className="text-lg font-semibold text-[#8B5CF6]"> <Text style={dynamicStyles.cardTitle}>
{exercise.title} {exercise.title}
</Text> </Text>
<Button <TouchableOpacity onPress={() => console.log('Open exercise options')}>
variant="ghost" <View>
size="icon" <MoreHorizontal size={20} color={isDarkColorScheme ? "#999" : "#666"} />
onPress={() => { </View>
// Open exercise options menu </TouchableOpacity>
console.log('Open exercise options');
}}
>
<MoreHorizontal className="text-muted-foreground" size={20} />
</Button>
</View> </View>
{/* Sets Info */} {/* Sets Info */}
<View className="px-4 py-1"> <View style={dynamicStyles.setsInfo}>
<Text className="text-sm text-muted-foreground"> <Text style={dynamicStyles.setsInfoText}>
{exercise.sets.filter(s => s.isCompleted).length} sets completed {exercise.sets.filter(s => s.isCompleted).length} sets completed
</Text> </Text>
</View> </View>
{/* Set Headers */} {/* Set Headers */}
<View className="flex-row px-4 py-1 border-t border-border bg-muted/30"> <View style={dynamicStyles.headerRow}>
<Text className="w-8 text-sm font-medium text-muted-foreground text-center">SET</Text> <Text style={[dynamicStyles.headerCell, dynamicStyles.setNumberCell]}>SET</Text>
<Text className="w-20 text-sm font-medium text-muted-foreground text-center">PREV</Text> <Text style={[dynamicStyles.headerCell, dynamicStyles.prevCell]}>PREV</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text> <Text style={[dynamicStyles.headerCell, dynamicStyles.valueCell]}>KG</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text> <Text style={[dynamicStyles.headerCell, dynamicStyles.valueCell]}>REPS</Text>
<View style={{ width: 44 }} /> {/* Space for the checkmark/complete button */} <View style={dynamicStyles.spacer} />
</View> </View>
{/* Exercise Sets */} {/* Exercise Sets */}
<CardContent className="p-0"> <View style={dynamicStyles.setsList}>
{exercise.sets.map((set, setIndex) => { {exercise.sets.map((set, setIndex) => {
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : undefined; const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : undefined;
@ -317,18 +388,22 @@ export default function CreateWorkoutScreen() {
/> />
); );
})} })}
</CardContent> </View>
{/* Add Set Button */} {/* Add Set Button */}
<Button <View style={dynamicStyles.actionButton}>
variant="ghost" <Button
className="flex-row justify-center items-center py-2 border-t border-border" variant="ghost"
onPress={() => handleAddSet(exerciseIndex)} className="flex-row justify-center items-center py-2"
> onPress={() => handleAddSet(exerciseIndex)}
<Plus size={18} className="text-foreground mr-2" /> >
<Text className="text-foreground">Add Set</Text> <View>
</Button> <Plus size={18} className="text-foreground mr-2" />
</Card> </View>
<Text className="text-foreground">Add Set</Text>
</Button>
</View>
</View>
))} ))}
{/* Add Exercises Button */} {/* Add Exercises Button */}
@ -352,7 +427,9 @@ export default function CreateWorkoutScreen() {
) : ( ) : (
// Empty State with nice message and icon // Empty State with nice message and icon
<View className="flex-1 justify-center items-center px-4"> <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"> <Text className="text-xl font-semibold text-center mb-2">
No exercises added No exercises added
</Text> </Text>

@ -1,6 +1,6 @@
// components/library/NewExerciseSheet.tsx // components/library/NewExerciseSheet.tsx
import React, { useState } from 'react'; 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 { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -106,96 +106,124 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
onClose(); onClose();
}; };
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
return ( return (
<Sheet isOpen={isOpen} onClose={onClose}> <Sheet isOpen={isOpen} onClose={onClose}>
<SheetHeader>
<SheetTitle>New Exercise</SheetTitle>
</SheetHeader>
<SheetContent> <SheetContent>
<ScrollView className="flex-1"> <KeyboardAvoidingView
<View className="gap-4 py-4"> behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
<View> style={{ flex: 1 }}
<Text className="text-base font-medium mb-2">Exercise Name</Text> >
<Input <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
value={formData.title} <View style={{ flex: 1 }}>
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))} <SheetHeader>
placeholder="e.g., Barbell Back Squat" <SheetTitle>Create New Exercise</SheetTitle>
className="text-foreground" </SheetHeader>
/> <ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
</View> <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> <View>
<Text className="text-base font-medium mb-2">Type</Text> <Text className="text-base font-medium mb-2">Type</Text>
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => ( {EXERCISE_TYPES.map((type) => (
<Button <Button
key={type} key={type}
variant={formData.type === type ? 'default' : 'outline'} variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))} 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' : ''}> <Text className="text-white font-semibold">Create Exercise</Text>
{type}
</Text>
</Button> </Button>
))} </View>
</View> </ScrollView>
</View> </View>
</TouchableWithoutFeedback>
<View> </KeyboardAvoidingView>
<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>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );

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

@ -1,11 +1,12 @@
// components/sheets/NostrLoginSheet.tsx // components/sheets/NostrLoginSheet.tsx
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal, View, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native'; import { View, ActivityIndicator, Modal, TouchableOpacity } from 'react-native';
import { Info, X } from 'lucide-react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { X, Info } from 'lucide-react-native';
import { useNDKAuth } from '@/lib/hooks/useNDK'; import { useNDKAuth } from '@/lib/hooks/useNDK';
import { useColorScheme } from '@/lib/useColorScheme';
interface NostrLoginSheetProps { interface NostrLoginSheetProps {
open: boolean; open: boolean;
@ -16,6 +17,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
const [privateKey, setPrivateKey] = useState(''); const [privateKey, setPrivateKey] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { login, generateKeys, isLoading } = useNDKAuth(); const { login, generateKeys, isLoading } = useNDKAuth();
const { isDarkColorScheme } = useColorScheme();
// Handle key generation // Handle key generation
const handleGenerateKeys = async () => { const handleGenerateKeys = async () => {
@ -52,8 +54,6 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
} }
}; };
if (!open) return null;
return ( return (
<Modal <Modal
visible={open} visible={open}
@ -61,120 +61,70 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
animationType="slide" animationType="slide"
onRequestClose={onClose} onRequestClose={onClose}
> >
<View style={styles.modalOverlay}> <View className="flex-1 justify-center items-center bg-black/70">
<View style={styles.modalContent}> <View className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[90%] max-w-md p-6 shadow-xl`}>
<View style={styles.header}> <View className="flex-row justify-between items-center mb-6">
<Text style={styles.title}>Login with Nostr</Text> <Text className="text-xl font-bold">Login with Nostr</Text>
<TouchableOpacity onPress={onClose}> <TouchableOpacity onPress={onClose} className="p-1">
<X size={24} /> <X size={24} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<KeyboardAvoidingView <View className="space-y-4">
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} <Text className="text-base">Enter your Nostr private key (nsec)</Text>
style={styles.container} <Input
> placeholder="nsec1..."
<ScrollView style={styles.scrollView}> value={privateKey}
<View style={styles.content}> onChangeText={setPrivateKey}
<Text className="mb-2 text-base">Enter your Nostr private key (nsec)</Text> secureTextEntry
<Input autoCapitalize="none"
placeholder="nsec1..." className="mb-2"
value={privateKey} style={{ paddingVertical: 12 }}
onChangeText={setPrivateKey} />
secureTextEntry
autoCapitalize="none" {error && (
className="mb-4" <View className="p-4 mb-2 bg-destructive/10 rounded-md border border-destructive">
/> <Text className="text-destructive">{error}</Text>
{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> </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>
</View> </View>
</Modal> </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,
}
});

@ -8,6 +8,7 @@ POWR needs to integrate social features that leverage the Nostr protocol while m
### Functional Requirements ### Functional Requirements
- Custom Nostr event types for exercises, workout templates, and workout records - Custom Nostr event types for exercises, workout templates, and workout records
- Social sharing of workout content via NIP-19 references - Social sharing of workout content via NIP-19 references
- Content management including deletion requests
- Comment system on exercises, templates, and workout records - Comment system on exercises, templates, and workout records
- Reactions and likes on shared content - Reactions and likes on shared content
- App discovery through NIP-89 handlers - 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 ## Design Decisions
### 1. Custom Event Kinds vs. Standard Kinds ### 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**: **Rationale**:
- Custom kinds enable clear data separation and easier filtering - 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 - Dependency on external wallet implementations
- Requires careful error handling for payment flows - 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 ## Technical Design
### Core Components ### Core Components
@ -124,9 +140,9 @@ interface WorkoutTemplate extends NostrEvent {
] ]
} }
// Workout Record Event (Kind 33403) // Workout Record Event (Kind 1301)
interface WorkoutRecord extends NostrEvent { interface WorkoutRecord extends NostrEvent {
kind: 33403; kind: 1301;
content: string; // Workout notes content: string; // Workout notes
tags: [ tags: [
["d", string], // Unique identifier ["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) // Comment (Kind 1111 - as per NIP-22)
interface WorkoutComment extends NostrEvent { interface WorkoutComment extends NostrEvent {
kind: 1111; kind: 1111;
@ -151,7 +195,7 @@ interface WorkoutComment extends NostrEvent {
tags: [ tags: [
// Root reference (exercise, template, or record) // Root reference (exercise, template, or record)
["e", string, string, string], // id, relay, marker "root" ["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 ["P", string, string], // Root pubkey, relay
// Parent comment (for replies) // Parent comment (for replies)
@ -178,7 +222,7 @@ interface AppHandler extends NostrEvent {
tags: [ tags: [
["k", "33401", "exercise-template"], ["k", "33401", "exercise-template"],
["k", "33402", "workout-template"], ["k", "33402", "workout-template"],
["k", "33403", "workout-record"], ["k", "1301", "workout-record"],
["web", string], // App URL ["web", string], // App URL
["name", string], // App name ["name", string], // App name
["description", string] // App description ["description", string] // App description
@ -234,23 +278,49 @@ class SocialService {
async reactToEvent( async reactToEvent(
event: NostrEvent, event: NostrEvent,
reaction: "+" | "🔥" | "👍" reaction: "+" | "🔥" | "👍"
): Promise<NostrEvent> { ): Promise<NostrEvent>;
const reactionEvent = {
kind: 7, // Request deletion of event
content: reaction, async requestDeletion(
tags: [ eventId: string,
["e", event.id, "<relay-url>"], eventKind: number,
["p", event.pubkey] reason?: string
], ): Promise<NostrEvent>;
created_at: Math.floor(Date.now() / 1000)
}; // Request deletion of addressable event
async requestAddressableDeletion(
// Sign and publish the reaction kind: number,
return await this.ndk.publish(reactionEvent); 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 ### Data Flow Diagram
```mermaid ```mermaid
@ -258,6 +328,7 @@ graph TD
subgraph User subgraph User
A[Create Content] --> B[Local Storage] A[Create Content] --> B[Local Storage]
G[View Content] --> F[UI Components] G[View Content] --> F[UI Components]
T[Request Deletion] --> U[Deletion Manager]
end end
subgraph LocalStorage subgraph LocalStorage
@ -268,6 +339,7 @@ graph TD
subgraph NostrNetwork subgraph NostrNetwork
D -->|Publish| E[Relays] D -->|Publish| E[Relays]
E -->|Subscribe| F E -->|Subscribe| F
U -->|Publish| E
end end
subgraph SocialInteractions subgraph SocialInteractions
@ -302,7 +374,7 @@ const templatesWithExerciseQuery = {
// Find workout records for a specific template // Find workout records for a specific template
const workoutRecordsQuery = { const workoutRecordsQuery = {
kinds: [33403], kinds: [1301],
"#template": [`33402:${pubkey}:${templateId}`] "#template": [`33402:${pubkey}:${templateId}`]
}; };
@ -310,13 +382,13 @@ const workoutRecordsQuery = {
const commentsQuery = { const commentsQuery = {
kinds: [1111], kinds: [1111],
"#e": [workoutEventId], "#e": [workoutEventId],
"#K": ["33403"] // Root kind filter "#K": ["1301"] // Root kind filter
}; };
// Find social posts (kind 1) that reference our workout events // Find social posts (kind 1) that reference our workout events
const socialReferencesQuery = { const socialReferencesQuery = {
kinds: [1], kinds: [1],
"#e": [workoutEventId] "#q": [workoutEventId]
}; };
// Get reactions to a workout record // Get reactions to a workout record
@ -325,100 +397,241 @@ const reactionsQuery = {
"#e": [workoutEventId] "#e": [workoutEventId]
}; };
// Find popular templates based on usage count // Find deletion requests for an event
async function findPopularTemplates() { const deletionRequestQuery = {
// First get all templates kinds: [5],
const templates = await ndk.fetchEvents({ "#e": [eventId]
kinds: [33402], };
limit: 100
// 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 for (const request of deletionRequests) {
const templateCounts = await Promise.all( // Verify the deletion request is from the original author
templates.map(async (template) => { if (request.pubkey === event.pubkey) {
const dTag = template.tags.find(t => t[0] === 'd')?.[1]; return { isDeleted: true, request };
if (!dTag) return { template, count: 0 }; }
}
const records = await ndk.fetchEvents({
kinds: [33403],
"#template": [`33402:${template.pubkey}:${dTag}`]
});
return {
template,
count: records.length
};
})
);
// Sort by usage count return { isDeleted: false };
return templateCounts.sort((a, b) => b.count - a.count);
} }
``` ```
### Integration Points ## User Interface Design
#### Nostr Protocol Integration ### Content Status Indicators
- 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
#### Application Integration The UI should clearly indicate the status of fitness content:
- **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)
- **Detail Screens**: 1. **Local Only**
- Shows comments and reactions on exercises, templates, and workout records - Visual indicator showing content is only on device
- Displays creator information with follow option - Options to publish to relays or share socially
- Presents usage statistics and popularity metrics
- Provides zap/tip options for content creators
- **Profile Screen**: 2. **Published to Relays**
- Displays user's created workouts, templates, and exercises - Indicator showing content is published
- Shows workout history and statistics visualization - Display relay publishing status
- Presents achievements, PRs, and milestone tracking - Option to create social share
- Includes user's social activity (comments, reactions)
- Provides analytics dashboard of workout progress and consistency
- **Settings Screen**: 3. **Socially Shared**
- Nostr Wallet Connect management - Indicator showing content has been shared socially
- Relay configuration and connection management - Link to view social post
- Social preferences (public/private sharing defaults) - Stats on social engagement (comments, reactions)
- Notification settings for social interactions
- Mute and content filtering options
- Profile visibility and privacy controls
- **Share Sheet**: 4. **Deletion Requested**
- Social sharing interface for workout records and achievements - Indicator showing deletion has been requested
- Options for including stats, images, or workout summaries - Option to delete locally if not already done
- Relay selection for content publishing - Explanation that deletion from all relays cannot be guaranteed
- Privacy option to share publicly or to specific relays only
- **Comment UI**: ### Deletion Interface
- Thread-based comment creation and display
- Reply functionality with proper nesting
- Reaction options with count displays
- Comment filtering and sorting options
#### External Dependencies The UI for deletion should be clear and informative:
- SQLite for local storage
- NDK (Nostr Development Kit) for Nostr integration 1. **Deletion Options**
- NWC libraries for wallet connectivity - "Delete Locally" - Removes from device only
- Lightning payment providers - "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 ## Implementation Plan
### Phase 1: Core Nostr Event Structure ### Phase 1: Core Nostr Event Structure
1. Implement custom event kinds (33401, 33402, 33403) 1. Implement custom event kinds (33401, 33402, 1301)
2. Create event validation and processing functions 2. Create local storage schema with publishing status tracking
3. Build local-first storage with Nostr event structure 3. Build basic event publishing to relays
4. Develop basic event publishing to relays 4. Implement NIP-09 deletion requests
### Phase 2: Social Interaction Foundation ### Phase 2: Social Interaction Foundation
1. Implement NIP-22 comments system 1. Implement NIP-22 comments system
@ -445,74 +658,26 @@ async function findPopularTemplates() {
### Unit Tests ### Unit Tests
- Event validation and processing tests - Event validation and processing tests
- Deletion request handling tests
- Comment threading logic tests - Comment threading logic tests
- Wallet connection management tests - Wallet connection management tests
- Relay communication tests - Relay communication tests
- Social share URL generation tests - Social share URL generation tests
### Integration Tests ### Integration Tests
- End-to-end comment flow testing - End-to-end publishing flow testing
- Reaction and like functionality testing - Deletion request workflow testing
- Comment and reaction functionality testing
- Template usage tracking tests - Template usage tracking tests
- Social sharing workflow tests - Social sharing workflow tests
- Zap flow testing - Zap flow testing
- Cross-client compatibility testing
### User Testing ### User Testing
- Usability of social sharing flows - Usability of publishing and deletion workflows
- Clarity of comment interfaces - Clarity of content status indicators
- Wallet connection experience - Wallet connection experience
- Performance on different devices and connection speeds - 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 ## Security Considerations
- Never store or request user private keys - Never store or request user private keys
- Secure management of NWC connection secrets - Secure management of NWC connection secrets
@ -521,61 +686,38 @@ async function findPopularTemplates() {
- User control over content visibility - User control over content visibility
- Protection against spam and abuse - 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 ## Rollout Strategy
### Development Phase ### Development Phase
1. Implement custom event kinds and validation 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 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 5. Implement wallet connection interface
6. Add documentation for Nostr integration 6. Add documentation for Nostr integration
### Beta Testing ### Beta Testing
1. Release to limited test group 1. Release to limited test group
2. Monitor relay performance and sync issues 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 4. Test cross-client compatibility
5. Evaluate Lightning payment reliability 5. Evaluate Lightning payment reliability
### Production Deployment ### Production Deployment
1. Deploy app handler registration 1. Deploy app handler registration
2. Roll out social features progressively 2. Roll out features progressively
3. Monitor engagement and performance metrics 3. Monitor engagement and performance metrics
4. Provide guides for social feature usage 4. Provide guides for feature usage
5. Establish relay connection recommendations 5. Establish relay connection recommendations
6. Create nostr:// URI scheme handlers 6. Create nostr:// URI scheme handlers
## References ## References
- [Nostr NIPs Repository](https://github.com/nostr-protocol/nips) - [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) - [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-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-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)