diff --git a/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_table_card.dart index 4f50479c8a90..b239bdcc49a2 100644 --- a/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_table_card.dart @@ -95,9 +95,13 @@ class _KnowledgePanelTableCardState extends State { return Column( children: [ for (List row in rowsWidgets) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: row, + Semantics( + excludeSemantics: true, + value: _buildSemanticsValue(row), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: row, + ), ), if (withPortionCalculator) const Divider(), if (withPortionCalculator) PortionCalculator(widget.product) @@ -253,6 +257,21 @@ class _KnowledgePanelTableCardState extends State { } } } + + String _buildSemanticsValue(List row) { + final StringBuffer buffer = StringBuffer(); + + for (final Widget widget in row) { + if (widget is TableCellWidget && widget.cell.text.isNotEmpty) { + if (buffer.isNotEmpty) { + buffer.write(' - '); + } + buffer.write(widget.cell.text); + } + } + + return buffer.toString(); + } } class TableCellWidget extends StatefulWidget { diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 18c3d011ae86..76a38c564c35 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1927,10 +1927,30 @@ "@scan_header_compare_button_valid_state_tooltip": { "description": "Tooltip (message visible with a long-press) on the Compare button on top of the scanner, when there is at least two prodiucts" }, - "portion_calculator_description": "Calculate nutrition facts for a specific quantity", + "portion_calculator_description": "Calculate nutrition facts for a specific quantity:", "@portion_calculator_description": { "description": "Sort of title that describes the portion calculator." }, + "portion_calculator_hint": "Quantity in", + "@portion_calculator_hint": { + "description": "Hint to show when a quantity is empty in the portion calculator." + }, + "portion_calculator_accessibility": "Input a quantity to calculate nutrition facts", + "@portion_calculator_accessibility": { + "description": "Hint for the acessibility to explain to enter a quantity." + }, + "portion_calculator_error": "Please enter a quantity between {min} and {max} g", + "@portion_calculator_error": { + "description": "Error message to explain that the quantity is invalid.", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "portion_calculator_result_title": "Nutrition facts for {grams} g (or ml)", "@portion_calculator_result_title": { "description": "Title of the results of the portion calculator.", diff --git a/packages/smooth_app/lib/pages/product/portion_calculator.dart b/packages/smooth_app/lib/pages/product/portion_calculator.dart index dc6e72215e1f..973679a28488 100644 --- a/packages/smooth_app/lib/pages/product/portion_calculator.dart +++ b/packages/smooth_app/lib/pages/product/portion_calculator.dart @@ -1,9 +1,10 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/pages/product/ordered_nutrients_cache.dart'; import 'package:smooth_app/pages/product/portion_helper.dart'; @@ -18,73 +19,129 @@ class PortionCalculator extends StatefulWidget { } class _PortionCalculatorState extends State { - /// Typical size needed for [CupertinoPicker]. - static const double _kItemExtent = DEFAULT_ICON_SIZE; - /// Max value for the picker. static const int _maxGrams = 1000; + static const int _minGrams = 10; - /// Value for the picker, with an initial value. - int _grams = 100; - - late final FixedExtentScrollController _controllerUnit; + final TextEditingController _quantityController = TextEditingController( + text: '100', + ); @override void initState() { super.initState(); - _controllerUnit = FixedExtentScrollController( - initialItem: _fromGramsToIndex(_grams), - ); + _quantityController.addListener(_onInputChanged); } + void _onInputChanged() => setState(() {}); + @override Widget build(BuildContext context) { + final MediaQueryData data = MediaQuery.of(context); final AppLocalizations appLocalizations = AppLocalizations.of(context); + final bool isQuantityValid = _isInputValid(); + return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - appLocalizations.portion_calculator_description, - textAlign: TextAlign.center, + // We have to manually add a Semantic node here, otherwise the text is + // not read + Semantics( + value: appLocalizations.portion_calculator_description, + excludeSemantics: true, + child: Text( + appLocalizations.portion_calculator_description, + style: Theme.of(context).textTheme.headlineMedium, + ), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: _kItemExtent * 2, - height: _kItemExtent * 5, - child: CupertinoPicker.builder( - scrollController: _controllerUnit, - itemExtent: _kItemExtent, - onSelectedItemChanged: (final int index) => - _grams = _fromIndexToGrams(index), - childCount: _fromGramsToIndex(_maxGrams) + 1, - itemBuilder: (final BuildContext context, final int index) => - Text( - '${_fromIndexToGrams(index)}', - style: Theme.of(context).textTheme.bodyMedium, + const SizedBox(height: MEDIUM_SPACE), + Container( + height: (data.textScaleFactor * (SMALL_SPACE * 2 + 15.0)) * 1.2, + padding: const EdgeInsets.symmetric(horizontal: MEDIUM_SPACE), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + SizedBox( + width: data.size.width * 0.3, + child: Semantics( + value: + '${_quantityController.text} ${UnitHelper.unitToString(Unit.G)}', + hint: appLocalizations.portion_calculator_accessibility, + textField: true, + excludeSemantics: true, + child: TextField( + controller: _quantityController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9]*')), + ], + enableSuggestions: false, + style: const TextStyle(letterSpacing: 5.0), + textAlign: TextAlign.center, + decoration: InputDecoration( + suffixText: UnitHelper.unitToString(Unit.G), + filled: true, + border: const OutlineInputBorder( + borderRadius: ANGULAR_BORDER_RADIUS, + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: SMALL_SPACE, + vertical: SMALL_SPACE, + ), + hintText: appLocalizations.portion_calculator_hint, + hintStyle: const TextStyle(letterSpacing: 1.0), + ), + textInputAction: TextInputAction.search, + onSubmitted: (_) { + if (_isInputValid()) { + _computeAndShow(); + } + }, + autofocus: false, + ), ), ), - ), - Text(UnitHelper.unitToString(Unit.G)!), - Padding( - padding: const EdgeInsets.only(left: SMALL_SPACE), - child: ElevatedButton( - onPressed: () async => _computeAndShow(), - child: Text(appLocalizations.calculate), + const SizedBox(width: MEDIUM_SPACE), + AnimatedOpacity( + opacity: isQuantityValid ? 1.0 : 0.5, + duration: SmoothAnimationsDuration.brief, + child: Tooltip( + message: !isQuantityValid + ? appLocalizations.portion_calculator_error( + _minGrams, + _maxGrams, + ) + : '', + excludeFromSemantics: isQuantityValid, + child: SizedBox( + height: double.infinity, + child: ElevatedButton( + onPressed: isQuantityValid + ? () async => _computeAndShow() + : null, + child: Text(appLocalizations.calculate), + ), + ), + ), ), - ), - ], + ], + ), ), ], ); } - int _fromIndexToGrams(final int index) => (index + 1) * 10; - - int _fromGramsToIndex(final int grams) => (grams ~/ 10) - 1; + bool _isInputValid() { + try { + final int value = int.parse(_quantityController.text); + return value >= _minGrams && value <= _maxGrams; + } on FormatException catch (_) { + return false; + } + } /// Computes all the nutrients with a portion factor, and displays a dialog. Future _computeAndShow() async { @@ -100,10 +157,12 @@ class _PortionCalculatorState extends State { if (!mounted) { return; } + + final int quantity = int.parse(_quantityController.text); final PortionHelper helper = PortionHelper( cache.orderedNutrients.nutrients, widget.product.nutriments!, - _grams, + quantity, ); if (helper.isEmpty) { return; @@ -111,7 +170,7 @@ class _PortionCalculatorState extends State { await showDialog( context: context, builder: (final BuildContext context) => SmoothAlertDialog( - title: appLocalizations.portion_calculator_result_title(_grams), + title: appLocalizations.portion_calculator_result_title(quantity), body: Column( children: List.generate( helper.length, @@ -132,4 +191,10 @@ class _PortionCalculatorState extends State { ), ); } + + @override + void dispose() { + _quantityController.addListener(_onInputChanged); + super.dispose(); + } }