Backend: - gemini/client.go: refactor to shared callGroq transport; add generateVisionContent using llama-3.2-11b-vision-preview model - gemini/recognition.go: RecognizeReceipt, RecognizeProducts, RecognizeDish (vision), ClassifyIngredient (text); shared parseJSON helper - ingredient/repository.go: add FuzzyMatch (wraps Search, returns best hit) - recognition/handler.go: POST /ai/recognize-receipt, /ai/recognize-products, /ai/recognize-dish; enrichItems with fuzzy match + AI classify fallback; parallel multi-image processing with deduplication - server.go + main.go: wire recognition handler under /ai routes Flutter: - pubspec.yaml: add image_picker ^1.1.0 - AndroidManifest.xml: add CAMERA and READ_EXTERNAL_STORAGE permissions - Info.plist: add NSCameraUsageDescription and NSPhotoLibraryUsageDescription - recognition_service.dart: RecognitionService wrapping /ai/* endpoints; RecognizedItem, ReceiptResult, DishResult models - scan_screen.dart: mode selector (receipt / products / dish / manual); image source picker; loading overlay; navigates to confirm or dish screen - recognition_confirm_screen.dart: editable list of recognized items; inline qty/unit editing; swipe-to-delete; batch-add to pantry - dish_result_screen.dart: dish name, KBZHU breakdown, similar dishes chips - app_router.dart: /scan, /scan/confirm, /scan/dish routes (no bottom nav) - products_screen.dart: FAB now shows bottom sheet with Manual / Scan options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
174 lines
5.5 KiB
Dart
174 lines
5.5 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/menu_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(),
|
|
),
|
|
// 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: 'Профиль',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|