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:
dbastrikin
2026-02-21 23:22:30 +02:00
parent 0dbda0cd57
commit b9b9e9fe11
20 changed files with 1585 additions and 32 deletions

View File

@@ -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: 'Профиль',
),
],
),
);

View File

@@ -0,0 +1,264 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/models/ingredient_mapping.dart';
import 'product_provider.dart';
class AddProductScreen extends ConsumerStatefulWidget {
const AddProductScreen({super.key});
@override
ConsumerState<AddProductScreen> createState() => _AddProductScreenState();
}
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _nameController = TextEditingController();
final _qtyController = TextEditingController(text: '1');
final _daysController = TextEditingController(text: '7');
String _unit = 'шт';
String? _category;
String? _mappingId;
bool _saving = false;
// Autocomplete state
List<IngredientMapping> _suggestions = [];
bool _searching = false;
Timer? _debounce;
static const _units = ['г', 'кг', 'мл', 'л', 'шт'];
@override
void dispose() {
_nameController.dispose();
_qtyController.dispose();
_daysController.dispose();
_debounce?.cancel();
super.dispose();
}
void _onNameChanged(String value) {
_debounce?.cancel();
// Reset mapping if user edits the name after selecting a suggestion
setState(() {
_mappingId = null;
});
if (value.trim().isEmpty) {
setState(() => _suggestions = []);
return;
}
_debounce = Timer(const Duration(milliseconds: 300), () async {
setState(() => _searching = true);
try {
final service = ref.read(productServiceProvider);
final results = await service.searchIngredients(value.trim());
if (mounted) setState(() => _suggestions = results);
} finally {
if (mounted) setState(() => _searching = false);
}
});
}
void _selectSuggestion(IngredientMapping mapping) {
setState(() {
_nameController.text = mapping.displayName;
_mappingId = mapping.id;
_category = mapping.category;
if (mapping.defaultUnit != null) {
// Map backend unit codes to display units
_unit = _mapUnit(mapping.defaultUnit!);
}
if (mapping.storageDays != null) {
_daysController.text = mapping.storageDays.toString();
}
_suggestions = [];
});
}
String _mapUnit(String backendUnit) {
switch (backendUnit.toLowerCase()) {
case 'g':
return 'г';
case 'kg':
return 'кг';
case 'ml':
return 'мл';
case 'l':
return 'л';
default:
return 'шт';
}
}
Future<void> _submit() async {
final name = _nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите название продукта')),
);
return;
}
final qty = double.tryParse(_qtyController.text) ?? 1;
final days = int.tryParse(_daysController.text) ?? 7;
setState(() => _saving = true);
try {
await ref.read(productsProvider.notifier).create(
name: name,
quantity: qty,
unit: _unit,
category: _category,
storageDays: days,
mappingId: _mappingId,
);
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось добавить продукт')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Добавить продукт')),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Name field with autocomplete dropdown
TextField(
controller: _nameController,
onChanged: _onNameChanged,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'Название',
border: const OutlineInputBorder(),
suffixIcon: _searching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
),
),
// Autocomplete suggestions
if (_suggestions.isNotEmpty)
Card(
margin: const EdgeInsets.only(top: 4),
child: Column(
children: _suggestions
.map((m) => ListTile(
title: Text(m.displayName),
subtitle: m.category != null
? Text(_categoryLabel(m.category!))
: null,
trailing: m.defaultUnit != null
? Text(m.defaultUnit!,
style:
Theme.of(context).textTheme.bodySmall)
: null,
onTap: () => _selectSuggestion(m),
))
.toList(),
),
),
const SizedBox(height: 16),
// Quantity + unit row
Row(
children: [
Expanded(
child: TextField(
controller: _qtyController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Количество',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _units.contains(_unit) ? _unit : _units.last,
items: _units
.map((u) =>
DropdownMenuItem(value: u, child: Text(u)))
.toList(),
onChanged: (v) => setState(() => _unit = v!),
),
),
],
),
const SizedBox(height: 16),
// Storage days
TextField(
controller: _daysController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Дней хранения',
helperText: 'Срок годности от сегодня',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Добавить'),
),
],
),
),
);
}
String _categoryLabel(String category) {
switch (category) {
case 'meat':
return 'Мясо';
case 'dairy':
return 'Молочные продукты';
case 'vegetable':
return 'Овощи';
case 'fruit':
return 'Фрукты';
case 'grain':
return 'Зерновые';
case 'seafood':
return 'Морепродукты';
case 'condiment':
return 'Приправы';
default:
return category;
}
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../shared/models/product.dart';
import 'product_service.dart';
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
final productServiceProvider = Provider<ProductService>((ref) {
return ProductService(ref.read(apiClientProvider));
});
final productsProvider =
StateNotifierProvider<ProductsNotifier, AsyncValue<List<Product>>>((ref) {
final service = ref.read(productServiceProvider);
return ProductsNotifier(service);
});
// ---------------------------------------------------------------------------
// Notifier
// ---------------------------------------------------------------------------
class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
ProductsNotifier(this._service) : super(const AsyncValue.loading()) {
_load();
}
final ProductService _service;
Future<void> _load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getProducts());
}
Future<void> refresh() => _load();
/// Adds a new product and inserts it into the sorted list.
Future<void> create({
required String name,
required double quantity,
required String unit,
String? category,
int storageDays = 7,
String? mappingId,
}) async {
final p = await _service.createProduct(
name: name,
quantity: quantity,
unit: unit,
category: category,
storageDays: storageDays,
mappingId: mappingId,
);
state.whenData((products) {
final updated = [...products, p]
..sort((a, b) => a.expiresAt.compareTo(b.expiresAt));
state = AsyncValue.data(updated);
});
}
/// Updates a product in-place, keeping list sort order.
Future<void> update(
String id, {
String? name,
double? quantity,
String? unit,
String? category,
int? storageDays,
}) async {
final p = await _service.updateProduct(
id,
name: name,
quantity: quantity,
unit: unit,
category: category,
storageDays: storageDays,
);
state.whenData((products) {
final updated = products.map((e) => e.id == id ? p : e).toList()
..sort((a, b) => a.expiresAt.compareTo(b.expiresAt));
state = AsyncValue.data(updated);
});
}
/// Optimistically removes the product, restores on error.
Future<void> delete(String id) async {
final previous = state;
state.whenData((products) {
state = AsyncValue.data(products.where((p) => p.id != id).toList());
});
try {
await _service.deleteProduct(id);
} catch (_) {
state = previous;
rethrow;
}
}
}

View File

@@ -0,0 +1,65 @@
import '../../core/api/api_client.dart';
import '../../shared/models/ingredient_mapping.dart';
import '../../shared/models/product.dart';
class ProductService {
const ProductService(this._client);
final ApiClient _client;
Future<List<Product>> getProducts() async {
final list = await _client.getList('/products');
return list.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Product> createProduct({
required String name,
required double quantity,
required String unit,
String? category,
int storageDays = 7,
String? mappingId,
}) async {
final data = await _client.post('/products', data: {
'name': name,
'quantity': quantity,
'unit': unit,
if (category != null) 'category': category,
'storage_days': storageDays,
if (mappingId != null) 'mapping_id': mappingId,
});
return Product.fromJson(data);
}
Future<Product> updateProduct(
String id, {
String? name,
double? quantity,
String? unit,
String? category,
int? storageDays,
}) async {
final data = await _client.put('/products/$id', data: {
if (name != null) 'name': name,
if (quantity != null) 'quantity': quantity,
if (unit != null) 'unit': unit,
if (category != null) 'category': category,
if (storageDays != null) 'storage_days': storageDays,
});
return Product.fromJson(data);
}
Future<void> deleteProduct(String id) =>
_client.deleteVoid('/products/$id');
Future<List<IngredientMapping>> searchIngredients(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(
'/ingredients/search',
params: {'q': query, 'limit': '10'},
);
return list
.map((e) => IngredientMapping.fromJson(e as Map<String, dynamic>))
.toList();
}
}

View File

@@ -1,13 +1,442 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class ProductsScreen extends StatelessWidget {
import '../../shared/models/product.dart';
import 'product_provider.dart';
class ProductsScreen extends ConsumerWidget {
const ProductsScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Продукты')),
body: const Center(child: Text('Раздел в разработке')),
appBar: AppBar(
title: const Text('Мои продукты'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(productsProvider.notifier).refresh(),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/products/add'),
icon: const Icon(Icons.add),
label: const Text('Добавить'),
),
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorView(
onRetry: () => ref.read(productsProvider.notifier).refresh(),
),
data: (products) => products.isEmpty
? _EmptyState(
onAdd: () => context.push('/products/add'),
)
: _ProductList(products: products),
),
);
}
}
// ---------------------------------------------------------------------------
// Product list split into expiring / normal sections
// ---------------------------------------------------------------------------
class _ProductList extends ConsumerWidget {
const _ProductList({required this.products});
final List<Product> products;
@override
Widget build(BuildContext context, WidgetRef ref) {
final expiring = products.where((p) => p.expiringSoon).toList();
final rest = products.where((p) => !p.expiringSoon).toList();
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.only(bottom: 80),
children: [
if (expiring.isNotEmpty) ...[
_SectionHeader(
icon: Icons.warning_amber_rounded,
iconColor: Colors.orange,
label: 'Истекает скоро',
),
...expiring.map((p) => _ProductTile(product: p)),
],
if (rest.isNotEmpty) ...[
_SectionHeader(
icon: Icons.kitchen,
label: 'Все продукты',
),
...rest.map((p) => _ProductTile(product: p)),
],
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.label,
this.icon,
this.iconColor,
});
final String label;
final IconData? icon;
final Color? iconColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: iconColor ?? theme.colorScheme.primary),
const SizedBox(width: 6),
],
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Single product tile with swipe-to-delete and tap-to-edit
// ---------------------------------------------------------------------------
class _ProductTile extends ConsumerWidget {
const _ProductTile({required this.product});
final Product product;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final daysColor = product.daysLeft <= 1
? Colors.red
: product.daysLeft <= 3
? Colors.orange
: theme.colorScheme.onSurfaceVariant;
return Dismissible(
key: ValueKey(product.id),
direction: DismissDirection.endToStart,
background: Container(
color: theme.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Icon(Icons.delete_outline, color: theme.colorScheme.onError),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Удалить продукт?'),
content: Text('«${product.name}» будет удалён из списка.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Удалить'),
),
],
),
);
},
onDismissed: (_) {
ref.read(productsProvider.notifier).delete(product.id);
},
child: ListTile(
leading: CircleAvatar(
backgroundColor: _categoryColor(product.category, theme).withValues(alpha: 0.15),
child: Text(
_categoryEmoji(product.category),
style: const TextStyle(fontSize: 20),
),
),
title: Text(product.name),
subtitle: Text(
'${_formatQty(product.quantity)} ${product.unit}',
style: theme.textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_daysLabel(product.daysLeft),
style: theme.textTheme.labelSmall?.copyWith(color: daysColor),
),
],
),
onTap: () => _showEditSheet(context, ref, product),
),
);
}
String _formatQty(double qty) {
if (qty == qty.roundToDouble()) return qty.toInt().toString();
return qty.toStringAsFixed(1);
}
String _daysLabel(int days) {
if (days == 0) return 'Истекает сегодня';
if (days == 1) return 'Остался 1 день';
if (days <= 4) return 'Осталось $days дня';
return 'Осталось $days дней';
}
Color _categoryColor(String? category, ThemeData theme) {
switch (category) {
case 'meat':
return Colors.red;
case 'dairy':
return Colors.blue;
case 'vegetable':
return Colors.green;
case 'fruit':
return Colors.orange;
case 'grain':
return Colors.amber;
default:
return theme.colorScheme.primary;
}
}
String _categoryEmoji(String? category) {
switch (category) {
case 'meat':
return '🥩';
case 'dairy':
return '🥛';
case 'vegetable':
return '🥕';
case 'fruit':
return '🍎';
case 'grain':
return '🌾';
case 'seafood':
return '🐟';
case 'condiment':
return '🧂';
default:
return '🛒';
}
}
void _showEditSheet(BuildContext context, WidgetRef ref, Product product) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => _EditProductSheet(product: product),
);
}
}
// ---------------------------------------------------------------------------
// Edit product bottom sheet
// ---------------------------------------------------------------------------
class _EditProductSheet extends ConsumerStatefulWidget {
const _EditProductSheet({required this.product});
final Product product;
@override
ConsumerState<_EditProductSheet> createState() => _EditProductSheetState();
}
class _EditProductSheetState extends ConsumerState<_EditProductSheet> {
late final _qtyController =
TextEditingController(text: widget.product.quantity.toString());
late String _unit = widget.product.unit;
late final _daysController =
TextEditingController(text: widget.product.storageDays.toString());
bool _saving = false;
static const _units = ['г', 'кг', 'мл', 'л', 'шт'];
@override
void dispose() {
_qtyController.dispose();
_daysController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final insets = MediaQuery.viewInsetsOf(context);
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.product.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _qtyController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Количество',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
DropdownButton<String>(
value: _units.contains(_unit) ? _unit : _units.first,
items: _units
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
.toList(),
onChanged: (v) => setState(() => _unit = v!),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _daysController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Дней хранения',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Сохранить'),
),
],
),
);
}
Future<void> _save() async {
setState(() => _saving = true);
try {
final qty = double.tryParse(_qtyController.text);
final days = int.tryParse(_daysController.text);
await ref.read(productsProvider.notifier).update(
widget.product.id,
quantity: qty,
unit: _unit,
storageDays: days,
);
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.onAdd});
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.kitchen_outlined,
size: 72,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Холодильник пуст',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Добавьте продукты вручную — или в Итерации 3 сфотографируйте чек',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add),
label: const Text('Добавить продукт'),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Error view
// ---------------------------------------------------------------------------
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text('Не удалось загрузить продукты'),
const SizedBox(height: 12),
FilledButton(
onPressed: onRetry,
child: const Text('Повторить'),
),
],
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
part 'ingredient_mapping.g.dart';
@JsonSerializable()
class IngredientMapping {
final String id;
@JsonKey(name: 'canonical_name')
final String canonicalName;
@JsonKey(name: 'canonical_name_ru')
final String? canonicalNameRu;
final String? category;
@JsonKey(name: 'default_unit')
final String? defaultUnit;
@JsonKey(name: 'storage_days')
final int? storageDays;
const IngredientMapping({
required this.id,
required this.canonicalName,
this.canonicalNameRu,
this.category,
this.defaultUnit,
this.storageDays,
});
/// Display name prefers Russian, falls back to canonical English name.
String get displayName => canonicalNameRu ?? canonicalName;
factory IngredientMapping.fromJson(Map<String, dynamic> json) =>
_$IngredientMappingFromJson(json);
Map<String, dynamic> toJson() => _$IngredientMappingToJson(this);
}

View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ingredient_mapping.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
IngredientMapping _$IngredientMappingFromJson(Map<String, dynamic> json) =>
IngredientMapping(
id: json['id'] as String,
canonicalName: json['canonical_name'] as String,
canonicalNameRu: json['canonical_name_ru'] as String?,
category: json['category'] as String?,
defaultUnit: json['default_unit'] as String?,
storageDays: (json['storage_days'] as num?)?.toInt(),
);
Map<String, dynamic> _$IngredientMappingToJson(IngredientMapping instance) =>
<String, dynamic>{
'id': instance.id,
'canonical_name': instance.canonicalName,
'canonical_name_ru': instance.canonicalNameRu,
'category': instance.category,
'default_unit': instance.defaultUnit,
'storage_days': instance.storageDays,
};

View File

@@ -0,0 +1,46 @@
import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart';
@JsonSerializable()
class Product {
final String id;
@JsonKey(name: 'user_id')
final String userId;
@JsonKey(name: 'mapping_id')
final String? mappingId;
final String name;
final double quantity;
final String unit;
final String? category;
@JsonKey(name: 'storage_days')
final int storageDays;
@JsonKey(name: 'added_at')
final DateTime addedAt;
@JsonKey(name: 'expires_at')
final DateTime expiresAt;
@JsonKey(name: 'days_left')
final int daysLeft;
@JsonKey(name: 'expiring_soon')
final bool expiringSoon;
const Product({
required this.id,
required this.userId,
this.mappingId,
required this.name,
required this.quantity,
required this.unit,
this.category,
required this.storageDays,
required this.addedAt,
required this.expiresAt,
required this.daysLeft,
required this.expiringSoon,
});
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}

View File

@@ -0,0 +1,37 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'product.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Product _$ProductFromJson(Map<String, dynamic> json) => Product(
id: json['id'] as String,
userId: json['user_id'] as String,
mappingId: json['mapping_id'] as String?,
name: json['name'] as String,
quantity: (json['quantity'] as num).toDouble(),
unit: json['unit'] as String,
category: json['category'] as String?,
storageDays: (json['storage_days'] as num).toInt(),
addedAt: DateTime.parse(json['added_at'] as String),
expiresAt: DateTime.parse(json['expires_at'] as String),
daysLeft: (json['days_left'] as num).toInt(),
expiringSoon: json['expiring_soon'] as bool,
);
Map<String, dynamic> _$ProductToJson(Product instance) => <String, dynamic>{
'id': instance.id,
'user_id': instance.userId,
'mapping_id': instance.mappingId,
'name': instance.name,
'quantity': instance.quantity,
'unit': instance.unit,
'category': instance.category,
'storage_days': instance.storageDays,
'added_at': instance.addedAt.toIso8601String(),
'expires_at': instance.expiresAt.toIso8601String(),
'days_left': instance.daysLeft,
'expiring_soon': instance.expiringSoon,
};