feat: implement Iteration 1 — AI recipe recommendations
Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go
Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()
Project:
- Add CLAUDE.md with English-only rule for comments and commit messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
109
client/lib/features/recipes/recipe_provider.dart
Normal file
109
client/lib/features/recipes/recipe_provider.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
import 'recipe_service.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final recipeServiceProvider = Provider<RecipeService>((ref) {
|
||||
return RecipeService(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recommendations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecommendationsNotifier
|
||||
extends StateNotifier<AsyncValue<List<Recipe>>> {
|
||||
final RecipeService _service;
|
||||
|
||||
RecommendationsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load({int count = 5}) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _service.getRecommendations(count: count),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final recommendationsProvider = StateNotifierProvider<RecommendationsNotifier,
|
||||
AsyncValue<List<Recipe>>>((ref) {
|
||||
return RecommendationsNotifier(ref.read(recipeServiceProvider));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saved recipes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SavedRecipesNotifier
|
||||
extends StateNotifier<AsyncValue<List<SavedRecipe>>> {
|
||||
final RecipeService _service;
|
||||
|
||||
SavedRecipesNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.getSavedRecipes());
|
||||
}
|
||||
|
||||
/// Saves [recipe] and reloads the list. Returns the saved record or null on error.
|
||||
Future<SavedRecipe?> save(Recipe recipe) async {
|
||||
try {
|
||||
final saved = await _service.saveRecipe(recipe);
|
||||
await load();
|
||||
return saved;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the recipe with [id] optimistically and reverts on error.
|
||||
Future<bool> delete(String id) async {
|
||||
final previous = state;
|
||||
state = state.whenData(
|
||||
(list) => list.where((r) => r.id != id).toList(),
|
||||
);
|
||||
try {
|
||||
await _service.deleteSavedRecipe(id);
|
||||
return true;
|
||||
} catch (_) {
|
||||
state = previous;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any saved recipe has the same title.
|
||||
bool isSaved(String title) {
|
||||
return state.whenOrNull(
|
||||
data: (list) => list.any((r) => r.title == title),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// Returns the saved recipe ID for the given title, or null.
|
||||
String? savedId(String title) {
|
||||
return state.whenOrNull(
|
||||
data: (list) {
|
||||
try {
|
||||
return list.firstWhere((r) => r.title == title).id;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final savedRecipesProvider = StateNotifierProvider<SavedRecipesNotifier,
|
||||
AsyncValue<List<SavedRecipe>>>((ref) {
|
||||
return SavedRecipesNotifier(ref.read(recipeServiceProvider));
|
||||
});
|
||||
Reference in New Issue
Block a user