import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:video_player/video_player.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/recipes/models/bookmark_category_model.dart'; import '../photo_gallery/photo_gallery_screen.dart'; import '../recipes/bookmark_dialog.dart'; import '../bookmarks/bookmark_category_recipes_screen.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 = []; List _selectedVideos = []; List _uploadedVideoUrls = []; bool _isUploading = false; bool _isSaving = false; String? _errorMessage; RecipeModel? _currentRecipe; // Track current recipe state for immediate UI updates // Inline editing state for view mode bool _isEditingTitle = false; bool _isEditingDescription = false; String _originalTitle = ''; String _originalDescription = ''; int _originalRating = 0; bool _originalIsFavourite = false; List _originalImageUrls = []; List _originalVideoUrls = []; List _originalTags = []; List _bookmarkCategories = []; List _originalBookmarkCategories = []; bool _isScrolledToBottom = false; final ScrollController _scrollController = ScrollController(); Timer? _scrollDebounceTimer; final ImagePicker _imagePicker = ImagePicker(); RecipeService? _recipeService; @override void initState() { super.initState(); _initializeService(); if (widget.recipe != null) { _loadRecipeData(); if (widget.viewMode) { _loadBookmarkCategories(); } } // Listen to scroll position for delete button visibility if (widget.viewMode) { _scrollController.addListener(_onScroll); // Check initial scroll position after first frame WidgetsBinding.instance.addPostFrameCallback((_) { _onScroll(); }); } } Future _loadBookmarkCategories() async { if (widget.recipe == null || _recipeService == null) return; try { final categories = await _recipeService!.getCategoriesForRecipe(widget.recipe!.id); if (mounted) { setState(() { _bookmarkCategories = categories; if (_originalBookmarkCategories.isEmpty) { _originalBookmarkCategories = List.from(categories); } }); } } catch (e) { Logger.error('Failed to load bookmark categories', e); } } Future _showBookmarkDialog() async { if (widget.recipe == null) return; final result = await showDialog( context: context, builder: (context) => BookmarkDialog( recipeId: widget.recipe!.id, currentCategories: _bookmarkCategories, ), ); // Reload bookmark categories after dialog closes if (mounted && widget.viewMode) { await _loadBookmarkCategories(); } } void _navigateToBookmark() { if (widget.recipe == null) return; // If recipe has bookmarks, navigate to the first one if (_bookmarkCategories.isNotEmpty) { Navigator.push( context, MaterialPageRoute( builder: (context) => BookmarkCategoryRecipesScreen( category: _bookmarkCategories.first, ), ), ).then((_) { // Reload bookmark categories when returning if (mounted && widget.viewMode) { _loadBookmarkCategories(); } }); } else { // If no bookmarks, show dialog to add bookmarks _showBookmarkDialog(); } } void _onScroll() { if (!_scrollController.hasClients) return; // Cancel any pending debounce timer _scrollDebounceTimer?.cancel(); // Debounce the scroll update to prevent flickering _scrollDebounceTimer = Timer(const Duration(milliseconds: 100), () { if (!_scrollController.hasClients || !mounted) return; final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.position.pixels; // If content doesn't scroll (maxScroll is 0 or very small), show delete button if (maxScroll <= 50) { if (!_isScrolledToBottom) { setState(() { _isScrolledToBottom = true; }); } return; } // Use hysteresis to prevent flickering: // - When button is hidden: show it when within 150px of bottom (easier to trigger) // - When button is visible: hide it when more than 50px from bottom (harder to hide) final distanceFromBottom = maxScroll - currentScroll; final isAtBottom = _isScrolledToBottom ? distanceFromBottom <= 50 // Hide when more than 50px away : distanceFromBottom <= 150; // Show when within 150px if (isAtBottom != _isScrolledToBottom) { setState(() { _isScrolledToBottom = isAtBottom; }); } }); } 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!; _currentRecipe = recipe; // Store current recipe state _titleController.text = recipe.title; _descriptionController.text = recipe.description ?? ''; _tagsController.text = recipe.tags.join(', '); _rating = recipe.rating; _isFavourite = recipe.isFavourite; _uploadedImageUrls = List.from(recipe.imageUrls); _uploadedVideoUrls = List.from(recipe.videoUrls); // Store original values for change detection in view mode if (widget.viewMode) { _originalTitle = recipe.title; _originalDescription = recipe.description ?? ''; _originalRating = recipe.rating; _originalIsFavourite = recipe.isFavourite; _originalImageUrls = List.from(recipe.imageUrls); _originalVideoUrls = List.from(recipe.videoUrls); _originalTags = List.from(recipe.tags); } } bool _hasUnsavedChanges() { if (!widget.viewMode || widget.recipe == null) return false; final currentTags = _recipe?.tags ?? []; // Check if bookmark categories have changed final bookmarkCategoriesChanged = !_bookmarkCategoriesEqual( _bookmarkCategories, _originalBookmarkCategories, ); return _titleController.text.trim() != _originalTitle || _descriptionController.text.trim() != _originalDescription || _rating != _originalRating || _isFavourite != _originalIsFavourite || !_listsEqual(_uploadedImageUrls, _originalImageUrls) || !_listsEqual(_uploadedVideoUrls, _originalVideoUrls) || !_listsEqual(currentTags, _originalTags) || bookmarkCategoriesChanged; } bool _bookmarkCategoriesEqual(List list1, List list2) { if (list1.length != list2.length) return false; final ids1 = list1.map((c) => c.id).toSet(); final ids2 = list2.map((c) => c.id).toSet(); return ids1.length == ids2.length && ids1.every((id) => ids2.contains(id)); } bool _listsEqual(List list1, List list2) { if (list1.length != list2.length) return false; for (int i = 0; i < list1.length; i++) { if (list1[i] != list2[i]) return false; } return true; } Future _saveInlineChanges() async { if (_recipeService == null || widget.recipe == null) return; // Upload any pending images and videos first if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) { setState(() { _isSaving = true; }); await _uploadImages(); if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) { setState(() { _isSaving = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Some media files failed to upload. Please try again.'), backgroundColor: Colors.red, ), ); return; } } setState(() { _isSaving = true; }); try { final currentTags = _recipe?.tags ?? widget.recipe!.tags; final updatedRecipe = RecipeModel( id: widget.recipe!.id, title: _titleController.text.trim(), description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), tags: currentTags, rating: _rating, isFavourite: _isFavourite, imageUrls: _uploadedImageUrls, videoUrls: _uploadedVideoUrls, createdAt: widget.recipe!.createdAt, updatedAt: DateTime.now().millisecondsSinceEpoch, isDeleted: widget.recipe!.isDeleted, nostrEventId: widget.recipe!.nostrEventId, ); await _recipeService!.updateRecipe(updatedRecipe); // Reload bookmark categories to get latest state await _loadBookmarkCategories(); // Update original values _originalTitle = updatedRecipe.title; _originalDescription = updatedRecipe.description ?? ''; _originalRating = updatedRecipe.rating; _originalIsFavourite = updatedRecipe.isFavourite; _originalImageUrls = List.from(updatedRecipe.imageUrls); _originalVideoUrls = List.from(updatedRecipe.videoUrls); _originalTags = List.from(updatedRecipe.tags); _originalBookmarkCategories = List.from(_bookmarkCategories); setState(() { _currentRecipe = updatedRecipe; _isEditingTitle = false; _isEditingDescription = false; _isSaving = false; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Recipe updated successfully'), backgroundColor: Colors.green, duration: Duration(seconds: 1), ), ); } } catch (e) { Logger.error('Failed to save recipe changes', e); if (mounted) { setState(() { _isSaving = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to save changes: $e'), backgroundColor: Colors.red, ), ); } } } Future _showRatingDialog() async { await showGeneralDialog( context: context, barrierColor: Colors.black.withOpacity(0.2), barrierDismissible: true, barrierLabel: 'Select rating', transitionDuration: const Duration(milliseconds: 300), pageBuilder: (context, animation, secondaryAnimation) { return Container(); }, transitionBuilder: (context, animation, secondaryAnimation, child) { // Slide from right to left (coming from the star position) final slideAnimation = Tween( begin: const Offset(1.0, 0.0), // Start from right (off-screen) end: Offset.zero, // End at final position ).animate(CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, )); // Scale animation to make it feel like it's expanding from the star final scaleAnimation = Tween( begin: 0.7, end: 1.0, ).animate(CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, )); // Fade animation final fadeAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: animation, curve: Curves.easeOut, )); return SlideTransition( position: slideAnimation, child: FadeTransition( opacity: fadeAnimation, child: ScaleTransition( scale: scaleAnimation, child: Align( alignment: Alignment.centerRight, child: Container( margin: const EdgeInsets.only(right: 16), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), blurRadius: 15, spreadRadius: 1, offset: const Offset(0, 2), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (index) { final rating = index + 1; final isFilled = rating <= _rating; return GestureDetector( onTap: () { Navigator.pop(context, rating); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 3), child: Icon( isFilled ? Icons.star : Icons.star_border, size: 32, color: isFilled ? Colors.amber : Colors.grey[400], ), ), ); }), ), ), ), ), ), ); }, ).then((result) { if (result != null && result != _rating) { setState(() { _rating = result; }); } }); } void _undoChanges() { if (widget.recipe == null) return; setState(() { _titleController.text = _originalTitle; _descriptionController.text = _originalDescription; _rating = _originalRating; _isFavourite = _originalIsFavourite; _uploadedImageUrls = List.from(_originalImageUrls); _uploadedVideoUrls = List.from(_originalVideoUrls); _selectedImages.clear(); _selectedVideos.clear(); _isEditingTitle = false; _isEditingDescription = false; // Restore original tags if (_currentRecipe != null) { _currentRecipe = _currentRecipe!.copyWith(tags: List.from(_originalTags)); } _tagsController.text = _originalTags.join(', '); // Restore original bookmark categories _bookmarkCategories = List.from(_originalBookmarkCategories); }); } /// Gets the current recipe (either from widget or _currentRecipe state) RecipeModel? get _recipe => _currentRecipe ?? widget.recipe; @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); _tagsController.dispose(); _scrollController.dispose(); _scrollDebounceTimer?.cancel(); super.dispose(); } Future _pickMedia() async { // First, show dialog to choose between photos and videos final String? mediaType = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('Select Media Type'), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.photo), title: const Text('Photos'), onTap: () => Navigator.of(context).pop('photo'), ), ListTile( leading: const Icon(Icons.videocam), title: const Text('Videos'), onTap: () => Navigator.of(context).pop('video'), ), ], ), ); }, ); if (mediaType == null) return; if (mediaType == 'photo') { await _pickImages(); } else if (mediaType == 'video') { await _pickVideos(); } } Future _pickImages() async { // Show dialog to choose between camera and gallery final ImageSource? source = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('Select Image Source'), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.camera_alt), title: const Text('Take Photo'), onTap: () => Navigator.of(context).pop(ImageSource.camera), ), ListTile( leading: const Icon(Icons.photo_library), title: const Text('Choose from Gallery'), onTap: () => Navigator.of(context).pop(ImageSource.gallery), ), ], ), ); }, ); if (source == null) return; try { List pickedFiles = []; if (source == ImageSource.camera) { // Take a single photo final XFile? pickedFile = await _imagePicker.pickImage(source: source); if (pickedFile != null) { pickedFiles = [pickedFile]; } } else { // Pick multiple images from gallery 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 _pickVideos() async { // Show dialog to choose between camera and gallery final ImageSource? source = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('Select Video Source'), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.videocam), title: const Text('Record Video'), onTap: () => Navigator.of(context).pop(ImageSource.camera), ), ListTile( leading: const Icon(Icons.video_library), title: const Text('Choose from Gallery'), onTap: () => Navigator.of(context).pop(ImageSource.gallery), ), ], ), ); }, ); if (source == null) return; try { List pickedFiles = []; if (source == ImageSource.camera) { // Record a single video final XFile? pickedFile = await _imagePicker.pickVideo(source: source); if (pickedFile != null) { pickedFiles = [pickedFile]; } } else { // Pick videos from gallery - pickMedia returns a single XFile or null // For multiple videos, we'll need to call it multiple times or use a different approach // For now, let's use pickVideo with gallery source which should work final XFile? pickedFile = await _imagePicker.pickVideo(source: ImageSource.gallery); if (pickedFile != null) { pickedFiles = [pickedFile]; } } if (pickedFiles.isEmpty) return; final newVideos = pickedFiles.map((file) => File(file.path)).toList(); setState(() { _selectedVideos.addAll(newVideos); _isUploading = true; }); // Auto-upload videos immediately await _uploadImages(); } catch (e) { Logger.error('Failed to pick videos', e); if (mounted) { setState(() { _isUploading = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to pick videos: $e'), backgroundColor: Colors.red, ), ); } } } Future _uploadImages() async { if (_selectedImages.isEmpty && _selectedVideos.isEmpty) return; final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Media service not available'), backgroundColor: Colors.orange, ), ); } return; } setState(() { _isUploading = true; _errorMessage = null; }); try { final List uploadedImageUrls = []; final List uploadedVideoUrls = []; final List failedImages = []; final List failedVideos = []; // Upload images for (final imageFile in _selectedImages) { try { final uploadResult = await mediaService.uploadImage(imageFile); // uploadResult contains 'id' or 'hash' and 'url' final imageUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['id'] as String? ?? uploadResult['hash'] as String? ?? ''); uploadedImageUrls.add(imageUrl); Logger.info('Image uploaded: $imageUrl'); } catch (e) { Logger.warning('Failed to upload image ${imageFile.path}: $e'); failedImages.add(imageFile); // Show user-friendly error message final errorMessage = _getUploadErrorMessage(e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessage), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } // Upload videos for (final videoFile in _selectedVideos) { try { final uploadResult = await mediaService.uploadVideo(videoFile); // uploadResult contains 'id' or 'hash' and 'url' final videoUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['id'] as String? ?? uploadResult['hash'] as String? ?? ''); uploadedVideoUrls.add(videoUrl); Logger.info('Video uploaded: $videoUrl'); } catch (e) { Logger.warning('Failed to upload video ${videoFile.path}: $e'); failedVideos.add(videoFile); // Show user-friendly error message final errorMessage = _getUploadErrorMessage(e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to upload video: $errorMessage'), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } setState(() { _uploadedImageUrls.addAll(uploadedImageUrls); _uploadedVideoUrls.addAll(uploadedVideoUrls); // Keep only failed files in the selected list for retry _selectedImages = failedImages; _selectedVideos = failedVideos; _isUploading = false; }); // In view mode, trigger rebuild to show save button if changes detected if (widget.viewMode) { setState(() {}); } if (mounted) { final totalUploaded = uploadedImageUrls.length + uploadedVideoUrls.length; final totalFailed = failedImages.length + failedVideos.length; if (totalUploaded > 0 && totalFailed == 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$totalUploaded media file(s) uploaded successfully'), backgroundColor: Colors.green, duration: const Duration(seconds: 1), ), ); } else if (totalUploaded > 0 && totalFailed > 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$totalUploaded file(s) uploaded, $totalFailed failed'), backgroundColor: Colors.orange, duration: const Duration(seconds: 2), ), ); } } } catch (e) { Logger.error('Failed to upload images', e); if (mounted) { setState(() { _isUploading = false; _errorMessage = _getUploadErrorMessage(e); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(_errorMessage!), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } /// Gets a user-friendly error message for upload failures. String _getUploadErrorMessage(dynamic error) { final errorString = error.toString().toLowerCase(); // Check for connection errors (host lookup, DNS, network issues) if (errorString.contains('failed host lookup') || errorString.contains('connection error') || errorString.contains('network') || errorString.contains('dns') || errorString.contains('unreachable') || errorString.contains('connection errored')) { return 'Upload failed: Cannot connect to media server. Please check your media server settings (URL and configuration).'; } // Check for authentication errors if (errorString.contains('unauthorized') || errorString.contains('401') || errorString.contains('authentication')) { return 'Upload failed: Authentication error. Please check your media server credentials.'; } // Check for server errors if (errorString.contains('500') || errorString.contains('502') || errorString.contains('503') || errorString.contains('server error')) { return 'Upload failed: Media server error. Please try again later.'; } // Generic error message return 'Upload failed. Please check your media server settings and try again.'; } 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 and videos first and wait for completion if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) { setState(() { _isSaving = true; _errorMessage = null; }); await _uploadImages(); // Check if upload failed if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) { setState(() { _isSaving = false; _errorMessage = 'Some media files 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, videoUrls: _uploadedVideoUrls, ); 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, duration: const Duration(seconds: 1), ), ); // 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); }); } void _removeVideo(int index) { setState(() { _uploadedVideoUrls.removeAt(index); }); } Future _toggleFavourite() async { if (widget.recipe == null) return; if (widget.viewMode) { // In view mode, just toggle the state - save button will handle saving setState(() { _isFavourite = !_isFavourite; }); } else { // In edit mode, save immediately if (_recipeService == null) return; try { setState(() { _isFavourite = !_isFavourite; }); final updatedRecipe = widget.recipe!.copyWith(isFavourite: _isFavourite); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( _isFavourite ? 'Added to favourites' : 'Removed from favourites', ), duration: const Duration(seconds: 1), ), ); } } catch (e) { if (mounted) { setState(() { _isFavourite = !_isFavourite; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error updating favourite: $e')), ); } } } } Future _removeTag(String tag) async { if (_recipe == null) return; final currentRecipe = _currentRecipe ?? widget.recipe; if (currentRecipe == null) return; if (widget.viewMode) { // In view mode, just update the state - save button will handle saving final updatedTags = List.from(currentRecipe.tags)..remove(tag); setState(() { _currentRecipe = currentRecipe.copyWith(tags: updatedTags); _tagsController.text = updatedTags.join(', '); }); } else { // In edit mode, save immediately if (_recipeService == null) return; try { final updatedTags = List.from(currentRecipe.tags)..remove(tag); final updatedRecipe = currentRecipe.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { _currentRecipe = updatedRecipe; _tagsController.text = updatedTags.join(', '); }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Tag removed'), duration: Duration(seconds: 1), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error removing tag: $e')), ); } } } } Future _showAddTagDialog() async { final tagController = TextEditingController(); try { // Get all existing tags from all recipes final allRecipes = await _recipeService?.getAllRecipes() ?? []; final allTags = {}; for (final recipe in allRecipes) { allTags.addAll(recipe.tags); } final existingTags = allTags.toList()..sort(); final result = await showDialog>( context: context, builder: (context) => _AddTagDialog( tagController: tagController, existingTags: existingTags, currentTags: _recipe?.tags ?? [], ), ); WidgetsBinding.instance.addPostFrameCallback((_) { tagController.dispose(); }); if (mounted && result != null && result.isNotEmpty) { await _addTags(result); } } catch (e) { WidgetsBinding.instance.addPostFrameCallback((_) { tagController.dispose(); }); } } Future _addTags(List tags) async { if (_recipe == null) return; final currentRecipe = _currentRecipe ?? widget.recipe; if (currentRecipe == null) return; final trimmedTags = tags.map((tag) => tag.trim()).where((tag) => tag.isNotEmpty).toList(); if (trimmedTags.isEmpty) return; // Filter out tags that already exist final newTags = trimmedTags.where((tag) => !currentRecipe.tags.contains(tag)).toList(); if (newTags.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('All tags already exist'), duration: Duration(seconds: 1), ), ); } return; } if (widget.viewMode) { // In view mode, just update the state - save button will handle saving final updatedTags = List.from(currentRecipe.tags)..addAll(newTags); setState(() { _currentRecipe = currentRecipe.copyWith(tags: updatedTags); _tagsController.text = updatedTags.join(', '); }); } else { // In edit mode, save immediately if (_recipeService == null) return; try { final updatedTags = List.from(currentRecipe.tags)..addAll(newTags); final updatedRecipe = currentRecipe.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { _currentRecipe = updatedRecipe; _tagsController.text = updatedTags.join(', '); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${newTags.length} tag${newTags.length > 1 ? 's' : ''} added'), duration: const Duration(seconds: 1), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error adding tags: $e')), ); } } } } Future _deleteRecipe() async { if (_recipeService == null || widget.recipe == null) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Recipe'), content: const Text('Are you sure you want to delete this recipe?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom( backgroundColor: Colors.red, ), child: const Text('Delete'), ), ], ), ); if (confirmed == true) { try { await _recipeService!.deleteRecipe(widget.recipe!.id); if (mounted) { Navigator.pop(context, true); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error deleting recipe: $e')), ); } } } } void _editRecipe() { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => AddRecipeScreen(recipe: widget.recipe), ), ); } Color _getRatingColor(int rating) { if (rating >= 4) return Colors.green; if (rating >= 2) return Colors.orange; return Colors.red; } Color _parseColor(String hexColor) { try { return Color(int.parse(hexColor.replaceAll('#', '0xFF'))); } catch (e) { return Colors.blue; } } String _formatDate(int timestamp) { final date = DateTime.fromMillisecondsSinceEpoch(timestamp); return '${date.day}/${date.month}/${date.year}'; } @override Widget build(BuildContext context) { // View mode: Use foodstrApp design if (widget.viewMode) { return _buildViewMode(context); } // Edit/Add mode: Use form design return Scaffold( appBar: AppBar( title: Text( widget.recipe != null ? 'Edit Recipe' : 'Add Recipe', ), ), 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 && _selectedImages.isNotEmpty) 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), // Videos section Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Videos', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), if (!widget.viewMode) ElevatedButton.icon( onPressed: _isUploading ? null : _pickVideos, icon: const Icon(Icons.videocam), label: const Text('Add Videos'), ), ], ), const SizedBox(height: 8), if (_isUploading && _selectedVideos.isNotEmpty) const Padding( padding: EdgeInsets.all(8), child: Row( children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 8), Text('Uploading videos...'), ], ), ), if (_uploadedVideoUrls.isNotEmpty) Wrap( spacing: 8, runSpacing: 8, children: _uploadedVideoUrls.asMap().entries.map((entry) { final index = entry.key; // Calculate the actual index in the gallery (images first, then videos) final galleryIndex = _uploadedImageUrls.length + index; return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: _uploadedImageUrls, videoUrls: _uploadedVideoUrls, initialIndex: galleryIndex, ), ), ); }, child: Stack( children: [ Container( width: 100, height: 100, decoration: BoxDecoration( border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8), color: Colors.black87, ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack( alignment: Alignment.center, children: [ const Icon(Icons.play_circle_filled, size: 40, color: Colors.white70), Positioned( bottom: 4, left: 4, right: 4, child: Text( 'MP4', style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), ), ], ), ), ), if (!widget.viewMode) Positioned( top: 4, right: 4, child: IconButton( icon: const Icon(Icons.close, size: 20), color: Colors.red, onPressed: () => _removeVideo(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 MediaService for authenticated access. Widget _buildImagePreview(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { return Image.network(imageUrl, fit: BoxFit.cover); } // Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original) final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); String? assetId; if (assetIdMatch != null) { assetId = assetIdMatch.group(1); } else { // For Blossom URLs, use the full URL or extract hash // Blossom URLs are typically: {baseUrl}/{hash} or just {hash} assetId = imageUrl; } if (assetId != null) { // Use MediaService to fetch image with proper authentication return FutureBuilder( future: mediaService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade800 : 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: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade800 : Colors.grey.shade200, child: Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ), ); }, ); } Widget _buildViewMode(BuildContext context) { // Combine images and videos, show up to 3 media items final allMedia = <({String url, bool isVideo})>[]; for (final url in _uploadedImageUrls.take(3)) { allMedia.add((url: url, isVideo: false)); } final remainingSlots = 3 - allMedia.length; if (remainingSlots > 0) { for (final url in _uploadedVideoUrls.take(remainingSlots)) { allMedia.add((url: url, isVideo: true)); } } final mediaToShow = allMedia.take(3).toList(); return Scaffold( floatingActionButton: _hasUnsavedChanges() ? Row( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton( onPressed: _isSaving ? null : _undoChanges, backgroundColor: Colors.grey[700], foregroundColor: Colors.white, heroTag: 'undo', child: const Icon(Icons.undo), ), const SizedBox(width: 12), FloatingActionButton.extended( onPressed: _isSaving ? null : _saveInlineChanges, backgroundColor: Colors.green[700], foregroundColor: Colors.white, heroTag: 'save', icon: _isSaving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : const Icon(Icons.save), label: const Text('Save'), ), ], ) : null, bottomNavigationBar: widget.recipe != null ? Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, -2), ), ], ), child: SafeArea( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Created date Text( 'Created: ${_formatDate(widget.recipe!.createdAt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), // Delete button (only show when scrolled to bottom) if (_isScrolledToBottom) TextButton.icon( onPressed: _deleteRecipe, icon: const Icon(Icons.delete, size: 16), label: const Text( 'Delete', style: TextStyle(fontSize: 12), ), style: TextButton.styleFrom( foregroundColor: Colors.red[700], padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ], ), ), ) : null, body: CustomScrollView( controller: _scrollController, slivers: [ // App bar with media SliverAppBar( expandedHeight: 300, pinned: true, leading: Container( margin: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.5) : Colors.white.withValues(alpha: 0.8), shape: BoxShape.circle, ), child: IconButton( icon: Icon( Icons.arrow_back, color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black87, ), onPressed: () => Navigator.pop(context), padding: EdgeInsets.zero, ), ), flexibleSpace: FlexibleSpaceBar( background: Stack( children: [ RepaintBoundary( child: mediaToShow.isEmpty ? Container( color: Colors.grey[200], child: const Center( child: Icon( Icons.restaurant_menu, size: 64, color: Colors.grey, ), ), ) : _buildTiledPhotoLayout(mediaToShow), ), // Floating Add Media button Positioned( bottom: 16, right: 16, child: FloatingActionButton( onPressed: _pickMedia, backgroundColor: Colors.green[700], foregroundColor: Colors.white, heroTag: 'add_media', child: const Icon(Icons.add_photo_alternate), ), ), ], ), ), actions: [], ), // Content SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Tags section Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Expanded( child: Wrap( spacing: 8, runSpacing: 8, children: [ ...(_recipe?.tags ?? []).map((tag) { return Chip( label: Text( tag, style: const TextStyle(fontSize: 12), ), onDeleted: () => _removeTag(tag), backgroundColor: Theme.of(context).colorScheme.primaryContainer, labelStyle: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 12, ), deleteIconColor: Theme.of(context).colorScheme.onPrimaryContainer, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); }), // Add tag button ActionChip( avatar: const Icon(Icons.add, size: 16), label: const Text( 'Add Tag', style: TextStyle(fontSize: 12), ), onPressed: _showAddTagDialog, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ], ), ), ], ), ), // Bookmark categories section if (widget.viewMode && _bookmarkCategories.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.bookmark, size: 18, color: Colors.grey[600], ), const SizedBox(width: 6), Text( 'Bookmarked in:', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], fontWeight: FontWeight.w500, ), ), ], ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: _bookmarkCategories.map((category) { return InkWell( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => BookmarkCategoryRecipesScreen( category: category, ), ), ).then((_) { // Reload bookmark categories when returning if (mounted && widget.viewMode) { _loadBookmarkCategories(); } }); }, borderRadius: BorderRadius.circular(16), child: Chip( label: Text( category.name, style: const TextStyle(fontSize: 12), ), avatar: category.color != null ? Container( width: 16, height: 16, decoration: BoxDecoration( color: _parseColor(category.color!), shape: BoxShape.circle, ), ) : const Icon(Icons.bookmark, size: 14), backgroundColor: Theme.of(context).colorScheme.secondaryContainer, labelStyle: TextStyle( color: Theme.of(context).colorScheme.onSecondaryContainer, fontSize: 12, ), ), ); }).toList(), ), ], ), ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title and Rating Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _isEditingTitle ? TextField( controller: _titleController, autofocus: true, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), decoration: InputDecoration( border: InputBorder.none, suffixIcon: IconButton( icon: const Icon(Icons.check), onPressed: () { setState(() { _isEditingTitle = false; }); }, ), ), onSubmitted: (_) { setState(() { _isEditingTitle = false; }); }, ) : Row( children: [ Expanded( child: Text( _titleController.text, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ), IconButton( icon: const Icon(Icons.edit, size: 20), onPressed: () { setState(() { _isEditingTitle = true; }); }, padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: 'Edit title', ), ], ), ), const SizedBox(width: 16), IconButton( icon: Icon( _isFavourite ? Icons.favorite : Icons.favorite_border, color: _isFavourite ? Colors.red : Colors.grey[600], ), onPressed: _toggleFavourite, padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: _isFavourite ? 'Remove from favourites' : 'Add to favourites', ), const SizedBox(width: 8), // Bookmark button IconButton( icon: Icon( _bookmarkCategories.isNotEmpty ? Icons.bookmark : Icons.bookmark_border, color: _bookmarkCategories.isNotEmpty ? Colors.blue : Colors.grey[600], ), onPressed: _navigateToBookmark, padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: _bookmarkCategories.isNotEmpty ? 'View bookmark' : 'Add to bookmarks', ), const SizedBox(width: 8), GestureDetector( onTap: _showRatingDialog, child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), decoration: BoxDecoration( color: _getRatingColor(_rating).withOpacity(0.1), borderRadius: BorderRadius.circular(24), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.star, color: Colors.amber, ), const SizedBox(width: 6), Text( _rating.toString(), style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.amber, ), ), ], ), ), ), ], ), const SizedBox(height: 24), // Description Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Description', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (!_isEditingDescription) IconButton( icon: const Icon(Icons.edit, size: 18), onPressed: () { setState(() { _isEditingDescription = true; }); }, padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: 'Edit description', ), ], ), const SizedBox(height: 8), _isEditingDescription ? TextField( controller: _descriptionController, autofocus: true, maxLines: null, style: Theme.of(context).textTheme.bodyLarge, decoration: InputDecoration( border: InputBorder.none, suffixIcon: IconButton( icon: const Icon(Icons.check), onPressed: () { setState(() { _isEditingDescription = false; }); }, ), ), onSubmitted: (_) { setState(() { _isEditingDescription = false; }); }, ) : Text( _descriptionController.text.isEmpty ? 'No description' : _descriptionController.text, style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontStyle: _descriptionController.text.isEmpty ? FontStyle.italic : FontStyle.normal, color: _descriptionController.text.isEmpty ? Colors.grey[600] : null, ), ), ], ), ), ], ), const SizedBox(height: 24), // Remaining media (smaller) - images and videos beyond the first 3 RepaintBoundary( child: Builder( builder: (context) { final remainingImages = _uploadedImageUrls.length > 3 ? _uploadedImageUrls.skip(3).toList() : []; final remainingVideos = _uploadedImageUrls.length >= 3 ? _uploadedVideoUrls : (_uploadedImageUrls.length + _uploadedVideoUrls.length > 3 ? _uploadedVideoUrls.skip(3 - _uploadedImageUrls.length).toList() : []); final totalRemaining = remainingImages.length + remainingVideos.length; if (totalRemaining == 0) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'More Media ($totalRemaining)', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), SizedBox( height: 120, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: totalRemaining, itemBuilder: (context, index) { final isVideo = index >= remainingImages.length; final actualIndex = isVideo ? _uploadedImageUrls.length + (index - remainingImages.length) : 3 + index; final mediaUrl = isVideo ? remainingVideos[index - remainingImages.length] : remainingImages[index]; return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: _uploadedImageUrls, videoUrls: _uploadedVideoUrls, initialIndex: actualIndex, ), ), ); }, child: Container( width: 120, margin: EdgeInsets.only( right: index < totalRemaining - 1 ? 12 : 0, ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: isVideo ? _VideoThumbnailPreview(videoUrl: mediaUrl) : _buildImagePreview(mediaUrl), ), ), ); }, ), ), const SizedBox(height: 24), ], ); }, ), ), // Add bottom padding to account for fixed bottom bar if (widget.recipe != null) const SizedBox(height: 60), ], ), ), ], ), ), ], ), ); } Widget _buildTiledPhotoLayout(List<({String url, bool isVideo})> mediaToShow) { final mediaCount = mediaToShow.length; return LayoutBuilder( builder: (context, constraints) { return SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, child: Row( children: [ if (mediaCount == 1) // Single media: full width Expanded( child: _buildMediaTile(mediaToShow[0], 0, showBorder: false), ) else if (mediaCount == 2) // Two media: split 50/50 ...mediaToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( child: _buildMediaTile( entry.value, index, showBorder: index == 0, ), ); }) else // Three media: one large on left, two stacked on right Expanded( flex: 2, child: _buildMediaTile( mediaToShow[0], 0, showBorder: false, ), ), if (mediaCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( child: _buildMediaTile( mediaToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( child: _buildMediaTile( mediaToShow[2], 2, showBorder: false, ), ), ], ), ), ], ], ), ); }, ); } Widget _buildMediaTile(({String url, bool isVideo}) media, int index, {required bool showBorder}) { // Calculate the actual index in the gallery (accounting for images first, then videos) final actualIndex = media.isVideo ? _uploadedImageUrls.length + _uploadedVideoUrls.indexOf(media.url) : _uploadedImageUrls.indexOf(media.url); return RepaintBoundary( child: GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: _uploadedImageUrls, videoUrls: _uploadedVideoUrls, initialIndex: actualIndex, ), ), ); }, child: Container( decoration: showBorder ? BoxDecoration( border: Border( right: BorderSide( color: Colors.white.withValues(alpha: 0.3), width: 2, ), ), ) : null, child: media.isVideo ? _VideoThumbnailPreview(videoUrl: media.url) : _buildImagePreviewForTile(media.url), ), ), ); } Widget _buildVideoThumbnailForTile(String videoUrl) { return Stack( fit: StackFit.expand, children: [ Container( color: Colors.black87, child: const Center( child: Icon( Icons.play_circle_filled, size: 60, color: Colors.white70, ), ), ), Positioned( bottom: 8, left: 8, right: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(4), ), child: const Text( 'MP4', style: TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), ), ), ], ); } /// Builds an image preview specifically for tiled layouts (ensures proper fit). Widget _buildImagePreviewForTile(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { return Image.network(imageUrl, fit: BoxFit.cover); } // Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original) final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); String? assetId; if (assetIdMatch != null) { assetId = assetIdMatch.group(1); } else { // For Blossom URLs, use the full URL or extract hash assetId = imageUrl; } if (assetId != null) { return FutureBuilder( future: mediaService.fetchImageBytes(assetId, isThumbnail: true), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade800 : Colors.grey.shade200, child: const Center(child: CircularProgressIndicator()), ); } if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { return Container( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade800 : Colors.grey.shade200, child: Icon( Icons.image_not_supported, size: 32, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade400 : Colors.grey, ), ); } return Image.memory( snapshot.data!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, ); }, ); } return Image.network( imageUrl, fit: BoxFit.cover, width: double.infinity, height: double.infinity, errorBuilder: (context, error, stackTrace) { return Container( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade800 : Colors.grey.shade200, child: Icon( Icons.image_not_supported, size: 32, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade400 : Colors.grey, ), ); }, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Container( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade800 : Colors.grey.shade200, child: Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ), ); }, ); } Widget _buildStatusBarButton({ required IconData icon, required String label, required Color color, required VoidCallback onTap, }) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: color, size: 20, ), const SizedBox(height: 2), Text( label, style: TextStyle( fontSize: 11, color: color, fontWeight: FontWeight.w500, ), ), ], ), ), ); } } /// Dialog for adding tags with existing tags selection class _AddTagDialog extends StatefulWidget { final TextEditingController tagController; final List existingTags; final List currentTags; const _AddTagDialog({ required this.tagController, required this.existingTags, required this.currentTags, }); @override State<_AddTagDialog> createState() => _AddTagDialogState(); } class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderStateMixin { late List _filteredTags; late ScrollController _scrollController; late AnimationController _animationController; late Animation _animation; bool _isUserScrolling = false; final Set _selectedTags = {}; @override void initState() { super.initState(); _filteredTags = List.from(widget.existingTags); widget.tagController.addListener(_filterTags); _scrollController = ScrollController(); _animationController = AnimationController( vsync: this, duration: const Duration(seconds: 8), // Slower animation ); _animation = Tween(begin: 0, end: 0.3).animate( // Only scroll 30% of the way CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); _startAutoScroll(); _animation.addListener(_animateScroll); } @override void dispose() { widget.tagController.removeListener(_filterTags); _animationController.dispose(); _scrollController.dispose(); super.dispose(); } void _startAutoScroll() { if (!_isUserScrolling && _filteredTags.length > 3) { _animationController.repeat(reverse: true); } } void _animateScroll() { if (!_isUserScrolling && _scrollController.hasClients && _filteredTags.length > 3) { final maxScroll = _scrollController.position.maxScrollExtent; if (maxScroll > 0) { // Only scroll a small amount (30% of max scroll) to suggest scrollability final targetScroll = _animation.value * maxScroll; if ((_scrollController.offset - targetScroll).abs() > 1) { _scrollController.jumpTo(targetScroll); } } } } void _filterTags() { final query = widget.tagController.text.toLowerCase(); setState(() { if (query.isEmpty) { _filteredTags = List.from(widget.existingTags); } else { _filteredTags = widget.existingTags .where((tag) => tag.toLowerCase().contains(query)) .toList(); } // Reset scroll position when tags change WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo(0); // Don't restart auto-scroll if user has interacted if (!_isUserScrolling) { _startAutoScroll(); } } }); }); } void _selectTag(String tag) { // Stop auto-scroll permanently when user selects a tag _isUserScrolling = true; _animationController.stop(); setState(() { if (_selectedTags.contains(tag)) { _selectedTags.remove(tag); } else { _selectedTags.add(tag); } }); } List _getTagsToAdd() { final tags = []; // Add selected tags from the list tags.addAll(_selectedTags); // Parse comma-separated tags from text field final textTags = widget.tagController.text .split(',') .map((tag) => tag.trim()) .where((tag) => tag.isNotEmpty) .toList(); tags.addAll(textTags); // Remove duplicates and tags that already exist in the recipe final uniqueTags = tags.toSet().where((tag) => !widget.currentTags.contains(tag)).toList(); return uniqueTags; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return AlertDialog( title: const Text('Add Tag'), contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 24), content: SizedBox( width: MediaQuery.of(context).size.width * 0.95, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Search field TextField( controller: widget.tagController, autofocus: true, decoration: InputDecoration( hintText: 'Enter tags (comma-separated) or select from existing', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.search), ), textCapitalization: TextCapitalization.none, ), const SizedBox(height: 16), // Existing tags scrollable list with fade hint if (_filteredTags.isNotEmpty) ...[ Text( 'Existing Tags', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), SizedBox( height: 50, child: NotificationListener( onNotification: (notification) { // Detect when user manually scrolls - stop auto-scroll permanently if (notification is ScrollStartNotification && notification.dragDetails != null) { _isUserScrolling = true; _animationController.stop(); } return false; }, child: ListView.builder( controller: _scrollController, scrollDirection: Axis.horizontal, itemCount: _filteredTags.length, itemBuilder: (context, index) { final tag = _filteredTags[index]; final isAlreadyAdded = widget.currentTags.contains(tag); final isSelected = _selectedTags.contains(tag); return Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( label: Text( tag, style: TextStyle( color: isSelected ? Colors.white : (isAlreadyAdded ? (isDark ? Colors.white : Colors.black87) : (isDark ? Colors.white : Colors.black87)), fontWeight: isSelected ? FontWeight.w700 : (isAlreadyAdded ? FontWeight.w500 : FontWeight.normal), fontSize: isSelected ? 14 : null, shadows: isSelected ? [ Shadow( color: Colors.black.withValues(alpha: 0.5), offset: const Offset(0, 1), blurRadius: 2, ), ] : null, ), ), selected: isSelected, onSelected: isAlreadyAdded ? null : (selected) { _selectTag(tag); }, selectedColor: theme.primaryColor, checkmarkColor: Colors.white, backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], disabledColor: isDark ? Colors.grey[600] : Colors.grey[100], side: isSelected ? BorderSide( color: Colors.white.withValues(alpha: 0.5), width: 2.5, ) : (isAlreadyAdded ? BorderSide( color: (isDark ? Colors.grey[500]! : Colors.grey[400]!), width: 1, ) : null), elevation: isSelected ? 3 : 0, padding: isSelected ? const EdgeInsets.symmetric(horizontal: 12, vertical: 8) : null, ), ); }, ), ), ), ] else if (widget.tagController.text.isNotEmpty) Text( 'No tags found', style: theme.textTheme.bodySmall?.copyWith( color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () { final tagsToAdd = _getTagsToAdd(); if (tagsToAdd.isNotEmpty) { Navigator.pop(context, tagsToAdd); } else { Navigator.pop(context); } }, child: Text(_selectedTags.isEmpty && widget.tagController.text.trim().isEmpty ? 'Add' : 'Add (${_getTagsToAdd().length})'), ), ], ); } } /// Widget that displays a video thumbnail preview with a few seconds of playback. class _VideoThumbnailPreview extends StatefulWidget { final String videoUrl; const _VideoThumbnailPreview({ required this.videoUrl, }); @override State<_VideoThumbnailPreview> createState() => _VideoThumbnailPreviewState(); } class _VideoThumbnailPreviewState extends State<_VideoThumbnailPreview> { VideoPlayerController? _controller; bool _isInitialized = false; bool _hasError = false; @override void initState() { super.initState(); _extractThumbnail(); } Future _extractThumbnail() async { try { _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); await _controller!.initialize(); if (mounted && _controller != null) { // Seek to first frame and pause await _controller!.seekTo(Duration.zero); _controller!.pause(); setState(() { _isInitialized = true; }); } } catch (e) { Logger.warning('Failed to extract video thumbnail: $e'); if (mounted) { setState(() { _hasError = true; }); } } } @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (_hasError || !_isInitialized || _controller == null) { return Stack( fit: StackFit.expand, children: [ Container( color: Colors.black87, child: const Center( child: Icon( Icons.play_circle_filled, size: 40, color: Colors.white70, ), ), ), // Transparent play button overlay Center( child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow, size: 48, color: Colors.white, ), ), ), ], ); } return Stack( fit: StackFit.expand, children: [ // Video thumbnail (first frame) AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ), // Transparent play button overlay centered Center( child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow, size: 48, color: Colors.white, ), ), ), ], ); } }