recipe-manager/mobile/lib/features/share_receiver/quick_share_sheet.dart

391 lines
13 KiB
Dart

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<QuickShareSheet> createState() => _QuickShareSheetState();
}
class _QuickShareSheetState extends ConsumerState<QuickShareSheet> {
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<void> _executeIngestion() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final api = ref.read(apiClientProvider);
try {
Map<String, dynamic> 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<void> _handleSave() async {
if (_scrapedRecipe == null) return;
final updated = _scrapedRecipe!.copyWith(
title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(),
servings: _servingsController.text.trim(),
prepTime: _prepTimeController.text.trim(),
cookTime: _cookTimeController.text.trim(),
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),
),
],
),
),
],
),
);
}
}