mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-02 07:28:25 -05:00
400 lines
13 KiB
Dart
400 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 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|