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
|
package com.forkfolio.mobile
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
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
|
// 5. Save or Update Recipe
|
||||||
Future<Recipe> saveRecipe(Recipe recipe) async {
|
Future<Recipe> saveRecipe(Recipe recipe) async {
|
||||||
try {
|
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 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/constants/app_theme.dart';
|
import '../../core/constants/app_theme.dart';
|
||||||
import '../../core/models/recipe.dart';
|
import '../../core/models/recipe.dart';
|
||||||
import '../../core/providers.dart';
|
import '../../core/providers.dart';
|
||||||
import '../cook_mode/cook_mode_screen.dart';
|
import '../cook_mode/cook_mode_screen.dart';
|
||||||
|
import '../editor/recipe_edit_screen.dart';
|
||||||
import '../scanner/scanner_screen.dart';
|
import '../scanner/scanner_screen.dart';
|
||||||
|
|
||||||
class RecipesScreen extends ConsumerStatefulWidget {
|
class RecipesScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -47,12 +49,21 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Logout',
|
tooltip: 'Profile & Settings',
|
||||||
icon: const Icon(Icons.logout_rounded),
|
icon: const Icon(Icons.account_circle_outlined),
|
||||||
onPressed: () => ref.read(authProvider.notifier).logout(),
|
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(
|
body: RefreshIndicator(
|
||||||
color: AppTheme.primary,
|
color: AppTheme.primary,
|
||||||
onRefresh: () => ref.read(recipeListProvider.notifier).loadRecipes(),
|
onRefresh: () => ref.read(recipeListProvider.notifier).loadRecipes(),
|
||||||
|
|
@ -278,8 +289,10 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
Row(
|
||||||
width: double.infinity,
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -287,9 +300,60 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe)));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe)));
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.outdoor_grill_rounded),
|
icon: const Icon(Icons.outdoor_grill_rounded),
|
||||||
label: const Text('Start Cook Mode'),
|
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 SizedBox(height: 24),
|
||||||
const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
@ -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