Files
food-ai/client/lib/core/router/app_router.dart
dbastrikin c7317c4335 feat: async product/receipt recognition via Kafka
Backend:
- Migration 002: product_recognition_jobs table with JSONB images column
  and job_type CHECK ('receipt' | 'products')
- New Kafka topics: ai.products.paid / ai.products.free
- ProductJob model, ProductJobRepository (mirrors dish job pattern)
- itemEnricher extracted from Handler — shared by HTTP handler and worker
- ProductSSEBroker: PG LISTEN on product_job_update channel
- ProductWorkerPool: 5 workers, branches on job_type to call
  RecognizeReceipt or RecognizeProducts per image in parallel
- Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted
  instead of blocking; 4 new endpoints: GET /ai/product-jobs,
  /product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream
- cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool
- cmd/server: wires productJobRepository + productSSEBroker; both SSE
  brokers started in App.Start()

Flutter client:
- ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent
  models + submitReceiptRecognition/submitProductsRecognition/stream methods
- Shared _openSseStream helper eliminates duplicate SSE parsing loop
- ScanScreen: replace blocking AI calls with async submit + navigate to
  ProductJobWatchScreen
- ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm
  when done, shows error on failure
- ProductsScreen: prepends _RecentScansSection (hidden when empty); compact
  horizontal list of recent scans with "See all" → history
- ProductJobHistoryScreen: full list of all product recognition jobs
- New routes: /scan/product-job-watch, /products/job-history
- L10n: 7 new keys in all 12 ARB files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:01:30 +02:00

226 lines
7.7 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_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(),
),
// 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,
),
],
),
);
}
}