You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

628 lines
20 KiB

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';
/// 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<AddRecipeScreen> createState() => _AddRecipeScreenState();
}
class _AddRecipeScreenState extends State<AddRecipeScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _tagsController = TextEditingController();
int _rating = 0;
bool _isFavourite = false;
List<File> _selectedImages = [];
List<String> _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<void> _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<void> _pickImages() async {
try {
final List<XFile> 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<void> _uploadImages() async {
if (_selectedImages.isEmpty) return;
final immichService = ServiceLocator.instance.immichService;
if (immichService == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Immich service not available'),
backgroundColor: Colors.orange,
),
);
}
return;
}
setState(() {
_isUploading = true;
_errorMessage = null;
});
try {
final List<String> uploadedUrls = [];
for (final imageFile in _selectedImages) {
try {
final uploadResponse = await immichService.uploadImage(imageFile);
final imageUrl = immichService.getImageUrl(uploadResponse.id);
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,
),
);
}
} catch (e) {
Logger.error('Failed to upload images', e);
if (mounted) {
setState(() {
_isUploading = false;
_errorMessage = 'Failed to upload images: $e';
});
}
}
}
Future<void> _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,
),
);
// 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);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.viewMode
? 'View Recipe'
: (widget.recipe != null ? 'Edit Recipe' : 'Add Recipe'),
),
actions: [
// Edit button in view mode
if (widget.viewMode)
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// Navigate to edit mode
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => AddRecipeScreen(recipe: widget.recipe),
),
);
},
tooltip: 'Edit',
),
],
),
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<Color>(Colors.white),
),
)
: Text(widget.recipe != null ? 'Update' : 'Save'),
),
),
],
),
],
),
),
);
}
/// Builds an image preview widget using ImmichService for authenticated access.
Widget _buildImagePreview(String imageUrl) {
final immichService = ServiceLocator.instance.immichService;
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl);
if (assetIdMatch != null && immichService != null) {
final assetId = assetIdMatch.group(1);
if (assetId != null) {
// Use ImmichService to fetch image with proper authentication
return FutureBuilder<Uint8List?>(
future: immichService.fetchImageBytes(assetId, isThumbnail: true),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
color: 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: Colors.grey.shade200,
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
);
}
}

Powered by TurnKey Linux.