From fb590aaceaf2382345324b9b45db52c3bc772688 Mon Sep 17 00:00:00 2001 From: gitea Date: Sat, 15 Nov 2025 15:10:48 +0100 Subject: [PATCH] improve the edit recipe --- lib/ui/add_recipe/add_recipe_screen.dart | 1089 +++++++++++++++++----- 1 file changed, 866 insertions(+), 223 deletions(-) diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 37369c6..ba7b898 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -7,7 +7,9 @@ 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'; /// Add Recipe screen for creating new recipes. class AddRecipeScreen extends StatefulWidget { @@ -40,6 +42,21 @@ class _AddRecipeScreenState extends State { 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(); final ImagePicker _imagePicker = ImagePicker(); RecipeService? _recipeService; @@ -50,6 +67,70 @@ class _AddRecipeScreenState extends State { _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 _onScroll() { + if (!_scrollController.hasClients) 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 + // Otherwise, show it only when scrolled to bottom + final isAtBottom = maxScroll <= 50 || currentScroll >= maxScroll - 50; // 50px threshold + + if (isAtBottom != _isScrolledToBottom) { + setState(() { + _isScrolledToBottom = isAtBottom; + }); } } @@ -87,6 +168,270 @@ class _AddRecipeScreenState extends State { _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) @@ -97,9 +442,45 @@ class _AddRecipeScreenState extends State { _titleController.dispose(); _descriptionController.dispose(); _tagsController.dispose(); + _scrollController.dispose(); 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( @@ -330,6 +711,11 @@ class _AddRecipeScreenState extends State { _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; @@ -516,68 +902,90 @@ class _AddRecipeScreenState extends State { } Future _toggleFavourite() async { - if (_recipeService == null || widget.recipe == null) return; + if (widget.recipe == null) return; - try { + 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; - 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) { + try { setState(() { _isFavourite = !_isFavourite; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error updating favourite: $e')), - ); + + 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; + if (_recipe == null) return; final currentRecipe = _currentRecipe ?? widget.recipe; if (currentRecipe == null) return; - try { + if (widget.viewMode) { + // In view mode, just update the state - save button will handle saving final updatedTags = List.from(currentRecipe.tags)..remove(tag); - final updatedRecipe = currentRecipe.copyWith(tags: updatedTags); - await _recipeService!.updateRecipe(updatedRecipe); + setState(() { + _currentRecipe = currentRecipe.copyWith(tags: updatedTags); + _tagsController.text = updatedTags.join(', '); + }); + } else { + // In edit mode, save immediately + if (_recipeService == null) return; - 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')), - ); + 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')), + ); + } } } } @@ -618,7 +1026,7 @@ class _AddRecipeScreenState extends State { } Future _addTags(List tags) async { - if (_recipeService == null || _recipe == null) return; + if (_recipe == null) return; final currentRecipe = _currentRecipe ?? widget.recipe; if (currentRecipe == null) return; @@ -640,28 +1048,40 @@ class _AddRecipeScreenState extends State { return; } - try { + if (widget.viewMode) { + // In view mode, just update the state - save button will handle saving final updatedTags = List.from(currentRecipe.tags)..addAll(newTags); - final updatedRecipe = currentRecipe.copyWith(tags: updatedTags); - await _recipeService!.updateRecipe(updatedRecipe); + setState(() { + _currentRecipe = currentRecipe.copyWith(tags: updatedTags); + _tagsController.text = updatedTags.join(', '); + }); + } else { + // In edit mode, save immediately + if (_recipeService == null) return; - if (mounted) { - setState(() { - _currentRecipe = updatedRecipe; // Update current recipe state immediately - _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')), - ); + 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')), + ); + } } } } @@ -719,6 +1139,14 @@ class _AddRecipeScreenState extends State { 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); @@ -1197,7 +1625,40 @@ class _AddRecipeScreenState extends State { 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, body: CustomScrollView( + controller: _scrollController, slivers: [ // App bar with media SliverAppBar( @@ -1223,18 +1684,20 @@ class _AddRecipeScreenState extends State { ), ), flexibleSpace: FlexibleSpaceBar( - background: mediaToShow.isEmpty - ? Container( - color: Colors.grey[200], - child: const Center( - child: Icon( - Icons.restaurant_menu, - size: 64, - color: Colors.grey, + background: RepaintBoundary( + child: mediaToShow.isEmpty + ? Container( + color: Colors.grey[200], + child: const Center( + child: Icon( + Icons.restaurant_menu, + size: 64, + color: Colors.grey, + ), ), - ), - ) - : _buildTiledPhotoLayout(mediaToShow), + ) + : _buildTiledPhotoLayout(mediaToShow), + ), ), actions: [], ), @@ -1258,20 +1721,21 @@ class _AddRecipeScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - // Edit button + // Add Media 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, + icon: Icons.add_photo_alternate, + label: 'Add Media', + color: Colors.green[700]!, + onTap: _pickMedia, ), + // Delete button (only show when scrolled to bottom) + if (_isScrolledToBottom) + _buildStatusBarButton( + icon: Icons.delete, + label: 'Delete', + color: Colors.red[700]!, + onTap: _deleteRecipe, + ), ], ), ), @@ -1322,6 +1786,61 @@ class _AddRecipeScreenState extends State { ], ), ), + // 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 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( @@ -1332,12 +1851,53 @@ class _AddRecipeScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - _titleController.text, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), + 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( @@ -1351,32 +1911,53 @@ class _AddRecipeScreenState extends State { 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), + // Bookmark button + IconButton( + icon: Icon( + _bookmarkCategories.isNotEmpty + ? Icons.bookmark + : Icons.bookmark_border, + color: _bookmarkCategories.isNotEmpty + ? Colors.blue + : Colors.grey[600], ), - 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), + onPressed: _showBookmarkDialog, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: _bookmarkCategories.isNotEmpty + ? 'Manage bookmarks' + : '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, + ), + ), + ], + ), ), ), ], @@ -1384,95 +1965,155 @@ class _AddRecipeScreenState extends State { const SizedBox(height: 24), // Description - if (_descriptionController.text.isNotEmpty) ...[ - Text( - 'Description', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + 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: 8), - Text( - _descriptionController.text, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 24), - ], + ], + ), + 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, + 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, + 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), ), - ); - }, - 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), - ], - ); - }, + const SizedBox(height: 24), + ], + ); + }, + ), ), // Created date @@ -1568,33 +2209,35 @@ class _AddRecipeScreenState extends State { ? _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, + 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, + ); + }, + 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), + ) + : null, + child: media.isVideo + ? _VideoThumbnailPreview(videoUrl: media.url) + : _buildImagePreviewForTile(media.url), + ), ), ); }