|
|
|
|
@ -553,9 +553,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
Future<void> _removeTag(String tag) async {
|
|
|
|
|
if (_recipeService == null || _recipe == null) return;
|
|
|
|
|
|
|
|
|
|
final currentRecipe = _currentRecipe ?? widget.recipe;
|
|
|
|
|
if (currentRecipe == null) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final updatedTags = List<String>.from(_recipe!.tags)..remove(tag);
|
|
|
|
|
final updatedRecipe = _recipe!.copyWith(tags: updatedTags);
|
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..remove(tag);
|
|
|
|
|
final updatedRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
|
await _recipeService!.updateRecipe(updatedRecipe);
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
@ -591,7 +594,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
}
|
|
|
|
|
final existingTags = allTags.toList()..sort();
|
|
|
|
|
|
|
|
|
|
final result = await showDialog<String>(
|
|
|
|
|
final result = await showDialog<List<String>>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => _AddTagDialog(
|
|
|
|
|
tagController: tagController,
|
|
|
|
|
@ -604,8 +607,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
tagController.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (mounted && result != null && result.trim().isNotEmpty) {
|
|
|
|
|
await _addTag(result.trim());
|
|
|
|
|
if (mounted && result != null && result.isNotEmpty) {
|
|
|
|
|
await _addTags(result);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
@ -614,17 +617,22 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _addTag(String tag) async {
|
|
|
|
|
Future<void> _addTags(List<String> tags) async {
|
|
|
|
|
if (_recipeService == null || _recipe == null) return;
|
|
|
|
|
|
|
|
|
|
final trimmedTag = tag.trim();
|
|
|
|
|
if (trimmedTag.isEmpty) return;
|
|
|
|
|
final currentRecipe = _currentRecipe ?? widget.recipe;
|
|
|
|
|
if (currentRecipe == null) return;
|
|
|
|
|
|
|
|
|
|
final trimmedTags = tags.map((tag) => tag.trim()).where((tag) => tag.isNotEmpty).toList();
|
|
|
|
|
if (trimmedTags.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
if (_recipe!.tags.contains(trimmedTag)) {
|
|
|
|
|
// Filter out tags that already exist
|
|
|
|
|
final newTags = trimmedTags.where((tag) => !currentRecipe.tags.contains(tag)).toList();
|
|
|
|
|
if (newTags.isEmpty) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(
|
|
|
|
|
content: Text('Tag already exists'),
|
|
|
|
|
content: Text('All tags already exist'),
|
|
|
|
|
duration: Duration(seconds: 1),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
@ -633,8 +641,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final updatedTags = List<String>.from(_recipe!.tags)..add(trimmedTag);
|
|
|
|
|
final updatedRecipe = _recipe!.copyWith(tags: updatedTags);
|
|
|
|
|
final updatedTags = List<String>.from(currentRecipe.tags)..addAll(newTags);
|
|
|
|
|
final updatedRecipe = currentRecipe.copyWith(tags: updatedTags);
|
|
|
|
|
await _recipeService!.updateRecipe(updatedRecipe);
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
@ -643,16 +651,16 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
|
|
|
|
|
_tagsController.text = updatedTags.join(', ');
|
|
|
|
|
});
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(
|
|
|
|
|
content: Text('Tag added'),
|
|
|
|
|
duration: Duration(seconds: 1),
|
|
|
|
|
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 tag: $e')),
|
|
|
|
|
SnackBar(content: Text('Error adding tags: $e')),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -1781,7 +1789,7 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
late AnimationController _animationController;
|
|
|
|
|
late Animation<double> _animation;
|
|
|
|
|
bool _isUserScrolling = false;
|
|
|
|
|
DateTime? _lastUserScrollTime;
|
|
|
|
|
final Set<String> _selectedTags = {};
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
@ -1846,16 +1854,47 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
if (_scrollController.hasClients) {
|
|
|
|
|
_scrollController.jumpTo(0);
|
|
|
|
|
_isUserScrolling = false;
|
|
|
|
|
_lastUserScrollTime = null;
|
|
|
|
|
// Don't restart auto-scroll if user has interacted
|
|
|
|
|
if (!_isUserScrolling) {
|
|
|
|
|
_startAutoScroll();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _selectTag(String tag) {
|
|
|
|
|
Navigator.pop(context, tag);
|
|
|
|
|
// Stop auto-scroll permanently when user selects a tag
|
|
|
|
|
_isUserScrolling = true;
|
|
|
|
|
_animationController.stop();
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
if (_selectedTags.contains(tag)) {
|
|
|
|
|
_selectedTags.remove(tag);
|
|
|
|
|
} else {
|
|
|
|
|
_selectedTags.add(tag);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<String> _getTagsToAdd() {
|
|
|
|
|
final tags = <String>[];
|
|
|
|
|
|
|
|
|
|
// Add selected tags from the list
|
|
|
|
|
tags.addAll(_selectedTags);
|
|
|
|
|
|
|
|
|
|
// Parse comma-separated tags from text field
|
|
|
|
|
final textTags = widget.tagController.text
|
|
|
|
|
.split(',')
|
|
|
|
|
.map((tag) => tag.trim())
|
|
|
|
|
.where((tag) => tag.isNotEmpty)
|
|
|
|
|
.toList();
|
|
|
|
|
tags.addAll(textTags);
|
|
|
|
|
|
|
|
|
|
// Remove duplicates and tags that already exist in the recipe
|
|
|
|
|
final uniqueTags = tags.toSet().where((tag) => !widget.currentTags.contains(tag)).toList();
|
|
|
|
|
|
|
|
|
|
return uniqueTags;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
@ -1877,16 +1916,11 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
controller: widget.tagController,
|
|
|
|
|
autofocus: true,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
hintText: 'Enter tag name or select from existing',
|
|
|
|
|
hintText: 'Enter tags (comma-separated) or select from existing',
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
|
),
|
|
|
|
|
textCapitalization: TextCapitalization.none,
|
|
|
|
|
onSubmitted: (value) {
|
|
|
|
|
if (value.trim().isNotEmpty) {
|
|
|
|
|
Navigator.pop(context, value.trim());
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
// Existing tags scrollable list with fade hint
|
|
|
|
|
@ -1902,25 +1936,10 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
height: 50,
|
|
|
|
|
child: NotificationListener<ScrollNotification>(
|
|
|
|
|
onNotification: (notification) {
|
|
|
|
|
// Detect when user manually scrolls
|
|
|
|
|
// Detect when user manually scrolls - stop auto-scroll permanently
|
|
|
|
|
if (notification is ScrollStartNotification && notification.dragDetails != null) {
|
|
|
|
|
_isUserScrolling = true;
|
|
|
|
|
_lastUserScrollTime = DateTime.now();
|
|
|
|
|
_animationController.stop();
|
|
|
|
|
} else if (notification is ScrollEndNotification) {
|
|
|
|
|
// Resume auto-scroll after user stops scrolling for 3 seconds
|
|
|
|
|
_lastUserScrollTime = DateTime.now();
|
|
|
|
|
Future.delayed(const Duration(seconds: 3), () {
|
|
|
|
|
if (mounted && _lastUserScrollTime != null) {
|
|
|
|
|
final timeSinceLastScroll = DateTime.now().difference(_lastUserScrollTime!);
|
|
|
|
|
if (timeSinceLastScroll.inSeconds >= 3) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isUserScrolling = false;
|
|
|
|
|
});
|
|
|
|
|
_startAutoScroll();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
@ -1931,11 +1950,34 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final tag = _filteredTags[index];
|
|
|
|
|
final isAlreadyAdded = widget.currentTags.contains(tag);
|
|
|
|
|
final isSelected = _selectedTags.contains(tag);
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(right: 8),
|
|
|
|
|
child: FilterChip(
|
|
|
|
|
label: Text(tag),
|
|
|
|
|
selected: isAlreadyAdded,
|
|
|
|
|
label: Text(
|
|
|
|
|
tag,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
color: isSelected
|
|
|
|
|
? Colors.white
|
|
|
|
|
: (isAlreadyAdded
|
|
|
|
|
? (isDark ? Colors.white : Colors.black87)
|
|
|
|
|
: (isDark ? Colors.white : Colors.black87)),
|
|
|
|
|
fontWeight: isSelected
|
|
|
|
|
? FontWeight.w700
|
|
|
|
|
: (isAlreadyAdded ? FontWeight.w500 : FontWeight.normal),
|
|
|
|
|
fontSize: isSelected ? 14 : null,
|
|
|
|
|
shadows: isSelected
|
|
|
|
|
? [
|
|
|
|
|
Shadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
|
|
|
offset: const Offset(0, 1),
|
|
|
|
|
blurRadius: 2,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
: null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
selected: isSelected,
|
|
|
|
|
onSelected: isAlreadyAdded
|
|
|
|
|
? null
|
|
|
|
|
: (selected) {
|
|
|
|
|
@ -1946,12 +1988,24 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
backgroundColor: isDark
|
|
|
|
|
? Colors.grey[800]
|
|
|
|
|
: Colors.grey[200],
|
|
|
|
|
disabledColor: Colors.grey[400],
|
|
|
|
|
labelStyle: TextStyle(
|
|
|
|
|
color: isAlreadyAdded
|
|
|
|
|
? Colors.white
|
|
|
|
|
: (isDark ? Colors.white : Colors.black87),
|
|
|
|
|
),
|
|
|
|
|
disabledColor: isDark
|
|
|
|
|
? Colors.grey[600]
|
|
|
|
|
: Colors.grey[100],
|
|
|
|
|
side: isSelected
|
|
|
|
|
? BorderSide(
|
|
|
|
|
color: Colors.white.withValues(alpha: 0.5),
|
|
|
|
|
width: 2.5,
|
|
|
|
|
)
|
|
|
|
|
: (isAlreadyAdded
|
|
|
|
|
? BorderSide(
|
|
|
|
|
color: (isDark ? Colors.grey[500]! : Colors.grey[400]!),
|
|
|
|
|
width: 1,
|
|
|
|
|
)
|
|
|
|
|
: null),
|
|
|
|
|
elevation: isSelected ? 3 : 0,
|
|
|
|
|
padding: isSelected
|
|
|
|
|
? const EdgeInsets.symmetric(horizontal: 12, vertical: 8)
|
|
|
|
|
: null,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
@ -1975,12 +2029,16 @@ class _AddTagDialogState extends State<_AddTagDialog> with SingleTickerProviderS
|
|
|
|
|
),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
final text = widget.tagController.text.trim();
|
|
|
|
|
if (text.isNotEmpty) {
|
|
|
|
|
Navigator.pop(context, text);
|
|
|
|
|
final tagsToAdd = _getTagsToAdd();
|
|
|
|
|
if (tagsToAdd.isNotEmpty) {
|
|
|
|
|
Navigator.pop(context, tagsToAdd);
|
|
|
|
|
} else {
|
|
|
|
|
Navigator.pop(context);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
child: const Text('Add'),
|
|
|
|
|
child: Text(_selectedTags.isEmpty && widget.tagController.text.trim().isEmpty
|
|
|
|
|
? 'Add'
|
|
|
|
|
: 'Add (${_getTagsToAdd().length})'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|