import * as SQLite from 'expo-sqlite'; import { Meal, FoodItem, CreateMealInput, MealWithItems } from '../../types/Database'; export class SQLiteService { private static instance: SQLiteService; private db: SQLite.SQLiteDatabase | null = null; private constructor() {} public static getInstance(): SQLiteService { if (!SQLiteService.instance) { SQLiteService.instance = new SQLiteService(); } return SQLiteService.instance; } public async initialize(): Promise { try { this.db = await SQLite.openDatabaseAsync('nutriphi.db'); await this.createTables(); await this.createIndices(); } catch (error) { console.error('Database initialization failed:', error); throw error; } } public async getDatabase(): Promise { if (!this.db) { throw new Error('Database not initialized. Call initialize() first.'); } return this.db; } private async createTables(): Promise { if (!this.db) throw new Error('Database not initialized'); // Meals Table await this.db.execAsync(` CREATE TABLE IF NOT EXISTS meals ( id INTEGER PRIMARY KEY AUTOINCREMENT, cloud_id TEXT UNIQUE, user_id TEXT, sync_status TEXT DEFAULT 'local', version INTEGER DEFAULT 1, last_sync_at TEXT, photo_path TEXT NOT NULL, photo_url TEXT, photo_size INTEGER, photo_dimensions TEXT, timestamp TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), meal_type TEXT, location TEXT, analysis_result TEXT, analysis_confidence REAL, analysis_status TEXT DEFAULT 'pending', total_calories INTEGER, total_protein REAL, total_carbs REAL, total_fat REAL, total_fiber REAL, total_sugar REAL, health_score REAL, health_category TEXT, user_notes TEXT, user_modified INTEGER DEFAULT 0, user_rating INTEGER, api_provider TEXT DEFAULT 'gemini', api_cost REAL, processing_time INTEGER ); `); // Food Items Table await this.db.execAsync(` CREATE TABLE IF NOT EXISTS food_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, cloud_id TEXT UNIQUE, meal_id INTEGER NOT NULL, sync_status TEXT DEFAULT 'local', version INTEGER DEFAULT 1, name TEXT NOT NULL, category TEXT, portion_size TEXT, calories INTEGER, protein REAL, carbs REAL, fat REAL, fiber REAL, sugar REAL, confidence REAL, bounding_box TEXT, is_organic INTEGER DEFAULT 0, is_processed INTEGER DEFAULT 0, allergens TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE ); `); // Sync Metadata Table await this.db.execAsync(` CREATE TABLE IF NOT EXISTS sync_metadata ( table_name TEXT NOT NULL, record_id INTEGER NOT NULL, cloud_id TEXT, last_sync_at TEXT, conflict_data TEXT, retry_count INTEGER DEFAULT 0, PRIMARY KEY (table_name, record_id) ); `); // User Preferences Table await this.db.execAsync(` CREATE TABLE IF NOT EXISTS user_preferences ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, value TEXT, type TEXT DEFAULT 'string', created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); `); } private async createIndices(): Promise { if (!this.db) throw new Error('Database not initialized'); await this.db.execAsync(` CREATE INDEX IF NOT EXISTS idx_meals_timestamp ON meals(timestamp DESC); CREATE INDEX IF NOT EXISTS idx_meals_sync_status ON meals(sync_status); CREATE INDEX IF NOT EXISTS idx_meals_meal_type ON meals(meal_type); CREATE INDEX IF NOT EXISTS idx_food_items_meal ON food_items(meal_id); CREATE INDEX IF NOT EXISTS idx_food_items_category ON food_items(category); CREATE INDEX IF NOT EXISTS idx_sync_metadata_status ON sync_metadata(table_name, last_sync_at); `); } // CRUD Operations für Meals public async createMeal(input: CreateMealInput): Promise { if (!this.db) throw new Error('Database not initialized'); const now = new Date().toISOString(); const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null; const result = await this.db.runAsync( ` INSERT INTO meals ( photo_path, photo_size, photo_dimensions, timestamp, created_at, updated_at, meal_type, location, latitude, longitude, location_accuracy, user_notes, analysis_status, api_provider ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ input.photo_path, input.photo_size || null, dimensions, now, now, now, input.meal_type || null, input.location || null, input.latitude || null, input.longitude || null, input.location_accuracy || null, input.user_notes || null, input.analysis_status || 'pending', input.api_provider || 'gemini', ] ); return result.lastInsertRowId; } public async getMealById(id: number): Promise { if (!this.db) throw new Error('Database not initialized'); const result = await this.db.getFirstAsync('SELECT * FROM meals WHERE id = ?', [id]); return result || null; } public async getMealWithItems(id: number): Promise { if (!this.db) throw new Error('Database not initialized'); const meal = await this.getMealById(id); if (!meal) return null; const foodItems = await this.db.getAllAsync( 'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at', [id] ); return { ...meal, food_items: foodItems, }; } public async getAllMeals(limit: number = 50, offset: number = 0): Promise { if (!this.db) throw new Error('Database not initialized'); return await this.db.getAllAsync( 'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?', [limit, offset] ); } public async getAllMealsWithItems( limit: number = 50, offset: number = 0 ): Promise { if (!this.db) throw new Error('Database not initialized'); const meals = await this.getAllMeals(limit, offset); const mealsWithItems: MealWithItems[] = []; for (const meal of meals) { const foodItems = await this.db.getAllAsync( 'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at', [meal.id!] ); mealsWithItems.push({ ...meal, food_items: foodItems, }); } return mealsWithItems; } public async updateMeal(id: number, updates: Partial): Promise { if (!this.db) throw new Error('Database not initialized'); const updateFields = Object.keys(updates).filter((key) => key !== 'id'); const updateValues = updateFields.map((key) => updates[key as keyof Meal]); const setClause = updateFields.map((key) => `${key} = ?`).join(', '); await this.db.runAsync( ` UPDATE meals SET ${setClause}, updated_at = datetime('now') WHERE id = ? `, [...updateValues, id] ); } public async deleteMeal(id: number): Promise { if (!this.db) throw new Error('Database not initialized'); await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]); } // CRUD Operations für Food Items public async createFoodItem(foodItem: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const result = await this.db.runAsync( ` INSERT INTO food_items ( meal_id, name, category, portion_size, calories, protein, carbs, fat, fiber, sugar, confidence, bounding_box, is_organic, is_processed, allergens ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ foodItem.meal_id, foodItem.name, foodItem.category, foodItem.portion_size, foodItem.calories || null, foodItem.protein || null, foodItem.carbs || null, foodItem.fat || null, foodItem.fiber || null, foodItem.sugar || null, foodItem.confidence || null, foodItem.bounding_box || null, foodItem.is_organic, foodItem.is_processed, foodItem.allergens || null, ] ); return result.lastInsertRowId; } public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise { if (!this.db) throw new Error('Database not initialized'); if (foodItems.length === 0) return []; const insertedIds: number[] = []; // Use a transaction for better performance await this.db.execAsync('BEGIN TRANSACTION'); try { for (const foodItem of foodItems) { const result = await this.db.runAsync( `INSERT INTO food_items ( meal_id, name, category, portion_size, calories, protein, carbs, fat, fiber, sugar, confidence, bounding_box, is_organic, is_processed, allergens ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ foodItem.meal_id, foodItem.name, foodItem.category || null, foodItem.portion_size || null, foodItem.calories || null, foodItem.protein || null, foodItem.carbs || null, foodItem.fat || null, foodItem.fiber || null, foodItem.sugar || null, foodItem.confidence || null, foodItem.bounding_box || null, foodItem.is_organic, foodItem.is_processed, foodItem.allergens || null, ] ); insertedIds.push(result.lastInsertRowId); } await this.db.execAsync('COMMIT'); return insertedIds; } catch (error) { await this.db.execAsync('ROLLBACK'); throw error; } } public async getFoodItemsByMealId(mealId: number): Promise { if (!this.db) throw new Error('Database not initialized'); return await this.db.getAllAsync( 'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at', [mealId] ); } // Statistiken und Aggregationen public async getMealStats(days: number = 7): Promise<{ totalMeals: number; avgCalories: number; avgHealthScore: number; }> { if (!this.db) throw new Error('Database not initialized'); const result = await this.db.getFirstAsync<{ count: number; avg_calories: number; avg_health_score: number; }>(` SELECT COUNT(*) as count, AVG(total_calories) as avg_calories, AVG(health_score) as avg_health_score FROM meals WHERE timestamp >= datetime('now', '-${days} days') AND analysis_status = 'completed' `); return { totalMeals: result?.count || 0, avgCalories: Math.round(result?.avg_calories || 0), avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10, }; } public async searchMeals(query: string): Promise { if (!this.db) throw new Error('Database not initialized'); return await this.db.getAllAsync( ` SELECT DISTINCT m.* FROM meals m LEFT JOIN food_items fi ON m.id = fi.meal_id WHERE m.user_notes LIKE ? OR m.meal_type LIKE ? OR fi.name LIKE ? ORDER BY m.timestamp DESC `, [`%${query}%`, `%${query}%`, `%${query}%`] ); } // Hilfsmethoden public async close(): Promise { if (this.db) { await this.db.closeAsync(); this.db = null; } } public async executeRaw(sql: string, params: any[] = []): Promise { if (!this.db) throw new Error('Database not initialized'); return await this.db.runAsync(sql, params); } }