Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improve the Nutrition Facts screen #4278

Merged
merged 1 commit into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,13 @@ class _KnowledgePanelTableCardState extends State<KnowledgePanelTableCard> {
return Column(
children: <Widget>[
for (List<Widget> 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)
Expand Down Expand Up @@ -253,6 +257,21 @@ class _KnowledgePanelTableCardState extends State<KnowledgePanelTableCard> {
}
}
}

String _buildSemanticsValue(List<Widget> 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 {
Expand Down
22 changes: 21 additions & 1 deletion packages/smooth_app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1939,10 +1939,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.",
Expand Down
159 changes: 112 additions & 47 deletions packages/smooth_app/lib/pages/product/portion_calculator.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -18,73 +19,129 @@ class PortionCalculator extends StatefulWidget {
}

class _PortionCalculatorState extends State<PortionCalculator> {
/// 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: <Widget>[
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: <Widget>[
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: <Widget>[
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: <TextInputFormatter>[
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 EdgeInsetsDirectional.only(start: 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<void> _computeAndShow() async {
Expand All @@ -100,18 +157,20 @@ class _PortionCalculatorState extends State<PortionCalculator> {
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;
}
await showDialog<void>(
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<Widget>.generate(
helper.length,
Expand All @@ -132,4 +191,10 @@ class _PortionCalculatorState extends State<PortionCalculator> {
),
);
}

@override
void dispose() {
_quantityController.addListener(_onInputChanged);
super.dispose();
}
}