diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 72cf11612ef..e5ede2768e8 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1792,6 +1792,15 @@ } }, "prices_barcode_reader_action": "Barcode reader", + "prices_barcode_already": "Barcode {barcode} is already in the list!", + "@prices_barcode_already": { + "description": "Validation error message about barcode already being in the barcode list", + "placeholders": { + "barcode": { + "type": "String" + } + } + }, "prices_view_prices": "View the prices", "prices_product_accessibility_summary": "{count,plural, =1{1 price} other{{count} prices}} for {product}", "@prices_product_accessibility_summary": { @@ -2476,6 +2485,10 @@ "@image_edit_url_error": { "description": "Error message, when editing image fails, due to missing url." }, + "remember_my_choice_session": "Remember my choice for the current session", + "@remember_my_choice_session": { + "description": "Checkbox label when we want to remember the choice" + }, "user_picture_source_remember": "Remember my choice", "@user_picture_source_remember": { "description": "Checkbox label when select a picture source" diff --git a/packages/smooth_app/lib/l10n/app_fr.arb b/packages/smooth_app/lib/l10n/app_fr.arb index fd68a1ba3b5..7e0de82ad90 100644 --- a/packages/smooth_app/lib/l10n/app_fr.arb +++ b/packages/smooth_app/lib/l10n/app_fr.arb @@ -1802,6 +1802,15 @@ } } }, + "prices_barcode_already": "Le code-barres {barcode} fait déjà partie de la liste !", + "@prices_barcode_already": { + "description": "Validation error message about barcode already being in the barcode list", + "placeholders": { + "barcode": { + "type": "String" + } + } + }, "prices_list_length_many_pages": "{pageSize} prix les plus récents (total : {total})", "@prices_list_length_many_pages": { "description": "Number of prices for one-page result", @@ -2464,6 +2473,10 @@ "@image_edit_url_error": { "description": "Error message, when editing image fails, due to missing url." }, + "remember_my_choice_session": "Mémoriser mon choix pour la session en cours", + "@remember_my_choice_session": { + "description": "Checkbox label when we want to remember the choice" + }, "user_picture_source_remember": "Mémoriser mon choix", "@user_picture_source_remember": { "description": "Checkbox label when select a picture source" diff --git a/packages/smooth_app/lib/pages/prices/price_amount_card.dart b/packages/smooth_app/lib/pages/prices/price_amount_card.dart index 314c5209045..9b09a7f51dd 100644 --- a/packages/smooth_app/lib/pages/prices/price_amount_card.dart +++ b/packages/smooth_app/lib/pages/prices/price_amount_card.dart @@ -12,45 +12,63 @@ import 'package:smooth_app/pages/prices/price_product_search_page.dart'; /// Card that displays the amounts (discounted or not) for price adding. class PriceAmountCard extends StatefulWidget { - PriceAmountCard({ + const PriceAmountCard({ required this.priceModel, required this.index, required this.refresh, - }) : model = priceModel.priceAmountModels[index], - total = priceModel.priceAmountModels.length; + this.focusNode, + super.key, + }); final PriceModel priceModel; - final PriceAmountModel model; final int index; - final int total; // TODO(monsieurtanuki): not elegant, the display was not refreshed when removing an item final VoidCallback refresh; + final FocusNode? focusNode; @override State createState() => _PriceAmountCardState(); } class _PriceAmountCardState extends State { - final TextEditingController _controllerPaid = TextEditingController(); - final TextEditingController _controllerWithoutDiscount = - TextEditingController(); + late final TextEditingController _controllerPaid; + late final TextEditingController _controllerWithoutDiscount; + + @override + void initState() { + super.initState(); + _controllerPaid = TextEditingController(text: _model.paidPrice); + _controllerWithoutDiscount = + TextEditingController(text: _model.priceWithoutDiscount); + } + + @override + void dispose() { + _controllerPaid.dispose(); + _controllerWithoutDiscount.dispose(); + super.dispose(); + } + + PriceAmountModel get _model => + widget.priceModel.priceAmountModels[widget.index]; + int get _total => widget.priceModel.priceAmountModels.length; @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); - final bool isEmpty = widget.model.product.barcode.isEmpty; + final bool isEmpty = _model.product.barcode.isEmpty; return SmoothCard( child: Column( children: [ Text( '${appLocalizations.prices_amount_subtitle}' - '${widget.total == 1 ? '' : ' (${widget.index + 1}/${widget.total})'}', + '${_total == 1 ? '' : ' (${widget.index + 1}/$_total)'}', ), PriceProductListTile( - product: widget.model.product, + product: _model.product, trailingIconData: isEmpty ? Icons.edit - : widget.total == 1 + : _total == 1 ? null : Icons.clear, onPressed: isEmpty @@ -59,15 +77,18 @@ class _PriceAmountCardState extends State { await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) => - const PriceProductSearchPage(), + PriceProductSearchPage( + barcodes: widget.priceModel.getBarcodes(), + ), ), ); if (product == null) { return; } - setState(() => widget.model.product = product); + _model.product = product; + widget.refresh.call(); } - : widget.total == 1 + : _total == 1 ? null : () { widget.priceModel.priceAmountModels @@ -76,12 +97,11 @@ class _PriceAmountCardState extends State { }, ), SmoothLargeButtonWithIcon( - icon: widget.model.promo - ? Icons.check_box - : Icons.check_box_outline_blank, + icon: + _model.promo ? Icons.check_box : Icons.check_box_outline_blank, text: appLocalizations.prices_amount_is_discounted, onPressed: () => setState( - () => widget.model.promo = !widget.model.promo, + () => _model.promo = !_model.promo, ), ), const SizedBox(height: SMALL_SPACE), @@ -89,19 +109,20 @@ class _PriceAmountCardState extends State { children: [ Expanded( child: PriceAmountField( + focusNode: widget.focusNode, controller: _controllerPaid, isPaidPrice: true, - model: widget.model, + model: _model, ), ), const SizedBox(width: LARGE_SPACE), Expanded( - child: !widget.model.promo + child: !_model.promo ? Container() : PriceAmountField( controller: _controllerWithoutDiscount, isPaidPrice: false, - model: widget.model, + model: _model, ), ), ], diff --git a/packages/smooth_app/lib/pages/prices/price_amount_field.dart b/packages/smooth_app/lib/pages/prices/price_amount_field.dart index a45093b5014..90f71a926c1 100644 --- a/packages/smooth_app/lib/pages/prices/price_amount_field.dart +++ b/packages/smooth_app/lib/pages/prices/price_amount_field.dart @@ -9,13 +9,14 @@ class PriceAmountField extends StatelessWidget { required this.model, required this.isPaidPrice, required this.controller, + this.focusNode, }); final PriceAmountModel model; final bool isPaidPrice; final TextEditingController controller; + final FocusNode? focusNode; - // TODO(monsieurtanuki): TextInputAction + focus static const TextInputType _priceTextInputType = TextInputType.numberWithOptions( signed: false, @@ -26,6 +27,7 @@ class PriceAmountField extends StatelessWidget { Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); return SmoothTextFormField( + focusNode: focusNode, type: TextFieldTypes.PLAIN_TEXT, controller: controller, hintText: !isPaidPrice diff --git a/packages/smooth_app/lib/pages/prices/price_amount_model.dart b/packages/smooth_app/lib/pages/prices/price_amount_model.dart index d635342fb33..089d4cfd4e3 100644 --- a/packages/smooth_app/lib/pages/prices/price_amount_model.dart +++ b/packages/smooth_app/lib/pages/prices/price_amount_model.dart @@ -10,12 +10,8 @@ class PriceAmountModel { PriceMetaProduct product; - String _paidPrice = ''; - String _priceWithoutDiscount = ''; - - set paidPrice(final String value) => _paidPrice = value; - - set priceWithoutDiscount(final String value) => _priceWithoutDiscount = value; + String paidPrice = ''; + String priceWithoutDiscount = ''; late double _checkedPaidPrice; double? _checkedPriceWithoutDiscount; @@ -37,11 +33,11 @@ class PriceAmountModel { if (product.barcode.isEmpty) { return appLocalizations.prices_amount_no_product; } - _checkedPaidPrice = validateDouble(_paidPrice)!; + _checkedPaidPrice = validateDouble(paidPrice)!; _checkedPriceWithoutDiscount = null; if (promo) { - if (_priceWithoutDiscount.isNotEmpty) { - _checkedPriceWithoutDiscount = validateDouble(_priceWithoutDiscount); + if (priceWithoutDiscount.isNotEmpty) { + _checkedPriceWithoutDiscount = validateDouble(priceWithoutDiscount); if (_checkedPriceWithoutDiscount == null) { return appLocalizations.prices_amount_price_incorrect; } diff --git a/packages/smooth_app/lib/pages/prices/price_model.dart b/packages/smooth_app/lib/pages/prices/price_model.dart index ae5978231ac..b94974b10c7 100644 --- a/packages/smooth_app/lib/pages/prices/price_model.dart +++ b/packages/smooth_app/lib/pages/prices/price_model.dart @@ -25,6 +25,14 @@ class PriceModel with ChangeNotifier { final List priceAmountModels; + List getBarcodes() { + final List result = []; + for (final PriceAmountModel priceAmountModel in priceAmountModels) { + result.add(priceAmountModel.product.barcode); + } + return result; + } + CropParameters? _cropParameters; CropParameters? get cropParameters => _cropParameters; diff --git a/packages/smooth_app/lib/pages/prices/price_product_search_page.dart b/packages/smooth_app/lib/pages/prices/price_product_search_page.dart index fcfc88b81fb..aab558e6029 100644 --- a/packages/smooth_app/lib/pages/prices/price_product_search_page.dart +++ b/packages/smooth_app/lib/pages/prices/price_product_search_page.dart @@ -23,12 +23,14 @@ import 'package:smooth_app/widgets/smooth_scaffold.dart'; /// Product Search Page, for Prices. class PriceProductSearchPage extends StatefulWidget { const PriceProductSearchPage({ - this.product, + required this.barcodes, }); - final PriceMetaProduct? product; + /// List of barcodes already in the list. + final List barcodes; - // TODO(monsieurtanuki): as a parameter, add a list of barcodes already there: we're not supposed to select twice the same product + /// "Should we check the server?", saved here at the session level. + static bool? checkServer; @override State createState() => _PriceProductSearchPageState(); @@ -36,17 +38,25 @@ class PriceProductSearchPage extends StatefulWidget { class _PriceProductSearchPageState extends State with TraceableClientMixin { - final TextEditingController _controller = TextEditingController(); + late final TextEditingController _controller; - late PriceMetaProduct? _product = widget.product; + PriceMetaProduct? _product; + late FocusNode _focusNode; - // TODO(monsieurtanuki): TextInputAction + focus static const TextInputType _textInputType = TextInputType.number; @override void initState() { super.initState(); - _controller.text = _product?.barcode ?? ''; + _controller = TextEditingController(text: ''); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); } static const String _barcodeHint = '7300400481588'; @@ -77,6 +87,7 @@ class _PriceProductSearchPageState extends State // TODO(monsieurtanuki): add a "clear" button // TODO(monsieurtanuki): add an automatic "validate barcode" feature (cf. https://en.wikipedia.org/wiki/International_Article_Number#Check_digit) SmoothTextFormField( + focusNode: _focusNode, type: TextFieldTypes.PLAIN_TEXT, controller: _controller, hintText: _barcodeHint, @@ -85,8 +96,18 @@ class _PriceProductSearchPageState extends State onFieldSubmitted: (_) async => _onFieldSubmitted(context), prefixIcon: const Icon(CupertinoIcons.barcode), textInputAction: TextInputAction.search, + validator: (final String? value) { + if (value == null || value.isEmpty) { + return null; + } + if (widget.barcodes.contains(value)) { + return appLocalizations.prices_barcode_already(value); + } + return null; + }, ), - if (priceMetaProduct.isValid) + if (priceMetaProduct.isValid && + !widget.barcodes.contains(priceMetaProduct.barcode)) Padding( padding: const EdgeInsets.symmetric(vertical: LARGE_SPACE), child: PriceProductListTile( @@ -194,7 +215,6 @@ class _PriceProductSearchPageState extends State } Future _scan(final BuildContext context) async { - final AppLocalizations appLocalizations = AppLocalizations.of(context); final String? barcode = await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) => const PriceScanPage(), @@ -207,6 +227,7 @@ class _PriceProductSearchPageState extends State if (!context.mounted) { return; } + _focusNode.requestFocus(); await _onChanged(context); if (_product != null) { return; @@ -214,20 +235,7 @@ class _PriceProductSearchPageState extends State if (!context.mounted) { return; } - final bool? accepts = await showDialog( - context: context, - builder: (final BuildContext context) => SmoothAlertDialog( - body: Text(appLocalizations.prices_barcode_search_question), - neutralAction: SmoothActionButton( - text: appLocalizations.cancel, - onPressed: () => Navigator.of(context).pop(false), - ), - positiveAction: SmoothActionButton( - text: appLocalizations.yes, - onPressed: () => Navigator.of(context).pop(true), - ), - ), - ); + final bool accepts = await _shouldCheckServer(); if (!context.mounted) { return; } @@ -236,4 +244,53 @@ class _PriceProductSearchPageState extends State } await _onFieldSubmitted(context); } + + Future _shouldCheckServer() async { + if (PriceProductSearchPage.checkServer != null) { + return PriceProductSearchPage.checkServer!; + } + final AppLocalizations appLocalizations = AppLocalizations.of(context); + bool remember = false; + final bool? accepts = await showDialog( + context: context, + builder: (final BuildContext context) => StatefulBuilder( + builder: ( + BuildContext context, + void Function(VoidCallback fn) setState, + ) => + SmoothAlertDialog( + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + title: Text(appLocalizations.prices_barcode_search_question), + ), + CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + value: remember, + title: Text(appLocalizations.remember_my_choice_session), + onChanged: (_) => setState(() => remember = !remember), + ), + ], + ), + neutralAction: SmoothActionButton( + text: appLocalizations.cancel, + onPressed: () => Navigator.of(context).pop(false), + ), + positiveAction: SmoothActionButton( + text: appLocalizations.yes, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ), + ); + if (accepts == null) { + return false; + } + if (remember) { + PriceProductSearchPage.checkServer = accepts; + } + return accepts; + } } diff --git a/packages/smooth_app/lib/pages/prices/product_price_add_page.dart b/packages/smooth_app/lib/pages/prices/product_price_add_page.dart index 733098d9703..71db9a2eb29 100644 --- a/packages/smooth_app/lib/pages/prices/product_price_add_page.dart +++ b/packages/smooth_app/lib/pages/prices/product_price_add_page.dart @@ -83,6 +83,9 @@ class _ProductPriceAddPageState extends State final GlobalKey _formKey = GlobalKey(); + int? _latestAddedItem; + FocusNode? _latestFocusNode; + @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); @@ -121,36 +124,48 @@ class _ProductPriceAddPageState extends State const SizedBox(height: LARGE_SPACE), for (int i = 0; i < _model.priceAmountModels.length; i++) PriceAmountCard( + key: Key(_model.priceAmountModels[i].product.barcode), priceModel: _model, index: i, refresh: () => setState(() {}), + focusNode: _latestAddedItem == i ? _latestFocusNode : null, ), - // TODO(monsieurtanuki): check if there's an empty barcode before displaying this card - SmoothCard( - child: SmoothLargeButtonWithIcon( - text: appLocalizations.prices_add_an_item, - icon: Icons.add, - onPressed: () async { - final PriceMetaProduct? product = - await Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => - const PriceProductSearchPage(), - ), - ); - if (product == null) { - return; - } - setState( - () => _model.priceAmountModels.add( - PriceAmountModel( - product: product, + if (_model.priceAmountModels.isNotEmpty && + _model.priceAmountModels.first.product.barcode.isNotEmpty) + SmoothCard( + child: SmoothLargeButtonWithIcon( + text: appLocalizations.prices_add_an_item, + icon: Icons.add, + onPressed: () async { + final PriceMetaProduct? product = + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + PriceProductSearchPage( + barcodes: _model.getBarcodes(), + ), ), - ), - ); - }, + ); + if (product == null) { + return; + } + setState( + () { + _model.priceAmountModels.add( + PriceAmountModel( + product: product, + ), + ); + _latestAddedItem = + _model.priceAmountModels.length - 1; + _latestFocusNode?.dispose(); + _latestFocusNode = FocusNode(); + _latestFocusNode!.requestFocus(); + }, + ); + }, + ), ), - ), // so that the last items don't get hidden by the FAB const SizedBox(height: MINIMUM_TOUCH_SIZE * 2), ],