feat: barcode scanning for shelf add + scan screen barcode option

- Add ShelfBarcodeScanScreen: scans barcode via mobile_scanner, looks up
  product via GET /products/barcode/{barcode} (Open Food Facts fallback),
  returns CatalogProduct to caller; loading overlay while looking up;
  "Add manually" fallback in AppBar for unknown products
- Extract AddToShelfSheet to add_to_shelf_sheet.dart (was private in
  product_search_screen.dart) so both search and scan screens can reuse it
- Add barcode icon button to ProductSearchScreen AppBar → opens scanner
- Add "Scan barcode" card (📷) to ScanScreen alongside receipt and photo modes
- Rename ScanScreen title: addFromReceiptOrPhoto → scanScreenTitle
  ("Сканировать" / "Scan & Recognize") to reflect all three modes
- Add 2 L10n keys (scanScreenTitle, barcodeScanSubtitle) across all 12 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-26 15:21:56 +02:00
parent 7b2f86c6a4
commit b2bdcbae6f
29 changed files with 537 additions and 277 deletions

View File

@@ -0,0 +1,282 @@
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';
// ---------------------------------------------------------------------------
// Add to shelf bottom sheet
// ---------------------------------------------------------------------------
class AddToShelfSheet extends ConsumerStatefulWidget {
const AddToShelfSheet({
super.key,
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

@@ -4,9 +4,10 @@ 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 'add_to_shelf_sheet.dart';
import 'shelf_barcode_scan_screen.dart';
import 'user_product_provider.dart';
class ProductSearchScreen extends ConsumerStatefulWidget {
@@ -52,6 +53,17 @@ class _ProductSearchScreenState extends ConsumerState<ProductSearchScreen> {
});
}
void _openBarcodeScanner() {
Navigator.push<CatalogProduct>(
context,
MaterialPageRoute(builder: (_) => const ShelfBarcodeScanScreen()),
).then((catalogProduct) {
if (catalogProduct != null && mounted) {
_openShelfSheet(catalogProduct);
}
});
}
void _openShelfSheet(CatalogProduct catalogProduct) {
final messenger = ScaffoldMessenger.of(context);
final productName = catalogProduct.displayName;
@@ -60,7 +72,7 @@ class _ProductSearchScreenState extends ConsumerState<ProductSearchScreen> {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _AddToShelfSheet(
builder: (_) => AddToShelfSheet(
catalogProduct: catalogProduct,
onAdded: () => messenger.showSnackBar(
SnackBar(content: Text('$productName$addedText')),
@@ -77,6 +89,11 @@ class _ProductSearchScreenState extends ConsumerState<ProductSearchScreen> {
appBar: AppBar(
title: Text(l10n.addProduct),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
tooltip: l10n.scanBarcode,
onPressed: _openBarcodeScanner,
),
TextButton(
onPressed: () => context.push('/products/add'),
child: Text(l10n.addManually),
@@ -305,277 +322,3 @@ class _CatalogProductTile extends StatelessWidget {
}
}
}
// ---------------------------------------------------------------------------
// 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

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import 'user_product_provider.dart';
/// Barcode scanner for the shelf / pantry add flow.
///
/// Returns the found [CatalogProduct] via [Navigator.pop] so the caller
/// can open [_AddToShelfSheet] for the user to review and adjust quantity.
/// On failure the user can tap "Add manually" to open [AddProductScreen].
class ShelfBarcodeScanScreen extends ConsumerStatefulWidget {
const ShelfBarcodeScanScreen({super.key});
@override
ConsumerState<ShelfBarcodeScanScreen> createState() =>
_ShelfBarcodeScanScreenState();
}
class _ShelfBarcodeScanScreenState
extends ConsumerState<ShelfBarcodeScanScreen> {
bool _scanning = true;
bool _lookingUp = false;
Future<void> _onBarcodeDetected(BarcodeCapture capture) async {
if (!_scanning || _lookingUp) return;
final rawValue = capture.barcodes.firstOrNull?.rawValue;
if (rawValue == null) return;
setState(() {
_scanning = false;
_lookingUp = true;
});
final service = ref.read(userProductServiceProvider);
final catalogProduct = await service.getByBarcode(rawValue);
if (!mounted) return;
if (catalogProduct != null) {
Navigator.pop(context, catalogProduct);
return;
}
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.productNotFound)),
);
setState(() {
_scanning = true;
_lookingUp = false;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.scanBarcode),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
context.push('/products/add');
},
child: Text(l10n.addManually),
),
],
),
body: Stack(
children: [
MobileScanner(onDetect: _onBarcodeDetected),
if (_lookingUp)
const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(),
),
),
),
],
),
);
}
}

View File

@@ -4,6 +4,9 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import '../products/add_to_shelf_sheet.dart';
import '../products/shelf_barcode_scan_screen.dart';
import 'recognition_service.dart';
/// Entry screen — lets the user choose how to add products.
@@ -19,7 +22,7 @@ class _ScanScreenState extends ConsumerState<ScanScreen> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.addFromReceiptOrPhoto)),
appBar: AppBar(title: Text(l10n.scanScreenTitle)),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
@@ -43,11 +46,43 @@ class _ScanScreenState extends ConsumerState<ScanScreen> {
subtitle: l10n.photoProductsSubtitle,
onTap: () => _pickAndRecognize(context, _Mode.products),
),
const SizedBox(height: 16),
_ModeCard(
emoji: '📷',
title: l10n.scanBarcode,
subtitle: l10n.barcodeScanSubtitle,
onTap: _openBarcode,
),
],
),
);
}
void _openBarcode() {
final messenger = ScaffoldMessenger.of(context);
final addedText = AppLocalizations.of(context)!.productAddedToShelf;
Navigator.push<CatalogProduct>(
context,
MaterialPageRoute(builder: (_) => const ShelfBarcodeScanScreen()),
).then((catalogProduct) {
if (catalogProduct != null && mounted) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => AddToShelfSheet(
catalogProduct: catalogProduct,
onAdded: () => messenger.showSnackBar(
SnackBar(
content: Text('${catalogProduct.displayName}$addedText'),
),
),
),
);
}
});
}
Future<void> _pickAndRecognize(
BuildContext context,
_Mode mode,