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": {
"name": "powr-rebuild",
"slug": "powr-rebuild",
"name": "POWR",
"slug": "POWR",
"version": "1.0.0",
"orientation": "portrait",
"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) => {
pagerRef.current?.setPage(index);
setActiveSection(index);
@ -235,13 +241,14 @@ export default function LibraryScreen() {
onPageSelected={handlePageSelected}
>
<View key="my-library" style={styles.pageContainer}>
<MyLibrary
savedContent={filteredContent}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
isLoading={isLoading}
isVisible
/>
<MyLibrary
savedContent={filteredContent}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
onDeleteContent={handleDeleteContent}
isLoading={isLoading}
isVisible={activeSection === 0}
/>
</View>
<View key="programs" style={styles.pageContainer}>

View File

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

View File

@ -1,16 +1,20 @@
// components/library/LibraryContentCard.tsx
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 { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
import { spacing } from '@/styles/sharedStyles';
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 {
content: LibraryContent;
onPress: () => void;
onFavoritePress: () => void;
onDelete?: () => Promise<void>;
isVerified?: boolean;
}
@ -18,67 +22,132 @@ export default function LibraryContentCard({
content,
onPress,
onFavoritePress,
onDelete,
isVerified
}: LibraryContentCardProps) {
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 (
<TouchableOpacity
style={[styles.container, { backgroundColor: colors.cardBg }]}
onPress={onPress}
activeOpacity={0.7}
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
friction={2}
enableTrackpadTwoFingerGesture
rightThreshold={40}
>
<View style={styles.header}>
<View style={styles.titleContainer}>
<ThemedText type="subtitle">
{content.title}
<RectButton
style={[styles.container, { backgroundColor: colors.cardBg }]}
onPress={onPress}
>
<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>
{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 && (
<ThemedText
style={styles.description}
numberOfLines={2}
>
{content.description}
</ThemedText>
)}
<View style={styles.footer}>
<View style={styles.tags}>
{content.tags.map(tag => (
<View
key={tag}
style={[styles.tag, { backgroundColor: colors.primary + '20' }]}
>
<ThemedText style={[styles.tagText, { color: colors.primary }]}>
{tag}
</ThemedText>
</View>
))}
<View style={styles.footer}>
<View style={styles.tags}>
{content.tags.map(tag => (
<View
key={tag}
style={[styles.tag, { backgroundColor: colors.primary + '20' }]}
>
<ThemedText style={[styles.tagText, { color: colors.primary }]}>
{tag}
</ThemedText>
</View>
))}
</View>
</View>
</View>
</TouchableOpacity>
</RectButton>
</Swipeable>
);
}
@ -136,4 +205,16 @@ const styles = StyleSheet.create({
fontSize: 12,
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 { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
import { libraryService } from '@/services/LibraryService';
import LibraryContentCard from '@/components/library/LibraryContentCard';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
@ -12,15 +13,16 @@ interface MyLibraryProps {
savedContent: LibraryContent[];
onContentPress: (content: LibraryContent) => void;
onFavoritePress: (content: LibraryContent) => Promise<void>;
onDeleteContent?: (content: LibraryContent) => void;
isLoading?: boolean;
isVisible?: boolean;
}
// components/library/MyLibrary.tsx
export default function MyLibrary({
savedContent,
onContentPress,
onFavoritePress,
onDeleteContent,
isVisible = true
}: MyLibraryProps) {
const { colors } = useColorScheme();
@ -30,6 +32,18 @@ export default function MyLibrary({
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
const exercises = savedContent.filter(content => content.type === 'exercise');
const workouts = savedContent.filter(content => content.type === 'workout');
@ -49,6 +63,7 @@ export default function MyLibrary({
content={item}
onPress={() => onContentPress(item)}
onFavoritePress={() => onFavoritePress(item)}
onDelete={item.type === 'exercise' ? () => handleDelete(item) : undefined}
/>
)}
keyExtractor={item => item.id}
@ -114,7 +129,4 @@ const styles = StyleSheet.create({
textAlign: 'center',
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",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "powr-rebuild",
"name": "powr",
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^14.0.4",
"@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/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
@ -3559,6 +3560,19 @@
"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": {
"version": "0.76.6",
"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",
"version": "1.0.0",
"scripts": {
@ -17,6 +17,7 @@
"dependencies": {
"@expo/vector-icons": "^14.0.4",
"@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/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",

View File

@ -8,9 +8,12 @@ import {
LibraryContent
} from '@/types/exercise';
import { WorkoutTemplate } from '@/types/workout';
import { StorageSource } from '@/types/shared';
import { SQLiteError } from '@/types/sqlite';
class LibraryService {
private db: DbService;
private readonly DEBUG = __DEV__;
constructor() {
this.db = new DbService('powr.db');
@ -20,13 +23,22 @@ class LibraryService {
const id = generateId();
const timestamp = Date.now();
if (this.DEBUG) {
console.log('Creating exercise with payload:', {
id,
timestamp,
exercise,
});
}
try {
await this.db.withTransaction(async () => {
// Insert main exercise data
await this.db.executeWrite(
// 1. First insert main exercise data
const mainResult = await this.db.executeWrite(
`INSERT INTO exercises (
id, title, type, category, equipment, description, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, title, type, category, equipment, description,
created_at, updated_at, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
exercise.title,
@ -35,35 +47,22 @@ class LibraryService {
exercise.equipment || null,
exercise.description || null,
timestamp,
timestamp
timestamp,
'local'
]
);
// Insert instructions if provided
if (exercise.instructions?.length) {
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]
);
}
if (this.DEBUG) {
console.log('Main exercise insert result:', mainResult);
}
// Insert tags if provided
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
await this.db.executeWrite(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[id, tag]
);
}
if (!mainResult.rowsAffected) {
throw new Error('Main exercise insert failed');
}
// Insert format settings if provided
// 2. Insert format settings if provided
if (exercise.format) {
await this.db.executeWrite(
const formatResult = await this.db.executeWrite(
`INSERT INTO exercise_format (
exercise_id, format_json, units_json
) VALUES (?, ?, ?)`,
@ -73,12 +72,86 @@ class LibraryService {
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;
} 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) {
console.error('Error adding exercise:', error);
console.error('Error deleting exercise:', error);
throw error;
}
}
@ -118,7 +191,7 @@ class LibraryService {
created_at: row.created_at,
updated_at: row.updated_at,
availability: {
source: ['local']
source: ['local' as StorageSource]
}
};
} catch (error) {
@ -163,7 +236,7 @@ class LibraryService {
created_at: row.created_at,
updated_at: row.updated_at,
availability: {
source: ['local']
source: ['local' as StorageSource]
}
};
});
@ -177,7 +250,7 @@ class LibraryService {
try {
// First get exercises
const exercises = await this.getExercises();
const exerciseContent = exercises.map(exercise => ({
const exerciseContent: LibraryContent[] = exercises.map(exercise => ({
id: exercise.id,
title: exercise.title,
type: 'exercise' as const,
@ -188,7 +261,7 @@ class LibraryService {
tags: exercise.tags,
created_at: exercise.created_at,
availability: {
source: ['local']
source: ['local' as StorageSource]
}
}));
@ -251,7 +324,8 @@ class LibraryService {
title: templateRow.title,
type: templateRow.type,
category: templateRow.category,
description: templateRow.description,
description: templateRow.description || '',
notes: templateRow.notes || '', // Added missing notes field
author: templateRow.author_name ? {
name: templateRow.author_name,
pubkey: templateRow.author_pubkey
@ -265,7 +339,9 @@ class LibraryService {
isPublic: Boolean(templateRow.is_public),
created_at: templateRow.created_at,
metadata: templateRow.metadata_json ? JSON.parse(templateRow.metadata_json) : undefined,
availability: JSON.parse(templateRow.availability_json)
availability: {
source: ['local' as StorageSource]
}
};
} catch (error) {
console.error('Error getting template:', error);
@ -275,8 +351,8 @@ class LibraryService {
private async getTemplateExercises(templateId: string): Promise<Array<{
exercise: BaseExercise;
targetSets?: number;
targetReps?: number;
targetSets: number;
targetReps: number;
notes?: string;
}>> {
try {
@ -295,6 +371,11 @@ class LibraryService {
const exercise = await this.getExercise(row.exercise_id);
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 {
exercise,
targetSets: row.target_sets,

View File

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

View File

@ -1,28 +1,42 @@
// utils/db/db-service.ts
import {
openDatabaseSync,
SQLiteDatabase
} from 'expo-sqlite';
import {
SQLiteResult,
SQLiteError,
SQLiteRow
} from '@/types/sqlite';
import * as SQLite from 'expo-sqlite';
import { SQLiteDatabase } from 'expo-sqlite';
import { SQLiteResult, SQLiteRow, SQLiteError } from '@/types/sqlite';
export class DbService {
private db: SQLiteDatabase | null = null;
private readonly DEBUG = __DEV__;
constructor(dbName: string) {
try {
this.db = openDatabaseSync(dbName);
console.log('Database opened:', this.db);
this.db = SQLite.openDatabaseSync(dbName);
if (this.DEBUG) {
console.log('Database opened:', dbName);
}
} catch (error) {
console.error('Error opening database:', 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,
params: (string | number | null)[] = []
): Promise<SQLiteResult<T>> {
@ -31,64 +45,112 @@ export class DbService {
}
try {
const statement = this.db.prepareSync(sql);
const result = statement.executeSync<T>(params);
statement.finalizeSync();
if (this.DEBUG) {
console.log('Executing SQL:', sql);
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) {
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;
}
}
async executeWrite<T extends SQLiteRow = any>(
sql: string,
params: (string | number | null)[] = []
): Promise<SQLiteResult<T>> {
return this.executeSql<T>(sql, params);
}
async executeWriteMany<T extends SQLiteRow = any>(
async executeWriteMany<T extends SQLiteRow>(
queries: Array<{
sql: string;
args?: (string | number | null)[]
}>
): Promise<SQLiteResult<T>[]> {
if (!this.db) {
throw new Error('Database not initialized');
}
const results: SQLiteResult<T>[] = [];
for (const query of queries) {
try {
return this.withTransaction(async () => {
const results: SQLiteResult<T>[] = [];
for (const query of queries) {
const result = await this.executeSql<T>(query.sql, query.args || []);
results.push(result);
} catch (error) {
console.error('Error executing query:', query, error);
throw error;
}
}
return results;
return results;
});
}
async tableExists(tableName: string): Promise<boolean> {
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=?`,
[tableName]
);
return result.rows._array.length > 0;
return !!result;
} catch (error) {
console.error('Error checking table existence:', error);
return false;
@ -97,10 +159,25 @@ export class DbService {
async initialize(): Promise<void> {
try {
await this.executeSql('PRAGMA foreign_keys = ON;');
await this.db?.execAsync(`
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
`);
} catch (error) {
console.error('Error initializing database:', 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')),
category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')),
description TEXT,
notes TEXT,
author_name TEXT,
author_pubkey TEXT,
rounds INTEGER,