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>
110 lines
3.1 KiB
Dart
110 lines
3.1 KiB
Dart
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));
|
|
});
|