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 '../photo_gallery/photo_gallery_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 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!; _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); } /// 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(); super.dispose(); } 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; }); 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 (_recipeService == null || widget.recipe == 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 (_recipeService == null || _recipe == null) return; try { final updatedTags = List.from(_recipe!.tags)..remove(tag); final updatedRecipe = _recipe!.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { _currentRecipe = updatedRecipe; // Update current recipe state immediately _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 { final result = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Add Tag'), content: TextField( controller: tagController, autofocus: true, decoration: const InputDecoration( hintText: 'Enter tag name', border: OutlineInputBorder(), ), textCapitalization: TextCapitalization.none, onSubmitted: (value) { if (value.trim().isNotEmpty) { Navigator.pop(context, value.trim()); } }, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () { final text = tagController.text.trim(); if (text.isNotEmpty) { Navigator.pop(context, text); } }, child: const Text('Add'), ), ], ), ); WidgetsBinding.instance.addPostFrameCallback((_) { tagController.dispose(); }); if (mounted && result != null && result.trim().isNotEmpty) { await _addTag(result.trim()); } } catch (e) { WidgetsBinding.instance.addPostFrameCallback((_) { tagController.dispose(); }); } } Future _addTag(String tag) async { if (_recipeService == null || _recipe == null) return; final trimmedTag = tag.trim(); if (trimmedTag.isEmpty) return; if (_recipe!.tags.contains(trimmedTag)) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Tag already exists'), duration: Duration(seconds: 1), ), ); } return; } try { final updatedTags = List.from(_recipe!.tags)..add(trimmedTag); final updatedRecipe = _recipe!.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { _currentRecipe = updatedRecipe; // Update current recipe state immediately _tagsController.text = updatedTags.join(', '); }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Tag added'), duration: Duration(seconds: 1), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error adding tag: $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; } 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( body: CustomScrollView( 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: mediaToShow.isEmpty ? Container( color: Colors.grey[200], child: const Center( child: Icon( Icons.restaurant_menu, size: 64, color: Colors.grey, ), ), ) : _buildTiledPhotoLayout(mediaToShow), ), actions: [], ), // Content SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Status bar with action buttons Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, border: Border( bottom: BorderSide( color: Theme.of(context).dividerColor.withValues(alpha: 0.1), width: 1, ), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // Edit button _buildStatusBarButton( icon: Icons.edit, label: 'Edit', color: Colors.blue[700]!, onTap: _editRecipe, ), // Delete button _buildStatusBarButton( icon: Icons.delete, label: 'Delete', color: Colors.red[700]!, onTap: _deleteRecipe, ), ], ), ), // Tags section directly under status bar 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, ), ], ), ), ], ), ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title and Rating Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( _titleController.text, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ), 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), Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), decoration: BoxDecoration( color: _getRatingColor(_rating).withValues(alpha: 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: _getRatingColor(_rating), ), ), ], ), ), ], ), const SizedBox(height: 24), // Description if (_descriptionController.text.isNotEmpty) ...[ Text( 'Description', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Text( _descriptionController.text, style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 24), ], // Remaining media (smaller) - images and videos beyond the first 3 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), ], ); }, ), // Created date if (widget.recipe != null) Text( 'Created: ${_formatDate(widget.recipe!.createdAt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), ], ), ), ], ), ), ], ), ); } 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 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, ), ), ], ), ), ); } } /// 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(); _initializeVideo(); } Future _initializeVideo() async { try { _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); await _controller!.initialize(); if (mounted) { setState(() { _isInitialized = true; }); // Play continuously in a loop _controller!.setLooping(true); _controller!.play(); } } catch (e) { Logger.warning('Failed to initialize 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, ), ), ), ], ); } return Stack( fit: StackFit.expand, children: [ AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ), // Play icon overlay Positioned( top: 4, right: 4, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle, ), child: const Icon( Icons.play_circle_filled, size: 24, color: Colors.white, ), ), ), ], ); } }