routerProvider was watching authProvider and returning a new GoRouter on every status transition (unknown → authenticated). This caused MaterialApp.router to rebuild the entire navigation tree, which triggered all data providers to start loading before auth was confirmed. Switch to refreshListenable pattern: GoRouter is created once, _RouterNotifier fires notifyListeners() when auth changes, and GoRouter re-runs redirect using ref.read(authProvider). Add /loading splash route shown during AuthStatus.unknown so no authenticated screen (and no API call) is initiated until the stored-token check completes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
6.9 KiB
Dart
218 lines
6.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/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 changes without recreating the router.
|
|
class _RouterNotifier extends ChangeNotifier {
|
|
_RouterNotifier(Ref ref) {
|
|
ref.listen<AuthState>(authProvider, (_, __) => 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');
|
|
|
|
// Show splash until the stored-token check completes.
|
|
if (authState.status == AuthStatus.unknown) return '/loading';
|
|
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
|
|
if (isLoggedIn && isAuthRoute) 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(),
|
|
),
|
|
// 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 _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: 'Профиль',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|