Implement native share, recipe delete, profile logout, and recipe editor with AI polish

This commit is contained in:
Chris 2026-11-02 00:01:15 -05:00
parent 86442f62e0
commit a645f7d5fe
4 changed files with 714 additions and 14 deletions

View file

@ -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()
}
}
}
} }

View file

@ -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 {

View 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),
],
),
),
);
}
}

View file

@ -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,17 +289,70 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( Row(
width: double.infinity, children: [
height: 48, Expanded(
child: ElevatedButton.icon( child: SizedBox(
onPressed: () { height: 48,
Navigator.pop(ctx); child: ElevatedButton.icon(
Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe))); onPressed: () {
}, Navigator.pop(ctx);
icon: const Icon(Icons.outdoor_grill_rounded), Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe)));
label: const Text('Start Cook Mode'), },
), 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 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)),
@ -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),
],
),
),
);
}
} }