sqlite db wip - add exercise template now works

This commit is contained in:
DocNR 2025-02-04 22:53:44 -05:00
parent 855c034a35
commit 18d5886e40
12 changed files with 612 additions and 253 deletions

View File

@ -1,7 +1,7 @@
{ {
"expo": { "expo": {
"name": "powr-rebuild", "name": "POWR",
"slug": "powr-rebuild", "slug": "POWR",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",

View File

@ -187,6 +187,12 @@ export default function LibraryScreen() {
} }
}; };
const handleDeleteContent = useCallback((deletedContent: LibraryContent) => {
setContent(prevContent =>
prevContent.filter(item => item.id !== deletedContent.id)
);
}, []);
const handleTabPress = useCallback((index: number) => { const handleTabPress = useCallback((index: number) => {
pagerRef.current?.setPage(index); pagerRef.current?.setPage(index);
setActiveSection(index); setActiveSection(index);
@ -235,13 +241,14 @@ export default function LibraryScreen() {
onPageSelected={handlePageSelected} onPageSelected={handlePageSelected}
> >
<View key="my-library" style={styles.pageContainer}> <View key="my-library" style={styles.pageContainer}>
<MyLibrary <MyLibrary
savedContent={filteredContent} savedContent={filteredContent}
onContentPress={handleContentPress} onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress} onFavoritePress={handleFavoritePress}
isLoading={isLoading} onDeleteContent={handleDeleteContent}
isVisible isLoading={isLoading}
/> isVisible={activeSection === 0}
/>
</View> </View>
<View key="programs" style={styles.pageContainer}> <View key="programs" style={styles.pageContainer}>

View File

@ -1,58 +1,70 @@
// app/(workout)/new-exercise.tsx // app/(workout)/new-exercise.tsx
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native'; import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { Picker } from '@react-native-picker/picker';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { Input } from '@/components/form/Input'; import { Input } from '@/components/form/Input';
import { Select } from '@/components/form/Select';
import { Button } from '@/components/form/Button'; import { Button } from '@/components/form/Button';
import { LibraryService } from '@/services/LibraryService'; import { libraryService } from '@/services/LibraryService';
import { spacing } from '@/styles/sharedStyles'; import { spacing } from '@/styles/sharedStyles';
import { generateId } from '@/utils/ids'; import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
// Types based on NIP-XX spec // Define valid options based on schema and NIP-XX constraints
const EQUIPMENT_OPTIONS = [ const EQUIPMENT_OPTIONS: Equipment[] = [
{ label: 'Barbell', value: 'barbell' }, 'bodyweight',
{ label: 'Dumbbell', value: 'dumbbell' }, 'barbell',
{ label: 'Bodyweight', value: 'bodyweight' }, 'dumbbell',
{ label: 'Machine', value: 'machine' }, 'kettlebell',
{ label: 'Cardio', value: 'cardio' } 'machine',
'cable',
'other'
];
const EXERCISE_TYPES: ExerciseType[] = [
'strength',
'cardio',
'bodyweight'
];
const CATEGORIES: ExerciseCategory[] = [
'Push',
'Pull',
'Legs',
'Core'
]; ];
const DIFFICULTY_OPTIONS = [ const DIFFICULTY_OPTIONS = [
{ label: 'Beginner', value: 'beginner' }, 'beginner',
{ label: 'Intermediate', value: 'intermediate' }, 'intermediate',
{ label: 'Advanced', value: 'advanced' } 'advanced'
]; ] as const;
const MUSCLE_GROUP_OPTIONS = [ type Difficulty = typeof DIFFICULTY_OPTIONS[number];
{ label: 'Chest', value: 'chest' },
{ label: 'Back', value: 'back' },
{ label: 'Legs', value: 'legs' },
{ label: 'Shoulders', value: 'shoulders' },
{ label: 'Arms', value: 'arms' },
{ label: 'Core', value: 'core' }
];
const MOVEMENT_TYPE_OPTIONS = [ const MOVEMENT_PATTERNS = [
{ label: 'Push', value: 'push' }, 'push',
{ label: 'Pull', value: 'pull' }, 'pull',
{ label: 'Squat', value: 'squat' }, 'squat',
{ label: 'Hinge', value: 'hinge' }, 'hinge',
{ label: 'Carry', value: 'carry' } 'carry',
]; 'rotation'
] as const;
type MovementPattern = typeof MOVEMENT_PATTERNS[number];
export default function NewExerciseScreen() { export default function NewExerciseScreen() {
const router = useRouter(); const router = useRouter();
const { colors } = useColorScheme(); const { colors } = useColorScheme();
// Form state matching Nostr spec // Required fields based on NIP-XX spec
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [equipment, setEquipment] = useState(''); const [exerciseType, setExerciseType] = useState<ExerciseType>(EXERCISE_TYPES[0]);
const [difficulty, setDifficulty] = useState(''); const [category, setCategory] = useState<ExerciseCategory>(CATEGORIES[0]);
const [muscleGroups, setMuscleGroups] = useState<string[]>([]); const [equipment, setEquipment] = useState<Equipment>(EQUIPMENT_OPTIONS[0]);
const [movementTypes, setMovementTypes] = useState<string[]>([]); const [difficulty, setDifficulty] = useState<Difficulty>(DIFFICULTY_OPTIONS[0]);
const [movementPattern, setMovementPattern] = useState<MovementPattern>(MOVEMENT_PATTERNS[0]);
const [instructions, setInstructions] = useState(''); const [instructions, setInstructions] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -67,39 +79,37 @@ export default function NewExerciseScreen() {
return; return;
} }
if (!equipment) {
setError('Equipment type is required');
return;
}
// Create exercise template following NIP-XX spec
const exerciseTemplate = { const exerciseTemplate = {
id: generateId(), // UUID for template identification
title: title.trim(), title: title.trim(),
type: 'exercise', type: exerciseType,
format: ['weight', 'reps', 'rpe', 'set_type'], // Required format params category,
format_units: ['kg', 'count', '0-10', 'warmup|normal|drop|failure'], // Required unit definitions
equipment, equipment,
difficulty, difficulty,
content: instructions.trim(), // Form instructions in content field description: instructions.trim(),
tags: [ tags: [
['d', generateId()], // Required UUID tag difficulty,
['title', title.trim()], movementPattern,
['equipment', equipment], category.toLowerCase()
...muscleGroups.map(group => ['t', group]), ],
...movementTypes.map(type => ['t', type]), format: {
['format', 'weight', 'reps', 'rpe', 'set_type'], weight: true,
['format_units', 'kg', 'count', '0-10', 'warmup|normal|drop|failure'], reps: true,
difficulty ? ['difficulty', difficulty] : [], rpe: true,
].filter(tag => tag.length > 0), // Remove empty tags set_type: true
source: 'local', },
created_at: Date.now(), format_units: {
availability: { weight: 'kg' as const,
source: ['local'] reps: 'count' as const,
rpe: '0-10' as const,
set_type: 'warmup|normal|drop|failure' as const
} }
}; };
await LibraryService.addExercise(exerciseTemplate); if (__DEV__) {
console.log('Creating exercise:', exerciseTemplate);
}
await libraryService.addExercise(exerciseTemplate);
router.back(); router.back();
} catch (err) { } catch (err) {
@ -116,10 +126,12 @@ export default function NewExerciseScreen() {
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
> >
<View style={styles.form}> <View style={styles.form}>
{/* Basic Information */}
<View style={styles.section}> <View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}> <ThemedText type="subtitle" style={styles.sectionTitle}>
Basic Information Basic Information
</ThemedText> </ThemedText>
<Input <Input
label="Exercise Name" label="Exercise Name"
value={title} value={title}
@ -127,49 +139,102 @@ export default function NewExerciseScreen() {
placeholder="e.g., Barbell Back Squat" placeholder="e.g., Barbell Back Squat"
required required
/> />
<Select
label="Equipment" <View style={styles.pickerContainer}>
value={equipment} <ThemedText style={styles.label}>Exercise Type</ThemedText>
onValueChange={setEquipment} <Picker<ExerciseType>
items={EQUIPMENT_OPTIONS} selectedValue={exerciseType}
placeholder="Select equipment type" onValueChange={(value: ExerciseType) => setExerciseType(value)}
required style={[styles.picker, { backgroundColor: colors.cardBg }]}
/> >
<Select {EXERCISE_TYPES.map((option) => (
label="Difficulty" <Picker.Item
value={difficulty} key={option}
onValueChange={setDifficulty} label={option.charAt(0).toUpperCase() + option.slice(1)}
items={DIFFICULTY_OPTIONS} value={option}
placeholder="Select difficulty level" />
/> ))}
</Picker>
</View>
<View style={styles.pickerContainer}>
<ThemedText style={styles.label}>Category</ThemedText>
<Picker<ExerciseCategory>
selectedValue={category}
onValueChange={(value: ExerciseCategory) => setCategory(value)}
style={[styles.picker, { backgroundColor: colors.cardBg }]}
>
{CATEGORIES.map((option) => (
<Picker.Item key={option} label={option} value={option} />
))}
</Picker>
</View>
<View style={styles.pickerContainer}>
<ThemedText style={styles.label}>Equipment</ThemedText>
<Picker<Equipment>
selectedValue={equipment}
onValueChange={(value: Equipment) => setEquipment(value)}
style={[styles.picker, { backgroundColor: colors.cardBg }]}
>
{EQUIPMENT_OPTIONS.map((option) => (
<Picker.Item
key={option}
label={option.charAt(0).toUpperCase() + option.slice(1)}
value={option}
/>
))}
</Picker>
</View>
</View> </View>
{/* Categorization */}
<View style={styles.section}> <View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}> <ThemedText type="subtitle" style={styles.sectionTitle}>
Categorization Categorization
</ThemedText> </ThemedText>
<Select
label="Muscle Groups" <View style={styles.pickerContainer}>
value={muscleGroups} <ThemedText style={styles.label}>Movement Pattern</ThemedText>
onValueChange={setMuscleGroups} <Picker<MovementPattern>
items={MUSCLE_GROUP_OPTIONS} selectedValue={movementPattern}
placeholder="Select muscle groups" onValueChange={(value: MovementPattern) => setMovementPattern(value)}
multiple style={[styles.picker, { backgroundColor: colors.cardBg }]}
/> >
<Select {MOVEMENT_PATTERNS.map((option) => (
label="Movement Types" <Picker.Item
value={movementTypes} key={option}
onValueChange={setMovementTypes} label={option.charAt(0).toUpperCase() + option.slice(1)}
items={MOVEMENT_TYPE_OPTIONS} value={option}
placeholder="Select movement types" />
multiple ))}
/> </Picker>
</View>
<View style={styles.pickerContainer}>
<ThemedText style={styles.label}>Difficulty</ThemedText>
<Picker<Difficulty>
selectedValue={difficulty}
onValueChange={(value: Difficulty) => setDifficulty(value)}
style={[styles.picker, { backgroundColor: colors.cardBg }]}
>
{DIFFICULTY_OPTIONS.map((option) => (
<Picker.Item
key={option}
label={option.charAt(0).toUpperCase() + option.slice(1)}
value={option}
/>
))}
</Picker>
</View>
</View> </View>
{/* Instructions */}
<View style={styles.section}> <View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}> <ThemedText type="subtitle" style={styles.sectionTitle}>
Instructions Instructions
</ThemedText> </ThemedText>
<Input <Input
label="Form Instructions" label="Form Instructions"
value={instructions} value={instructions}
@ -205,6 +270,7 @@ const styles = StyleSheet.create({
}, },
content: { content: {
padding: spacing.medium, padding: spacing.medium,
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
}, },
form: { form: {
gap: spacing.large, gap: spacing.large,
@ -213,14 +279,29 @@ const styles = StyleSheet.create({
gap: spacing.medium, gap: spacing.medium,
}, },
sectionTitle: { sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: spacing.small, marginBottom: spacing.small,
}, },
error: { error: {
textAlign: 'center', textAlign: 'center',
marginTop: spacing.small, marginTop: spacing.small,
fontSize: 14,
}, },
buttonContainer: { buttonContainer: {
marginTop: spacing.large, marginTop: spacing.large,
paddingBottom: Platform.OS === 'ios' ? spacing.xl : spacing.large, paddingBottom: Platform.OS === 'ios' ? spacing.xl : spacing.large,
}, },
pickerContainer: {
marginBottom: spacing.medium,
},
label: {
fontSize: 16,
fontWeight: '500',
marginBottom: spacing.small,
},
picker: {
borderRadius: 8,
padding: spacing.small,
},
}); });

View File

@ -10,6 +10,8 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import * as ExpoSplashScreen from 'expo-splash-screen'; import * as ExpoSplashScreen from 'expo-splash-screen';
import SplashScreen from '@/components/SplashScreen'; import SplashScreen from '@/components/SplashScreen';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
// Prevent auto-hide of splash screen // Prevent auto-hide of splash screen
ExpoSplashScreen.preventAutoHideAsync(); ExpoSplashScreen.preventAutoHideAsync();
@ -114,14 +116,16 @@ function RootLayoutNav() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<SafeAreaProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<NavigationContainer> <SafeAreaProvider>
<AppearanceProvider> <NavigationContainer>
<WorkoutProvider> <AppearanceProvider>
<RootLayoutNav /> <WorkoutProvider>
</WorkoutProvider> <RootLayoutNav />
</AppearanceProvider> </WorkoutProvider>
</NavigationContainer> </AppearanceProvider>
</SafeAreaProvider> </NavigationContainer>
</SafeAreaProvider>
</GestureHandlerRootView>
); );
} }

View File

@ -1,16 +1,20 @@
// components/library/LibraryContentCard.tsx // components/library/LibraryContentCard.tsx
import React from 'react'; import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { View, StyleSheet, Alert } from 'react-native';
import { Feather } from '@expo/vector-icons'; import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise'; import { LibraryContent } from '@/types/exercise';
import { spacing } from '@/styles/sharedStyles'; import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import Swipeable from 'react-native-gesture-handler/Swipeable';
import { RectButton } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics';
export interface LibraryContentCardProps { export interface LibraryContentCardProps {
content: LibraryContent; content: LibraryContent;
onPress: () => void; onPress: () => void;
onFavoritePress: () => void; onFavoritePress: () => void;
onDelete?: () => Promise<void>;
isVerified?: boolean; isVerified?: boolean;
} }
@ -18,67 +22,132 @@ export default function LibraryContentCard({
content, content,
onPress, onPress,
onFavoritePress, onFavoritePress,
onDelete,
isVerified isVerified
}: LibraryContentCardProps) { }: LibraryContentCardProps) {
const { colors } = useColorScheme(); const { colors } = useColorScheme();
const swipeableRef = React.useRef<Swipeable>(null);
const handleDelete = async () => {
try {
// Play haptic feedback
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
// Show confirmation alert
Alert.alert(
'Delete Exercise',
'Are you sure you want to delete this exercise?',
[
{
text: 'Cancel',
style: 'cancel',
onPress: () => {
swipeableRef.current?.close();
},
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
if (onDelete) {
await onDelete();
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
console.error('Error deleting exercise:', error);
Alert.alert('Error', 'Failed to delete exercise');
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
},
},
],
{ cancelable: true }
);
} catch (error) {
console.error('Error handling delete:', error);
}
};
const renderRightActions = () => {
if (!onDelete) return null;
return (
<RectButton
style={[styles.deleteAction, { backgroundColor: colors.error }]}
onPress={handleDelete}
>
<Feather name="trash-2" size={24} color="white" />
<ThemedText style={[styles.deleteText, { color: 'white' }]}>
Delete
</ThemedText>
</RectButton>
);
};
return ( return (
<TouchableOpacity <Swipeable
style={[styles.container, { backgroundColor: colors.cardBg }]} ref={swipeableRef}
onPress={onPress} renderRightActions={renderRightActions}
activeOpacity={0.7} friction={2}
enableTrackpadTwoFingerGesture
rightThreshold={40}
> >
<View style={styles.header}> <RectButton
<View style={styles.titleContainer}> style={[styles.container, { backgroundColor: colors.cardBg }]}
<ThemedText type="subtitle"> onPress={onPress}
{content.title} >
<View style={styles.header}>
<View style={styles.titleContainer}>
<ThemedText type="subtitle">
{content.title}
</ThemedText>
{isVerified && (
<View style={styles.verifiedBadge}>
<Feather name="check-circle" size={16} color={colors.primary} />
<ThemedText style={[styles.verifiedText, { color: colors.primary }]}>
POW Verified
</ThemedText>
</View>
)}
</View>
<RectButton
onPress={onFavoritePress}
style={styles.favoriteButton}
>
<Feather
name="star"
size={24}
color={colors.textSecondary}
/>
</RectButton>
</View>
{content.description && (
<ThemedText
style={styles.description}
numberOfLines={2}
>
{content.description}
</ThemedText> </ThemedText>
{isVerified && ( )}
<View style={styles.verifiedBadge}>
<Feather name="check-circle" size={16} color={colors.primary} />
<ThemedText style={[styles.verifiedText, { color: colors.primary }]}>
POW Verified
</ThemedText>
</View>
)}
</View>
<TouchableOpacity
onPress={onFavoritePress}
style={styles.favoriteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Feather
name="star"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{content.description && ( <View style={styles.footer}>
<ThemedText <View style={styles.tags}>
style={styles.description} {content.tags.map(tag => (
numberOfLines={2} <View
> key={tag}
{content.description} style={[styles.tag, { backgroundColor: colors.primary + '20' }]}
</ThemedText> >
)} <ThemedText style={[styles.tagText, { color: colors.primary }]}>
{tag}
<View style={styles.footer}> </ThemedText>
<View style={styles.tags}> </View>
{content.tags.map(tag => ( ))}
<View </View>
key={tag}
style={[styles.tag, { backgroundColor: colors.primary + '20' }]}
>
<ThemedText style={[styles.tagText, { color: colors.primary }]}>
{tag}
</ThemedText>
</View>
))}
</View> </View>
</View> </RectButton>
</TouchableOpacity> </Swipeable>
); );
} }
@ -136,4 +205,16 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
deleteAction: {
flex: 1,
backgroundColor: 'red',
justifyContent: 'center',
alignItems: 'center',
width: 80,
},
deleteText: {
fontSize: 12,
fontWeight: '600',
marginTop: 4,
},
}); });

View File

@ -4,6 +4,7 @@ import { View, FlatList, StyleSheet, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons'; import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise'; import { LibraryContent } from '@/types/exercise';
import { libraryService } from '@/services/LibraryService';
import LibraryContentCard from '@/components/library/LibraryContentCard'; import LibraryContentCard from '@/components/library/LibraryContentCard';
import { spacing } from '@/styles/sharedStyles'; import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
@ -12,15 +13,16 @@ interface MyLibraryProps {
savedContent: LibraryContent[]; savedContent: LibraryContent[];
onContentPress: (content: LibraryContent) => void; onContentPress: (content: LibraryContent) => void;
onFavoritePress: (content: LibraryContent) => Promise<void>; onFavoritePress: (content: LibraryContent) => Promise<void>;
onDeleteContent?: (content: LibraryContent) => void;
isLoading?: boolean; isLoading?: boolean;
isVisible?: boolean; isVisible?: boolean;
} }
// components/library/MyLibrary.tsx
export default function MyLibrary({ export default function MyLibrary({
savedContent, savedContent,
onContentPress, onContentPress,
onFavoritePress, onFavoritePress,
onDeleteContent,
isVisible = true isVisible = true
}: MyLibraryProps) { }: MyLibraryProps) {
const { colors } = useColorScheme(); const { colors } = useColorScheme();
@ -30,6 +32,18 @@ export default function MyLibrary({
return null; return null;
} }
const handleDelete = async (content: LibraryContent) => {
try {
if (content.type === 'exercise') {
await libraryService.deleteExercise(content.id);
onDeleteContent?.(content);
}
} catch (error) {
console.error('Error deleting content:', error);
throw error;
}
};
// Separate exercises and workouts // Separate exercises and workouts
const exercises = savedContent.filter(content => content.type === 'exercise'); const exercises = savedContent.filter(content => content.type === 'exercise');
const workouts = savedContent.filter(content => content.type === 'workout'); const workouts = savedContent.filter(content => content.type === 'workout');
@ -49,6 +63,7 @@ export default function MyLibrary({
content={item} content={item}
onPress={() => onContentPress(item)} onPress={() => onContentPress(item)}
onFavoritePress={() => onFavoritePress(item)} onFavoritePress={() => onFavoritePress(item)}
onDelete={item.type === 'exercise' ? () => handleDelete(item) : undefined}
/> />
)} )}
keyExtractor={item => item.id} keyExtractor={item => item.id}
@ -114,7 +129,4 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
maxWidth: '80%', maxWidth: '80%',
}, },
hidden: {
display: 'none',
},
}); });

18
package-lock.json generated
View File

@ -1,15 +1,16 @@
{ {
"name": "powr-rebuild", "name": "powr",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "powr-rebuild", "name": "powr",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0", "@react-navigation/native-stack": "^7.2.0",
@ -3559,6 +3560,19 @@
"react-native": "^0.0.0-0 || >=0.60 <1.0" "react-native": "^0.0.0-0 || >=0.60 <1.0"
} }
}, },
"node_modules/@react-native-picker/picker": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.0.tgz",
"integrity": "sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg==",
"license": "MIT",
"workspaces": [
"example"
],
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.76.6", "version": "0.76.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz",

View File

@ -1,5 +1,5 @@
{ {
"name": "powr-rebuild", "name": "powr",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0", "@react-navigation/native-stack": "^7.2.0",

View File

@ -8,9 +8,12 @@ import {
LibraryContent LibraryContent
} from '@/types/exercise'; } from '@/types/exercise';
import { WorkoutTemplate } from '@/types/workout'; import { WorkoutTemplate } from '@/types/workout';
import { StorageSource } from '@/types/shared';
import { SQLiteError } from '@/types/sqlite';
class LibraryService { class LibraryService {
private db: DbService; private db: DbService;
private readonly DEBUG = __DEV__;
constructor() { constructor() {
this.db = new DbService('powr.db'); this.db = new DbService('powr.db');
@ -20,13 +23,22 @@ class LibraryService {
const id = generateId(); const id = generateId();
const timestamp = Date.now(); const timestamp = Date.now();
if (this.DEBUG) {
console.log('Creating exercise with payload:', {
id,
timestamp,
exercise,
});
}
try { try {
await this.db.withTransaction(async () => { await this.db.withTransaction(async () => {
// Insert main exercise data // 1. First insert main exercise data
await this.db.executeWrite( const mainResult = await this.db.executeWrite(
`INSERT INTO exercises ( `INSERT INTO exercises (
id, title, type, category, equipment, description, created_at, updated_at id, title, type, category, equipment, description,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, created_at, updated_at, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
id, id,
exercise.title, exercise.title,
@ -35,35 +47,22 @@ class LibraryService {
exercise.equipment || null, exercise.equipment || null,
exercise.description || null, exercise.description || null,
timestamp, timestamp,
timestamp timestamp,
'local'
] ]
); );
// Insert instructions if provided if (this.DEBUG) {
if (exercise.instructions?.length) { console.log('Main exercise insert result:', mainResult);
for (const [index, instruction] of exercise.instructions.entries()) {
await this.db.executeWrite(
`INSERT INTO exercise_instructions (
exercise_id, instruction, display_order
) VALUES (?, ?, ?)`,
[id, instruction, index]
);
}
} }
// Insert tags if provided if (!mainResult.rowsAffected) {
if (exercise.tags?.length) { throw new Error('Main exercise insert failed');
for (const tag of exercise.tags) {
await this.db.executeWrite(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[id, tag]
);
}
} }
// Insert format settings if provided // 2. Insert format settings if provided
if (exercise.format) { if (exercise.format) {
await this.db.executeWrite( const formatResult = await this.db.executeWrite(
`INSERT INTO exercise_format ( `INSERT INTO exercise_format (
exercise_id, format_json, units_json exercise_id, format_json, units_json
) VALUES (?, ?, ?)`, ) VALUES (?, ?, ?)`,
@ -73,12 +72,86 @@ class LibraryService {
JSON.stringify(exercise.format_units || {}) JSON.stringify(exercise.format_units || {})
] ]
); );
if (this.DEBUG) {
console.log('Format insert result:', formatResult);
}
}
// 3. Insert tags if provided
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
const tagResult = await this.db.executeWrite(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[id, tag]
);
if (this.DEBUG) {
console.log('Tag insert result:', tagResult);
}
}
}
// 4. Insert instructions if provided
if (exercise.instructions?.length) {
for (const [index, instruction] of exercise.instructions.entries()) {
const instructionResult = await this.db.executeWrite(
`INSERT INTO exercise_instructions (
exercise_id, instruction, display_order
) VALUES (?, ?, ?)`,
[id, instruction, index]
);
if (this.DEBUG) {
console.log('Instruction insert result:', instructionResult);
}
}
} }
}); });
if (this.DEBUG) {
console.log('Exercise successfully created with ID:', id);
}
return id; return id;
} catch (err) {
if (this.DEBUG) {
// Type check the error
if (err instanceof Error) {
const sqlError = err as SQLiteError;
console.error('Detailed error in addExercise:', {
message: err.message,
sql: 'sql' in sqlError ? sqlError.sql : undefined,
params: 'params' in sqlError ? sqlError.params : undefined,
code: 'code' in sqlError ? sqlError.code : undefined
});
} else {
console.error('Unknown error in addExercise:', err);
}
}
throw new Error(err instanceof Error ? err.message : 'Failed to insert exercise');
}
}
async deleteExercise(id: string): Promise<void> {
try {
if (this.DEBUG) {
console.log('Deleting exercise:', id);
}
await this.db.withTransaction(async () => {
// Delete from main exercise table
const result = await this.db.executeWrite(
'DELETE FROM exercises WHERE id = ?',
[id]
);
if (!result.rowsAffected) {
throw new Error('Exercise not found');
}
});
} catch (error) { } catch (error) {
console.error('Error adding exercise:', error); console.error('Error deleting exercise:', error);
throw error; throw error;
} }
} }
@ -118,7 +191,7 @@ class LibraryService {
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
availability: { availability: {
source: ['local'] source: ['local' as StorageSource]
} }
}; };
} catch (error) { } catch (error) {
@ -163,7 +236,7 @@ class LibraryService {
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
availability: { availability: {
source: ['local'] source: ['local' as StorageSource]
} }
}; };
}); });
@ -177,7 +250,7 @@ class LibraryService {
try { try {
// First get exercises // First get exercises
const exercises = await this.getExercises(); const exercises = await this.getExercises();
const exerciseContent = exercises.map(exercise => ({ const exerciseContent: LibraryContent[] = exercises.map(exercise => ({
id: exercise.id, id: exercise.id,
title: exercise.title, title: exercise.title,
type: 'exercise' as const, type: 'exercise' as const,
@ -188,7 +261,7 @@ class LibraryService {
tags: exercise.tags, tags: exercise.tags,
created_at: exercise.created_at, created_at: exercise.created_at,
availability: { availability: {
source: ['local'] source: ['local' as StorageSource]
} }
})); }));
@ -251,7 +324,8 @@ class LibraryService {
title: templateRow.title, title: templateRow.title,
type: templateRow.type, type: templateRow.type,
category: templateRow.category, category: templateRow.category,
description: templateRow.description, description: templateRow.description || '',
notes: templateRow.notes || '', // Added missing notes field
author: templateRow.author_name ? { author: templateRow.author_name ? {
name: templateRow.author_name, name: templateRow.author_name,
pubkey: templateRow.author_pubkey pubkey: templateRow.author_pubkey
@ -265,7 +339,9 @@ class LibraryService {
isPublic: Boolean(templateRow.is_public), isPublic: Boolean(templateRow.is_public),
created_at: templateRow.created_at, created_at: templateRow.created_at,
metadata: templateRow.metadata_json ? JSON.parse(templateRow.metadata_json) : undefined, metadata: templateRow.metadata_json ? JSON.parse(templateRow.metadata_json) : undefined,
availability: JSON.parse(templateRow.availability_json) availability: {
source: ['local' as StorageSource]
}
}; };
} catch (error) { } catch (error) {
console.error('Error getting template:', error); console.error('Error getting template:', error);
@ -275,8 +351,8 @@ class LibraryService {
private async getTemplateExercises(templateId: string): Promise<Array<{ private async getTemplateExercises(templateId: string): Promise<Array<{
exercise: BaseExercise; exercise: BaseExercise;
targetSets?: number; targetSets: number;
targetReps?: number; targetReps: number;
notes?: string; notes?: string;
}>> { }>> {
try { try {
@ -295,6 +371,11 @@ class LibraryService {
const exercise = await this.getExercise(row.exercise_id); const exercise = await this.getExercise(row.exercise_id);
if (!exercise) throw new Error(`Exercise ${row.exercise_id} not found`); if (!exercise) throw new Error(`Exercise ${row.exercise_id} not found`);
// Ensure required fields are present
if (typeof row.target_sets !== 'number' || typeof row.target_reps !== 'number') {
throw new Error(`Missing required target sets/reps for exercise ${row.exercise_id}`);
}
return { return {
exercise, exercise,
targetSets: row.target_sets, targetSets: row.target_sets,

View File

@ -3,7 +3,6 @@
export interface SQLiteRow { export interface SQLiteRow {
[key: string]: any; [key: string]: any;
} }
export interface SQLiteResult<T = SQLiteRow> { export interface SQLiteResult<T = SQLiteRow> {
rows: { rows: {
_array: T[]; _array: T[];
@ -16,6 +15,7 @@ export interface SQLiteResult<T = SQLiteRow> {
export interface SQLiteError extends Error { export interface SQLiteError extends Error {
code?: number; code?: number;
message: string;
} }
export interface SQLiteStatement { export interface SQLiteStatement {

View File

@ -1,28 +1,42 @@
// utils/db/db-service.ts // utils/db/db-service.ts
import { import * as SQLite from 'expo-sqlite';
openDatabaseSync, import { SQLiteDatabase } from 'expo-sqlite';
SQLiteDatabase import { SQLiteResult, SQLiteRow, SQLiteError } from '@/types/sqlite';
} from 'expo-sqlite';
import {
SQLiteResult,
SQLiteError,
SQLiteRow
} from '@/types/sqlite';
export class DbService { export class DbService {
private db: SQLiteDatabase | null = null; private db: SQLiteDatabase | null = null;
private readonly DEBUG = __DEV__;
constructor(dbName: string) { constructor(dbName: string) {
try { try {
this.db = openDatabaseSync(dbName); this.db = SQLite.openDatabaseSync(dbName);
console.log('Database opened:', this.db); if (this.DEBUG) {
console.log('Database opened:', dbName);
}
} catch (error) { } catch (error) {
console.error('Error opening database:', error); console.error('Error opening database:', error);
throw error; throw error;
} }
} }
async executeSql<T extends SQLiteRow = any>( async withTransaction<T>(operation: () => Promise<T>): Promise<T> {
if (!this.db) throw new Error('Database not initialized');
try {
if (this.DEBUG) console.log('Starting transaction');
await this.db.runAsync('BEGIN TRANSACTION');
const result = await operation();
await this.db.runAsync('COMMIT');
if (this.DEBUG) console.log('Transaction committed');
return result;
} catch (error) {
if (this.DEBUG) console.log('Rolling back transaction due to:', error);
await this.db.runAsync('ROLLBACK');
throw error;
}
}
async executeSql<T extends SQLiteRow>(
sql: string, sql: string,
params: (string | number | null)[] = [] params: (string | number | null)[] = []
): Promise<SQLiteResult<T>> { ): Promise<SQLiteResult<T>> {
@ -31,64 +45,112 @@ export class DbService {
} }
try { try {
const statement = this.db.prepareSync(sql); if (this.DEBUG) {
const result = statement.executeSync<T>(params); console.log('Executing SQL:', sql);
statement.finalizeSync(); console.log('Parameters:', params);
}
// Use the appropriate method based on the SQL operation type
const isSelect = sql.trim().toUpperCase().startsWith('SELECT');
if (isSelect) {
const results = await this.db.getAllAsync<T>(sql, params);
return {
rows: {
_array: results,
length: results.length,
item: (idx: number) => {
// For existing interface compatibility, return first item of array
// when index is out of bounds instead of undefined
return results[idx >= 0 && idx < results.length ? idx : 0];
}
},
rowsAffected: 0 // SELECT doesn't modify rows
};
} else {
const result = await this.db.runAsync(sql, params);
return {
rows: {
_array: [],
length: 0,
item: (_: number) => ({} as T) // Return empty object for non-SELECT operations
},
rowsAffected: result.changes,
insertId: result.lastInsertRowId
};
}
return {
rows: {
_array: Array.isArray(result) ? result : [],
length: Array.isArray(result) ? result.length : 0,
item: (idx: number) => (Array.isArray(result) ? result[idx] : null) as T
},
rowsAffected: Array.isArray(result) ? result.length : 0,
insertId: undefined // SQLite doesn't provide this directly
};
} catch (error) { } catch (error) {
console.error('SQL Error:', error, sql, params); // Create proper SQLiteError with all required Error properties
const sqlError: SQLiteError = Object.assign(new Error(), {
name: 'SQLiteError',
message: error instanceof Error ? error.message : String(error),
code: error instanceof Error ? (error as any).code : undefined
});
console.error('SQL Error:', {
message: sqlError.message,
code: sqlError.code,
sql,
params
});
throw sqlError;
}
}
async executeWrite<T extends SQLiteRow>(
sql: string,
params: (string | number | null)[] = []
): Promise<SQLiteResult<T>> {
try {
const result = await this.executeSql<T>(sql, params);
// For INSERT/UPDATE/DELETE operations, verify the operation had an effect
const isWriteOperation = /^(INSERT|UPDATE|DELETE)\b/i.test(sql.trim());
if (isWriteOperation && !result.rowsAffected) {
const error = `Write operation failed: ${sql.split(' ')[0]}`;
if (this.DEBUG) {
console.warn(error, { sql, params });
}
throw new Error(error);
}
return result;
} catch (error) {
if (this.DEBUG) {
console.error('Write operation failed:', error);
}
throw error; throw error;
} }
} }
async executeWrite<T extends SQLiteRow = any>( async executeWriteMany<T extends SQLiteRow>(
sql: string,
params: (string | number | null)[] = []
): Promise<SQLiteResult<T>> {
return this.executeSql<T>(sql, params);
}
async executeWriteMany<T extends SQLiteRow = any>(
queries: Array<{ queries: Array<{
sql: string; sql: string;
args?: (string | number | null)[] args?: (string | number | null)[]
}> }>
): Promise<SQLiteResult<T>[]> { ): Promise<SQLiteResult<T>[]> {
if (!this.db) { return this.withTransaction(async () => {
throw new Error('Database not initialized'); const results: SQLiteResult<T>[] = [];
}
for (const query of queries) {
const results: SQLiteResult<T>[] = [];
for (const query of queries) {
try {
const result = await this.executeSql<T>(query.sql, query.args || []); const result = await this.executeSql<T>(query.sql, query.args || []);
results.push(result); results.push(result);
} catch (error) {
console.error('Error executing query:', query, error);
throw error;
} }
}
return results;
return results; });
} }
async tableExists(tableName: string): Promise<boolean> { async tableExists(tableName: string): Promise<boolean> {
try { try {
const result = await this.executeSql<{ name: string }>( const result = await this.db?.getFirstAsync<{ name: string }>(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
[tableName] [tableName]
); );
return result.rows._array.length > 0; return !!result;
} catch (error) { } catch (error) {
console.error('Error checking table existence:', error); console.error('Error checking table existence:', error);
return false; return false;
@ -97,10 +159,25 @@ export class DbService {
async initialize(): Promise<void> { async initialize(): Promise<void> {
try { try {
await this.executeSql('PRAGMA foreign_keys = ON;'); await this.db?.execAsync(`
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
`);
} catch (error) { } catch (error) {
console.error('Error initializing database:', error); console.error('Error initializing database:', error);
throw error; throw error;
} }
} }
async close(): Promise<void> {
try {
if (this.db) {
await this.db.closeAsync();
this.db = null;
}
} catch (error) {
console.error('Error closing database:', error);
throw error;
}
}
} }

View File

@ -69,6 +69,7 @@ class Schema {
type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')), type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')),
category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')), category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')),
description TEXT, description TEXT,
notes TEXT,
author_name TEXT, author_name TEXT,
author_pubkey TEXT, author_pubkey TEXT,
rounds INTEGER, rounds INTEGER,