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:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

View File

@@ -42,4 +42,16 @@ class ApiClient {
final response = await _dio.delete(path);
return response.data;
}
/// Returns a list for endpoints that respond with a JSON array.
Future<List<dynamic>> getList(String path,
{Map<String, dynamic>? params}) async {
final response = await _dio.get(path, queryParameters: params);
return response.data as List<dynamic>;
}
/// Deletes a resource and expects no response body (204 No Content).
Future<void> deleteVoid(String path) async {
await _dio.delete(path);
}
}

View File

@@ -8,8 +8,11 @@ import '../../features/auth/register_screen.dart';
import '../../features/home/home_screen.dart';
import '../../features/products/products_screen.dart';
import '../../features/menu/menu_screen.dart';
import '../../features/recipes/recipe_detail_screen.dart';
import '../../features/recipes/recipes_screen.dart';
import '../../features/profile/profile_screen.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
@@ -34,6 +37,21 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/auth/register',
builder: (_, __) => const RegisterScreen(),
),
// Full-screen recipe detail — shown without the bottom navigation bar.
GoRoute(
path: '/recipe-detail',
builder: (context, state) {
final extra = state.extra;
if (extra is Recipe) {
return RecipeDetailScreen(recipe: extra);
}
if (extra is SavedRecipe) {
return RecipeDetailScreen(saved: extra);
}
// Fallback: pop back if navigated without a valid extra.
return const _InvalidRoute();
},
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
@@ -52,6 +70,18 @@ final routerProvider = Provider<GoRouter>((ref) {
);
});
class _InvalidRoute extends StatelessWidget {
const _InvalidRoute();
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).pop();
});
return const Scaffold(body: SizedBox.shrink());
}
}
class MainShell extends StatelessWidget {
final Widget child;