feat: product search screen with catalog add and success feedback

- Add product search screen (/products/search) as primary add flow;
  "Add" button on products list opens search, manual entry remains as fallback
- Add to shelf bottom sheet with AnimatedSwitcher success view (green checkmark)
  and SnackBar confirmation on the search screen via onAdded callback
- Manual add (AddProductScreen) shows SnackBar on success before popping back
- Extend AddProductScreen with optional nutrition fields (calories, protein,
  fat, carbs, fiber); auto-fills from catalog selection and auto-expands section
- Auto-upsert catalog product on backend when nutrition data is provided
  without a primary_product_id, linking the user product to the catalog
- Add fiber_per_100g field to CatalogProduct model and CreateRequest
- Add 16 new L10n keys across all 12 locales (addProduct, addManually,
  searchProducts, quantity, storageDays, addToShelf, nutritionOptional,
  calories, protein, fat, carbs, fiber, productAddedToShelf, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-26 14:04:58 +02:00
parent 16d944c80e
commit 8d33a4eb30
40 changed files with 2167 additions and 74 deletions

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/locale/unit_provider.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import 'user_product_provider.dart';
@@ -23,17 +24,30 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
String? _category;
String? _primaryProductId;
bool _saving = false;
bool _showNutrition = false;
// Autocomplete state
List<CatalogProduct> _suggestions = [];
bool _searching = false;
Timer? _debounce;
// Optional nutrition fields
final _caloriesController = TextEditingController();
final _proteinController = TextEditingController();
final _fatController = TextEditingController();
final _carbsController = TextEditingController();
final _fiberController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
_qtyController.dispose();
_daysController.dispose();
_caloriesController.dispose();
_proteinController.dispose();
_fatController.dispose();
_carbsController.dispose();
_fiberController.dispose();
_debounce?.cancel();
super.dispose();
}
@@ -73,6 +87,36 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
if (catalogProduct.storageDays != null) {
_daysController.text = catalogProduct.storageDays.toString();
}
// Pre-fill nutrition from catalog data.
if (catalogProduct.caloriesPer100g != null) {
_caloriesController.text =
catalogProduct.caloriesPer100g!.toStringAsFixed(1);
}
if (catalogProduct.proteinPer100g != null) {
_proteinController.text =
catalogProduct.proteinPer100g!.toStringAsFixed(1);
}
if (catalogProduct.fatPer100g != null) {
_fatController.text = catalogProduct.fatPer100g!.toStringAsFixed(1);
}
if (catalogProduct.carbsPer100g != null) {
_carbsController.text =
catalogProduct.carbsPer100g!.toStringAsFixed(1);
}
if (catalogProduct.fiberPer100g != null) {
_fiberController.text =
catalogProduct.fiberPer100g!.toStringAsFixed(1);
}
// Auto-expand nutrition section when any value is available.
final hasNutrition = catalogProduct.caloriesPer100g != null ||
catalogProduct.proteinPer100g != null ||
catalogProduct.fatPer100g != null ||
catalogProduct.carbsPer100g != null ||
catalogProduct.fiberPer100g != null;
if (hasNutrition) _showNutrition = true;
_suggestions = [];
});
}
@@ -88,6 +132,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final qty = double.tryParse(_qtyController.text) ?? 1;
final days = int.tryParse(_daysController.text) ?? 7;
final messenger = ScaffoldMessenger.of(context);
final addedText = AppLocalizations.of(context)!.productAddedToShelf;
setState(() => _saving = true);
try {
@@ -98,8 +144,18 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
category: _category,
storageDays: days,
primaryProductId: _primaryProductId,
caloriesPer100g: double.tryParse(_caloriesController.text),
proteinPer100g: double.tryParse(_proteinController.text),
fatPer100g: double.tryParse(_fatController.text),
carbsPer100g: double.tryParse(_carbsController.text),
fiberPer100g: double.tryParse(_fiberController.text),
);
if (mounted) Navigator.pop(context);
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('$name$addedText')),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -113,6 +169,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: const Text('Добавить продукт')),
body: GestureDetector(
@@ -210,7 +267,80 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
),
const SizedBox(height: 24),
const SizedBox(height: 16),
// Nutrition section (optional, collapsed by default)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => setState(() => _showNutrition = !_showNutrition),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
_showNutrition
? Icons.expand_less
: Icons.expand_more,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
l10n.nutritionOptional,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
if (_showNutrition) ...[
const SizedBox(height: 8),
_NutritionField(
controller: _caloriesController,
label: l10n.calories,
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _NutritionField(
controller: _proteinController,
label: l10n.protein,
),
),
const SizedBox(width: 10),
Expanded(
child: _NutritionField(
controller: _fatController,
label: l10n.fat,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _NutritionField(
controller: _carbsController,
label: l10n.carbs,
),
),
const SizedBox(width: 10),
Expanded(
child: _NutritionField(
controller: _fiberController,
label: l10n.fiber,
),
),
],
),
const SizedBox(height: 8),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _submit,
@@ -249,3 +379,27 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}
}
}
// ---------------------------------------------------------------------------
// Compact numeric text field for nutrition values
// ---------------------------------------------------------------------------
class _NutritionField extends StatelessWidget {
const _NutritionField({required this.controller, required this.label});
final TextEditingController controller;
final String label;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
);
}
}

View File

@@ -0,0 +1,581 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/locale/unit_provider.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import 'user_product_provider.dart';
class ProductSearchScreen extends ConsumerStatefulWidget {
const ProductSearchScreen({super.key});
@override
ConsumerState<ProductSearchScreen> createState() =>
_ProductSearchScreenState();
}
class _ProductSearchScreenState extends ConsumerState<ProductSearchScreen> {
final _searchController = TextEditingController();
Timer? _debounce;
List<CatalogProduct> _results = [];
bool _isLoading = false;
String _query = '';
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
void _onQueryChanged(String value) {
_debounce?.cancel();
setState(() => _query = value.trim());
if (value.trim().isEmpty) {
setState(() => _results = []);
return;
}
_debounce = Timer(const Duration(milliseconds: 300), () async {
setState(() => _isLoading = true);
try {
final service = ref.read(userProductServiceProvider);
final searchResults = await service.searchProducts(value.trim());
if (mounted) setState(() => _results = searchResults);
} finally {
if (mounted) setState(() => _isLoading = false);
}
});
}
void _openShelfSheet(CatalogProduct catalogProduct) {
final messenger = ScaffoldMessenger.of(context);
final productName = catalogProduct.displayName;
final addedText = AppLocalizations.of(context)!.productAddedToShelf;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _AddToShelfSheet(
catalogProduct: catalogProduct,
onAdded: () => messenger.showSnackBar(
SnackBar(content: Text('$productName$addedText')),
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.addProduct),
actions: [
TextButton(
onPressed: () => context.push('/products/add'),
child: Text(l10n.addManually),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TextField(
controller: _searchController,
onChanged: _onQueryChanged,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: l10n.searchProducts,
prefixIcon: const Icon(Icons.search),
suffixIcon: _isLoading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: _query.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onQueryChanged('');
},
)
: null,
border: const OutlineInputBorder(),
),
),
),
Expanded(
child: _buildBody(l10n),
),
],
),
);
}
Widget _buildBody(AppLocalizations l10n) {
if (_query.isEmpty) {
return _EmptyQueryPrompt(onAddManually: () => context.push('/products/add'));
}
if (_isLoading && _results.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (!_isLoading && _results.isEmpty) {
return _NoResultsView(
query: _query,
onAddManually: () => context.push('/products/add'),
);
}
return ListView.separated(
itemCount: _results.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) => _CatalogProductTile(
catalogProduct: _results[index],
onTap: () => _openShelfSheet(_results[index]),
),
);
}
}
// ---------------------------------------------------------------------------
// Empty query state
// ---------------------------------------------------------------------------
class _EmptyQueryPrompt extends StatelessWidget {
const _EmptyQueryPrompt({required this.onAddManually});
final VoidCallback onAddManually;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.searchProductsHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: onAddManually,
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.addManually),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// No results state
// ---------------------------------------------------------------------------
class _NoResultsView extends StatelessWidget {
const _NoResultsView({required this.query, required this.onAddManually});
final String query;
final VoidCallback onAddManually;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.noSearchResults(query),
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onAddManually,
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.addManually),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Catalog product tile
// ---------------------------------------------------------------------------
class _CatalogProductTile extends StatelessWidget {
const _CatalogProductTile({
required this.catalogProduct,
required this.onTap,
});
final CatalogProduct catalogProduct;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.secondaryContainer,
child: Text(
_categoryEmoji(catalogProduct.category),
style: const TextStyle(fontSize: 20),
),
),
title: Text(catalogProduct.displayName),
subtitle: catalogProduct.categoryName != null
? Text(
catalogProduct.categoryName!,
style: theme.textTheme.bodySmall,
)
: null,
trailing: catalogProduct.caloriesPer100g != null
? Text(
'${catalogProduct.caloriesPer100g!.round()} kcal',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
onTap: onTap,
);
}
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 '🛒';
}
}
}
// ---------------------------------------------------------------------------
// Add to shelf bottom sheet
// ---------------------------------------------------------------------------
class _AddToShelfSheet extends ConsumerStatefulWidget {
const _AddToShelfSheet({
required this.catalogProduct,
required this.onAdded,
});
final CatalogProduct catalogProduct;
final VoidCallback onAdded;
@override
ConsumerState<_AddToShelfSheet> createState() => _AddToShelfSheetState();
}
class _AddToShelfSheetState extends ConsumerState<_AddToShelfSheet> {
late final TextEditingController _qtyController;
late final TextEditingController _daysController;
late String _unit;
bool _saving = false;
bool _success = false;
@override
void initState() {
super.initState();
_qtyController = TextEditingController(text: '1');
_daysController = TextEditingController(
text: (widget.catalogProduct.storageDays ?? 7).toString(),
);
_unit = widget.catalogProduct.defaultUnit ?? 'pcs';
}
@override
void dispose() {
_qtyController.dispose();
_daysController.dispose();
super.dispose();
}
Future<void> _confirm() async {
final quantity = double.tryParse(_qtyController.text) ?? 1;
final storageDays = int.tryParse(_daysController.text) ?? 7;
setState(() => _saving = true);
try {
await ref.read(userProductsProvider.notifier).create(
name: widget.catalogProduct.displayName,
quantity: quantity,
unit: _unit,
category: widget.catalogProduct.category,
storageDays: storageDays,
primaryProductId: widget.catalogProduct.id,
);
if (mounted) {
setState(() {
_saving = false;
_success = true;
});
await Future.delayed(const Duration(milliseconds: 700));
if (mounted) {
widget.onAdded();
Navigator.pop(context);
}
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.errorGeneric)),
);
setState(() => _saving = false);
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final insets = MediaQuery.viewInsetsOf(context);
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_success) ...[
Text(
widget.catalogProduct.displayName,
style: theme.textTheme.titleMedium,
),
if (widget.catalogProduct.categoryName != null) ...[
const SizedBox(height: 2),
Text(
widget.catalogProduct.categoryName!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 16),
],
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: _success
? _SuccessView(
key: const ValueKey('success'),
productName: widget.catalogProduct.displayName,
)
: _ShelfForm(
key: const ValueKey('form'),
l10n: l10n,
theme: theme,
qtyController: _qtyController,
daysController: _daysController,
unit: _unit,
saving: _saving,
onUnitChanged: (value) => setState(() => _unit = value),
onConfirm: _confirm,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Form content extracted for AnimatedSwitcher key stability
// ---------------------------------------------------------------------------
class _ShelfForm extends ConsumerWidget {
const _ShelfForm({
super.key,
required this.l10n,
required this.theme,
required this.qtyController,
required this.daysController,
required this.unit,
required this.saving,
required this.onUnitChanged,
required this.onConfirm,
});
final AppLocalizations l10n;
final ThemeData theme;
final TextEditingController qtyController;
final TextEditingController daysController;
final String unit;
final bool saving;
final ValueChanged<String> onUnitChanged;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: qtyController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: l10n.quantity,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
ref.watch(unitsProvider).when(
data: (units) => DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: units.containsKey(unit) ? unit : units.keys.first,
items: units.entries
.map((entry) => DropdownMenuItem(
value: entry.key,
child: Text(entry.value),
))
.toList(),
onChanged: (value) => onUnitChanged(value!),
),
),
loading: () => const SizedBox(
width: 60,
child: LinearProgressIndicator(),
),
error: (_, __) => const Text('?'),
),
],
),
const SizedBox(height: 12),
TextField(
controller: daysController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.storageDays,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: saving ? null : onConfirm,
child: saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.addToShelf),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Success view shown briefly after the product is added
// ---------------------------------------------------------------------------
class _SuccessView extends StatelessWidget {
const _SuccessView({super.key, required this.productName});
final String productName;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.green.shade50,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_rounded,
color: Colors.green,
size: 36,
),
),
),
const SizedBox(height: 16),
Text(
productName,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.productAddedToShelf,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
],
);
}
}

View File

@@ -9,33 +9,28 @@ import '../scan/recognition_service.dart';
import 'product_job_provider.dart';
import 'user_product_provider.dart';
void _showAddMenu(BuildContext context) {
showModalBottomSheet(
Future<void> _confirmClearAll(
BuildContext context, WidgetRef ref, AppLocalizations l10n) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Добавить вручную'),
onTap: () {
Navigator.pop(ctx);
context.push('/products/add');
},
),
ListTile(
leading: const Icon(Icons.document_scanner_outlined),
title: const Text('Сканировать чек или фото'),
onTap: () {
Navigator.pop(ctx);
context.push('/scan');
},
),
],
),
builder: (ctx) => AlertDialog(
title: Text(l10n.clearAllConfirmTitle),
content: Text(l10n.clearAllConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.clearAllProducts),
),
],
),
);
if (confirmed == true) {
ref.read(userProductsProvider.notifier).deleteAll();
}
}
class ProductsScreen extends ConsumerWidget {
@@ -44,22 +39,24 @@ class ProductsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(userProductsProvider);
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Мои продукты'),
actions: [
if (state.valueOrNull?.isNotEmpty == true)
IconButton(
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: l10n.clearAllProducts,
onPressed: () => _confirmClearAll(context, ref, l10n),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(userProductsProvider.notifier).refresh(),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMenu(context),
icon: const Icon(Icons.add),
label: const Text('Добавить'),
),
body: Column(
children: [
const _RecentScansSection(),
@@ -70,10 +67,11 @@ class ProductsScreen extends ConsumerWidget {
onRetry: () => ref.read(userProductsProvider.notifier).refresh(),
),
data: (products) => products.isEmpty
? _EmptyState(onAdd: () => _showAddMenu(context))
? const _EmptyState()
: _ProductList(products: products),
),
),
const _BottomActionBar(),
],
),
);
@@ -252,7 +250,7 @@ class _ProductList extends ConsumerWidget {
return RefreshIndicator(
onRefresh: () => ref.read(userProductsProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.only(bottom: 80),
padding: EdgeInsets.zero,
children: [
if (expiring.isNotEmpty) ...[
_SectionHeader(
@@ -563,9 +561,7 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> {
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.onAdd});
final VoidCallback onAdd;
const _EmptyState();
@override
Widget build(BuildContext context) {
@@ -588,17 +584,59 @@ class _EmptyState extends StatelessWidget {
),
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('Добавить продукт'),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Bottom action toolbar
// ---------------------------------------------------------------------------
class _BottomActionBar extends StatelessWidget {
const _BottomActionBar();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.outlineVariant,
width: 0.5,
),
),
),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => context.push('/products/search'),
icon: const Icon(Icons.add, size: 18),
label: Text(l10n.addProduct),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => context.push('/scan'),
icon: const Icon(Icons.document_scanner_outlined, size: 18),
label: Text(l10n.scan),
),
),
],
),

View File

@@ -46,6 +46,11 @@ class UserProductsNotifier
String? category,
int storageDays = 7,
String? primaryProductId,
double? caloriesPer100g,
double? proteinPer100g,
double? fatPer100g,
double? carbsPer100g,
double? fiberPer100g,
}) async {
final userProduct = await _service.createProduct(
name: name,
@@ -54,6 +59,11 @@ class UserProductsNotifier
category: category,
storageDays: storageDays,
primaryProductId: primaryProductId,
caloriesPer100g: caloriesPer100g,
proteinPer100g: proteinPer100g,
fatPer100g: fatPer100g,
carbsPer100g: carbsPer100g,
fiberPer100g: fiberPer100g,
);
state.whenData((products) {
final updated = [...products, userProduct]
@@ -86,6 +96,20 @@ class UserProductsNotifier
});
}
/// Optimistically clears all products, restores on error.
Future<void> deleteAll() async {
final previous = state;
state.whenData((_) {
state = const AsyncValue.data([]);
});
try {
await _service.deleteAllProducts();
} catch (_) {
state = previous;
rethrow;
}
}
/// Optimistically removes the product, restores on error.
Future<void> delete(String id) async {
final previous = state;

View File

@@ -21,6 +21,11 @@ class UserProductService {
String? category,
int storageDays = 7,
String? primaryProductId,
double? caloriesPer100g,
double? proteinPer100g,
double? fatPer100g,
double? carbsPer100g,
double? fiberPer100g,
}) async {
final data = await _client.post('/user-products', data: {
'name': name,
@@ -29,6 +34,11 @@ class UserProductService {
if (category != null) 'category': category,
'storage_days': storageDays,
if (primaryProductId != null) 'primary_product_id': primaryProductId,
if (caloriesPer100g != null) 'calories_per_100g': caloriesPer100g,
if (proteinPer100g != null) 'protein_per_100g': proteinPer100g,
if (fatPer100g != null) 'fat_per_100g': fatPer100g,
if (carbsPer100g != null) 'carbs_per_100g': carbsPer100g,
if (fiberPer100g != null) 'fiber_per_100g': fiberPer100g,
});
return UserProduct.fromJson(data);
}
@@ -54,6 +64,8 @@ class UserProductService {
Future<void> deleteProduct(String id) =>
_client.deleteVoid('/user-products/$id');
Future<void> deleteAllProducts() => _client.deleteVoid('/user-products');
Future<List<CatalogProduct>> searchProducts(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(