- Add product search screen (/products/search) as primary add flow; "Add" button on products list opens search, manual entry remains as fallback - Add to shelf bottom sheet with AnimatedSwitcher success view (green checkmark) and SnackBar confirmation on the search screen via onAdded callback - Manual add (AddProductScreen) shows SnackBar on success before popping back - Extend AddProductScreen with optional nutrition fields (calories, protein, fat, carbs, fiber); auto-fills from catalog selection and auto-expands section - Auto-upsert catalog product on backend when nutrition data is provided without a primary_product_id, linking the user product to the catalog - Add fiber_per_100g field to CatalogProduct model and CreateRequest - Add 16 new L10n keys across all 12 locales (addProduct, addManually, searchProducts, quantity, storageDays, addToShelf, nutritionOptional, calories, protein, fat, carbs, fiber, productAddedToShelf, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
7.9 KiB
Dart
232 lines
7.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../l10n/app_localizations.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/products/product_search_screen.dart';
|
|
import '../../features/products/product_job_history_screen.dart';
|
|
import '../../features/scan/product_job_watch_screen.dart';
|
|
import '../../features/scan/scan_screen.dart';
|
|
import '../../features/scan/recognition_confirm_screen.dart';
|
|
import '../../features/scan/recognition_service.dart';
|
|
import '../../features/menu/menu_screen.dart';
|
|
import '../../features/menu/shopping_list_screen.dart';
|
|
import '../../features/profile/profile_screen.dart';
|
|
import '../../features/products/user_product_provider.dart';
|
|
import '../../features/scan/recognition_history_screen.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(),
|
|
),
|
|
// Add product — shown without the bottom navigation bar.
|
|
GoRoute(
|
|
path: '/products/add',
|
|
builder: (_, __) => const AddProductScreen(),
|
|
),
|
|
// Product search — search catalog before falling back to manual add.
|
|
GoRoute(
|
|
path: '/products/search',
|
|
builder: (_, __) => const ProductSearchScreen(),
|
|
),
|
|
// 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);
|
|
},
|
|
),
|
|
// 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/history',
|
|
builder: (_, __) => const RecognitionHistoryScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/scan/product-job-watch',
|
|
builder: (context, state) {
|
|
final jobCreated = state.extra as ProductJobCreated;
|
|
return ProductJobWatchScreen(jobCreated: jobCreated);
|
|
},
|
|
),
|
|
GoRoute(
|
|
path: '/products/job-history',
|
|
builder: (_, __) => const ProductJobHistoryScreen(),
|
|
),
|
|
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: '/profile', builder: (_, __) => const ProfileScreen()),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
class _SplashScreen extends StatelessWidget {
|
|
const _SplashScreen();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
class MainShell extends ConsumerWidget {
|
|
final Widget child;
|
|
|
|
const MainShell({super.key, required this.child});
|
|
|
|
static const _tabs = [
|
|
'/home',
|
|
'/products',
|
|
'/menu',
|
|
'/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(userProductsProvider).maybeWhen(
|
|
data: (products) => products.where((p) => p.expiringSoon).length,
|
|
orElse: () => 0,
|
|
);
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
return Scaffold(
|
|
body: child,
|
|
bottomNavigationBar: BottomNavigationBar(
|
|
currentIndex: currentIndex,
|
|
onTap: (index) => context.go(_tabs[index]),
|
|
items: [
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.home),
|
|
label: l10n.navHome,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Badge(
|
|
isLabelVisible: expiringCount > 0,
|
|
label: Text('$expiringCount'),
|
|
child: const Icon(Icons.kitchen),
|
|
),
|
|
label: l10n.navProducts,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.calendar_month),
|
|
label: l10n.menu,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.person),
|
|
label: l10n.profileTitle,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|