import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/constants/app_theme.dart'; import '../../core/models/recipe.dart'; import '../../core/providers.dart'; class QuickShareSheet extends ConsumerStatefulWidget { final String? url; final String? rawCaption; final String? imagePath; const QuickShareSheet({ super.key, this.url, this.rawCaption, this.imagePath, }); @override ConsumerState createState() => _QuickShareSheetState(); } class _QuickShareSheetState extends ConsumerState { bool _isLoading = true; String? _errorMessage; Recipe? _scrapedRecipe; late TextEditingController _titleController; late TextEditingController _servingsController; late TextEditingController _prepTimeController; late TextEditingController _cookTimeController; @override void initState() { super.initState(); _titleController = TextEditingController(); _servingsController = TextEditingController(); _prepTimeController = TextEditingController(); _cookTimeController = TextEditingController(); _executeIngestion(); } @override void dispose() { _titleController.dispose(); _servingsController.dispose(); _prepTimeController.dispose(); _cookTimeController.dispose(); super.dispose(); } Future _executeIngestion() async { setState(() { _isLoading = true; _errorMessage = null; }); final api = ref.read(apiClientProvider); try { Map data; if (widget.imagePath != null) { // Shared photo ingestion via Gemini Multimodal endpoint final bytes = await File(widget.imagePath!).readAsBytes(); final base64Image = base64Encode(bytes); data = await api.scanRecipePhoto(imageBase64: base64Image); } else { // Shared link or social post caption ingestion data = await api.scrapeRecipe( url: widget.url, rawCaption: widget.rawCaption, ); } final recipe = Recipe.fromJson(data); setState(() { _scrapedRecipe = recipe; _titleController.text = recipe.title; _servingsController.text = recipe.servings ?? ''; _prepTimeController.text = recipe.prepTime ?? ''; _cookTimeController.text = recipe.cookTime ?? ''; _isLoading = false; }); } catch (e) { setState(() { _errorMessage = e.toString(); _isLoading = false; }); } } Future _handleSave() async { if (_scrapedRecipe == null) return; final prepTime = _prepTimeController.text.trim(); final cookTime = _cookTimeController.text.trim(); final totalTime = RecipeTimeUtils.calculateTotalTime( prepTime: prepTime, cookTime: cookTime, totalTime: _scrapedRecipe!.totalTime, ); final updated = _scrapedRecipe!.copyWith( title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(), servings: _servingsController.text.trim(), prepTime: prepTime, cookTime: cookTime, totalTime: totalTime, sourceUrl: widget.url ?? _scrapedRecipe!.sourceUrl, ); try { await ref.read(recipeListProvider.notifier).addOrUpdate(updated); if (mounted) { Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: AppTheme.secondary, content: Row( children: [ const Icon(Icons.check_circle_rounded, color: Colors.white), const SizedBox(width: 10), Expanded(child: Text('Saved "${updated.title}" to ForkFolio!')), ], ), ), ); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(backgroundColor: AppTheme.danger, content: Text('Save error: $e')), ); } } @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), padding: EdgeInsets.only( top: 16, left: 20, right: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.88, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Drag handle Container( width: 44, height: 4, decoration: BoxDecoration( color: AppTheme.border, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 16), // Header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppTheme.primary.withOpacity(0.15), borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.fork_right_rounded, color: AppTheme.primary, size: 20), ), const SizedBox(width: 10), const Text( 'Save to ForkFolio', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), ], ), IconButton( icon: const Icon(Icons.close_rounded, color: AppTheme.textMuted), onPressed: () => Navigator.of(context).pop(), ), ], ), const Divider(color: AppTheme.border, height: 24), // Content body Expanded( child: _isLoading ? _buildLoadingState() : _errorMessage != null ? _buildErrorState() : _buildRecipePreview(), ), // Action button if (!_isLoading && _scrapedRecipe != null) ...[ const SizedBox(height: 16), SizedBox( width: double.infinity, height: 50, child: ElevatedButton.icon( onPressed: _handleSave, icon: const Icon(Icons.bookmark_add_rounded), label: const Text('Save to My Cookbook'), ), ), ], ], ), ); } Widget _buildLoadingState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox( width: 48, height: 48, child: CircularProgressIndicator(color: AppTheme.primary, strokeWidth: 3), ), const SizedBox(height: 20), Text( widget.imagePath != null ? 'Analyzing recipe photo with Gemini Vision...' : 'Scraping recipe details...', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppTheme.textMain), ), const SizedBox(height: 6), Text( widget.url ?? widget.rawCaption ?? 'Ingesting shared content', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), ), ], ), ); } Widget _buildErrorState() { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline_rounded, color: AppTheme.danger, size: 48), const SizedBox(height: 14), const Text( 'Could not extract recipe', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.textMain), ), const SizedBox(height: 8), Text( _errorMessage ?? 'Unknown error occurred.', textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: AppTheme.textMuted), ), const SizedBox(height: 20), ElevatedButton.icon( onPressed: _executeIngestion, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry Scrape'), ), ], ), ), ); } Widget _buildRecipePreview() { final recipe = _scrapedRecipe!; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Hero cover preview if present if (recipe.image != null && recipe.image!.isNotEmpty) ...[ ClipRRect( borderRadius: BorderRadius.circular(12), child: recipe.image!.startsWith('data:image') ? Image.memory( base64Decode(recipe.image!.split(',').last), height: 160, width: double.infinity, fit: BoxFit.cover, ) : Image.network( recipe.image!, height: 160, width: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const SizedBox(), ), ), const SizedBox(height: 16), ], // Title field const Text('Recipe Title', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.textMuted)), const SizedBox(height: 6), TextField( controller: _titleController, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), decoration: const InputDecoration(hintText: 'Recipe title'), ), const SizedBox(height: 14), // Time & Servings Row Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Prep Time', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), const SizedBox(height: 4), TextField( controller: _prepTimeController, decoration: const InputDecoration(hintText: 'e.g. 15m', isDense: true), ), ], ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Cook Time', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), const SizedBox(height: 4), TextField( controller: _cookTimeController, decoration: const InputDecoration(hintText: 'e.g. 30m', isDense: true), ), ], ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Servings', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), const SizedBox(height: 4), TextField( controller: _servingsController, decoration: const InputDecoration(hintText: 'e.g. 4', isDense: true), ), ], ), ), ], ), const SizedBox(height: 18), // Ingredients Count Pill Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: AppTheme.surfaceElevated, borderRadius: BorderRadius.circular(10), border: Border.all(color: AppTheme.border), ), child: Row( children: [ const Icon(Icons.egg_alt_outlined, color: AppTheme.secondary, size: 18), const SizedBox(width: 10), Text( '${recipe.ingredients.length} Ingredients Detected', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), ), const Spacer(), Text( '${recipe.instructions.length} Steps', style: const TextStyle(color: AppTheme.textMuted, fontSize: 13), ), ], ), ), ], ), ); } }