|
|
|
@ -7,7 +7,9 @@ import '../../core/service_locator.dart';
|
|
|
|
import '../../core/logger.dart';
|
|
|
|
import '../../core/logger.dart';
|
|
|
|
import '../../data/recipes/recipe_service.dart';
|
|
|
|
import '../../data/recipes/recipe_service.dart';
|
|
|
|
import '../../data/recipes/models/recipe_model.dart';
|
|
|
|
import '../../data/recipes/models/recipe_model.dart';
|
|
|
|
|
|
|
|
import '../../data/recipes/models/bookmark_category_model.dart';
|
|
|
|
import '../photo_gallery/photo_gallery_screen.dart';
|
|
|
|
import '../photo_gallery/photo_gallery_screen.dart';
|
|
|
|
|
|
|
|
import '../recipes/bookmark_dialog.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// Add Recipe screen for creating new recipes.
|
|
|
|
/// Add Recipe screen for creating new recipes.
|
|
|
|
class AddRecipeScreen extends StatefulWidget {
|
|
|
|
class AddRecipeScreen extends StatefulWidget {
|
|
|
|
@ -41,6 +43,21 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
String? _errorMessage;
|
|
|
|
String? _errorMessage;
|
|
|
|
RecipeModel? _currentRecipe; // Track current recipe state for immediate UI updates
|
|
|
|
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<String> _originalImageUrls = [];
|
|
|
|
|
|
|
|
List<String> _originalVideoUrls = [];
|
|
|
|
|
|
|
|
List<String> _originalTags = [];
|
|
|
|
|
|
|
|
List<BookmarkCategory> _bookmarkCategories = [];
|
|
|
|
|
|
|
|
List<BookmarkCategory> _originalBookmarkCategories = [];
|
|
|
|
|
|
|
|
bool _isScrolledToBottom = false;
|
|
|
|
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
|
|
|
|
|
|
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
|
|
RecipeService? _recipeService;
|
|
|
|
RecipeService? _recipeService;
|
|
|
|
|
|
|
|
|
|
|
|
@ -50,6 +67,70 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
_initializeService();
|
|
|
|
_initializeService();
|
|
|
|
if (widget.recipe != null) {
|
|
|
|
if (widget.recipe != null) {
|
|
|
|
_loadRecipeData();
|
|
|
|
_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<void> _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<void> _showBookmarkDialog() async {
|
|
|
|
|
|
|
|
if (widget.recipe == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final result = await showDialog<BookmarkCategory>(
|
|
|
|
|
|
|
|
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<AddRecipeScreen> {
|
|
|
|
_isFavourite = recipe.isFavourite;
|
|
|
|
_isFavourite = recipe.isFavourite;
|
|
|
|
_uploadedImageUrls = List.from(recipe.imageUrls);
|
|
|
|
_uploadedImageUrls = List.from(recipe.imageUrls);
|
|
|
|
_uploadedVideoUrls = List.from(recipe.videoUrls);
|
|
|
|
_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<BookmarkCategory> list1, List<BookmarkCategory> 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<String> list1, List<String> 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<void> _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<void> _showRatingDialog() async {
|
|
|
|
|
|
|
|
await showGeneralDialog<int>(
|
|
|
|
|
|
|
|
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<Offset>(
|
|
|
|
|
|
|
|
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<double>(
|
|
|
|
|
|
|
|
begin: 0.7,
|
|
|
|
|
|
|
|
end: 1.0,
|
|
|
|
|
|
|
|
).animate(CurvedAnimation(
|
|
|
|
|
|
|
|
parent: animation,
|
|
|
|
|
|
|
|
curve: Curves.easeOutCubic,
|
|
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fade animation
|
|
|
|
|
|
|
|
final fadeAnimation = Tween<double>(
|
|
|
|
|
|
|
|
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)
|
|
|
|
/// Gets the current recipe (either from widget or _currentRecipe state)
|
|
|
|
@ -97,9 +442,45 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
_titleController.dispose();
|
|
|
|
_titleController.dispose();
|
|
|
|
_descriptionController.dispose();
|
|
|
|
_descriptionController.dispose();
|
|
|
|
_tagsController.dispose();
|
|
|
|
_tagsController.dispose();
|
|
|
|
|
|
|
|
_scrollController.dispose();
|
|
|
|
super.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _pickMedia() async {
|
|
|
|
|
|
|
|
// First, show dialog to choose between photos and videos
|
|
|
|
|
|
|
|
final String? mediaType = await showDialog<String>(
|
|
|
|
|
|
|
|
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<void> _pickImages() async {
|
|
|
|
Future<void> _pickImages() async {
|
|
|
|
// Show dialog to choose between camera and gallery
|
|
|
|
// Show dialog to choose between camera and gallery
|
|
|
|
final ImageSource? source = await showDialog<ImageSource>(
|
|
|
|
final ImageSource? source = await showDialog<ImageSource>(
|
|
|
|
@ -331,6 +712,11 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
_isUploading = false;
|
|
|
|
_isUploading = false;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// In view mode, trigger rebuild to show save button if changes detected
|
|
|
|
|
|
|
|
if (widget.viewMode) {
|
|
|
|
|
|
|
|
setState(() {});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
if (mounted) {
|
|
|
|
final totalUploaded = uploadedImageUrls.length + uploadedVideoUrls.length;
|
|
|
|
final totalUploaded = uploadedImageUrls.length + uploadedVideoUrls.length;
|
|
|
|
final totalFailed = failedImages.length + failedVideos.length;
|
|
|
|
final totalFailed = failedImages.length + failedVideos.length;
|
|
|
|
@ -516,7 +902,16 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _toggleFavourite() async {
|
|
|
|
Future<void> _toggleFavourite() async {
|
|
|
|
if (_recipeService == null || widget.recipe == null) return;
|
|
|
|
if (widget.recipe == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (widget.viewMode) {
|
|
|
|
|
|
|
|
// In view mode, just toggle the state - save button will handle saving
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_isFavourite = !_isFavourite;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// In edit mode, save immediately
|
|
|
|
|
|
|
|
if (_recipeService == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
@ -549,13 +944,25 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _removeTag(String tag) async {
|
|
|
|
Future<void> _removeTag(String tag) async {
|
|
|
|
if (_recipeService == null || _recipe == null) return;
|
|
|
|
if (_recipe == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
final currentRecipe = _currentRecipe ?? widget.recipe;
|
|
|
|
final currentRecipe = _currentRecipe ?? widget.recipe;
|
|
|
|
if (currentRecipe == null) return;
|
|
|
|
if (currentRecipe == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (widget.viewMode) {
|
|
|
|
|
|
|
|
// In view mode, just update the state - save button will handle saving
|
|
|
|
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..remove(tag);
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_currentRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// In edit mode, save immediately
|
|
|
|
|
|
|
|
if (_recipeService == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..remove(tag);
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..remove(tag);
|
|
|
|
final updatedRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
final updatedRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
@ -563,7 +970,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
_currentRecipe = updatedRecipe; // Update current recipe state immediately
|
|
|
|
_currentRecipe = updatedRecipe;
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
});
|
|
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
@ -581,6 +988,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _showAddTagDialog() async {
|
|
|
|
Future<void> _showAddTagDialog() async {
|
|
|
|
final tagController = TextEditingController();
|
|
|
|
final tagController = TextEditingController();
|
|
|
|
@ -618,7 +1026,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _addTags(List<String> tags) async {
|
|
|
|
Future<void> _addTags(List<String> tags) async {
|
|
|
|
if (_recipeService == null || _recipe == null) return;
|
|
|
|
if (_recipe == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
final currentRecipe = _currentRecipe ?? widget.recipe;
|
|
|
|
final currentRecipe = _currentRecipe ?? widget.recipe;
|
|
|
|
if (currentRecipe == null) return;
|
|
|
|
if (currentRecipe == null) return;
|
|
|
|
@ -640,6 +1048,17 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (widget.viewMode) {
|
|
|
|
|
|
|
|
// In view mode, just update the state - save button will handle saving
|
|
|
|
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..addAll(newTags);
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_currentRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// In edit mode, save immediately
|
|
|
|
|
|
|
|
if (_recipeService == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..addAll(newTags);
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..addAll(newTags);
|
|
|
|
final updatedRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
final updatedRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
@ -647,7 +1066,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
_currentRecipe = updatedRecipe; // Update current recipe state immediately
|
|
|
|
_currentRecipe = updatedRecipe;
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
});
|
|
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
@ -665,6 +1084,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _deleteRecipe() async {
|
|
|
|
Future<void> _deleteRecipe() async {
|
|
|
|
if (_recipeService == null || widget.recipe == null) return;
|
|
|
|
if (_recipeService == null || widget.recipe == null) return;
|
|
|
|
@ -720,6 +1140,14 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
return Colors.red;
|
|
|
|
return Colors.red;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Color _parseColor(String hexColor) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return Color(int.parse(hexColor.replaceAll('#', '0xFF')));
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
return Colors.blue;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String _formatDate(int timestamp) {
|
|
|
|
String _formatDate(int timestamp) {
|
|
|
|
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
|
|
|
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
|
|
|
return '${date.day}/${date.month}/${date.year}';
|
|
|
|
return '${date.day}/${date.month}/${date.year}';
|
|
|
|
@ -1197,7 +1625,40 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
final mediaToShow = allMedia.take(3).toList();
|
|
|
|
final mediaToShow = allMedia.take(3).toList();
|
|
|
|
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
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<Color>(Colors.white),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
: const Icon(Icons.save),
|
|
|
|
|
|
|
|
label: const Text('Save'),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
: null,
|
|
|
|
body: CustomScrollView(
|
|
|
|
body: CustomScrollView(
|
|
|
|
|
|
|
|
controller: _scrollController,
|
|
|
|
slivers: [
|
|
|
|
slivers: [
|
|
|
|
// App bar with media
|
|
|
|
// App bar with media
|
|
|
|
SliverAppBar(
|
|
|
|
SliverAppBar(
|
|
|
|
@ -1223,7 +1684,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
|
|
background: mediaToShow.isEmpty
|
|
|
|
background: RepaintBoundary(
|
|
|
|
|
|
|
|
child: mediaToShow.isEmpty
|
|
|
|
? Container(
|
|
|
|
? Container(
|
|
|
|
color: Colors.grey[200],
|
|
|
|
color: Colors.grey[200],
|
|
|
|
child: const Center(
|
|
|
|
child: const Center(
|
|
|
|
@ -1236,6 +1698,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
: _buildTiledPhotoLayout(mediaToShow),
|
|
|
|
: _buildTiledPhotoLayout(mediaToShow),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
actions: [],
|
|
|
|
actions: [],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
// Content
|
|
|
|
// Content
|
|
|
|
@ -1258,14 +1721,15 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
child: Row(
|
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
|
|
children: [
|
|
|
|
children: [
|
|
|
|
// Edit button
|
|
|
|
// Add Media button
|
|
|
|
_buildStatusBarButton(
|
|
|
|
_buildStatusBarButton(
|
|
|
|
icon: Icons.edit,
|
|
|
|
icon: Icons.add_photo_alternate,
|
|
|
|
label: 'Edit',
|
|
|
|
label: 'Add Media',
|
|
|
|
color: Colors.blue[700]!,
|
|
|
|
color: Colors.green[700]!,
|
|
|
|
onTap: _editRecipe,
|
|
|
|
onTap: _pickMedia,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
// Delete button
|
|
|
|
// Delete button (only show when scrolled to bottom)
|
|
|
|
|
|
|
|
if (_isScrolledToBottom)
|
|
|
|
_buildStatusBarButton(
|
|
|
|
_buildStatusBarButton(
|
|
|
|
icon: Icons.delete,
|
|
|
|
icon: Icons.delete,
|
|
|
|
label: 'Delete',
|
|
|
|
label: 'Delete',
|
|
|
|
@ -1322,6 +1786,61 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
],
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
// 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(
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
child: Column(
|
|
|
|
child: Column(
|
|
|
|
@ -1330,6 +1849,33 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
// Title and Rating
|
|
|
|
// Title and Rating
|
|
|
|
Row(
|
|
|
|
Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
|
|
child: _isEditingTitle
|
|
|
|
|
|
|
|
? TextField(
|
|
|
|
|
|
|
|
controller: _titleController,
|
|
|
|
|
|
|
|
autofocus: true,
|
|
|
|
|
|
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
|
|
|
border: InputBorder.none,
|
|
|
|
|
|
|
|
suffixIcon: IconButton(
|
|
|
|
|
|
|
|
icon: const Icon(Icons.check),
|
|
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_isEditingTitle = false;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
onSubmitted: (_) {
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_isEditingTitle = false;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
: Row(
|
|
|
|
children: [
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
Expanded(
|
|
|
|
child: Text(
|
|
|
|
child: Text(
|
|
|
|
@ -1339,6 +1885,20 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
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),
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
IconButton(
|
|
|
|
IconButton(
|
|
|
|
icon: Icon(
|
|
|
|
icon: Icon(
|
|
|
|
@ -1351,13 +1911,33 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
tooltip: _isFavourite ? 'Remove from favourites' : 'Add to favourites',
|
|
|
|
tooltip: _isFavourite ? 'Remove from favourites' : 'Add to favourites',
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
Container(
|
|
|
|
// Bookmark button
|
|
|
|
|
|
|
|
IconButton(
|
|
|
|
|
|
|
|
icon: Icon(
|
|
|
|
|
|
|
|
_bookmarkCategories.isNotEmpty
|
|
|
|
|
|
|
|
? Icons.bookmark
|
|
|
|
|
|
|
|
: Icons.bookmark_border,
|
|
|
|
|
|
|
|
color: _bookmarkCategories.isNotEmpty
|
|
|
|
|
|
|
|
? Colors.blue
|
|
|
|
|
|
|
|
: Colors.grey[600],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
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(
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
horizontal: 16,
|
|
|
|
horizontal: 16,
|
|
|
|
vertical: 8,
|
|
|
|
vertical: 8,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: _getRatingColor(_rating).withValues(alpha: 0.1),
|
|
|
|
color: _getRatingColor(_rating).withOpacity(0.1),
|
|
|
|
borderRadius: BorderRadius.circular(24),
|
|
|
|
borderRadius: BorderRadius.circular(24),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: Row(
|
|
|
|
child: Row(
|
|
|
|
@ -1373,34 +1953,94 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
style: TextStyle(
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 18,
|
|
|
|
fontSize: 18,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
color: _getRatingColor(_rating),
|
|
|
|
color: Colors.amber,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
],
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
|
|
|
|
// Description
|
|
|
|
// Description
|
|
|
|
if (_descriptionController.text.isNotEmpty) ...[
|
|
|
|
Row(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
Text(
|
|
|
|
'Description',
|
|
|
|
'Description',
|
|
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
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),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
Text(
|
|
|
|
_isEditingDescription
|
|
|
|
_descriptionController.text,
|
|
|
|
? TextField(
|
|
|
|
|
|
|
|
controller: _descriptionController,
|
|
|
|
|
|
|
|
autofocus: true,
|
|
|
|
|
|
|
|
maxLines: null,
|
|
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
|
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
|
|
|
border: InputBorder.none,
|
|
|
|
|
|
|
|
suffixIcon: IconButton(
|
|
|
|
|
|
|
|
icon: const Icon(Icons.check),
|
|
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_isEditingDescription = false;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
onSubmitted: (_) {
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_isEditingDescription = false;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
: Text(
|
|
|
|
|
|
|
|
_descriptionController.text.isEmpty
|
|
|
|
|
|
|
|
? 'No description'
|
|
|
|
|
|
|
|
: _descriptionController.text,
|
|
|
|
|
|
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
|
|
|
|
|
|
fontStyle: _descriptionController.text.isEmpty
|
|
|
|
|
|
|
|
? FontStyle.italic
|
|
|
|
|
|
|
|
: FontStyle.normal,
|
|
|
|
|
|
|
|
color: _descriptionController.text.isEmpty
|
|
|
|
|
|
|
|
? Colors.grey[600]
|
|
|
|
|
|
|
|
: null,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
],
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
|
|
|
|
// Remaining media (smaller) - images and videos beyond the first 3
|
|
|
|
// Remaining media (smaller) - images and videos beyond the first 3
|
|
|
|
Builder(
|
|
|
|
RepaintBoundary(
|
|
|
|
|
|
|
|
child: Builder(
|
|
|
|
builder: (context) {
|
|
|
|
builder: (context) {
|
|
|
|
final remainingImages = _uploadedImageUrls.length > 3
|
|
|
|
final remainingImages = _uploadedImageUrls.length > 3
|
|
|
|
? _uploadedImageUrls.skip(3).toList()
|
|
|
|
? _uploadedImageUrls.skip(3).toList()
|
|
|
|
@ -1474,6 +2114,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
);
|
|
|
|
);
|
|
|
|
},
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
// Created date
|
|
|
|
// Created date
|
|
|
|
if (widget.recipe != null)
|
|
|
|
if (widget.recipe != null)
|
|
|
|
@ -1568,7 +2209,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
? _uploadedImageUrls.length + _uploadedVideoUrls.indexOf(media.url)
|
|
|
|
? _uploadedImageUrls.length + _uploadedVideoUrls.indexOf(media.url)
|
|
|
|
: _uploadedImageUrls.indexOf(media.url);
|
|
|
|
: _uploadedImageUrls.indexOf(media.url);
|
|
|
|
|
|
|
|
|
|
|
|
return GestureDetector(
|
|
|
|
return RepaintBoundary(
|
|
|
|
|
|
|
|
child: GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
onTap: () {
|
|
|
|
Navigator.push(
|
|
|
|
Navigator.push(
|
|
|
|
context,
|
|
|
|
context,
|
|
|
|
@ -1596,6 +2238,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
? _VideoThumbnailPreview(videoUrl: media.url)
|
|
|
|
? _VideoThumbnailPreview(videoUrl: media.url)
|
|
|
|
: _buildImagePreviewForTile(media.url),
|
|
|
|
: _buildImagePreviewForTile(media.url),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|