import 'dart:convert'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../local/local_storage_service.dart'; import '../nostr/nostr_service.dart'; import '../nostr/models/nostr_event.dart'; import '../nostr/models/nostr_keypair.dart'; import '../../core/logger.dart'; import 'models/recipe_model.dart'; /// Service for managing recipes with local storage and Nostr sync. class RecipeService { /// Local storage service. final LocalStorageService _localStorage; /// Nostr service (optional). final NostrService? _nostrService; /// Nostr keypair for signing events (optional). NostrKeyPair? _nostrKeyPair; /// Database instance (null until initialized). Database? _db; /// Optional database path for testing (null uses default). final String? _testDbPath; /// Creates a [RecipeService] instance. RecipeService({ required LocalStorageService localStorage, NostrService? nostrService, NostrKeyPair? nostrKeyPair, String? testDbPath, }) : _localStorage = localStorage, _nostrService = nostrService, _nostrKeyPair = nostrKeyPair, _testDbPath = testDbPath; /// Sets the Nostr keypair for signing events. void setNostrKeyPair(NostrKeyPair keypair) { _nostrKeyPair = keypair; } /// Initializes the recipes table in the database. /// /// Must be called before using any other methods. Future initialize({String? sessionDbPath}) async { await _localStorage.initialize(); // Get database path final dbPath = sessionDbPath ?? _testDbPath ?? await _getDatabasePath(); // Open database _db = await openDatabase( dbPath, version: 1, onCreate: _onCreate, ); Logger.info('RecipeService initialized'); } /// Creates the recipes table if it doesn't exist. Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE IF NOT EXISTS recipes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, tags TEXT NOT NULL, rating INTEGER NOT NULL DEFAULT 0, is_favourite INTEGER NOT NULL DEFAULT 0, image_urls TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, nostr_event_id TEXT ) '''); // Create index for faster queries await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_created_at ON recipes(created_at DESC) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_is_deleted ON recipes(is_deleted) '''); } /// Gets the path to the database file. Future _getDatabasePath() async { final documentsDirectory = await getApplicationDocumentsDirectory(); return path.join(documentsDirectory.path, 'recipes.db'); } /// Ensures the database is initialized. void _ensureInitialized() { if (_db == null) { throw Exception('RecipeService not initialized. Call initialize() first.'); } } /// Creates a new recipe. /// /// [recipe] - The recipe to create. /// /// Returns the created recipe with updated timestamps. /// /// Throws [Exception] if creation fails. Future createRecipe(RecipeModel recipe) async { _ensureInitialized(); try { final now = DateTime.now().millisecondsSinceEpoch; final recipeWithTimestamps = recipe.copyWith( createdAt: now, updatedAt: now, ); await _db!.insert( 'recipes', recipeWithTimestamps.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.info('Recipe created: ${recipe.id}'); // Publish to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { await _publishRecipeToNostr(recipeWithTimestamps); } catch (e) { Logger.warning('Failed to publish recipe to Nostr: $e'); // Don't fail the creation if Nostr publish fails } } return recipeWithTimestamps; } catch (e) { throw Exception('Failed to create recipe: $e'); } } /// Updates an existing recipe. /// /// [recipe] - The recipe with updated data. /// /// Returns the updated recipe. /// /// Throws [Exception] if update fails or recipe doesn't exist. Future updateRecipe(RecipeModel recipe) async { _ensureInitialized(); try { final updatedRecipe = recipe.copyWith( updatedAt: DateTime.now().millisecondsSinceEpoch, ); final updated = await _db!.update( 'recipes', updatedRecipe.toMap(), where: 'id = ?', whereArgs: [recipe.id], ); if (updated == 0) { throw Exception('Recipe with id ${recipe.id} not found'); } Logger.info('Recipe updated: ${recipe.id}'); // Publish to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { await _publishRecipeToNostr(updatedRecipe); } catch (e) { Logger.warning('Failed to publish recipe update to Nostr: $e'); // Don't fail the update if Nostr publish fails } } return updatedRecipe; } catch (e) { throw Exception('Failed to update recipe: $e'); } } /// Deletes a recipe. /// /// [recipeId] - The ID of the recipe to delete. /// /// Throws [Exception] if deletion fails. Future deleteRecipe(String recipeId) async { _ensureInitialized(); try { // Mark as deleted instead of actually deleting final updated = await _db!.update( 'recipes', { 'is_deleted': 1, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [recipeId], ); if (updated == 0) { throw Exception('Recipe with id $recipeId not found'); } Logger.info('Recipe deleted: $recipeId'); // Publish deletion event to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { await _publishRecipeDeletionToNostr(recipeId); } catch (e) { Logger.warning('Failed to publish recipe deletion to Nostr: $e'); // Don't fail the deletion if Nostr publish fails } } } catch (e) { throw Exception('Failed to delete recipe: $e'); } } /// Gets a recipe by ID. /// /// [recipeId] - The ID of the recipe. /// /// Returns the recipe if found, null otherwise. /// /// Throws [Exception] if retrieval fails. Future getRecipe(String recipeId) async { _ensureInitialized(); try { final List> maps = await _db!.query( 'recipes', where: 'id = ? AND is_deleted = 0', whereArgs: [recipeId], limit: 1, ); if (maps.isEmpty) { return null; } return RecipeModel.fromMap(maps.first); } catch (e) { throw Exception('Failed to get recipe: $e'); } } /// Gets all recipes. /// /// [includeDeleted] - Whether to include deleted recipes (default: false). /// /// Returns a list of all recipes, ordered by creation date (newest first). /// /// Throws [Exception] if retrieval fails. Future> getAllRecipes({bool includeDeleted = false}) async { _ensureInitialized(); try { final List> maps = await _db!.query( 'recipes', where: includeDeleted ? null : 'is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (e) { throw Exception('Failed to get all recipes: $e'); } } /// Gets recipes by tag. /// /// [tag] - The tag to filter by. /// /// Returns a list of recipes with the specified tag. /// /// Throws [Exception] if retrieval fails. Future> getRecipesByTag(String tag) async { try { final allRecipes = await getAllRecipes(); return allRecipes.where((recipe) => recipe.tags.contains(tag)).toList(); } catch (e) { throw Exception('Failed to get recipes by tag: $e'); } } /// Gets favourite recipes. /// /// Returns a list of recipes marked as favourite. /// /// Throws [Exception] if retrieval fails. Future> getFavouriteRecipes() async { _ensureInitialized(); try { final List> maps = await _db!.query( 'recipes', where: 'is_favourite = 1 AND is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (e) { throw Exception('Failed to get favourite recipes: $e'); } } /// Publishes a recipe to Nostr as a kind 30078 event. Future _publishRecipeToNostr(RecipeModel recipe) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Create tags final tags = >[ ['d', recipe.id], // Replaceable event identifier ]; // Add image tags for (final imageUrl in recipe.imageUrls) { tags.add(['image', imageUrl]); } // Add tag tags for (final tag in recipe.tags) { tags.add(['t', tag]); } // Add rating tag tags.add(['rating', recipe.rating.toString()]); // Add favourite tag tags.add(['favourite', recipe.isFavourite.toString()]); // Create event content as JSON final content = recipe.toJson(); // Create and sign the event final event = NostrEvent.create( content: jsonEncode(content), kind: 30078, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published recipe ${recipe.id} to $successCount/${results.length} relays'); // Update recipe with Nostr event ID final updatedRecipe = recipe.copyWith(nostrEventId: event.id); await _db!.update( 'recipes', updatedRecipe.toMap(), where: 'id = ?', whereArgs: [recipe.id], ); } catch (e) { Logger.error('Failed to publish recipe to Nostr', e); rethrow; } } /// Publishes a recipe deletion to Nostr as a kind 5 event. Future _publishRecipeDeletionToNostr(String recipeId) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Get the recipe to find its Nostr event ID final recipe = await getRecipe(recipeId); if (recipe == null || recipe.nostrEventId == null) { Logger.warning('Recipe $recipeId has no Nostr event ID, skipping deletion event'); return; } // Create a kind 5 deletion event // Tags should reference the event IDs to delete final tags = >[ ['e', recipe.nostrEventId!], // Reference the original event ]; // Create event content (can be empty for deletions) final event = NostrEvent.create( content: '', kind: 5, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published recipe deletion for $recipeId to $successCount/${results.length} relays'); } catch (e) { Logger.error('Failed to publish recipe deletion to Nostr', e); rethrow; } } }