diff --git a/app.json b/app.json
index a271c6a..1f4d670 100644
--- a/app.json
+++ b/app.json
@@ -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",
diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx
index 2e8c6be..14926a9 100644
--- a/app/(tabs)/library.tsx
+++ b/app/(tabs)/library.tsx
@@ -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}
>
-
+
diff --git a/app/(workout)/new-exercise.tsx b/app/(workout)/new-exercise.tsx
index 32b969c..50eecf1 100644
--- a/app/(workout)/new-exercise.tsx
+++ b/app/(workout)/new-exercise.tsx
@@ -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([]);
- const [movementTypes, setMovementTypes] = useState([]);
+ const [exerciseType, setExerciseType] = useState(EXERCISE_TYPES[0]);
+ const [category, setCategory] = useState(CATEGORIES[0]);
+ const [equipment, setEquipment] = useState(EQUIPMENT_OPTIONS[0]);
+ const [difficulty, setDifficulty] = useState(DIFFICULTY_OPTIONS[0]);
+ const [movementPattern, setMovementPattern] = useState(MOVEMENT_PATTERNS[0]);
const [instructions, setInstructions] = useState('');
const [error, setError] = useState(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}
>
+ {/* Basic Information */}
Basic Information
+
-
-
+
+
+ Exercise Type
+
+ selectedValue={exerciseType}
+ onValueChange={(value: ExerciseType) => setExerciseType(value)}
+ style={[styles.picker, { backgroundColor: colors.cardBg }]}
+ >
+ {EXERCISE_TYPES.map((option) => (
+
+ ))}
+
+
+
+
+ Category
+
+ selectedValue={category}
+ onValueChange={(value: ExerciseCategory) => setCategory(value)}
+ style={[styles.picker, { backgroundColor: colors.cardBg }]}
+ >
+ {CATEGORIES.map((option) => (
+
+ ))}
+
+
+
+
+ Equipment
+
+ selectedValue={equipment}
+ onValueChange={(value: Equipment) => setEquipment(value)}
+ style={[styles.picker, { backgroundColor: colors.cardBg }]}
+ >
+ {EQUIPMENT_OPTIONS.map((option) => (
+
+ ))}
+
+
+ {/* Categorization */}
Categorization
-
-
+
+
+ Movement Pattern
+
+ selectedValue={movementPattern}
+ onValueChange={(value: MovementPattern) => setMovementPattern(value)}
+ style={[styles.picker, { backgroundColor: colors.cardBg }]}
+ >
+ {MOVEMENT_PATTERNS.map((option) => (
+
+ ))}
+
+
+
+
+ Difficulty
+
+ selectedValue={difficulty}
+ onValueChange={(value: Difficulty) => setDifficulty(value)}
+ style={[styles.picker, { backgroundColor: colors.cardBg }]}
+ >
+ {DIFFICULTY_OPTIONS.map((option) => (
+
+ ))}
+
+
+ {/* Instructions */}
Instructions
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
\ No newline at end of file
diff --git a/components/library/LibraryContentCard.tsx b/components/library/LibraryContentCard.tsx
index 3e639c9..7e1c306 100644
--- a/components/library/LibraryContentCard.tsx
+++ b/components/library/LibraryContentCard.tsx
@@ -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;
isVerified?: boolean;
}
@@ -18,67 +22,132 @@ export default function LibraryContentCard({
content,
onPress,
onFavoritePress,
+ onDelete,
isVerified
}: LibraryContentCardProps) {
const { colors } = useColorScheme();
+ const swipeableRef = React.useRef(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 (
+
+
+
+ Delete
+
+
+ );
+ };
return (
-
-
-
-
- {content.title}
+
+
+
+
+ {content.title}
+
+ {isVerified && (
+
+
+
+ POW Verified
+
+
+ )}
+
+
+
+
+
+
+ {content.description && (
+
+ {content.description}
- {isVerified && (
-
-
-
- POW Verified
-
-
- )}
-
-
-
-
-
+ )}
- {content.description && (
-
- {content.description}
-
- )}
-
-
-
- {content.tags.map(tag => (
-
-
- {tag}
-
-
- ))}
+
+
+ {content.tags.map(tag => (
+
+
+ {tag}
+
+
+ ))}
+
-
-
+
+
);
}
@@ -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,
+ },
});
\ No newline at end of file
diff --git a/components/library/MyLibrary.tsx b/components/library/MyLibrary.tsx
index b2a21ab..802ea01 100644
--- a/components/library/MyLibrary.tsx
+++ b/components/library/MyLibrary.tsx
@@ -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;
+ 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',
- },
});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 9cff568..921685e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 2f34ddb..b79b721 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/services/LibraryService.ts b/services/LibraryService.ts
index 4e82b04..408d463 100644
--- a/services/LibraryService.ts
+++ b/services/LibraryService.ts
@@ -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 {
+ 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> {
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,
diff --git a/types/sqlite.ts b/types/sqlite.ts
index 91ec70b..a7f53e1 100644
--- a/types/sqlite.ts
+++ b/types/sqlite.ts
@@ -3,7 +3,6 @@
export interface SQLiteRow {
[key: string]: any;
}
-
export interface SQLiteResult {
rows: {
_array: T[];
@@ -16,6 +15,7 @@ export interface SQLiteResult {
export interface SQLiteError extends Error {
code?: number;
+ message: string;
}
export interface SQLiteStatement {
diff --git a/utils/db/db-service.ts b/utils/db/db-service.ts
index c6de429..fe1fa48 100644
--- a/utils/db/db-service.ts
+++ b/utils/db/db-service.ts
@@ -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(
+ async withTransaction(operation: () => Promise): Promise {
+ 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(
sql: string,
params: (string | number | null)[] = []
): Promise> {
@@ -31,64 +45,112 @@ export class DbService {
}
try {
- const statement = this.db.prepareSync(sql);
- const result = statement.executeSync(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(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(
+ sql: string,
+ params: (string | number | null)[] = []
+ ): Promise> {
+ try {
+ const result = await this.executeSql(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(
- sql: string,
- params: (string | number | null)[] = []
- ): Promise> {
- return this.executeSql(sql, params);
- }
-
- async executeWriteMany(
+ async executeWriteMany(
queries: Array<{
sql: string;
args?: (string | number | null)[]
}>
): Promise[]> {
- if (!this.db) {
- throw new Error('Database not initialized');
- }
-
- const results: SQLiteResult[] = [];
-
- for (const query of queries) {
- try {
+ return this.withTransaction(async () => {
+ const results: SQLiteResult[] = [];
+
+ for (const query of queries) {
const result = await this.executeSql(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 {
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 {
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 {
+ try {
+ if (this.db) {
+ await this.db.closeAsync();
+ this.db = null;
+ }
+ } catch (error) {
+ console.error('Error closing database:', error);
+ throw error;
+ }
+ }
}
\ No newline at end of file
diff --git a/utils/db/schema.ts b/utils/db/schema.ts
index 6209b2f..851b45a 100644
--- a/utils/db/schema.ts
+++ b/utils/db/schema.ts
@@ -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,