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:
264
client/lib/features/products/add_product_screen.dart
Normal file
264
client/lib/features/products/add_product_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
client/lib/features/products/product_provider.dart
Normal file
100
client/lib/features/products/product_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
client/lib/features/products/product_service.dart
Normal file
65
client/lib/features/products/product_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user