import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/service_locator.dart'; import '../../core/logger.dart'; import '../../data/recipes/recipe_service.dart'; import '../../data/recipes/models/recipe_model.dart'; import '../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 = []; bool _isUploading = false; bool _isSaving = false; String? _errorMessage; final ImagePicker _imagePicker = ImagePicker(); RecipeService? _recipeService; @override void initState() { super.initState(); _initializeService(); if (widget.recipe != null) { _loadRecipeData(); } } Future _initializeService() async { try { // Use the shared RecipeService from ServiceLocator instead of creating a new instance // This ensures we're using the same instance that SessionService manages _recipeService = ServiceLocator.instance.recipeService; if (_recipeService == null) { throw Exception('RecipeService not available in ServiceLocator'); } // The database should already be initialized by SessionService on login // If not initialized, we'll get an error when trying to use it } catch (e) { Logger.error('Failed to initialize RecipeService', e); if (mounted) { setState(() { _errorMessage = 'Failed to initialize recipe service: $e'; }); } } } void _loadRecipeData() { if (widget.recipe == null) return; final recipe = widget.recipe!; _titleController.text = recipe.title; _descriptionController.text = recipe.description ?? ''; _tagsController.text = recipe.tags.join(', '); _rating = recipe.rating; _isFavourite = recipe.isFavourite; _uploadedImageUrls = List.from(recipe.imageUrls); } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); _tagsController.dispose(); super.dispose(); } Future _pickImages() async { // 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 _uploadImages() async { if (_selectedImages.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 uploadedUrls = []; 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? ?? ''); uploadedUrls.add(imageUrl); Logger.info('Image uploaded: $imageUrl'); } catch (e) { Logger.warning('Failed to upload image ${imageFile.path}: $e'); // Continue with other images } } setState(() { _uploadedImageUrls.addAll(uploadedUrls); _selectedImages.clear(); _isUploading = false; }); if (mounted && uploadedUrls.isNotEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${uploadedUrls.length} image(s) uploaded successfully'), backgroundColor: Colors.green, duration: const Duration(seconds: 1), ), ); } } catch (e) { Logger.error('Failed to upload images', e); if (mounted) { setState(() { _isUploading = false; _errorMessage = 'Failed to upload images: $e'; }); } } } Future _saveRecipe() async { if (!_formKey.currentState!.validate()) { return; } if (_recipeService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Recipe service not initialized'), backgroundColor: Colors.red, ), ); } return; } // Upload any pending images first and wait for completion if (_selectedImages.isNotEmpty) { setState(() { _isSaving = true; _errorMessage = null; }); await _uploadImages(); // Check if upload failed if (_selectedImages.isNotEmpty) { setState(() { _isSaving = false; _errorMessage = 'Some images failed to upload. Please try again.'; }); return; } } else { setState(() { _isSaving = true; _errorMessage = null; }); } // Wait for any ongoing uploads to complete while (_isUploading) { await Future.delayed(const Duration(milliseconds: 100)); } try { // Parse tags final tags = _tagsController.text .split(',') .map((tag) => tag.trim()) .where((tag) => tag.isNotEmpty) .toList(); final recipe = RecipeModel( id: widget.recipe?.id ?? 'recipe-${DateTime.now().millisecondsSinceEpoch}', title: _titleController.text.trim(), description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), tags: tags, rating: _rating, isFavourite: _isFavourite, imageUrls: _uploadedImageUrls, ); if (widget.recipe != null) { // Update existing recipe await _recipeService!.updateRecipe(recipe); } else { // Create new recipe await _recipeService!.createRecipe(recipe); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(widget.recipe != null ? 'Recipe updated successfully' : 'Recipe added successfully'), backgroundColor: Colors.green, 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); }); } 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 || widget.recipe == null) return; final originalTags = List.from(widget.recipe!.tags); try { final updatedTags = List.from(widget.recipe!.tags)..remove(tag); final updatedRecipe = widget.recipe!.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { _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 || widget.recipe == null) return; final trimmedTag = tag.trim(); if (trimmedTag.isEmpty) return; if (widget.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(widget.recipe!.tags)..add(trimmedTag); final updatedRecipe = widget.recipe!.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { _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) const Padding( padding: EdgeInsets.all(8), child: Row( children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 8), Text('Uploading images...'), ], ), ), if (_uploadedImageUrls.isNotEmpty) Wrap( spacing: 8, runSpacing: 8, children: _uploadedImageUrls.asMap().entries.map((entry) { final index = entry.key; final url = entry.value; return Stack( children: [ Container( width: 100, height: 100, decoration: BoxDecoration( border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: _buildImagePreview(url), ), ), if (!widget.viewMode) Positioned( top: 4, right: 4, child: IconButton( icon: const Icon(Icons.close, size: 20), color: Colors.red, onPressed: () => _removeImage(index), ), ), ], ); }).toList(), ), ], ), ), ), const SizedBox(height: 16), // Error message if (_errorMessage != null) Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.shade200), ), child: Row( children: [ Icon(Icons.error_outline, color: Colors.red.shade700), const SizedBox(width: 8), Expanded( child: Text( _errorMessage!, style: TextStyle(color: Colors.red.shade700), ), ), ], ), ), const SizedBox(height: 16), // Save and Cancel buttons (only show in edit/add mode) if (!widget.viewMode) Row( children: [ Expanded( child: OutlinedButton( onPressed: _isSaving ? null : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ), const SizedBox(width: 16), Expanded( child: ElevatedButton( onPressed: _isSaving ? null : _saveRecipe, child: _isSaving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : Text(widget.recipe != null ? 'Update' : 'Save'), ), ), ], ), ], ), ), ); } /// Builds an image preview widget using 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) { final imagesToShow = _uploadedImageUrls.take(3).toList(); return Scaffold( body: CustomScrollView( slivers: [ // App bar with images SliverAppBar( expandedHeight: 300, pinned: true, leading: Container( margin: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).brightness == Brightness.dark ? Colors.black.withOpacity(0.5) : Colors.white.withOpacity(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: imagesToShow.isEmpty ? Container( color: Colors.grey[200], child: const Center( child: Icon( Icons.restaurant_menu, size: 64, color: Colors.grey, ), ), ) : _buildTiledPhotoLayout(imagesToShow), ), 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.withOpacity(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: [ ...widget.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).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: _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 photos (smaller) if (_uploadedImageUrls.length > 3) ...[ Text( 'More Photos (${_uploadedImageUrls.length - 3})', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), SizedBox( height: 120, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _uploadedImageUrls.length - 3, itemBuilder: (context, index) { final actualIndex = index + 3; final imageUrl = _uploadedImageUrls[actualIndex]; return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: _uploadedImageUrls, initialIndex: actualIndex, ), ), ); }, child: Container( width: 120, margin: EdgeInsets.only( right: index < _uploadedImageUrls.length - 4 ? 12 : 0, ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: _buildImagePreview(imageUrl), ), ), ); }, ), ), 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 imagesToShow) { final imageCount = imagesToShow.length; return LayoutBuilder( builder: (context, constraints) { return SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, child: Row( children: [ if (imageCount == 1) // Single image: full width Expanded( child: _buildImageTile(imagesToShow[0], 0, showBorder: false), ) else if (imageCount == 2) // Two images: split 50/50 ...imagesToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( child: _buildImageTile( entry.value, index, showBorder: index == 0, ), ); }) else // Three images: one large on left, two stacked on right Expanded( flex: 2, child: _buildImageTile( imagesToShow[0], 0, showBorder: false, ), ), if (imageCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( child: _buildImageTile( imagesToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( child: _buildImageTile( imagesToShow[2], 2, showBorder: false, ), ), ], ), ), ], ], ), ); }, ); } Widget _buildImageTile(String imageUrl, int index, {required bool showBorder}) { return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: _uploadedImageUrls, initialIndex: index, ), ), ); }, child: Container( decoration: showBorder ? BoxDecoration( border: Border( right: BorderSide( color: Colors.white.withOpacity(0.3), width: 2, ), ), ) : null, child: _buildImagePreviewForTile(imageUrl), ), ); } /// 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, ), ), ], ), ), ); } }