parent
d1d192ae91
commit
cc9cef9e84
@ -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,
|
||||
),
|
||||
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;
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Recipe creation form will appear here',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
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(
|
||||
'Will integrate with ImmichService for image uploads',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
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',
|
||||
),
|
||||
),
|
||||
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<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.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<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…
Reference in new issue