From a2d8c7646d13c97318fe3f3902136ab5dd5903af Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Tue, 18 Jun 2024 19:06:41 +0200 Subject: [PATCH 1/2] feat: 5403 - improvements for "price adding" Impacted files: * `app_en.arb`: added a "remember my choice" and a "barcode already there" labels * `app_fr.arb`: added a "remember my choice" and a "barcode already there" labels * `price_amount_card.dart`: now using a focus node and a `key`; stronger resilience * `price_amount_field.dart`: now using a focus node * `price_amount_model.dart`: minor refactoring * `price_model.dart`: new `getBarcodes` method * `price_product_search_page.dart`: now managing a list of existing barcodes; now storing at session level the answer of the "server look-up?" question * `product_price_add_page.dart`: now manages a focus on the latest added product, and keys for item deletion; not displaying the "add product" button if irrelevant --- packages/smooth_app/lib/l10n/app_en.arb | 13 +++ packages/smooth_app/lib/l10n/app_fr.arb | 13 +++ .../lib/pages/prices/price_amount_card.dart | 52 ++++++---- .../lib/pages/prices/price_amount_field.dart | 4 +- .../lib/pages/prices/price_amount_model.dart | 14 +-- .../lib/pages/prices/price_model.dart | 8 ++ .../prices/price_product_search_page.dart | 99 +++++++++++++++---- .../pages/prices/product_price_add_page.dart | 63 +++++++----- 8 files changed, 192 insertions(+), 74 deletions(-) diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index f966f3943b4..1a292709db2 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1726,6 +1726,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_list_length_one_page": "{count,plural, =0{No price yet} =1{Only one price} other{All {count} prices}}", "@prices_list_length_one_page": { @@ -2354,6 +2363,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 92aa57cbeea..ebb818a4f6f 100644 --- a/packages/smooth_app/lib/l10n/app_fr.arb +++ b/packages/smooth_app/lib/l10n/app_fr.arb @@ -1704,6 +1704,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", @@ -2317,6 +2326,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..fe8928f1120 100644 --- a/packages/smooth_app/lib/pages/prices/price_amount_card.dart +++ b/packages/smooth_app/lib/pages/prices/price_amount_card.dart @@ -12,19 +12,19 @@ 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(); @@ -35,22 +35,33 @@ class _PriceAmountCardState extends State { final TextEditingController _controllerWithoutDiscount = TextEditingController(); + @override + void initState() { + super.initState(); + _controllerPaid.text = _model.paidPrice; + _controllerWithoutDiscount.text = _model.priceWithoutDiscount; + } + + 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 +70,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 +90,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 +102,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 e16b639b764..23556392d3e 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 @@ -22,11 +22,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; - // TODO(monsieurtanuki): as a parameter, add a list of barcodes already there: we're not supposed to select twice the same product + /// List of barcodes already in the list. + final List barcodes; + + /// "Should we check the server?", saved here at the session level. + static bool? checkServer; @override State createState() => _PriceProductSearchPageState(); @@ -35,15 +38,22 @@ class PriceProductSearchPage extends StatefulWidget { class _PriceProductSearchPageState extends State { final TextEditingController _controller = TextEditingController(); - 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 ?? ''; + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); } static const String _barcodeHint = '7300400481588'; @@ -74,6 +84,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, @@ -82,8 +93,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( @@ -191,7 +212,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(), @@ -204,6 +224,7 @@ class _PriceProductSearchPageState extends State { if (!context.mounted) { return; } + _focusNode.requestFocus(); await _onChanged(context); if (_product != null) { return; @@ -211,20 +232,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; } @@ -233,4 +241,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 d851750fc1f..a9c98a11f25 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 @@ -80,6 +80,9 @@ class _ProductPriceAddPageState extends State { final GlobalKey _formKey = GlobalKey(); + int? _latestAddedItem; + FocusNode? _latestFocusNode; + @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); @@ -118,36 +121,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), ], From 1454d7eb0e438857854dc42629dc92419cedbd9a Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Thu, 20 Jun 2024 15:42:38 +0200 Subject: [PATCH 2/2] Added TextEditingController.dispose calls --- .../lib/pages/prices/price_amount_card.dart | 17 ++++++++++++----- .../pages/prices/price_product_search_page.dart | 5 +++-- 2 files changed, 15 insertions(+), 7 deletions(-) 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 fe8928f1120..9b09a7f51dd 100644 --- a/packages/smooth_app/lib/pages/prices/price_amount_card.dart +++ b/packages/smooth_app/lib/pages/prices/price_amount_card.dart @@ -31,15 +31,22 @@ class PriceAmountCard extends StatefulWidget { } 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.text = _model.paidPrice; - _controllerWithoutDiscount.text = _model.priceWithoutDiscount; + _controllerPaid = TextEditingController(text: _model.paidPrice); + _controllerWithoutDiscount = + TextEditingController(text: _model.priceWithoutDiscount); + } + + @override + void dispose() { + _controllerPaid.dispose(); + _controllerWithoutDiscount.dispose(); + super.dispose(); } PriceAmountModel get _model => 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 23556392d3e..4e29e4cd826 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 @@ -36,7 +36,7 @@ class PriceProductSearchPage extends StatefulWidget { } class _PriceProductSearchPageState extends State { - final TextEditingController _controller = TextEditingController(); + late final TextEditingController _controller; PriceMetaProduct? _product; late FocusNode _focusNode; @@ -46,12 +46,13 @@ class _PriceProductSearchPageState extends State { @override void initState() { super.initState(); - _controller.text = _product?.barcode ?? ''; + _controller = TextEditingController(text: ''); _focusNode = FocusNode(); } @override void dispose() { + _controller.dispose(); _focusNode.dispose(); super.dispose(); }