feat: implement Iteration 2 — product management
Backend: - migrations/005: add pg_trgm extension + search indexes on ingredient_mappings - migrations/006: products table with computed expires_at column - ingredient: add Search method (aliases + ILIKE + trgm) + HTTP handler - product: full package — model, repository (CRUD + BatchCreate + ListForPrompt), handler - gemini: add AvailableProducts field to RecipeRequest, include in prompt - recommendation: add ProductLister interface, load user products for personalised prompts - server/main: wire ingredient and product handlers with new routes Flutter: - models: Product, IngredientMapping with json_serializable - ProductService: getProducts, createProduct, updateProduct, deleteProduct, searchIngredients - ProductsNotifier: create/update/delete with optimistic delete - ProductsScreen: expiring-soon section, normal section, swipe-to-delete, edit bottom sheet - AddProductScreen: name field with 300ms debounce autocomplete, qty/unit/days fields - app_router: /products/add route + Badge on Products nav tab showing expiring count - MainShell converted to ConsumerWidget for badge reactivity Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,12 @@ 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/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';
|
||||
|
||||
@@ -48,10 +50,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
if (extra is SavedRecipe) {
|
||||
return RecipeDetailScreen(saved: extra);
|
||||
}
|
||||
// Fallback: pop back if navigated without a valid extra.
|
||||
return const _InvalidRoute();
|
||||
},
|
||||
),
|
||||
// Add product — shown without the bottom navigation bar.
|
||||
GoRoute(
|
||||
path: '/products/add',
|
||||
builder: (_, __) => const AddProductScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => MainShell(child: child),
|
||||
routes: [
|
||||
@@ -82,7 +88,7 @@ class _InvalidRoute extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class MainShell extends StatelessWidget {
|
||||
class MainShell extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const MainShell({super.key, required this.child});
|
||||
@@ -96,26 +102,46 @@ class MainShell extends StatelessWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 [
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Главная',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home), label: 'Главная'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.kitchen), label: 'Продукты'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.calendar_month), label: 'Меню'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.menu_book), label: 'Рецепты'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.person), label: 'Профиль'),
|
||||
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: 'Профиль',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user