adding recipes and synch to nostr

master
gitea 2 months ago
parent d1d192ae91
commit cc9cef9e84

@ -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", "<recipe-id>"]` - Replaceable event identifier
- `["image", "<immich-url>"]` - One tag per image URL
- `["t", "<tag>"]` - One tag per recipe tag
- `["rating", "<0-5>"]` - Recipe rating
- `["favourite", "<true/false>"]` - 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.

@ -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<String> 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<String> 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<String>? tags,
this.rating = 0,
this.isFavourite = false,
List<String>? 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<String, dynamic> 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<dynamic>)
.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<dynamic>)
.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<String, dynamic> 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<String, dynamic> json) {
return RecipeModel(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
tags: (json['tags'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
rating: json['rating'] as int? ?? 0,
isFavourite: json['isFavourite'] as bool? ?? false,
imageUrls: (json['imageUrls'] as List<dynamic>?)?.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<String, dynamic> 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<String>? tags,
int? rating,
bool? isFavourite,
List<String>? 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)';
}
}

@ -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<void> 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<void> _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<String> _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<RecipeModel> 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<RecipeModel> 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<void> 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<RecipeModel?> getRecipe(String recipeId) async {
_ensureInitialized();
try {
final List<Map<String, dynamic>> 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<List<RecipeModel>> getAllRecipes({bool includeDeleted = false}) async {
_ensureInitialized();
try {
final List<Map<String, dynamic>> 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<List<RecipeModel>> 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<List<RecipeModel>> getFavouriteRecipes() async {
_ensureInitialized();
try {
final List<Map<String, dynamic>> 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<void> _publishRecipeToNostr(RecipeModel recipe) async {
if (_nostrService == null || _nostrKeyPair == null) {
return;
}
try {
// Create tags
final tags = <List<String>>[
['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<void> _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 = <List<String>>[
['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;
}
}
}

@ -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<AddRecipeScreen> createState() => _AddRecipeScreenState();
}
class _AddRecipeScreenState extends State<AddRecipeScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _tagsController = TextEditingController();
int _rating = 0;
bool _isFavourite = false;
List<File> _selectedImages = [];
List<String> _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<void> _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<void> _pickImages() async {
try {
final List<XFile> 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<void> _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<String> 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<void> _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,
// 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: 16),
Text(
'Add Recipe Screen',
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',
),
),
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: 24,
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Recipe creation form will appear here',
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,
color: Colors.grey,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
'Will integrate with ImmichService for image uploads',
style: TextStyle(
fontSize: 14,
color: Colors.grey,
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<Color>(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<Uint8List?>(
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,
),
),
);
},
);
}
}

@ -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<RecipesScreen> createState() => _RecipesScreenState();
}
class _RecipesScreenState extends State<RecipesScreen> {
List<RecipeModel> _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<void> _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<void> _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<void> _deleteRecipe(RecipeModel recipe) async {
if (_recipeService == null) return;
final confirmed = await showDialog<bool>(
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.menu_book,
size: 64,
color: Colors.grey,
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'),
),
],
),
SizedBox(height: 16),
),
);
}
if (_recipes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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<Uint8List?>(
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,
),
],
),
],
),
),
],
),
),
);
}
}

@ -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<Exception>()),
);
});
test('throws exception when deleting non-existent recipe', () async {
expect(
() => recipeService.deleteRecipe('non-existent'),
throwsA(isA<Exception>()),
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.