mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 10:51:19 +00:00
add exercise to active workout and bug fixes
This commit is contained in:
parent
f870f2a0ca
commit
98a5b9ed09
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
}
|
||||
});
|
||||
}
|
@ -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)
|
Loading…
x
Reference in New Issue
Block a user