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:
282
client/lib/features/products/add_to_shelf_sheet.dart
Normal file
282
client/lib/features/products/add_to_shelf_sheet.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
90
client/lib/features/products/shelf_barcode_scan_screen.dart
Normal file
90
client/lib/features/products/shelf_barcode_scan_screen.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user