mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-02 07:28:25 -05:00
Implement native share, recipe delete, profile logout, and recipe editor with AI polish
This commit is contained in:
parent
86442f62e0
commit
a645f7d5fe
4 changed files with 714 additions and 14 deletions
|
|
@ -1,6 +1,33 @@
|
|||
package com.forkfolio.mobile
|
||||
|
||||
import android.content.Intent
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
private val CHANNEL = "com.forkfolio.mobile/native_share"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
if (call.method == "shareText") {
|
||||
val text = call.argument<String>("text") ?: ""
|
||||
val subject = call.argument<String>("subject") ?: ""
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
if (subject.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
}
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, subject.ifEmpty { "Share Recipe" })
|
||||
startActivity(shareIntent)
|
||||
result.success(true)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,31 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
// 4b. AI Polish Recipe with Gemini
|
||||
Future<Map<String, dynamic>> aiPolishRecipe({
|
||||
String? title,
|
||||
String? rawCaption,
|
||||
List<String>? ingredients,
|
||||
List<String>? instructions,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/recipes/ai-polish',
|
||||
data: {
|
||||
'title': title ?? '',
|
||||
'rawCaption': rawCaption ?? '',
|
||||
'ingredients': ingredients ?? [],
|
||||
'instructions': instructions ?? [],
|
||||
},
|
||||
);
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
if (err is ApiException) throw err;
|
||||
throw ApiException(e.message ?? 'AI Polish failed');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Save or Update Recipe
|
||||
Future<Recipe> saveRecipe(Recipe recipe) async {
|
||||
try {
|
||||
|
|
|
|||
408
mobile/lib/features/editor/recipe_edit_screen.dart
Normal file
408
mobile/lib/features/editor/recipe_edit_screen.dart
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
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 RecipeEditScreen extends ConsumerStatefulWidget {
|
||||
final Recipe? recipe;
|
||||
|
||||
const RecipeEditScreen({super.key, this.recipe});
|
||||
|
||||
@override
|
||||
ConsumerState<RecipeEditScreen> createState() => _RecipeEditScreenState();
|
||||
}
|
||||
|
||||
class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late TextEditingController _titleController;
|
||||
late TextEditingController _descController;
|
||||
late TextEditingController _servingsController;
|
||||
late TextEditingController _prepTimeController;
|
||||
late TextEditingController _cookTimeController;
|
||||
late TextEditingController _sourceUrlController;
|
||||
late TextEditingController _imageUrlController;
|
||||
late TextEditingController _ingredientsController;
|
||||
late TextEditingController _instructionsController;
|
||||
|
||||
bool _isSaving = false;
|
||||
bool _isPolishing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final r = widget.recipe;
|
||||
_titleController = TextEditingController(text: r?.title ?? '');
|
||||
_descController = TextEditingController(text: r?.description ?? '');
|
||||
_servingsController = TextEditingController(text: r?.servings ?? '');
|
||||
_prepTimeController = TextEditingController(text: r?.prepTime ?? '');
|
||||
_cookTimeController = TextEditingController(text: r?.cookTime ?? '');
|
||||
_sourceUrlController = TextEditingController(text: r?.sourceUrl ?? '');
|
||||
_imageUrlController = TextEditingController(text: r?.image ?? '');
|
||||
_ingredientsController = TextEditingController(text: r?.ingredients.join('\n') ?? '');
|
||||
_instructionsController = TextEditingController(text: r?.instructions.join('\n') ?? '');
|
||||
|
||||
_prepTimeController.addListener(_updateTimes);
|
||||
_cookTimeController.addListener(_updateTimes);
|
||||
}
|
||||
|
||||
void _updateTimes() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descController.dispose();
|
||||
_servingsController.dispose();
|
||||
_prepTimeController.dispose();
|
||||
_cookTimeController.dispose();
|
||||
_sourceUrlController.dispose();
|
||||
_imageUrlController.dispose();
|
||||
_ingredientsController.dispose();
|
||||
_instructionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _calculatedTotal {
|
||||
return RecipeTimeUtils.calculateTotalTime(
|
||||
prepTime: _prepTimeController.text.trim(),
|
||||
cookTime: _cookTimeController.text.trim(),
|
||||
totalTime: widget.recipe?.totalTime,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAiPolish() async {
|
||||
final title = _titleController.text.trim();
|
||||
final rawIng = _ingredientsController.text.trim();
|
||||
final rawInst = _instructionsController.text.trim();
|
||||
|
||||
if (rawIng.isEmpty && rawInst.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please add ingredients or instructions first to polish with AI.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isPolishing = true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final result = await api.aiPolishRecipe(
|
||||
title: title,
|
||||
ingredients: rawIng.split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(),
|
||||
instructions: rawInst.split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (result['title'] != null && result['title'].toString().isNotEmpty) {
|
||||
_titleController.text = result['title'].toString();
|
||||
}
|
||||
if (result['description'] != null && result['description'].toString().isNotEmpty) {
|
||||
_descController.text = result['description'].toString();
|
||||
}
|
||||
if (result['servings'] != null && result['servings'].toString().isNotEmpty) {
|
||||
_servingsController.text = result['servings'].toString();
|
||||
}
|
||||
if (result['prepTime'] != null && result['prepTime'].toString().isNotEmpty) {
|
||||
_prepTimeController.text = result['prepTime'].toString();
|
||||
}
|
||||
if (result['cookTime'] != null && result['cookTime'].toString().isNotEmpty) {
|
||||
_cookTimeController.text = result['cookTime'].toString();
|
||||
}
|
||||
if (result['ingredients'] is List) {
|
||||
final list = (result['ingredients'] as List).map((e) => e.toString()).toList();
|
||||
_ingredientsController.text = list.join('\n');
|
||||
}
|
||||
if (result['instructions'] is List) {
|
||||
final list = (result['instructions'] as List).map((e) => e.toString()).toList();
|
||||
_instructionsController.text = list.join('\n');
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppTheme.secondary,
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, color: Colors.white, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Text('Recipe polished and organized by Gemini AI!'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.danger,
|
||||
content: Text('AI Polish failed: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isPolishing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSave() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final isNew = widget.recipe == null;
|
||||
|
||||
final prep = _prepTimeController.text.trim();
|
||||
final cook = _cookTimeController.text.trim();
|
||||
final total = RecipeTimeUtils.calculateTotalTime(
|
||||
prepTime: prep,
|
||||
cookTime: cook,
|
||||
totalTime: widget.recipe?.totalTime,
|
||||
);
|
||||
|
||||
final ingList = _ingredientsController.text
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final instList = _instructionsController.text
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final updated = Recipe(
|
||||
id: widget.recipe?.id,
|
||||
title: _titleController.text.trim(),
|
||||
description: _descController.text.trim(),
|
||||
servings: _servingsController.text.trim(),
|
||||
prepTime: prep,
|
||||
cookTime: cook,
|
||||
totalTime: total,
|
||||
sourceUrl: _sourceUrlController.text.trim(),
|
||||
image: _imageUrlController.text.trim().isNotEmpty
|
||||
? _imageUrlController.text.trim()
|
||||
: widget.recipe?.image,
|
||||
images: widget.recipe?.images ?? [],
|
||||
ingredients: ingList,
|
||||
instructions: instList,
|
||||
shareToken: widget.recipe?.shareToken,
|
||||
);
|
||||
|
||||
try {
|
||||
await ref.read(recipeListProvider.notifier).addOrUpdate(updated);
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.secondary,
|
||||
content: Text(isNew ? 'Created "${updated.title}"' : 'Updated "${updated.title}"'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.danger,
|
||||
content: Text('Failed to save recipe: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isNew = widget.recipe == null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isNew ? 'New Recipe' : 'Edit Recipe'),
|
||||
actions: [
|
||||
if (_isPolishing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.secondary))),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
onPressed: _handleAiPolish,
|
||||
icon: const Icon(Icons.auto_awesome, color: AppTheme.secondary, size: 18),
|
||||
label: const Text('AI Polish', style: TextStyle(color: AppTheme.secondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _isSaving
|
||||
? const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary)))
|
||||
: ElevatedButton(
|
||||
onPressed: _handleSave,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
minimumSize: const Size(60, 36),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Title Field
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Recipe Title *',
|
||||
hintText: 'e.g. Creamy Tuscan Garlic Chicken',
|
||||
prefixIcon: Icon(Icons.restaurant_rounded),
|
||||
),
|
||||
validator: (val) => (val == null || val.trim().isEmpty) ? 'Please enter a recipe title' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Description Field
|
||||
TextFormField(
|
||||
controller: _descController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
hintText: 'A short overview or tasting notes',
|
||||
prefixIcon: Icon(Icons.description_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Servings, Prep Time, Cook Time Row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _servingsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Servings',
|
||||
hintText: 'e.g. 4',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _prepTimeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prep Time',
|
||||
hintText: '15 mins',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _cookTimeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cook Time',
|
||||
hintText: '30 mins',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Total Time live preview badge
|
||||
if (_calculatedTotal.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.timer_rounded, size: 14, color: AppTheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Calculated Total Time: $_calculatedTotal',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Source URL
|
||||
TextFormField(
|
||||
controller: _sourceUrlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Source URL',
|
||||
hintText: 'https://...',
|
||||
prefixIcon: Icon(Icons.link_rounded),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Image URL
|
||||
TextFormField(
|
||||
controller: _imageUrlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cover Image URL',
|
||||
hintText: 'https://...',
|
||||
prefixIcon: Icon(Icons.image_outlined),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Ingredients Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
||||
Text(
|
||||
'One per line',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.textMuted.withOpacity(0.8)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _ingredientsController,
|
||||
maxLines: 8,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '2 chicken breasts, diced\n1 cup heavy cream\n2 cloves garlic, minced\n1 cup baby spinach',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Instructions Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Instructions', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
||||
Text(
|
||||
'One step per line',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.textMuted.withOpacity(0.8)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _instructionsController,
|
||||
maxLines: 8,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Heat olive oil in a skillet over medium heat.\nAdd garlic and cook until fragrant.\nPour in heavy cream and simmer until thickened.',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/constants/app_theme.dart';
|
||||
import '../../core/models/recipe.dart';
|
||||
import '../../core/providers.dart';
|
||||
import '../cook_mode/cook_mode_screen.dart';
|
||||
import '../editor/recipe_edit_screen.dart';
|
||||
import '../scanner/scanner_screen.dart';
|
||||
|
||||
class RecipesScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -47,12 +49,21 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
|||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Logout',
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
onPressed: () => ref.read(authProvider.notifier).logout(),
|
||||
tooltip: 'Profile & Settings',
|
||||
icon: const Icon(Icons.account_circle_outlined),
|
||||
onPressed: _showProfileDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
backgroundColor: AppTheme.primary,
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('New Recipe', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const RecipeEditScreen()));
|
||||
},
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppTheme.primary,
|
||||
onRefresh: () => ref.read(recipeListProvider.notifier).loadRecipes(),
|
||||
|
|
@ -278,17 +289,70 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe)));
|
||||
},
|
||||
icon: const Icon(Icons.outdoor_grill_rounded),
|
||||
label: const Text('Start Cook Mode'),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe)));
|
||||
},
|
||||
icon: const Icon(Icons.outdoor_grill_rounded),
|
||||
label: const Text('Cook Mode'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
width: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: 'Edit Recipe',
|
||||
icon: const Icon(Icons.edit_outlined, color: AppTheme.secondary, size: 20),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => RecipeEditScreen(recipe: recipe)));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
width: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: 'Share Recipe',
|
||||
icon: const Icon(Icons.share_rounded, color: AppTheme.primary, size: 20),
|
||||
onPressed: () => _shareRecipe(recipe),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
width: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: 'Delete Recipe',
|
||||
icon: const Icon(Icons.delete_outline_rounded, color: AppTheme.danger, size: 20),
|
||||
onPressed: () => _confirmDelete(ctx, recipe),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
||||
|
|
@ -344,4 +408,180 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareRecipe(Recipe recipe) async {
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final serverUrl = await storage.getServerUrl() ?? '';
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('🍳 ${recipe.title}');
|
||||
if (recipe.calculatedTotalTime.isNotEmpty) {
|
||||
buffer.writeln('⏱️ Total Time: ${recipe.calculatedTotalTime}');
|
||||
}
|
||||
if (recipe.servings != null && recipe.servings!.isNotEmpty) {
|
||||
buffer.writeln('👥 Servings: ${recipe.servings}');
|
||||
}
|
||||
if (recipe.shareToken != null && recipe.shareToken!.isNotEmpty && serverUrl.isNotEmpty) {
|
||||
final cleanBase = serverUrl.endsWith('/') ? serverUrl.substring(0, serverUrl.length - 1) : serverUrl;
|
||||
buffer.writeln('🔗 ForkFolio Link: $cleanBase/share/${recipe.shareToken}');
|
||||
} else if (recipe.sourceUrl != null && recipe.sourceUrl!.isNotEmpty) {
|
||||
buffer.writeln('🔗 Source: ${recipe.sourceUrl}');
|
||||
}
|
||||
|
||||
buffer.writeln('\n🛒 Ingredients:');
|
||||
for (final ing in recipe.ingredients) {
|
||||
buffer.writeln('• $ing');
|
||||
}
|
||||
|
||||
buffer.writeln('\n📋 Instructions:');
|
||||
for (var i = 0; i < recipe.instructions.length; i++) {
|
||||
buffer.writeln('${i + 1}. ${recipe.instructions[i]}');
|
||||
}
|
||||
|
||||
try {
|
||||
const platform = MethodChannel('com.forkfolio.mobile/native_share');
|
||||
await platform.invokeMethod('shareText', {
|
||||
'text': buffer.toString(),
|
||||
'subject': recipe.title,
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not trigger share: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext sheetCtx, Recipe recipe) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dlgCtx) => AlertDialog(
|
||||
backgroundColor: AppTheme.surfaceElevated,
|
||||
title: const Text('Delete Recipe?'),
|
||||
content: Text('Are you sure you want to delete "${recipe.title}"? This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dlgCtx, false),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppTheme.textMuted)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger),
|
||||
onPressed: () => Navigator.pop(dlgCtx, true),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && recipe.id != null) {
|
||||
Navigator.pop(sheetCtx);
|
||||
try {
|
||||
await ref.read(recipeListProvider.notifier).remove(recipe.id!);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.surfaceElevated,
|
||||
content: Text('Deleted "${recipe.title}"'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.danger,
|
||||
content: Text('Failed to delete recipe: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showProfileDialog() async {
|
||||
final user = ref.read(authProvider).user;
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final serverUrl = await storage.getServerUrl();
|
||||
final username = user?['username'] ?? 'User';
|
||||
final role = user?['isAdmin'] == true
|
||||
? 'Administrator'
|
||||
: (user?['isRecipeManager'] == true ? 'Recipe Manager' : 'Member');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppTheme.primary.withOpacity(0.2),
|
||||
child: Text(
|
||||
username.isNotEmpty ? username[0].toUpperCase() : 'U',
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(username, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(role, style: const TextStyle(fontSize: 13, color: AppTheme.secondary, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(color: AppTheme.border),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.dns_rounded, size: 16, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
serverUrl ?? 'Connected Server',
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textMuted),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.danger,
|
||||
side: const BorderSide(color: AppTheme.danger),
|
||||
),
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
label: const Text('Log Out'),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(authProvider.notifier).logout();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue