Files
food-ai/client/lib/core/router/app_router.dart
dbastrikin e57ff8e06c 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>
2026-02-21 22:43:29 +02:00

124 lines
3.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../auth/auth_provider.dart';
import '../../features/auth/login_screen.dart';
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);
return GoRouter(
initialLocation: '/home',
redirect: (context, state) {
final isLoggedIn = authState.status == AuthStatus.authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (authState.status == AuthStatus.unknown) return null;
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
if (isLoggedIn && isAuthRoute) return '/home';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (_, __) => const LoginScreen(),
),
GoRoute(
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: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/products',
builder: (_, __) => const ProductsScreen()),
GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()),
GoRoute(
path: '/recipes', builder: (_, __) => const RecipesScreen()),
GoRoute(
path: '/profile', builder: (_, __) => const ProfileScreen()),
],
),
],
);
});
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;
const MainShell({super.key, required this.child});
static const _tabs = [
'/home',
'/products',
'/menu',
'/recipes',
'/profile',
];
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1);
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: (index) => context.go(_tabs[index]),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home), label: 'Главная'),
BottomNavigationBarItem(
icon: Icon(Icons.kitchen), label: 'Продукты'),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_month), label: 'Меню'),
BottomNavigationBarItem(
icon: Icon(Icons.menu_book), label: 'Рецепты'),
BottomNavigationBarItem(
icon: Icon(Icons.person), label: 'Профиль'),
],
),
);
}
}