Backend: - Migrations 007 (menu_plans, menu_items, shopping_lists) and 008 (meal_diary) - gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call - internal/menu: model, repository (GetByWeek, SaveMenuInTx, shopping list CRUD), handler (GET/PUT/DELETE /menu, POST /ai/generate-menu, shopping list endpoints) - internal/diary: model, repository, handler (GET/POST/DELETE /diary) - Increase server WriteTimeout to 120s for long AI calls - api_client.go: add patch() and postList() helpers Flutter: - shared/models: menu.dart, shopping_item.dart, diary_entry.dart - features/menu: menu_service.dart, menu_provider.dart (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family) - MenuScreen: 7-day view, week nav, skeleton on generation, generate FAB with confirmation dialog - ShoppingListScreen: items by category, optimistic checkbox toggle - DiaryScreen: daily entries with swipe-to-delete, add-entry sheet - Router: /menu/shopping-list and /menu/diary routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
192 lines
6.1 KiB
Dart
192 lines
6.1 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/products/add_product_screen.dart';
|
|
import '../../features/scan/scan_screen.dart';
|
|
import '../../features/scan/recognition_confirm_screen.dart';
|
|
import '../../features/scan/dish_result_screen.dart';
|
|
import '../../features/scan/recognition_service.dart';
|
|
import '../../features/menu/diary_screen.dart';
|
|
import '../../features/menu/menu_screen.dart';
|
|
import '../../features/menu/shopping_list_screen.dart';
|
|
import '../../features/recipes/recipe_detail_screen.dart';
|
|
import '../../features/recipes/recipes_screen.dart';
|
|
import '../../features/profile/profile_screen.dart';
|
|
import '../../features/products/product_provider.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);
|
|
}
|
|
return const _InvalidRoute();
|
|
},
|
|
),
|
|
// Add product — shown without the bottom navigation bar.
|
|
GoRoute(
|
|
path: '/products/add',
|
|
builder: (_, __) => const AddProductScreen(),
|
|
),
|
|
// Shopping list — full-screen, no bottom nav.
|
|
GoRoute(
|
|
path: '/menu/shopping-list',
|
|
builder: (context, state) {
|
|
final week = state.extra as String? ?? '';
|
|
return ShoppingListScreen(week: week);
|
|
},
|
|
),
|
|
// Diary — full-screen, no bottom nav.
|
|
GoRoute(
|
|
path: '/menu/diary',
|
|
builder: (context, state) {
|
|
final date = state.extra as String? ?? '';
|
|
return DiaryScreen(date: date);
|
|
},
|
|
),
|
|
// Scan / recognition flow — all without bottom nav.
|
|
GoRoute(
|
|
path: '/scan',
|
|
builder: (_, __) => const ScanScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/scan/confirm',
|
|
builder: (context, state) {
|
|
final items = state.extra as List<RecognizedItem>? ?? [];
|
|
return RecognitionConfirmScreen(items: items);
|
|
},
|
|
),
|
|
GoRoute(
|
|
path: '/scan/dish',
|
|
builder: (context, state) {
|
|
final dish = state.extra as DishResult?;
|
|
if (dish == null) return const _InvalidRoute();
|
|
return DishResultScreen(dish: dish);
|
|
},
|
|
),
|
|
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 ConsumerWidget {
|
|
final Widget child;
|
|
|
|
const MainShell({super.key, required this.child});
|
|
|
|
static const _tabs = [
|
|
'/home',
|
|
'/products',
|
|
'/menu',
|
|
'/recipes',
|
|
'/profile',
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final location = GoRouterState.of(context).matchedLocation;
|
|
final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1);
|
|
|
|
// Count products expiring soon for the badge.
|
|
final expiringCount = ref.watch(productsProvider).maybeWhen(
|
|
data: (products) => products.where((p) => p.expiringSoon).length,
|
|
orElse: () => 0,
|
|
);
|
|
|
|
return Scaffold(
|
|
body: child,
|
|
bottomNavigationBar: BottomNavigationBar(
|
|
currentIndex: currentIndex,
|
|
onTap: (index) => context.go(_tabs[index]),
|
|
items: [
|
|
const BottomNavigationBarItem(
|
|
icon: Icon(Icons.home),
|
|
label: 'Главная',
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Badge(
|
|
isLabelVisible: expiringCount > 0,
|
|
label: Text('$expiringCount'),
|
|
child: const Icon(Icons.kitchen),
|
|
),
|
|
label: 'Продукты',
|
|
),
|
|
const BottomNavigationBarItem(
|
|
icon: Icon(Icons.calendar_month),
|
|
label: 'Меню',
|
|
),
|
|
const BottomNavigationBarItem(
|
|
icon: Icon(Icons.menu_book),
|
|
label: 'Рецепты',
|
|
),
|
|
const BottomNavigationBarItem(
|
|
icon: Icon(Icons.person),
|
|
label: 'Профиль',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|