From cc9cef9e84444ac91ce01a6ebc6598bea0dd2626 Mon Sep 17 00:00:00 2001 From: gitea Date: Sat, 8 Nov 2025 17:50:03 +0100 Subject: [PATCH] adding recipes and synch to nostr --- README.md | 155 +++++- lib/data/recipes/models/recipe_model.dart | 166 ++++++ lib/data/recipes/recipe_service.dart | 411 ++++++++++++++ lib/ui/add_recipe/add_recipe_screen.dart | 618 +++++++++++++++++++-- lib/ui/recipes/recipes_screen.dart | 446 ++++++++++++++- test/data/recipes/recipe_service_test.dart | 228 ++++++++ 6 files changed, 1976 insertions(+), 48 deletions(-) create mode 100644 lib/data/recipes/models/recipe_model.dart create mode 100644 lib/data/recipes/recipe_service.dart create mode 100644 test/data/recipes/recipe_service_test.dart diff --git a/README.md b/README.md index cca86a5..ab8fcb4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The app uses a custom bottom navigation bar with 4 main tabs and a centered Add ### Main Navigation Tabs 1. **Home** (`lib/ui/home/home_screen.dart`) - Displays local storage items and cached content -2. **Recipes** (`lib/ui/recipes/recipes_screen.dart`) - Recipe collection (ready for LocalStorageService integration) +2. **Recipes** (`lib/ui/recipes/recipes_screen.dart`) - Recipe collection with full CRUD operations 3. **Favourites** (`lib/ui/favourites/favourites_screen.dart`) - Favorite recipes 4. **User/Session** (`lib/ui/session/session_screen.dart`) - User session management and login @@ -101,6 +101,159 @@ Service for interacting with Immich API. Uploads images, fetches asset lists, an **Key Methods:** `uploadImage()`, `fetchAssets()`, `getCachedAsset()`, `getCachedAssets()` +## Recipe Management + +Full-featured recipe management system with offline storage and Nostr synchronization. Users can create, edit, and delete recipes with rich metadata including images, tags, ratings, and favourites. + +### Features + +- **Create Recipes**: Add recipes with title, description, tags, rating (0-5 stars), favourite flag, and multiple images +- **Edit Recipes**: Update existing recipes with all fields +- **Delete Recipes**: Soft-delete recipes (marked as deleted, can be recovered) +- **Image Upload**: Automatic upload to Immich service with URL retrieval +- **Offline-First**: All recipes stored locally in SQLite database +- **Nostr Sync**: Recipes automatically published to Nostr as kind 30078 events +- **Tag Support**: Multiple tags per recipe for organization +- **Rating System**: 0-5 star rating for recipes +- **Favourites**: Mark recipes as favourites for quick access + +### Data Model + +**RecipeModel** (`lib/data/recipes/models/recipe_model.dart`): +- `id`: Unique identifier +- `title`: Recipe title (required) +- `description`: Optional description +- `tags`: List of tags +- `rating`: 0-5 star rating +- `isFavourite`: Boolean favourite flag +- `imageUrls`: List of image URLs (from Immich) +- `createdAt` / `updatedAt`: Timestamps +- `isDeleted`: Soft-delete flag +- `nostrEventId`: Nostr event ID if synced + +### Service + +**RecipeService** (`lib/data/recipes/recipe_service.dart`): +- `createRecipe()`: Create a new recipe +- `updateRecipe()`: Update an existing recipe +- `deleteRecipe()`: Soft-delete a recipe +- `getRecipe()`: Get a recipe by ID +- `getAllRecipes()`: Get all recipes (with optional includeDeleted flag) +- `getRecipesByTag()`: Filter recipes by tag +- `getFavouriteRecipes()`: Get all favourite recipes + +### UI Screens + +**AddRecipeScreen** (`lib/ui/add_recipe/add_recipe_screen.dart`): +- Form with all recipe fields +- Image picker with multi-image support +- Automatic Immich upload integration +- Validation (title required) +- Edit mode support (pass existing recipe) + +**RecipesScreen** (`lib/ui/recipes/recipes_screen.dart`): +- List view with recipe cards +- Pull-to-refresh +- Edit and delete actions +- Empty state handling +- Image display with error handling + +### Nostr Integration + +Recipes are published to Nostr as **kind 30078** events (replaceable events) with the following structure: + +**Event Tags:** +- `["d", ""]` - Replaceable event identifier +- `["image", ""]` - One tag per image URL +- `["t", ""]` - One tag per recipe tag +- `["rating", "<0-5>"]` - Recipe rating +- `["favourite", ""]` - Favourite flag + +**Event Content:** +JSON object with all recipe fields: +```json +{ + "id": "recipe-123", + "title": "Recipe Title", + "description": "Recipe description", + "tags": ["tag1", "tag2"], + "rating": 4, + "isFavourite": true, + "imageUrls": ["https://..."], + "createdAt": 1234567890, + "updatedAt": 1234567890 +} +``` + +**Deletion:** +When a recipe is deleted, a **kind 5** event is published referencing the original recipe event ID. + +### Database Schema + +Recipes are stored in a SQLite database (`recipes.db`) with the following schema: + +```sql +CREATE TABLE recipes ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + tags TEXT NOT NULL, -- JSON array + rating INTEGER NOT NULL DEFAULT 0, + is_favourite INTEGER NOT NULL DEFAULT 0, + image_urls TEXT NOT NULL, -- JSON array + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + is_deleted INTEGER NOT NULL DEFAULT 0, + nostr_event_id TEXT +); +``` + +### Usage + +**Creating a Recipe:** +1. Navigate to Add Recipe screen (tap + button in bottom nav) +2. Fill in title (required), description, tags, rating, favourite flag +3. Select images from gallery +4. Images are automatically uploaded to Immich +5. Tap "Save" to create recipe +6. Recipe is saved locally and published to Nostr (if logged in with nsec) + +**Editing a Recipe:** +1. Open Recipes screen +2. Tap on a recipe card or tap Edit icon +3. Modify fields as needed +4. Tap "Update" to save changes +5. Changes are synced to Nostr + +**Deleting a Recipe:** +1. Open Recipes screen +2. Tap Delete icon on a recipe card +3. Confirm deletion +4. Recipe is soft-deleted (marked as deleted) +5. Deletion event is published to Nostr + +### Testing + +Run recipe service tests: +```bash +flutter test test/data/recipes/recipe_service_test.dart +``` + +Tests cover: +- CRUD operations +- Tag filtering +- Favourite filtering +- Soft-delete functionality +- Error handling + +### Files + +- `lib/data/recipes/models/recipe_model.dart` - Recipe data model +- `lib/data/recipes/recipe_service.dart` - Recipe service with CRUD and Nostr sync +- `lib/ui/add_recipe/add_recipe_screen.dart` - Add/Edit recipe form +- `lib/ui/recipes/recipes_screen.dart` - Recipe list display +- `test/data/recipes/recipe_service_test.dart` - Unit tests + ## Nostr Integration Service for decentralized metadata synchronization using Nostr protocol. Generates keypairs, publishes events, and syncs metadata across multiple relays. Modular design allows testing without real relay connections. diff --git a/lib/data/recipes/models/recipe_model.dart b/lib/data/recipes/models/recipe_model.dart new file mode 100644 index 0000000..e45edb2 --- /dev/null +++ b/lib/data/recipes/models/recipe_model.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; + +/// Represents a recipe with metadata. +class RecipeModel { + /// Unique identifier for the recipe. + final String id; + + /// Recipe title. + final String title; + + /// Recipe description. + final String? description; + + /// List of tags. + final List tags; + + /// Rating (0-5). + final int rating; + + /// Whether the recipe is marked as favourite. + final bool isFavourite; + + /// List of image URLs (from Immich). + final List imageUrls; + + /// Timestamp when recipe was created (milliseconds since epoch). + final int createdAt; + + /// Timestamp when recipe was last updated (milliseconds since epoch). + final int updatedAt; + + /// Whether the recipe has been deleted. + final bool isDeleted; + + /// Nostr event ID if synced to Nostr. + final String? nostrEventId; + + /// Creates a [RecipeModel] instance. + RecipeModel({ + required this.id, + required this.title, + this.description, + List? tags, + this.rating = 0, + this.isFavourite = false, + List? imageUrls, + int? createdAt, + int? updatedAt, + this.isDeleted = false, + this.nostrEventId, + }) : tags = tags ?? [], + imageUrls = imageUrls ?? [], + createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch, + updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch; + + /// Creates a [RecipeModel] from a database row (Map). + factory RecipeModel.fromMap(Map map) { + return RecipeModel( + id: map['id'] as String, + title: map['title'] as String, + description: map['description'] as String?, + tags: map['tags'] != null + ? (jsonDecode(map['tags'] as String) as List) + .map((e) => e.toString()) + .toList() + : [], + rating: map['rating'] as int? ?? 0, + isFavourite: (map['is_favourite'] as int? ?? 0) == 1, + imageUrls: map['image_urls'] != null + ? (jsonDecode(map['image_urls'] as String) as List) + .map((e) => e.toString()) + .toList() + : [], + createdAt: map['created_at'] as int, + updatedAt: map['updated_at'] as int, + isDeleted: (map['is_deleted'] as int? ?? 0) == 1, + nostrEventId: map['nostr_event_id'] as String?, + ); + } + + /// Converts the [RecipeModel] to a Map for database storage. + Map toMap() { + return { + 'id': id, + 'title': title, + 'description': description, + 'tags': jsonEncode(tags), + 'rating': rating, + 'is_favourite': isFavourite ? 1 : 0, + 'image_urls': jsonEncode(imageUrls), + 'created_at': createdAt, + 'updated_at': updatedAt, + 'is_deleted': isDeleted ? 1 : 0, + 'nostr_event_id': nostrEventId, + }; + } + + /// Creates a [RecipeModel] from JSON (for Nostr content). + factory RecipeModel.fromJson(Map json) { + return RecipeModel( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String?, + tags: (json['tags'] as List?)?.map((e) => e.toString()).toList() ?? [], + rating: json['rating'] as int? ?? 0, + isFavourite: json['isFavourite'] as bool? ?? false, + imageUrls: (json['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], + createdAt: json['createdAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, + updatedAt: json['updatedAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, + isDeleted: json['isDeleted'] as bool? ?? false, + nostrEventId: json['nostrEventId'] as String?, + ); + } + + /// Converts the [RecipeModel] to JSON (for Nostr content). + Map toJson() { + return { + 'id': id, + 'title': title, + if (description != null) 'description': description, + 'tags': tags, + 'rating': rating, + 'isFavourite': isFavourite, + 'imageUrls': imageUrls, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'isDeleted': isDeleted, + if (nostrEventId != null) 'nostrEventId': nostrEventId, + }; + } + + /// Creates a copy of this [RecipeModel] with updated fields. + RecipeModel copyWith({ + String? id, + String? title, + String? description, + List? tags, + int? rating, + bool? isFavourite, + List? imageUrls, + int? createdAt, + int? updatedAt, + bool? isDeleted, + String? nostrEventId, + }) { + return RecipeModel( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + tags: tags ?? this.tags, + rating: rating ?? this.rating, + isFavourite: isFavourite ?? this.isFavourite, + imageUrls: imageUrls ?? this.imageUrls, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + isDeleted: isDeleted ?? this.isDeleted, + nostrEventId: nostrEventId ?? this.nostrEventId, + ); + } + + @override + String toString() { + return 'RecipeModel(id: $id, title: $title, tags: ${tags.length}, rating: $rating, favourite: $isFavourite)'; + } +} + diff --git a/lib/data/recipes/recipe_service.dart b/lib/data/recipes/recipe_service.dart new file mode 100644 index 0000000..cd5ef78 --- /dev/null +++ b/lib/data/recipes/recipe_service.dart @@ -0,0 +1,411 @@ +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; + } + } +} + diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 2aff455..89bc5b6 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -1,52 +1,608 @@ +import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../core/service_locator.dart'; +import '../../core/logger.dart'; +import '../../data/recipes/recipe_service.dart'; +import '../../data/recipes/models/recipe_model.dart'; +import '../../data/nostr/models/nostr_keypair.dart'; +import '../../data/immich/immich_service.dart'; import '../shared/primary_app_bar.dart'; +import '../navigation/app_router.dart'; /// Add Recipe screen for creating new recipes. -class AddRecipeScreen extends StatelessWidget { - const AddRecipeScreen({super.key}); +class AddRecipeScreen extends StatefulWidget { + final RecipeModel? recipe; // For editing existing recipes + + const AddRecipeScreen({ + super.key, + this.recipe, + }); + + @override + State createState() => _AddRecipeScreenState(); +} + +class _AddRecipeScreenState extends State { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _tagsController = TextEditingController(); + + int _rating = 0; + bool _isFavourite = false; + List _selectedImages = []; + List _uploadedImageUrls = []; + bool _isUploading = false; + bool _isSaving = false; + String? _errorMessage; + + final ImagePicker _imagePicker = ImagePicker(); + RecipeService? _recipeService; + + @override + void initState() { + super.initState(); + _initializeService(); + if (widget.recipe != null) { + _loadRecipeData(); + } + } + + Future _initializeService() async { + try { + final localStorage = ServiceLocator.instance.localStorageService; + final nostrService = ServiceLocator.instance.nostrService; + final sessionService = ServiceLocator.instance.sessionService; + + // Get Nostr keypair from session if available + NostrKeyPair? nostrKeyPair; + if (sessionService != null && sessionService.currentUser != null) { + final user = sessionService.currentUser!; + if (user.nostrPrivateKey != null) { + try { + nostrKeyPair = NostrKeyPair.fromNsec(user.nostrPrivateKey!); + } catch (e) { + Logger.warning('Failed to parse Nostr keypair from session: $e'); + } + } + } + + _recipeService = RecipeService( + localStorage: localStorage, + nostrService: nostrService, + nostrKeyPair: nostrKeyPair, + ); + + await _recipeService!.initialize(); + } catch (e) { + Logger.error('Failed to initialize RecipeService', e); + if (mounted) { + setState(() { + _errorMessage = 'Failed to initialize recipe service: $e'; + }); + } + } + } + + void _loadRecipeData() { + if (widget.recipe == null) return; + + final recipe = widget.recipe!; + _titleController.text = recipe.title; + _descriptionController.text = recipe.description ?? ''; + _tagsController.text = recipe.tags.join(', '); + _rating = recipe.rating; + _isFavourite = recipe.isFavourite; + _uploadedImageUrls = List.from(recipe.imageUrls); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _tagsController.dispose(); + super.dispose(); + } + + Future _pickImages() async { + try { + final List pickedFiles = await _imagePicker.pickMultiImage(); + + if (pickedFiles.isEmpty) return; + + final newImages = pickedFiles.map((file) => File(file.path)).toList(); + + setState(() { + _selectedImages.addAll(newImages); + _isUploading = true; + }); + + // Auto-upload images immediately + await _uploadImages(); + } catch (e) { + Logger.error('Failed to pick images', e); + if (mounted) { + setState(() { + _isUploading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to pick images: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _uploadImages() async { + if (_selectedImages.isEmpty) return; + + final immichService = ServiceLocator.instance.immichService; + if (immichService == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Immich service not available'), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + setState(() { + _isUploading = true; + _errorMessage = null; + }); + + try { + final List uploadedUrls = []; + + for (final imageFile in _selectedImages) { + try { + final uploadResponse = await immichService.uploadImage(imageFile); + final imageUrl = immichService.getImageUrl(uploadResponse.id); + uploadedUrls.add(imageUrl); + Logger.info('Image uploaded: $imageUrl'); + } catch (e) { + Logger.warning('Failed to upload image ${imageFile.path}: $e'); + // Continue with other images + } + } + + setState(() { + _uploadedImageUrls.addAll(uploadedUrls); + _selectedImages.clear(); + _isUploading = false; + }); + + if (mounted && uploadedUrls.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${uploadedUrls.length} image(s) uploaded successfully'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + Logger.error('Failed to upload images', e); + if (mounted) { + setState(() { + _isUploading = false; + _errorMessage = 'Failed to upload images: $e'; + }); + } + } + } + + Future _saveRecipe() async { + if (!_formKey.currentState!.validate()) { + return; + } + + if (_recipeService == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recipe service not initialized'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Upload any pending images first and wait for completion + if (_selectedImages.isNotEmpty) { + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + await _uploadImages(); + + // Check if upload failed + if (_selectedImages.isNotEmpty) { + setState(() { + _isSaving = false; + _errorMessage = 'Some images failed to upload. Please try again.'; + }); + return; + } + } else { + setState(() { + _isSaving = true; + _errorMessage = null; + }); + } + + // Wait for any ongoing uploads to complete + while (_isUploading) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + try { + // Parse tags + final tags = _tagsController.text + .split(',') + .map((tag) => tag.trim()) + .where((tag) => tag.isNotEmpty) + .toList(); + + final recipe = RecipeModel( + id: widget.recipe?.id ?? 'recipe-${DateTime.now().millisecondsSinceEpoch}', + title: _titleController.text.trim(), + description: _descriptionController.text.trim().isEmpty + ? null + : _descriptionController.text.trim(), + tags: tags, + rating: _rating, + isFavourite: _isFavourite, + imageUrls: _uploadedImageUrls, + ); + + if (widget.recipe != null) { + // Update existing recipe + await _recipeService!.updateRecipe(recipe); + } else { + // Create new recipe + await _recipeService!.createRecipe(recipe); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.recipe != null + ? 'Recipe updated successfully' + : 'Recipe added successfully'), + backgroundColor: Colors.green, + ), + ); + + // Navigate back to Recipes screen + Navigator.of(context).pop(); + } + } catch (e) { + Logger.error('Failed to save recipe', e); + if (mounted) { + setState(() { + _isSaving = false; + _errorMessage = 'Failed to save recipe: $e'; + }); + } + } + } + + void _removeImage(int index) { + setState(() { + _uploadedImageUrls.removeAt(index); + }); + } @override Widget build(BuildContext context) { return Scaffold( - appBar: PrimaryAppBar(title: 'Add Recipe'), - body: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + appBar: PrimaryAppBar( + title: widget.recipe != null ? 'Edit Recipe' : 'Add Recipe', + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), children: [ - Icon( - Icons.add_circle_outline, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'Add Recipe Screen', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.grey, + // Title field + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title *', + hintText: 'Enter recipe title', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Description field + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description', + hintText: 'Enter recipe description', + border: OutlineInputBorder(), ), + maxLines: 4, ), - SizedBox(height: 8), - Text( - 'Recipe creation form will appear here', - style: TextStyle( - fontSize: 16, - color: Colors.grey, + const SizedBox(height: 16), + + // Tags field + TextFormField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags', + hintText: 'Enter tags separated by commas', + border: OutlineInputBorder(), + helperText: 'Separate tags with commas', ), ), - SizedBox(height: 8), - Text( - 'Will integrate with ImmichService for image uploads', - style: TextStyle( - fontSize: 14, - color: Colors.grey, + const SizedBox(height: 16), + + // Rating + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rating', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: List.generate(5, (index) { + return IconButton( + icon: Icon( + index < _rating ? Icons.star : Icons.star_border, + color: index < _rating ? Colors.amber : Colors.grey, + size: 32, + ), + onPressed: () { + setState(() { + _rating = index + 1; + }); + }, + ); + }), + ), + ], + ), ), ), + const SizedBox(height: 16), + + // Favourite toggle + Card( + child: SwitchListTile( + title: const Text('Favourite'), + value: _isFavourite, + onChanged: (value) { + setState(() { + _isFavourite = value; + }); + }, + ), + ), + const SizedBox(height: 16), + + // Images section + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Images', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ElevatedButton.icon( + onPressed: _isUploading ? null : _pickImages, + icon: const Icon(Icons.add_photo_alternate), + label: const Text('Add Images'), + ), + ], + ), + const SizedBox(height: 8), + if (_isUploading) + const Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 8), + Text('Uploading images...'), + ], + ), + ), + if (_uploadedImageUrls.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _uploadedImageUrls.asMap().entries.map((entry) { + final index = entry.key; + final url = entry.value; + return Stack( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildImagePreview(url), + ), + ), + Positioned( + top: 4, + right: 4, + child: IconButton( + icon: const Icon(Icons.close, size: 20), + color: Colors.red, + onPressed: () => _removeImage(index), + ), + ), + ], + ); + }).toList(), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Error message + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: Colors.red.shade700), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Save and Cancel buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _isSaving ? null : _saveRecipe, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(widget.recipe != null ? 'Update' : 'Save'), + ), + ), + ], + ), ], ), ), ); } -} + /// Builds an image preview widget using ImmichService for authenticated access. + Widget _buildImagePreview(String imageUrl) { + final immichService = ServiceLocator.instance.immichService; + + // Try to extract asset ID from URL (format: .../api/assets/{id}/original) + final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); + + if (assetIdMatch != null && immichService != null) { + final assetId = assetIdMatch.group(1); + if (assetId != null) { + // Use ImmichService to fetch image with proper authentication + return FutureBuilder( + future: immichService.fetchImageBytes(assetId, isThumbnail: true), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + color: Colors.grey.shade200, + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return const Icon(Icons.broken_image); + } + + return Image.memory( + snapshot.data!, + fit: BoxFit.cover, + ); + }, + ); + } + } + + // Fallback to direct network image if not an Immich URL or service unavailable + return Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.broken_image); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 3b2c8dd..302366e 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -1,44 +1,458 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import '../../core/service_locator.dart'; +import '../../core/logger.dart'; +import '../../data/recipes/recipe_service.dart'; +import '../../data/recipes/models/recipe_model.dart'; +import '../../data/nostr/models/nostr_keypair.dart'; +import '../../data/immich/immich_service.dart'; import '../shared/primary_app_bar.dart'; +import '../add_recipe/add_recipe_screen.dart'; /// Recipes screen displaying user's recipe collection. -class RecipesScreen extends StatelessWidget { +class RecipesScreen extends StatefulWidget { const RecipesScreen({super.key}); + @override + State createState() => _RecipesScreenState(); +} + +class _RecipesScreenState extends State { + List _recipes = []; + bool _isLoading = false; + String? _errorMessage; + RecipeService? _recipeService; + + @override + void initState() { + super.initState(); + _initializeService(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Reload recipes when returning to this screen + if (_recipeService != null) { + _loadRecipes(); + } + } + + Future _initializeService() async { + try { + final localStorage = ServiceLocator.instance.localStorageService; + final nostrService = ServiceLocator.instance.nostrService; + final sessionService = ServiceLocator.instance.sessionService; + + // Get Nostr keypair from session if available + NostrKeyPair? nostrKeyPair; + if (sessionService != null && sessionService.currentUser != null) { + final user = sessionService.currentUser!; + if (user.nostrPrivateKey != null) { + try { + nostrKeyPair = NostrKeyPair.fromNsec(user.nostrPrivateKey!); + } catch (e) { + Logger.warning('Failed to parse Nostr keypair from session: $e'); + } + } + } + + _recipeService = RecipeService( + localStorage: localStorage, + nostrService: nostrService, + nostrKeyPair: nostrKeyPair, + ); + + await _recipeService!.initialize(); + _loadRecipes(); + } catch (e) { + Logger.error('Failed to initialize RecipeService', e); + if (mounted) { + setState(() { + _errorMessage = 'Failed to initialize recipe service: $e'; + _isLoading = false; + }); + } + } + } + + Future _loadRecipes() async { + if (_recipeService == null) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final recipes = await _recipeService!.getAllRecipes(); + if (mounted) { + setState(() { + _recipes = recipes; + _isLoading = false; + }); + } + } catch (e) { + Logger.error('Failed to load recipes', e); + if (mounted) { + setState(() { + _errorMessage = 'Failed to load recipes: $e'; + _isLoading = false; + }); + } + } + } + + Future _deleteRecipe(RecipeModel recipe) async { + if (_recipeService == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Recipe'), + content: Text('Are you sure you want to delete "${recipe.title}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + await _recipeService!.deleteRecipe(recipe.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recipe deleted successfully'), + backgroundColor: Colors.green, + ), + ); + _loadRecipes(); + } + } catch (e) { + Logger.error('Failed to delete recipe', e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to delete recipe: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _editRecipe(RecipeModel recipe) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddRecipeScreen(recipe: recipe), + ), + ).then((_) { + // Reload recipes after editing + _loadRecipes(); + }); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: PrimaryAppBar(title: 'Recipes'), - body: const Center( + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red.shade300), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle(color: Colors.red.shade700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadRecipes, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_recipes.isEmpty) { + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.menu_book, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), + Icon(Icons.menu_book, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), Text( - 'Recipes Screen', + 'No recipes yet', style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.grey, + fontSize: 18, + color: Colors.grey.shade600, ), ), - SizedBox(height: 8), + const SizedBox(height: 8), Text( - 'Your recipe collection will appear here', + 'Tap the + button to add your first recipe', style: TextStyle( - fontSize: 16, - color: Colors.grey, + fontSize: 14, + color: Colors.grey.shade500, ), ), ], ), + ); + } + + return RefreshIndicator( + onRefresh: _loadRecipes, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _recipes.length, + itemBuilder: (context, index) { + final recipe = _recipes[index]; + return _RecipeCard( + recipe: recipe, + onEdit: () => _editRecipe(recipe), + onDelete: () => _deleteRecipe(recipe), + ); + }, ), ); } } +/// Card widget for displaying a recipe. +class _RecipeCard extends StatelessWidget { + final RecipeModel recipe; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _RecipeCard({ + required this.recipe, + required this.onEdit, + required this.onDelete, + }); + + /// Builds an image widget using ImmichService for authenticated access. + Widget _buildRecipeImage(String imageUrl) { + final immichService = ServiceLocator.instance.immichService; + + // Try to extract asset ID from URL (format: .../api/assets/{id}/original) + final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); + + if (assetIdMatch != null && immichService != null) { + final assetId = assetIdMatch.group(1); + if (assetId != null) { + // Use ImmichService to fetch image with proper authentication + return FutureBuilder( + future: immichService.fetchImageBytes(assetId, isThumbnail: true), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + color: Colors.grey.shade200, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 48), + ); + } + + return Image.memory( + snapshot.data!, + fit: BoxFit.cover, + ); + }, + ); + } + } + + // Fallback to direct network image if not an Immich URL or service unavailable + return Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 48), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + child: InkWell( + onTap: onEdit, + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image section + if (recipe.imageUrls.isNotEmpty) + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + _buildRecipeImage(recipe.imageUrls.first), + if (recipe.isFavourite) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.8), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.favorite, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + ), + ), + // Content section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + recipe.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + if (recipe.rating > 0) + Row( + children: List.generate(5, (index) { + return Icon( + index < recipe.rating + ? Icons.star + : Icons.star_border, + size: 16, + color: index < recipe.rating + ? Colors.amber + : Colors.grey, + ); + }), + ), + ], + ), + if (recipe.description != null && recipe.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + recipe.description!, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (recipe.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: recipe.tags.map((tag) { + return Chip( + label: Text( + tag, + style: const TextStyle(fontSize: 12), + ), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: onEdit, + tooltip: 'Edit', + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: onDelete, + tooltip: 'Delete', + color: Colors.red, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/data/recipes/recipe_service_test.dart b/test/data/recipes/recipe_service_test.dart new file mode 100644 index 0000000..e6bca83 --- /dev/null +++ b/test/data/recipes/recipe_service_test.dart @@ -0,0 +1,228 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import '../../../lib/data/local/local_storage_service.dart'; +import '../../../lib/data/recipes/recipe_service.dart'; +import '../../../lib/data/recipes/models/recipe_model.dart'; +import '../../../lib/data/nostr/nostr_service.dart'; +import '../../../lib/data/nostr/models/nostr_keypair.dart'; + +void main() { + // Initialize Flutter bindings for path_provider to work in tests + TestWidgetsFlutterBinding.ensureInitialized(); + + // Initialize FFI for testing + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + group('RecipeService', () { + late LocalStorageService localStorageService; + late RecipeService recipeService; + late String testDbPath; + + setUp(() async { + // Create a temporary directory for test database + final tempDir = await Directory.systemTemp.createTemp('recipe_test_'); + testDbPath = path.join(tempDir.path, 'test_recipes.db'); + final localStorageDbPath = path.join(tempDir.path, 'test_local_storage.db'); + final cacheDir = Directory(path.join(tempDir.path, 'cache')); + + localStorageService = LocalStorageService( + testDbPath: localStorageDbPath, + testCacheDir: cacheDir, + ); + await localStorageService.initialize( + sessionDbPath: localStorageDbPath, + sessionCacheDir: cacheDir, + ); + + recipeService = RecipeService( + localStorage: localStorageService, + testDbPath: testDbPath, + ); + await recipeService.initialize(sessionDbPath: testDbPath); + }); + + tearDown(() async { + // Clean up test database + try { + final dbFile = File(testDbPath); + if (await dbFile.exists()) { + await dbFile.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + }); + + test('creates a recipe successfully', () async { + final recipe = RecipeModel( + id: 'test-recipe-1', + title: 'Test Recipe', + description: 'A test recipe', + tags: ['test', 'recipe'], + rating: 4, + isFavourite: true, + imageUrls: ['https://example.com/image.jpg'], + ); + + final created = await recipeService.createRecipe(recipe); + + expect(created.id, equals(recipe.id)); + expect(created.title, equals(recipe.title)); + expect(created.description, equals(recipe.description)); + expect(created.tags, equals(recipe.tags)); + expect(created.rating, equals(recipe.rating)); + expect(created.isFavourite, equals(recipe.isFavourite)); + expect(created.imageUrls, equals(recipe.imageUrls)); + expect(created.createdAt, greaterThan(0)); + expect(created.updatedAt, greaterThan(0)); + }); + + test('gets a recipe by ID', () async { + final recipe = RecipeModel( + id: 'test-recipe-2', + title: 'Test Recipe 2', + tags: ['test'], + ); + + await recipeService.createRecipe(recipe); + final retrieved = await recipeService.getRecipe('test-recipe-2'); + + expect(retrieved, isNotNull); + expect(retrieved!.id, equals(recipe.id)); + expect(retrieved.title, equals(recipe.title)); + }); + + test('returns null for non-existent recipe', () async { + final retrieved = await recipeService.getRecipe('non-existent'); + expect(retrieved, isNull); + }); + + test('updates a recipe successfully', () async { + final recipe = RecipeModel( + id: 'test-recipe-3', + title: 'Original Title', + tags: ['original'], + ); + + await recipeService.createRecipe(recipe); + + final updated = recipe.copyWith( + title: 'Updated Title', + tags: ['updated'], + rating: 5, + ); + + final result = await recipeService.updateRecipe(updated); + + expect(result.title, equals('Updated Title')); + expect(result.tags, equals(['updated'])); + expect(result.rating, equals(5)); + expect(result.updatedAt, greaterThan(recipe.updatedAt)); + }); + + test('deletes a recipe (marks as deleted)', () async { + final recipe = RecipeModel( + id: 'test-recipe-4', + title: 'Recipe to Delete', + tags: ['delete'], + ); + + await recipeService.createRecipe(recipe); + await recipeService.deleteRecipe('test-recipe-4'); + + // Recipe should not be found (soft delete) + final retrieved = await recipeService.getRecipe('test-recipe-4'); + expect(retrieved, isNull); + + // But should exist when including deleted + final allRecipes = await recipeService.getAllRecipes(includeDeleted: true); + final deletedRecipe = allRecipes.firstWhere((r) => r.id == 'test-recipe-4'); + expect(deletedRecipe.isDeleted, isTrue); + }); + + test('gets all recipes', () async { + await recipeService.createRecipe(RecipeModel( + id: 'recipe-1', + title: 'Recipe 1', + tags: ['tag1'], + )); + await recipeService.createRecipe(RecipeModel( + id: 'recipe-2', + title: 'Recipe 2', + tags: ['tag2'], + )); + + final allRecipes = await recipeService.getAllRecipes(); + + expect(allRecipes.length, equals(2)); + expect(allRecipes.map((r) => r.id), containsAll(['recipe-1', 'recipe-2'])); + }); + + test('gets recipes by tag', () async { + await recipeService.createRecipe(RecipeModel( + id: 'recipe-tag1', + title: 'Recipe with Tag1', + tags: ['tag1', 'common'], + )); + await recipeService.createRecipe(RecipeModel( + id: 'recipe-tag2', + title: 'Recipe with Tag2', + tags: ['tag2', 'common'], + )); + + final tag1Recipes = await recipeService.getRecipesByTag('tag1'); + expect(tag1Recipes.length, equals(1)); + expect(tag1Recipes.first.id, equals('recipe-tag1')); + + final commonRecipes = await recipeService.getRecipesByTag('common'); + expect(commonRecipes.length, equals(2)); + }); + + test('gets favourite recipes', () async { + await recipeService.createRecipe(RecipeModel( + id: 'favourite-1', + title: 'Favourite Recipe 1', + tags: ['favourite'], + isFavourite: true, + )); + await recipeService.createRecipe(RecipeModel( + id: 'not-favourite', + title: 'Not Favourite', + tags: ['normal'], + isFavourite: false, + )); + + final favourites = await recipeService.getFavouriteRecipes(); + + expect(favourites.length, equals(1)); + expect(favourites.first.id, equals('favourite-1')); + }); + + test('throws exception when updating non-existent recipe', () async { + final recipe = RecipeModel( + id: 'non-existent', + title: 'Non Existent', + tags: [], + ); + + expect( + () => recipeService.updateRecipe(recipe), + throwsA(isA()), + ); + }); + + test('throws exception when deleting non-existent recipe', () async { + expect( + () => recipeService.deleteRecipe('non-existent'), + throwsA(isA()), + ); + }); + }); +} +