Backend: - Translate all recognition prompts (receipt, products, dish) from Russian to English - Add lang parameter to Recognizer interface and pass locale.FromContext in handlers - DishResult type uses candidates array for multi-candidate responses Client: - Add meal tracking: diary provider, date selector, meal type model - DishResult parser: backward-compatible with legacy flat format and new candidates format - DishResultScreen: sticky bottom button, full-width portion/meal-type inputs, КБЖУ disclaimer moved under nutrition card, add date field to diary POST body - Recognition prompts now return dish/product names in user's preferred language - Onboarding, profile, home screen visual updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
261 lines
8.8 KiB
Dart
261 lines
8.8 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/onboarding/onboarding_screen.dart';
|
|
import '../../features/profile/profile_provider.dart';
|
|
import '../../shared/models/user.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';
|
|
|
|
// Notifies GoRouter when auth state or profile state changes.
|
|
class _RouterNotifier extends ChangeNotifier {
|
|
_RouterNotifier(Ref ref) {
|
|
ref.listen<AuthState>(authProvider, (previous, next) {
|
|
// Reload profile whenever auth transitions to authenticated.
|
|
// This handles the case where profileProvider did an initial load before
|
|
// tokens were available (401 → AsyncError) and needs a fresh load after login.
|
|
if (next.status == AuthStatus.authenticated &&
|
|
previous?.status != AuthStatus.authenticated) {
|
|
ref.read(profileProvider.notifier).load();
|
|
}
|
|
notifyListeners();
|
|
});
|
|
ref.listen<AsyncValue<User>>(profileProvider, (_, __) => notifyListeners());
|
|
}
|
|
}
|
|
|
|
final routerProvider = Provider<GoRouter>((ref) {
|
|
final notifier = _RouterNotifier(ref);
|
|
ref.onDispose(notifier.dispose);
|
|
|
|
return GoRouter(
|
|
initialLocation: '/loading',
|
|
refreshListenable: notifier,
|
|
redirect: (context, state) {
|
|
// Use ref.read — this runs inside GoRouter, not inside a Riverpod build.
|
|
final authState = ref.read(authProvider);
|
|
final isLoggedIn = authState.status == AuthStatus.authenticated;
|
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
|
final isOnboarding = state.matchedLocation.startsWith('/onboarding');
|
|
|
|
// Show splash until the stored-token check completes.
|
|
if (authState.status == AuthStatus.unknown) return '/loading';
|
|
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
|
|
|
|
if (isLoggedIn) {
|
|
// Reading profileProvider triggers its lazy initialization (load() in constructor).
|
|
final profileState = ref.read(profileProvider);
|
|
// Keep showing splash while profile loads.
|
|
if (profileState.isLoading) {
|
|
return state.matchedLocation == '/loading' ? null : '/loading';
|
|
}
|
|
final profileUser = profileState.valueOrNull;
|
|
// If profile failed to load, don't block navigation.
|
|
if (profileUser == null) {
|
|
if (isAuthRoute || state.matchedLocation == '/loading') return '/home';
|
|
return null;
|
|
}
|
|
|
|
final needsOnboarding = !profileUser.hasCompletedOnboarding;
|
|
if (isAuthRoute || state.matchedLocation == '/loading') {
|
|
return needsOnboarding ? '/onboarding' : '/home';
|
|
}
|
|
if (needsOnboarding && !isOnboarding) return '/onboarding';
|
|
if (!needsOnboarding && isOnboarding) return '/home';
|
|
}
|
|
|
|
return null;
|
|
},
|
|
routes: [
|
|
// Splash shown while auth status is unknown.
|
|
GoRoute(
|
|
path: '/loading',
|
|
builder: (_, __) => const _SplashScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/auth/login',
|
|
builder: (_, __) => const LoginScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/auth/register',
|
|
builder: (_, __) => const RegisterScreen(),
|
|
),
|
|
// Onboarding — full-screen, no bottom nav, shown to new users.
|
|
GoRoute(
|
|
path: '/onboarding',
|
|
builder: (_, __) => const OnboardingScreen(),
|
|
),
|
|
// 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 extra = state.extra as Map<String, dynamic>?;
|
|
final dish = extra?['dish'] as DishResult?;
|
|
final mealType = extra?['meal_type'] as String?;
|
|
if (dish == null) return const _InvalidRoute();
|
|
return DishResultScreen(dish: dish, preselectedMealType: mealType);
|
|
},
|
|
),
|
|
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 _SplashScreen extends StatelessWidget {
|
|
const _SplashScreen();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
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: 'Профиль',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|