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'; /// Add Recipe screen for creating new recipes. class AddRecipeScreen extends StatefulWidget { final RecipeModel? recipe; // For editing existing recipes final bool viewMode; // If true, display in view-only mode const AddRecipeScreen({ super.key, this.recipe, this.viewMode = false, }); @override State createState() => _AddRecipeScreenState(); } class _AddRecipeScreenState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); final _descriptionController = TextEditingController(); final _tagsController = TextEditingController(); int _rating = 0; bool _isFavourite = false; List _selectedImages = []; List _uploadedImageUrls = []; bool _isUploading = false; bool _isSaving = false; String? _errorMessage; final ImagePicker _imagePicker = ImagePicker(); RecipeService? _recipeService; @override void initState() { super.initState(); _initializeService(); if (widget.recipe != null) { _loadRecipeData(); } } Future _initializeService() async { try { // Use the shared RecipeService from ServiceLocator instead of creating a new instance // This ensures we're using the same instance that SessionService manages _recipeService = ServiceLocator.instance.recipeService; if (_recipeService == null) { throw Exception('RecipeService not available in ServiceLocator'); } // The database should already be initialized by SessionService on login // If not initialized, we'll get an error when trying to use it } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; }); } } } void _loadRecipeData() { if (widget.recipe == null) return; final recipe = widget.recipe!; _titleController.text = recipe.title; _descriptionController.text = recipe.description ?? ''; _tagsController.text = recipe.tags.join(', '); _rating = recipe.rating; _isFavourite = recipe.isFavourite; _uploadedImageUrls = List.from(recipe.imageUrls); } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); _tagsController.dispose(); super.dispose(); } Future _pickImages() async { try { final List pickedFiles = await _imagePicker.pickMultiImage(); if (pickedFiles.isEmpty) return; final newImages = pickedFiles.map((file) => File(file.path)).toList(); setState(() { _selectedImages.addAll(newImages); _isUploading = true; }); // Auto-upload images immediately await _uploadImages(); } catch (e) { Logger.error('Failed to pick images', e); if (mounted) { setState(() { _isUploading = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to pick images: $e'), backgroundColor: Colors.red, ), ); } } } Future _uploadImages() async { if (_selectedImages.isEmpty) return; final immichService = ServiceLocator.instance.immichService; if (immichService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Immich service not available'), backgroundColor: Colors.orange, ), ); } return; } setState(() { _isUploading = true; _errorMessage = null; }); try { final List uploadedUrls = []; for (final imageFile in _selectedImages) { try { final uploadResponse = await immichService.uploadImage(imageFile); final imageUrl = immichService.getImageUrl(uploadResponse.id); uploadedUrls.add(imageUrl); Logger.info('Image uploaded: $imageUrl'); } catch (e) { Logger.warning('Failed to upload image ${imageFile.path}: $e'); // Continue with other images } } setState(() { _uploadedImageUrls.addAll(uploadedUrls); _selectedImages.clear(); _isUploading = false; }); if (mounted && uploadedUrls.isNotEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${uploadedUrls.length} image(s) uploaded successfully'), backgroundColor: Colors.green, ), ); } } catch (e) { Logger.error('Failed to upload images', e); if (mounted) { setState(() { _isUploading = false; _errorMessage = 'Failed to upload images: $e'; }); } } } Future _saveRecipe() async { if (!_formKey.currentState!.validate()) { return; } if (_recipeService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Recipe service not initialized'), backgroundColor: Colors.red, ), ); } return; } // Upload any pending images first and wait for completion if (_selectedImages.isNotEmpty) { setState(() { _isSaving = true; _errorMessage = null; }); await _uploadImages(); // Check if upload failed if (_selectedImages.isNotEmpty) { setState(() { _isSaving = false; _errorMessage = 'Some images failed to upload. Please try again.'; }); return; } } else { setState(() { _isSaving = true; _errorMessage = null; }); } // Wait for any ongoing uploads to complete while (_isUploading) { await Future.delayed(const Duration(milliseconds: 100)); } try { // Parse tags final tags = _tagsController.text .split(',') .map((tag) => tag.trim()) .where((tag) => tag.isNotEmpty) .toList(); final recipe = RecipeModel( id: widget.recipe?.id ?? 'recipe-${DateTime.now().millisecondsSinceEpoch}', title: _titleController.text.trim(), description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), tags: tags, rating: _rating, isFavourite: _isFavourite, imageUrls: _uploadedImageUrls, ); if (widget.recipe != null) { // Update existing recipe await _recipeService!.updateRecipe(recipe); } else { // Create new recipe await _recipeService!.createRecipe(recipe); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(widget.recipe != null ? 'Recipe updated successfully' : 'Recipe added successfully'), backgroundColor: Colors.green, ), ); // Navigate back to Recipes screen with result to indicate success Navigator.of(context).pop(true); } } 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: AppBar( title: Text( widget.viewMode ? 'View Recipe' : (widget.recipe != null ? 'Edit Recipe' : 'Add Recipe'), ), actions: [ // Edit button in view mode if (widget.viewMode) IconButton( icon: const Icon(Icons.edit), onPressed: () { // Navigate to edit mode Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: widget.recipe), ), ); }, tooltip: 'Edit', ), ], ), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ // Title field TextFormField( controller: _titleController, enabled: !widget.viewMode, readOnly: widget.viewMode, decoration: const InputDecoration( labelText: 'Title *', hintText: 'Enter recipe title', border: OutlineInputBorder(), ), validator: widget.viewMode ? null : (value) { if (value == null || value.trim().isEmpty) { return 'Title is required'; } return null; }, ), const SizedBox(height: 16), // Description field TextFormField( controller: _descriptionController, enabled: !widget.viewMode, readOnly: widget.viewMode, decoration: const InputDecoration( labelText: 'Description', hintText: 'Enter recipe description', border: OutlineInputBorder(), ), maxLines: 4, ), const SizedBox(height: 16), // Tags field TextFormField( controller: _tagsController, enabled: !widget.viewMode, readOnly: widget.viewMode, 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: widget.viewMode ? null : () { setState(() { _rating = index + 1; }); }, ); }), ), ], ), ), ), const SizedBox(height: 16), // Favourite toggle Card( child: SwitchListTile( title: const Text('Favourite'), value: _isFavourite, onChanged: widget.viewMode ? null : (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, ), ), if (!widget.viewMode) 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), ), ), if (!widget.viewMode) 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 (only show in edit/add mode) if (!widget.viewMode) Row( children: [ Expanded( child: OutlinedButton( onPressed: _isSaving ? null : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ), const SizedBox(width: 16), Expanded( child: ElevatedButton( onPressed: _isSaving ? null : _saveRecipe, child: _isSaving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : Text(widget.recipe != null ? 'Update' : 'Save'), ), ), ], ), ], ), ), ); } /// Builds an image preview widget using ImmichService for authenticated access. Widget _buildImagePreview(String imageUrl) { final immichService = ServiceLocator.instance.immichService; // Try to extract asset ID from URL (format: .../api/assets/{id}/original) final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); if (assetIdMatch != null && immichService != null) { final assetId = assetIdMatch.group(1); if (assetId != null) { // Use ImmichService to fetch image with proper authentication return FutureBuilder( future: immichService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( color: Colors.grey.shade200, child: const Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { return const Icon(Icons.broken_image); } return Image.memory( snapshot.data!, fit: BoxFit.cover, ); }, ); } } // Fallback to direct network image if not an Immich URL or service unavailable return Image.network( imageUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return const Icon(Icons.broken_image); }, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Container( color: Colors.grey.shade200, child: Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ), ); }, ); } }