mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 18:31:03 +00:00
sqlite db wip - add exercise template now works
This commit is contained in:
parent
855c034a35
commit
18d5886e40
4
app.json
4
app.json
@ -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",
|
||||||
|
@ -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}>
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
@ -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
18
package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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>[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
const results: SQLiteResult<T>[] = [];
|
for (const query of queries) {
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user