From ef0a67e8d70334be5d1dbf417d88000a23a24e67 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Fri, 7 Jan 2022 21:15:35 +0100 Subject: [PATCH 1/8] Collapse nutritable columns as requested by the server --- .../knowledge_panel_table_card.dart | 249 ++++++++++++++---- 1 file changed, 201 insertions(+), 48 deletions(-) diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart index 3856431f327..244df19fe5a 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:openfoodfacts/model/KnowledgePanel.dart'; @@ -5,71 +7,140 @@ import 'package:openfoodfacts/model/KnowledgePanelElement.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_ui_library/util/ui_helpers.dart'; -class KnowledgePanelTableCard extends StatelessWidget { +const int kMaxCellLengthInARow = 50; + +class ColumnGroup { + ColumnGroup( + {this.currentColumnIndex, this.currentColumn, required this.columns}); + + int? currentColumnIndex; + KnowledgePanelTableColumn? currentColumn; + final List columns; +} + +class TableCell { + TableCell({ + required this.text, + required this.color, + required this.isHeader, + this.columnGroup, + }); + + final String text; + final Color? color; + final bool isHeader; + // ColumnGroup is set for header cells. + final ColumnGroup? columnGroup; +} + +class KnowledgePanelTableCard extends StatefulWidget { const KnowledgePanelTableCard({ required this.tableElement, }); final KnowledgePanelTableElement tableElement; + @override + State createState() => + _KnowledgePanelTableCardState(); +} + +class _KnowledgePanelTableCardState extends State { + List columnGroups = []; + + @override + void initState() { + super.initState(); + // Build columnGroups + int index = 0; + final Map groupIdToColumnGroup = + {}; + for (final KnowledgePanelTableColumn column + in widget.tableElement.columns) { + if (column.columnGroupId == null) { + // Doesn't belong to a group, create a group with just this column. + columnGroups.add( + ColumnGroup( + currentColumnIndex: index, + currentColumn: column, + columns: [column], + ), + ); + } else { + final bool groupExists = + groupIdToColumnGroup.containsKey(column.columnGroupId); + if (!groupExists) { + final ColumnGroup newGroup = + ColumnGroup(columns: []); + columnGroups.add(newGroup); + groupIdToColumnGroup[column.columnGroupId!] = newGroup; + } + final ColumnGroup group = groupIdToColumnGroup[column.columnGroupId!]!; + // If no current column data is set, set it. + // If it's set and if [showByDefault] is true for this column, set it with this column's data. + if (column.showByDefault ?? false || group.currentColumn == null) { + group.currentColumnIndex = index; + group.currentColumn = column; + } + group.columns.add(column); + } + index++; + } + } + @override Widget build(BuildContext context) { - final List> rows = >[]; - rows.add([]); + final List> rows = >[]; + rows.add([]); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - // Dynamically calculate the width of each cell = Available space / total columns. - final double parentWidgetPadding = - SMOOTH_CARD_PADDING.left + SMOOTH_CARD_PADDING.right; - final double cellWidth = (constraints.maxWidth - parentWidgetPadding) / - tableElement.columns.length; - for (final KnowledgePanelTableColumn column in tableElement.columns) { + final List displayableColumnIndices = []; + for (final ColumnGroup columnGroup in columnGroups) { + final KnowledgePanelTableColumn column = columnGroup.currentColumn!; + final String text = column.textForSmallScreens ?? column.text; + displayableColumnIndices.add(columnGroup.currentColumnIndex!); switch (column.type) { case null: case KnowledgePanelColumnType.TEXT: - rows[0].add( - _buildTableCell( - context: context, - text: column.text, - cellWidth: cellWidth, - textColor: Colors.grey, + rows[0].add(TableCell( + text: text, + color: Colors.grey, isHeader: true, - ), - ); + columnGroup: columnGroup)); break; case KnowledgePanelColumnType.PERCENT: // TODO(jasmeet): Implement percent knowledge panels. - rows[0].add( - _buildTableCell( - context: context, - text: column.text, - cellWidth: cellWidth, - textColor: Colors.grey, + rows[0].add(TableCell( + text: text, + color: Colors.grey, isHeader: true, - ), - ); + columnGroup: columnGroup)); break; } } - for (final KnowledgePanelTableRowElement row in tableElement.rows) { - rows.add([]); + for (final KnowledgePanelTableRowElement row + in widget.tableElement.rows) { + rows.add([]); + int index = -1; for (final KnowledgePanelTableCell cell in row.values) { - rows[rows.length - 1].add( - _buildTableCell( - context: context, + index++; + if (!displayableColumnIndices.contains(index)) { + // This cell is not displayable. + continue; + } + rows[rows.length - 1].add(TableCell( text: cell.text, - cellWidth: cellWidth, - textColor: getTextColorFromKnowledgePanelElementEvaluation( + color: getTextColorFromKnowledgePanelElementEvaluation( cell.evaluation ?? Evaluation.UNKNOWN), - ), - ); + isHeader: false)); } } + final List> rowsWidgets = + _buildRowWidgets(rows, constraints); return Column( children: [ - for (List row in rows) + for (List row in rowsWidgets) Row( - crossAxisAlignment: CrossAxisAlignment.start, children: row, mainAxisAlignment: MainAxisAlignment.spaceBetween, ) @@ -78,32 +149,114 @@ class KnowledgePanelTableCard extends StatelessWidget { }); } - Widget _buildTableCell({ + List> _buildRowWidgets( + List> rows, BoxConstraints constraints) { + // [availableWidth] is parent's width - total padding we want in between columns. + final double availableWidth = constraints.maxWidth - LARGE_SPACE; + // [columnMaxLength] contains the maximum length of the cells in the columns. + // This helps us assign a dynamic width to the column depending upon the + // length of it's cells. + final List columnMaxLength = []; + for (final List row in rows) { + int index = 0; + for (final TableCell cell in row) { + if (cell.isHeader) { + // Set value for the header row. + columnMaxLength.add(cell.text.length); + } else { + if (cell.text.length > columnMaxLength[index]) { + columnMaxLength[index] = + min(kMaxCellLengthInARow, cell.text.length); + } + } + index++; + } + } + final int totalMaxColumnWidth = + columnMaxLength.reduce((int sum, int width) => sum + width); + + final List> rowsWidgets = >[]; + for (final List row in rows) { + final List rowWidgets = []; + int index = 0; + for (final TableCell cell in row) { + final double cellWidth = + availableWidth / totalMaxColumnWidth * columnMaxLength[index++]; + rowWidgets.add(_buildTableCellWidget( + context: context, + cell: cell, + cellWidth: cellWidth, + )); + } + rowsWidgets.add(rowWidgets); + } + return rowsWidgets; + } + + Widget _buildTableCellWidget({ required BuildContext context, - required String text, + required TableCell cell, required double cellWidth, - Color? textColor, - bool isHeader = false, }) { EdgeInsetsGeometry padding = const EdgeInsets.only(bottom: VERY_SMALL_SPACE); // header cells get a bigger vertical padding. - if (isHeader) { + if (cell.isHeader) { padding = const EdgeInsets.symmetric(vertical: SMALL_SPACE); } TextStyle style = Theme.of(context).textTheme.bodyText2!; - if (textColor != null) { - style = style.apply(color: textColor); + if (cell.color != null) { + style = style.apply(color: cell.color); } - return Padding( - padding: padding, - child: SizedBox( + Widget textWidget; + if (!cell.isHeader || cell.columnGroup!.columns.length == 1) { + textWidget = SizedBox( width: cellWidth, child: HtmlWidget( - text, + cell.text, textStyle: style, ), - ), + ); + } else { + textWidget = SizedBox( + width: cellWidth, + child: DropdownButtonHideUnderline( + child: ButtonTheme( + child: DropdownButton( + value: cell.columnGroup!.currentColumn, + items: cell.columnGroup!.columns + .map((KnowledgePanelTableColumn column) { + return DropdownMenuItem( + value: column, + child: Container( + // 24 px buffer is to allow the dropdown arrow. + constraints: + BoxConstraints(maxWidth: cellWidth - 24).normalize(), + child: Text(column.textForSmallScreens ?? column.text), + ), + ); + }).toList(), + onChanged: (KnowledgePanelTableColumn? selectedColumn) { + cell.columnGroup!.currentColumn = selectedColumn; + int i = 0; + for (final KnowledgePanelTableColumn column + in widget.tableElement.columns) { + if (column == selectedColumn) { + cell.columnGroup!.currentColumnIndex = i; + } + i++; + } + setState(() {}); + }, + style: style, + ), + ), + ), + ); + } + return Padding( + padding: padding, + child: textWidget, ); } } From ca934b7c6b1d73c56dc95d59e22d8532bcf5afd4 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Fri, 7 Jan 2022 21:46:39 +0100 Subject: [PATCH 2/8] Comments --- .../knowledge_panel_table_card.dart | 164 +++++++++++------- 1 file changed, 101 insertions(+), 63 deletions(-) diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart index 244df19fe5a..7b32b2cda3c 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart @@ -4,20 +4,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:openfoodfacts/model/KnowledgePanel.dart'; import 'package:openfoodfacts/model/KnowledgePanelElement.dart'; -import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_ui_library/util/ui_helpers.dart'; -const int kMaxCellLengthInARow = 50; - +/// ColumnGroup is a group of columns collapsed into a single column. Purpose of +/// this is to show a dropdown menu which the users can use to select which column +/// to display. A group can also have a single column, in which case there will +/// be no dropdown on the UI. class ColumnGroup { - ColumnGroup( - {this.currentColumnIndex, this.currentColumn, required this.columns}); + ColumnGroup({ + this.currentColumnIndex, + this.currentColumn, + required this.columns, + }); + /// The index of the column that is displayed in the [ColumnGroup]. int? currentColumnIndex; + + /// []KnowledgePanelTableColumn that is displayed in the [ColumnGroup]. KnowledgePanelTableColumn? currentColumn; + + /// List of columns in this [ColumnGroup]. final List columns; } +/// Represents the data in a single cell in this table. class TableCell { TableCell({ required this.text, @@ -29,7 +39,8 @@ class TableCell { final String text; final Color? color; final bool isHeader; - // ColumnGroup is set for header cells. + // [columnGroup] is set only cells that have [isHeader = true]. This is used + // to show a dropdown of other column headers in the group for this column. final ColumnGroup? columnGroup; } @@ -51,8 +62,9 @@ class _KnowledgePanelTableCardState extends State { @override void initState() { super.initState(); - // Build columnGroups + // Build [columnGroups] for the first time. int index = 0; + // Used to locate [columnGroup] for a given [column.columnGroupId]. final Map groupIdToColumnGroup = {}; for (final KnowledgePanelTableColumn column @@ -67,17 +79,20 @@ class _KnowledgePanelTableCardState extends State { ), ); } else { + // Try to find the group if it already exists. final bool groupExists = groupIdToColumnGroup.containsKey(column.columnGroupId); if (!groupExists) { + // Create a group since one doesn't exist yet. final ColumnGroup newGroup = ColumnGroup(columns: []); columnGroups.add(newGroup); groupIdToColumnGroup[column.columnGroupId!] = newGroup; } + // Look up the already existing or newly created group. final ColumnGroup group = groupIdToColumnGroup[column.columnGroupId!]!; - // If no current column data is set, set it. - // If it's set and if [showByDefault] is true for this column, set it with this column's data. + // If [showByDefault] is true, set this as the currentColumn on the group. + // As a safeguard (in case no column has [showByDefault] as true, also set currentColumn if it isn't set yet. if (column.showByDefault ?? false || group.currentColumn == null) { group.currentColumnIndex = index; group.currentColumn = column; @@ -90,53 +105,10 @@ class _KnowledgePanelTableCardState extends State { @override Widget build(BuildContext context) { - final List> rows = >[]; - rows.add([]); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - final List displayableColumnIndices = []; - for (final ColumnGroup columnGroup in columnGroups) { - final KnowledgePanelTableColumn column = columnGroup.currentColumn!; - final String text = column.textForSmallScreens ?? column.text; - displayableColumnIndices.add(columnGroup.currentColumnIndex!); - switch (column.type) { - case null: - case KnowledgePanelColumnType.TEXT: - rows[0].add(TableCell( - text: text, - color: Colors.grey, - isHeader: true, - columnGroup: columnGroup)); - break; - case KnowledgePanelColumnType.PERCENT: - // TODO(jasmeet): Implement percent knowledge panels. - rows[0].add(TableCell( - text: text, - color: Colors.grey, - isHeader: true, - columnGroup: columnGroup)); - break; - } - } - for (final KnowledgePanelTableRowElement row - in widget.tableElement.rows) { - rows.add([]); - int index = -1; - for (final KnowledgePanelTableCell cell in row.values) { - index++; - if (!displayableColumnIndices.contains(index)) { - // This cell is not displayable. - continue; - } - rows[rows.length - 1].add(TableCell( - text: cell.text, - color: getTextColorFromKnowledgePanelElementEvaluation( - cell.evaluation ?? Evaluation.UNKNOWN), - isHeader: false)); - } - } final List> rowsWidgets = - _buildRowWidgets(rows, constraints); + _buildRowWidgets(_buildRowCells(), constraints); return Column( children: [ for (List row in rowsWidgets) @@ -149,14 +121,72 @@ class _KnowledgePanelTableCardState extends State { }); } + List> _buildRowCells() { + final List> rows = >[]; + rows.add([]); + // Only [displayableColumnIndices] columns will be displayed. + final List displayableColumnIndices = []; + for (final ColumnGroup columnGroup in columnGroups) { + final KnowledgePanelTableColumn column = columnGroup.currentColumn!; + final String text = column.textForSmallScreens ?? column.text; + displayableColumnIndices.add(columnGroup.currentColumnIndex!); + switch (column.type) { + case null: + case KnowledgePanelColumnType.TEXT: + rows[0].add( + TableCell( + text: text, + color: Colors.grey, + isHeader: true, + columnGroup: columnGroup), + ); + break; + case KnowledgePanelColumnType.PERCENT: + // TODO(jasmeet): Implement percent knowledge panels. + rows[0].add( + TableCell( + text: text, + color: Colors.grey, + isHeader: true, + columnGroup: columnGroup), + ); + break; + } + } + for (final KnowledgePanelTableRowElement row in widget.tableElement.rows) { + rows.add([]); + int index = -1; + for (final KnowledgePanelTableCell cell in row.values) { + index++; + if (!displayableColumnIndices.contains(index)) { + // This cell is not displayable. + continue; + } + rows[rows.length - 1].add( + TableCell( + text: cell.text, + color: getTextColorFromKnowledgePanelElementEvaluation( + cell.evaluation ?? Evaluation.UNKNOWN), + isHeader: false), + ); + } + } + return rows; + } + List> _buildRowWidgets( List> rows, BoxConstraints constraints) { // [availableWidth] is parent's width - total padding we want in between columns. final double availableWidth = constraints.maxWidth - LARGE_SPACE; - // [columnMaxLength] contains the maximum length of the cells in the columns. + // [columnMaxLength] contains the length of the largest cells in the columns. // This helps us assign a dynamic width to the column depending upon the - // length of it's cells. + // largest cell in the column. final List columnMaxLength = []; + // Cells with a lot of text can get very large, we don't want to allocate + // the whole width to columns with these cells. So we cap the cell length + // considered for width allocation to [kMaxCellLengthInARow]. Cells with + // text larger than this limit will be wrapped in multiple rows. + const int maxCellLengthInARow = 50; for (final List row in rows) { int index = 0; for (final TableCell cell in row) { @@ -165,13 +195,14 @@ class _KnowledgePanelTableCardState extends State { columnMaxLength.add(cell.text.length); } else { if (cell.text.length > columnMaxLength[index]) { - columnMaxLength[index] = - min(kMaxCellLengthInARow, cell.text.length); + columnMaxLength[index] = min(maxCellLengthInARow, cell.text.length); } } index++; } } + // We now allocate width to each column as follows: + // [availableWidth] / [column's largest cell width] * [totalMaxColumnWidth]. final int totalMaxColumnWidth = columnMaxLength.reduce((int sum, int width) => sum + width); @@ -182,11 +213,13 @@ class _KnowledgePanelTableCardState extends State { for (final TableCell cell in row) { final double cellWidth = availableWidth / totalMaxColumnWidth * columnMaxLength[index++]; - rowWidgets.add(_buildTableCellWidget( - context: context, - cell: cell, - cellWidth: cellWidth, - )); + rowWidgets.add( + _buildTableCellWidget( + context: context, + cell: cell, + cellWidth: cellWidth, + ), + ); } rowsWidgets.add(rowWidgets); } @@ -210,6 +243,7 @@ class _KnowledgePanelTableCardState extends State { } Widget textWidget; if (!cell.isHeader || cell.columnGroup!.columns.length == 1) { + // non-header cells and columnGroups with a single column are simple html text widgets. textWidget = SizedBox( width: cellWidth, child: HtmlWidget( @@ -218,6 +252,7 @@ class _KnowledgePanelTableCardState extends State { ), ); } else { + // Now we finally render [ColumnGroup]s as drop down menus. textWidget = SizedBox( width: cellWidth, child: DropdownButtonHideUnderline( @@ -229,7 +264,7 @@ class _KnowledgePanelTableCardState extends State { return DropdownMenuItem( value: column, child: Container( - // 24 px buffer is to allow the dropdown arrow. + // 24 px buffer is to allow the dropdown arrow icon. constraints: BoxConstraints(maxWidth: cellWidth - 24).normalize(), child: Text(column.textForSmallScreens ?? column.text), @@ -243,9 +278,12 @@ class _KnowledgePanelTableCardState extends State { in widget.tableElement.columns) { if (column == selectedColumn) { cell.columnGroup!.currentColumnIndex = i; + break; } i++; } + // Since we have modified [currentColumn], re-rendering the + // widget will automagically select [selectedColumn]. setState(() {}); }, style: style, From c95ef387bf268b5c9977c4cecb0842af60cfc567 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Fri, 7 Jan 2022 22:10:28 +0100 Subject: [PATCH 3/8] Cosmetic change --- .../knowledge_panels/knowledge_panel_table_card.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart index 7b32b2cda3c..e56784acd6f 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart @@ -20,7 +20,7 @@ class ColumnGroup { /// The index of the column that is displayed in the [ColumnGroup]. int? currentColumnIndex; - /// []KnowledgePanelTableColumn that is displayed in the [ColumnGroup]. + /// [KnowledgePanelTableColumn] that is displayed in the [ColumnGroup]. KnowledgePanelTableColumn? currentColumn; /// List of columns in this [ColumnGroup]. @@ -39,7 +39,7 @@ class TableCell { final String text; final Color? color; final bool isHeader; - // [columnGroup] is set only cells that have [isHeader = true]. This is used + // [columnGroup] is set only for cells that have [isHeader = true]. This is used // to show a dropdown of other column headers in the group for this column. final ColumnGroup? columnGroup; } @@ -178,12 +178,12 @@ class _KnowledgePanelTableCardState extends State { List> rows, BoxConstraints constraints) { // [availableWidth] is parent's width - total padding we want in between columns. final double availableWidth = constraints.maxWidth - LARGE_SPACE; - // [columnMaxLength] contains the length of the largest cells in the columns. + // [columnMaxLength] contains the length of the largest cell in the columns. // This helps us assign a dynamic width to the column depending upon the // largest cell in the column. final List columnMaxLength = []; // Cells with a lot of text can get very large, we don't want to allocate - // the whole width to columns with these cells. So we cap the cell length + // most of [availableWidth] to columns with large cells. So we cap the cell length // considered for width allocation to [kMaxCellLengthInARow]. Cells with // text larger than this limit will be wrapped in multiple rows. const int maxCellLengthInARow = 50; From 521a5dfe6629989c27c23e21ce0d2a90524dcfe9 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Sat, 8 Jan 2022 00:19:09 +0100 Subject: [PATCH 4/8] Cosmetic change --- .../knowledge_panels/knowledge_panel_table_card.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart index e56784acd6f..db90431ce84 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart @@ -57,7 +57,7 @@ class KnowledgePanelTableCard extends StatefulWidget { } class _KnowledgePanelTableCardState extends State { - List columnGroups = []; + List columnGroups = []; @override void initState() { From 622a16e5f2a120109191b0f578810f021c112ea2 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Sat, 8 Jan 2022 00:21:56 +0100 Subject: [PATCH 5/8] maxCellLength from 50 to 40 --- .../knowledge_panels/knowledge_panel_table_card.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart index db90431ce84..46df92c670f 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart @@ -186,7 +186,7 @@ class _KnowledgePanelTableCardState extends State { // most of [availableWidth] to columns with large cells. So we cap the cell length // considered for width allocation to [kMaxCellLengthInARow]. Cells with // text larger than this limit will be wrapped in multiple rows. - const int maxCellLengthInARow = 50; + const int maxCellLengthInARow = 40; for (final List row in rows) { int index = 0; for (final TableCell cell in row) { From 20b2a223ec9271e00e3ae5c038b38bb43eb6f499 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Tue, 11 Jan 2022 15:41:25 +0100 Subject: [PATCH 6/8] Fixes for collapsing nutri tables --- .../knowledge_panel_table_card.dart | 278 +++++++++++------- 1 file changed, 164 insertions(+), 114 deletions(-) diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart index 46df92c670f..a6fb7a64cb7 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_table_card.dart @@ -1,11 +1,18 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/painting.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:openfoodfacts/model/KnowledgePanel.dart'; import 'package:openfoodfacts/model/KnowledgePanelElement.dart'; import 'package:smooth_ui_library/util/ui_helpers.dart'; +// Cells with a lot of text can get very large, we don't want to allocate +// most of [availableWidth] to columns with large cells. So we cap the cell length +// considered for width allocation to [kMaxCellLengthInARow]. Cells with +// text larger than this limit will be wrapped in multiple rows. +const int kMaxCellLengthInARow = 40; + /// ColumnGroup is a group of columns collapsed into a single column. Purpose of /// this is to show a dropdown menu which the users can use to select which column /// to display. A group can also have a single column, in which case there will @@ -57,50 +64,14 @@ class KnowledgePanelTableCard extends StatefulWidget { } class _KnowledgePanelTableCardState extends State { - List columnGroups = []; + final List _columnGroups = []; + final List _columnsMaxLength = []; @override void initState() { super.initState(); - // Build [columnGroups] for the first time. - int index = 0; - // Used to locate [columnGroup] for a given [column.columnGroupId]. - final Map groupIdToColumnGroup = - {}; - for (final KnowledgePanelTableColumn column - in widget.tableElement.columns) { - if (column.columnGroupId == null) { - // Doesn't belong to a group, create a group with just this column. - columnGroups.add( - ColumnGroup( - currentColumnIndex: index, - currentColumn: column, - columns: [column], - ), - ); - } else { - // Try to find the group if it already exists. - final bool groupExists = - groupIdToColumnGroup.containsKey(column.columnGroupId); - if (!groupExists) { - // Create a group since one doesn't exist yet. - final ColumnGroup newGroup = - ColumnGroup(columns: []); - columnGroups.add(newGroup); - groupIdToColumnGroup[column.columnGroupId!] = newGroup; - } - // Look up the already existing or newly created group. - final ColumnGroup group = groupIdToColumnGroup[column.columnGroupId!]!; - // If [showByDefault] is true, set this as the currentColumn on the group. - // As a safeguard (in case no column has [showByDefault] as true, also set currentColumn if it isn't set yet. - if (column.showByDefault ?? false || group.currentColumn == null) { - group.currentColumnIndex = index; - group.currentColumn = column; - } - group.columns.add(column); - } - index++; - } + _initColumnGroups(); + _initColumnsMaxLength(); } @override @@ -126,7 +97,7 @@ class _KnowledgePanelTableCardState extends State { rows.add([]); // Only [displayableColumnIndices] columns will be displayed. final List displayableColumnIndices = []; - for (final ColumnGroup columnGroup in columnGroups) { + for (final ColumnGroup columnGroup in _columnGroups) { final KnowledgePanelTableColumn column = columnGroup.currentColumn!; final String text = column.textForSmallScreens ?? column.text; displayableColumnIndices.add(columnGroup.currentColumnIndex!); @@ -164,10 +135,11 @@ class _KnowledgePanelTableCardState extends State { } rows[rows.length - 1].add( TableCell( - text: cell.text, - color: getTextColorFromKnowledgePanelElementEvaluation( - cell.evaluation ?? Evaluation.UNKNOWN), - isHeader: false), + text: cell.text, + color: getTextColorFromKnowledgePanelElementEvaluation( + cell.evaluation ?? Evaluation.UNKNOWN), + isHeader: false, + ), ); } } @@ -178,33 +150,10 @@ class _KnowledgePanelTableCardState extends State { List> rows, BoxConstraints constraints) { // [availableWidth] is parent's width - total padding we want in between columns. final double availableWidth = constraints.maxWidth - LARGE_SPACE; - // [columnMaxLength] contains the length of the largest cell in the columns. - // This helps us assign a dynamic width to the column depending upon the - // largest cell in the column. - final List columnMaxLength = []; - // Cells with a lot of text can get very large, we don't want to allocate - // most of [availableWidth] to columns with large cells. So we cap the cell length - // considered for width allocation to [kMaxCellLengthInARow]. Cells with - // text larger than this limit will be wrapped in multiple rows. - const int maxCellLengthInARow = 40; - for (final List row in rows) { - int index = 0; - for (final TableCell cell in row) { - if (cell.isHeader) { - // Set value for the header row. - columnMaxLength.add(cell.text.length); - } else { - if (cell.text.length > columnMaxLength[index]) { - columnMaxLength[index] = min(maxCellLengthInARow, cell.text.length); - } - } - index++; - } - } // We now allocate width to each column as follows: // [availableWidth] / [column's largest cell width] * [totalMaxColumnWidth]. final int totalMaxColumnWidth = - columnMaxLength.reduce((int sum, int width) => sum + width); + _columnsMaxLength.reduce((int sum, int width) => sum + width); final List> rowsWidgets = >[]; for (final List row in rows) { @@ -212,13 +161,13 @@ class _KnowledgePanelTableCardState extends State { int index = 0; for (final TableCell cell in row) { final double cellWidth = - availableWidth / totalMaxColumnWidth * columnMaxLength[index++]; + availableWidth / totalMaxColumnWidth * _columnsMaxLength[index++]; rowWidgets.add( - _buildTableCellWidget( - context: context, - cell: cell, - cellWidth: cellWidth, - ), + TableCellWidget( + cell: cell, + cellWidth: cellWidth, + tableElement: widget.tableElement, + rebuildTable: setState), ); } rowsWidgets.add(rowWidgets); @@ -226,75 +175,176 @@ class _KnowledgePanelTableCardState extends State { return rowsWidgets; } - Widget _buildTableCellWidget({ - required BuildContext context, - required TableCell cell, - required double cellWidth, - }) { - EdgeInsetsGeometry padding = - const EdgeInsets.only(bottom: VERY_SMALL_SPACE); + void _initColumnGroups() { + int index = 0; + // Used to locate [columnGroup] for a given [column.columnGroupId]. + final Map groupIdToColumnGroup = + {}; + for (final KnowledgePanelTableColumn column + in widget.tableElement.columns) { + if (column.columnGroupId == null) { + // Doesn't belong to a group, create a group with just this column. + _columnGroups.add( + ColumnGroup( + currentColumnIndex: index, + currentColumn: column, + columns: [column], + ), + ); + } else { + // Try to find the group if it already exists. + final bool groupExists = + groupIdToColumnGroup.containsKey(column.columnGroupId); + if (!groupExists) { + // Create a group since one doesn't exist yet. + final ColumnGroup newGroup = + ColumnGroup(columns: []); + _columnGroups.add(newGroup); + groupIdToColumnGroup[column.columnGroupId!] = newGroup; + } + // Look up the already existing or newly created group. + final ColumnGroup group = groupIdToColumnGroup[column.columnGroupId!]!; + // If [showByDefault] is true, set this as the currentColumn on the group. + // As a safeguard (in case no column has [showByDefault] as true, also set currentColumn if it isn't set yet. + if (column.showByDefault ?? false || group.currentColumn == null) { + group.currentColumnIndex = index; + group.currentColumn = column; + } + group.columns.add(column); + } + index++; + } + } + + void _initColumnsMaxLength() { + final List> rows = _buildRowCells(); + // [columnMaxLength] contains the length of the largest cell in the columns. + // This helps us assign a dynamic width to the column depending upon the + // largest cell in the column. + for (final List row in rows) { + int index = 0; + for (final TableCell cell in row) { + if (cell.isHeader) { + // Set value for the header row. + _columnsMaxLength.add(cell.text.length); + } else { + if (cell.text.length > _columnsMaxLength[index]) { + _columnsMaxLength[index] = + min(kMaxCellLengthInARow, cell.text.length); + } + } + index++; + } + } + } +} + +class TableCellWidget extends StatefulWidget { + const TableCellWidget({ + required this.cell, + required this.cellWidth, + required this.tableElement, + required this.rebuildTable, + }); + + final TableCell cell; + final double cellWidth; + final KnowledgePanelTableElement tableElement; + final void Function(VoidCallback fn) rebuildTable; + + @override + State createState() => _TableCellWidgetState(); +} + +class _TableCellWidgetState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + EdgeInsets padding = const EdgeInsets.only(bottom: VERY_SMALL_SPACE); // header cells get a bigger vertical padding. - if (cell.isHeader) { + if (widget.cell.isHeader) { padding = const EdgeInsets.symmetric(vertical: SMALL_SPACE); } TextStyle style = Theme.of(context).textTheme.bodyText2!; - if (cell.color != null) { - style = style.apply(color: cell.color); + if (widget.cell.color != null) { + style = style.apply(color: widget.cell.color); + } + if (!widget.cell.isHeader || widget.cell.columnGroup!.columns.length == 1) { + return _buildHtmlCell(padding, style); + } + return _buildDropDownColumnHeader(padding, style); + } + + Widget _buildHtmlCell(EdgeInsets padding, TextStyle style) { + String cellText = widget.cell.text; + if (!_isExpanded) { + const String htmlStyle = ''' + "text-overflow: ellipsis; + overflow: hidden; + max-lines: 2;" + '''; + cellText = '
${widget.cell.text}
'; } - Widget textWidget; - if (!cell.isHeader || cell.columnGroup!.columns.length == 1) { - // non-header cells and columnGroups with a single column are simple html text widgets. - textWidget = SizedBox( - width: cellWidth, - child: HtmlWidget( - cell.text, - textStyle: style, + return InkWell( + onTap: () => setState(() { + _isExpanded = true; + }), + child: Padding( + padding: padding, + child: SizedBox( + width: widget.cellWidth, + child: HtmlWidget( + cellText, + textStyle: style, + ), ), - ); - } else { - // Now we finally render [ColumnGroup]s as drop down menus. - textWidget = SizedBox( - width: cellWidth, + ), + ); + } + + Widget _buildDropDownColumnHeader(EdgeInsets padding, TextStyle style) { + // Now we finally render [ColumnGroup]s as drop down menus. + return Padding( + padding: padding, + child: SizedBox( + width: widget.cellWidth, child: DropdownButtonHideUnderline( child: ButtonTheme( child: DropdownButton( - value: cell.columnGroup!.currentColumn, - items: cell.columnGroup!.columns + value: widget.cell.columnGroup!.currentColumn, + items: widget.cell.columnGroup!.columns .map((KnowledgePanelTableColumn column) { return DropdownMenuItem( value: column, child: Container( - // 24 px buffer is to allow the dropdown arrow icon. - constraints: - BoxConstraints(maxWidth: cellWidth - 24).normalize(), + // 24 dp buffer is to allow the dropdown arrow icon to be displayed. + constraints: BoxConstraints(maxWidth: widget.cellWidth - 24) + .normalize(), child: Text(column.textForSmallScreens ?? column.text), ), ); }).toList(), onChanged: (KnowledgePanelTableColumn? selectedColumn) { - cell.columnGroup!.currentColumn = selectedColumn; + widget.cell.columnGroup!.currentColumn = selectedColumn; int i = 0; for (final KnowledgePanelTableColumn column in widget.tableElement.columns) { if (column == selectedColumn) { - cell.columnGroup!.currentColumnIndex = i; - break; + widget.cell.columnGroup!.currentColumnIndex = i; + // Since we have modified [currentColumn], re-rendering the + // table will automagically select [selectedColumn]. + widget.rebuildTable(() {}); + return; } i++; } - // Since we have modified [currentColumn], re-rendering the - // widget will automagically select [selectedColumn]. - setState(() {}); }, style: style, ), ), ), - ); - } - return Padding( - padding: padding, - child: textWidget, + ), ); } } From 7be3a52a567bd82fcbb17513e25ea23f933d84d3 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Tue, 11 Jan 2022 23:05:08 +0100 Subject: [PATCH 7/8] Show unanswered questions to the users on product page. Provide a path to sign in if they answer. --- .../product_cards/product_image_carousel.dart | 72 ++++ .../product_cards/product_title_card.dart | 36 ++ .../cards/product_cards/question_card.dart | 386 ++++++++++++++++++ .../database/robotoff_questions_query.dart | 20 + packages/smooth_app/lib/l10n/app_en.arb | 5 + .../lib/pages/product/new_product_page.dart | 73 +--- .../lib/pages/product/summary_card.dart | 80 +++- .../lib/widgets/smooth_card.dart | 4 +- 8 files changed, 592 insertions(+), 84 deletions(-) create mode 100644 packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart create mode 100644 packages/smooth_app/lib/cards/product_cards/product_title_card.dart create mode 100644 packages/smooth_app/lib/cards/product_cards/question_card.dart create mode 100644 packages/smooth_app/lib/database/robotoff_questions_query.dart diff --git a/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart b/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart new file mode 100644 index 00000000000..02296286786 --- /dev/null +++ b/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart @@ -0,0 +1,72 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/model/Product.dart'; +import 'package:openfoodfacts/model/ProductImage.dart'; +import 'package:smooth_app/cards/data_cards/image_upload_card.dart'; + +class ProductImageCarousel extends StatelessWidget { + const ProductImageCarousel(this.product, {required this.height}); + + final Product product; + final double height; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + final List carouselItems = [ + ImageUploadCard( + product: product, + imageField: ImageField.FRONT, + imageUrl: product.imageFrontUrl, + title: appLocalizations.product, + buttonText: appLocalizations.front_photo, + ), + ImageUploadCard( + product: product, + imageField: ImageField.INGREDIENTS, + imageUrl: product.imageIngredientsUrl, + title: appLocalizations.ingredients, + buttonText: appLocalizations.ingredients_photo, + ), + ImageUploadCard( + product: product, + imageField: ImageField.NUTRITION, + imageUrl: product.imageNutritionUrl, + title: appLocalizations.nutrition, + buttonText: appLocalizations.nutrition_facts_photo, + ), + ImageUploadCard( + product: product, + imageField: ImageField.PACKAGING, + imageUrl: product.imagePackagingUrl, + title: appLocalizations.packaging_information, + buttonText: appLocalizations.packaging_information_photo, + ), + ImageUploadCard( + product: product, + imageField: ImageField.OTHER, + imageUrl: null, + title: appLocalizations.more_photos, + buttonText: appLocalizations.more_photos, + ), + ]; + + return SizedBox( + height: height, + child: ListView( + // This next line does the trick. + scrollDirection: Axis.horizontal, + children: carouselItems + .map( + (ImageUploadCard item) => Container( + margin: const EdgeInsets.fromLTRB(0, 0, 5, 0), + decoration: const BoxDecoration(color: Colors.black12), + child: item, + ), + ) + .toList(), + ), + ); + } +} diff --git a/packages/smooth_app/lib/cards/product_cards/product_title_card.dart b/packages/smooth_app/lib/cards/product_cards/product_title_card.dart new file mode 100644 index 00000000000..a8924553579 --- /dev/null +++ b/packages/smooth_app/lib/cards/product_cards/product_title_card.dart @@ -0,0 +1,36 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/model/Product.dart'; + +class ProductTitleCard extends StatelessWidget { + const ProductTitleCard(this.product, {this.dense = false}); + + final Product product; + final bool dense; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + final ThemeData themeData = Theme.of(context); + return Align( + alignment: Alignment.topLeft, + child: ListTile( + dense: dense, + contentPadding: EdgeInsets.zero, + title: Text( + _getProductName(appLocalizations), + style: themeData.textTheme.headline4, + ), + subtitle: Text(product.brands ?? appLocalizations.unknownBrand), + trailing: Text( + product.quantity ?? '', + style: themeData.textTheme.headline3, + ), + ), + ); + } + + String _getProductName(final AppLocalizations appLocalizations) => + product.productName ?? appLocalizations.unknownProductName; +} diff --git a/packages/smooth_app/lib/cards/product_cards/question_card.dart b/packages/smooth_app/lib/cards/product_cards/question_card.dart new file mode 100644 index 00000000000..82f0995984b --- /dev/null +++ b/packages/smooth_app/lib/cards/product_cards/question_card.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/model/Product.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/cards/product_cards/product_image_carousel.dart'; +import 'package:smooth_app/cards/product_cards/product_title_card.dart'; +import 'package:smooth_app/helpers/user_management_helper.dart'; +import 'package:smooth_app/pages/user_management/login_page.dart'; +import 'package:smooth_ui_library/util/ui_helpers.dart'; + +class QuestionCard extends StatefulWidget { + const QuestionCard({ + required this.product, + required this.questions, + }); + + final Product product; + final List questions; + + @override + State createState() => _QuestionCardState(); +} + +class _QuestionCardState extends State + with SingleTickerProviderStateMixin { + int _currentQuestionIndex = 0; + InsightAnnotation? _lastAnswer; + late Future _isUserLoggedInFuture; + + @override + void initState() { + super.initState(); + _isUserLoggedInFuture = UserManagementHelper.credentialsInStorage(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xff4f4f4f), + appBar: AppBar(), + body: _buildAnimationSwitcher(), + ); + } + + AnimatedSwitcher _buildAnimationSwitcher() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (Widget child, Animation animation) { + final Offset animationStartOffset = _getAnimationStartOffset(); + final Animation inAnimation = Tween( + begin: animationStartOffset, + end: Offset.zero, + ).animate(animation); + final Animation outAnimation = Tween( + begin: animationStartOffset.scale(-1, -1), + end: Offset.zero, + ).animate(animation); + + if (child.key == ValueKey(_currentQuestionIndex)) { + return ClipRect( + child: SlideTransition( + position: inAnimation, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: child, + ), + ), + ); + } else { + return ClipRect( + child: SlideTransition( + position: outAnimation, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: child, + ), + ), + ); + } + }, + child: Container( + key: ValueKey(_currentQuestionIndex), + child: _buildWidget(context, _currentQuestionIndex), + ), + ); + } + + Offset _getAnimationStartOffset() { + Offset animationStartOffset; + switch (_lastAnswer) { + case InsightAnnotation.YES: + // For [InsightAnnotation.YES]: Animation starts from bottom left. + animationStartOffset = const Offset(-1.0, 0); + break; + case InsightAnnotation.NO: + // For [InsightAnnotation.YES]: Animation starts from bottom right. + animationStartOffset = const Offset(1.0, 0); + break; + case InsightAnnotation.MAYBE: + case null: + animationStartOffset = const Offset(0, 1); + } + return animationStartOffset; + } + + Widget _buildWidget(BuildContext context, int currentQuestionIndex) { + final List questions = widget.questions; + if (questions.length == currentQuestionIndex) { + return _buildCongratsWidget(context); + } + return Column( + children: [ + _buildQuestionCard( + context, widget.product, questions[currentQuestionIndex]), + _buildAnswerOptions( + context, + questions, + currentQuestionIndex: currentQuestionIndex, + ) + ], + ); + } + + Widget _buildQuestionCard( + BuildContext context, Product product, RobotoffQuestion question) { + final Size screenSize = MediaQuery.of(context).size; + return Card( + elevation: 4, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(VERY_LARGE_SPACE), + ), + ), + child: Column( + children: [ + ProductImageCarousel(widget.product, height: screenSize.height / 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: SMALL_SPACE), + child: Column( + children: [ + ProductTitleCard(widget.product, dense: true), + ], + ), + ), + _buildQuestionText(context, question), + ], + ), + ); + } + + Widget _buildQuestionText(BuildContext context, RobotoffQuestion question) { + return Container( + color: const Color(0xFFFFEFB7), + padding: const EdgeInsets.all(SMALL_SPACE), + child: Column( + children: [ + Container( + alignment: Alignment.center, + padding: const EdgeInsets.only(bottom: SMALL_SPACE), + child: Text( + question.question!, + style: Theme.of(context).textTheme.headline4, + ), + ), + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular(SMALL_SPACE), + ), + color: Colors.black, + ), + padding: const EdgeInsets.all(SMALL_SPACE), + child: Text( + question.value!, + style: Theme.of(context) + .textTheme + .headline4! + .apply(color: Colors.white), + ), + ), + ], + ), + ); + } + + Widget _buildAnswerOptions( + BuildContext context, List questions, + {required int currentQuestionIndex}) { + final Size screenSize = MediaQuery.of(context).size; + final double yesNoButtonWidth = screenSize.width / 3; + final RobotoffQuestion question = questions[currentQuestionIndex]; + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: yesNoButtonWidth, + height: yesNoButtonWidth / 1.25, + child: _buildInsightAnnotationButton( + insightId: question.insightId, + insightAnnotation: InsightAnnotation.NO, + backgroundColor: Colors.redAccent, + contentColor: Colors.white, + currentQuestionIndex: currentQuestionIndex, + ), + ), + SizedBox( + width: yesNoButtonWidth, + height: yesNoButtonWidth / 1.25, + child: _buildInsightAnnotationButton( + insightId: question.insightId, + insightAnnotation: InsightAnnotation.YES, + backgroundColor: Colors.lightGreen, + contentColor: Colors.white, + currentQuestionIndex: currentQuestionIndex, + ), + ), + ], + ), + AspectRatio( + aspectRatio: 8, + child: _buildInsightAnnotationButton( + insightId: question.insightId, + insightAnnotation: InsightAnnotation.MAYBE, + backgroundColor: Colors.white, + contentColor: Colors.grey, + currentQuestionIndex: currentQuestionIndex, + ), + ), + ], + ); + } + + Widget _buildInsightAnnotationButton( + {required String? insightId, + required InsightAnnotation insightAnnotation, + required Color backgroundColor, + required Color contentColor, + required int currentQuestionIndex}) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + String buttonText; + IconData? icon; + switch (insightAnnotation) { + case InsightAnnotation.YES: + buttonText = appLocalizations.yes; + icon = Icons.check; + break; + case InsightAnnotation.NO: + buttonText = appLocalizations.no; + icon = Icons.clear; + break; + case InsightAnnotation.MAYBE: + buttonText = appLocalizations.skip; + } + return GestureDetector( + onTap: () { + saveAnswer(insightId: insightId, insightAnnotation: insightAnnotation); + setState(() { + _lastAnswer = insightAnnotation; + _currentQuestionIndex++; + }); + }, + child: Card( + elevation: 4, + color: backgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(VERY_LARGE_SPACE)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Icon( + icon, + color: Colors.white, + size: 36, + ), + Text( + buttonText, + style: Theme.of(context) + .textTheme + .headline2! + .apply(color: contentColor), + ), + ], + ), + ), + ); + } + + Widget _buildCongratsWidget(BuildContext context) { + final TextStyle bodyTextStyle = + Theme.of(context).textTheme.bodyText2!.apply(color: Colors.white); + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.grade, + color: Colors.white, + size: 72, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), + child: Text( + appLocalizations.thanks_for_contributing, + style: bodyTextStyle, + ), + ), + FutureBuilder( + future: _isUserLoggedInFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final bool isUserLoggedIn = snapshot.data!; + if (isUserLoggedIn) { + // TODO(jasmeet): Show leaderboard button. + return EMPTY_WIDGET; + } + return Column( + children: [ + InkWell( + onTap: () async { + Navigator.pop(context); + await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => + const LoginPage(), + ), + ); + }, + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular(SMALL_SPACE), + ), + color: Colors.grey, + ), + width: 150, + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Center( + child: Text( + appLocalizations.sign_in, + style: Theme.of(context).textTheme.headline3, + ), + ), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), + child: Text( + appLocalizations.sign_in_text, + style: bodyTextStyle, + ), + ), + ], + ); + } else { + return EMPTY_WIDGET; + } + }), + ], + ), + ); + } +} + +Future saveAnswer({ + required String? insightId, + required InsightAnnotation insightAnnotation, +}) async { + // TODO(jasmeet): Send answer to the Backend. + // TODO(jasmeet): Fix for iOS. https://pub.dev/packages/device_info + // DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + // AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + // + // final Status status = await OpenFoodAPIClient.postInsightAnnotation( + // insightId, + // insightAnnotation, + // deviceId: androidInfo.id, + // ); +} diff --git a/packages/smooth_app/lib/database/robotoff_questions_query.dart b/packages/smooth_app/lib/database/robotoff_questions_query.dart new file mode 100644 index 00000000000..7be315222d1 --- /dev/null +++ b/packages/smooth_app/lib/database/robotoff_questions_query.dart @@ -0,0 +1,20 @@ +import 'dart:async'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/utils/QueryType.dart'; +import 'package:smooth_app/database/product_query.dart'; + +class RobotoffQuestionsQuery { + RobotoffQuestionsQuery(this.barcode); + final String barcode; + + Future> getRobotoffQuestionsForProduct() async { + final RobotoffQuestionResult result = + await OpenFoodAPIClient.getRobotoffQuestionsForProduct( + barcode, + ProductQuery.getLanguage().code, + count: 3, + queryType: QueryType.PROD, + ); + return result.questions ?? []; + } +} diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index b7f9009290b..11631d24e20 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -13,6 +13,7 @@ "description": "A label on a button that says 'Next', pressing the button takes the user to the next screen." }, "save": "Save", + "skip": "Skip", "cancel": "Cancel", "@cancel": {}, "close": "Close", @@ -173,6 +174,10 @@ "@darkmode_light": { "description": "Indicator inside the darkmode switch" }, + "thanks_for_contributing": "Thanks for contributing", + "@contributors": { + "description": "Text shown to the user after they have contributed to the project." + }, "contributors": "Contributors", "@contributors": { "description": "Button label: Opens a pop up window where all contributors of this app are shown" diff --git a/packages/smooth_app/lib/pages/product/new_product_page.dart b/packages/smooth_app/lib/pages/product/new_product_page.dart index 6249628d162..1ae75a4db49 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -3,14 +3,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/model/KnowledgePanels.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; -import 'package:smooth_app/cards/data_cards/image_upload_card.dart'; import 'package:smooth_app/cards/product_cards/knowledge_panels/knowledge_panels_builder.dart'; +import 'package:smooth_app/cards/product_cards/product_image_carousel.dart'; import 'package:smooth_app/data_models/fetched_product.dart'; import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/knowledge_panels_query.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/database/robotoff_questions_query.dart'; import 'package:smooth_app/helpers/launch_url_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/product/common/product_dialog_helper.dart'; @@ -34,11 +35,14 @@ enum ProductPageMenuItem { WEB, REFRESH } class _ProductPageState extends State { late Product _product; late ProductPreferences _productPreferences; + late Future> _productQuestions; @override void initState() { super.initState(); _product = widget.product; + _productQuestions = RobotoffQuestionsQuery(_product.barcode!) + .getRobotoffQuestionsForProduct(); _updateLocalDatabaseWithProductHistory(context, _product); } @@ -125,76 +129,23 @@ class _ProductPageState extends State { localDatabase.notifyListeners(); } - Widget _buildProductImagesCarousel(BuildContext context) { - final AppLocalizations appLocalizations = AppLocalizations.of(context)!; - final List carouselItems = [ - ImageUploadCard( - product: _product, - imageField: ImageField.FRONT, - imageUrl: _product.imageFrontUrl, - title: appLocalizations.product, - buttonText: appLocalizations.front_photo, - ), - ImageUploadCard( - product: _product, - imageField: ImageField.INGREDIENTS, - imageUrl: _product.imageIngredientsUrl, - title: appLocalizations.ingredients, - buttonText: appLocalizations.ingredients_photo, - ), - ImageUploadCard( - product: _product, - imageField: ImageField.NUTRITION, - imageUrl: _product.imageNutritionUrl, - title: appLocalizations.nutrition, - buttonText: appLocalizations.nutrition_facts_photo, - ), - ImageUploadCard( - product: _product, - imageField: ImageField.PACKAGING, - imageUrl: _product.imagePackagingUrl, - title: appLocalizations.packaging_information, - buttonText: appLocalizations.packaging_information_photo, - ), - ImageUploadCard( - product: _product, - imageField: ImageField.OTHER, - imageUrl: null, - title: appLocalizations.more_photos, - buttonText: appLocalizations.more_photos, - ), - ]; - - return SizedBox( - height: 200, - child: ListView( - // This next line does the trick. - scrollDirection: Axis.horizontal, - children: carouselItems - .map( - (ImageUploadCard item) => Container( - margin: const EdgeInsets.fromLTRB(0, 0, 5, 0), - decoration: const BoxDecoration(color: Colors.black12), - child: item, - ), - ) - .toList(), - ), - ); - } - Widget _buildProductBody(BuildContext context) { return ListView(children: [ Align( heightFactor: 0.7, alignment: Alignment.topLeft, - child: _buildProductImagesCarousel(context), + child: ProductImageCarousel(_product, height: 200), ), Padding( padding: const EdgeInsets.symmetric( horizontal: SMALL_SPACE, ), - child: SummaryCard(_product, _productPreferences, isFullVersion: true), + child: SummaryCard( + _product, + _productPreferences, + isFullVersion: true, + productQuestions: _productQuestions, + ), ), _buildKnowledgePanelCards(), ]); diff --git a/packages/smooth_app/lib/pages/product/summary_card.dart b/packages/smooth_app/lib/pages/product/summary_card.dart index df0bfe24850..2750e3232d2 100644 --- a/packages/smooth_app/lib/pages/product/summary_card.dart +++ b/packages/smooth_app/lib/pages/product/summary_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/model/Attribute.dart'; import 'package:openfoodfacts/model/AttributeGroup.dart'; @@ -6,6 +7,8 @@ import 'package:openfoodfacts/model/Product.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:openfoodfacts/personalized_search/preference_importance.dart'; import 'package:smooth_app/cards/data_cards/score_card.dart'; +import 'package:smooth_app/cards/product_cards/product_title_card.dart'; +import 'package:smooth_app/cards/product_cards/question_card.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; import 'package:smooth_app/helpers/attributes_card_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; @@ -28,7 +31,7 @@ const int SUMMARY_CARD_ROW_HEIGHT = 40; class SummaryCard extends StatefulWidget { const SummaryCard(this._product, this._productPreferences, - {this.isFullVersion = false}); + {this.isFullVersion = false, this.productQuestions}); final Product _product; final ProductPreferences _productPreferences; @@ -37,6 +40,8 @@ class SummaryCard extends StatefulWidget { /// smaller screens. final bool isFullVersion; + final Future>? productQuestions; + @override State createState() => _SummaryCardState(); } @@ -186,7 +191,7 @@ class _SummaryCardState extends State { ); return Column( children: [ - _buildProductTitleTile(context), + ProductTitleCard(widget._product), for (final Attribute attribute in scoreAttributes) ScoreCard( iconUrl: attribute.iconUrl!, @@ -194,6 +199,7 @@ class _SummaryCardState extends State { attribute.descriptionShort ?? attribute.description ?? '', cardEvaluation: getCardEvaluationFromAttribute(attribute), ), + _buildProductQuestionsWidget(), attributesContainer, if (widget._product.statesTags ?.contains('en:categories-to-be-completed') ?? @@ -235,26 +241,6 @@ class _SummaryCardState extends State { ); } - Widget _buildProductTitleTile(BuildContext context) { - final AppLocalizations appLocalizations = AppLocalizations.of(context)!; - final ThemeData themeData = Theme.of(context); - return Align( - alignment: Alignment.topLeft, - child: ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - getProductName(widget._product, appLocalizations), - style: themeData.textTheme.headline4, - ), - subtitle: Text(widget._product.brands ?? appLocalizations.unknownBrand), - trailing: Text( - widget._product.quantity ?? '', - style: themeData.textTheme.headline3, - ), - ), - ); - } - Widget _buildAttributeGroup( final Widget header, final List attributeChips, @@ -390,4 +376,54 @@ class _SummaryCardState extends State { } return result; } + + Widget _buildProductQuestionsWidget() { + if (widget.productQuestions == null) { + return EMPTY_WIDGET; + } + return FutureBuilder>( + future: widget.productQuestions, + builder: (BuildContext context, + AsyncSnapshot> snapshot) { + final List questions = + snapshot.data ?? []; + if (questions.isNotEmpty) { + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => QuestionCard( + product: widget._product, + questions: questions, + ), + ), + ); + }, + child: SmoothCard( + margin: EdgeInsets.zero, + color: const Color(0xfff5f6fa), + elevation: 0, + padding: const EdgeInsets.all( + SMALL_SPACE, + ), + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + const Text('🏅 Tap here to answer questions'), + Container( + padding: const EdgeInsets.only(top: SMALL_SPACE), + child: const Text( + 'Help food transparency and get reward badges'), + ), + ], + ), + ), + ), + ); + } + return EMPTY_WIDGET; + }); + } } diff --git a/packages/smooth_ui_library/lib/widgets/smooth_card.dart b/packages/smooth_ui_library/lib/widgets/smooth_card.dart index e2c293ddfee..810c4be4148 100644 --- a/packages/smooth_ui_library/lib/widgets/smooth_card.dart +++ b/packages/smooth_ui_library/lib/widgets/smooth_card.dart @@ -20,6 +20,7 @@ class SmoothCard extends StatelessWidget { bottom: VERY_SMALL_SPACE, ), this.padding = const EdgeInsets.all(5.0), + this.elevation = 8, }); final Widget child; @@ -27,13 +28,14 @@ class SmoothCard extends StatelessWidget { final Widget? header; final EdgeInsets? margin; final EdgeInsets? padding; + final double elevation; static const Radius CIRCULAR_RADIUS = Radius.circular(10.0); @override Widget build(BuildContext context) { final Widget result = Material( - elevation: SMALL_SPACE, + elevation: elevation, shadowColor: Colors.black45, borderRadius: const BorderRadius.all(CIRCULAR_RADIUS), color: color ?? Theme.of(context).colorScheme.surface, From f9abd00793bcc30b8fff784b10278e247e788a01 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Wed, 12 Jan 2022 13:43:46 +0100 Subject: [PATCH 8/8] ... --- .../cards/product_cards/question_card.dart | 48 ++++++++++--------- packages/smooth_app/lib/l10n/app_en.arb | 10 +++- .../lib/pages/product/summary_card.dart | 13 +++-- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/smooth_app/lib/cards/product_cards/question_card.dart b/packages/smooth_app/lib/cards/product_cards/question_card.dart index 82f0995984b..b611955dd6e 100644 --- a/packages/smooth_app/lib/cards/product_cards/question_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/question_card.dart @@ -57,6 +57,7 @@ class _QuestionCardState extends State ).animate(animation); if (child.key == ValueKey(_currentQuestionIndex)) { + // Animate in the new question card. return ClipRect( child: SlideTransition( position: inAnimation, @@ -67,6 +68,7 @@ class _QuestionCardState extends State ), ); } else { + // Animate out the old question card. return ClipRect( child: SlideTransition( position: outAnimation, @@ -86,21 +88,18 @@ class _QuestionCardState extends State } Offset _getAnimationStartOffset() { - Offset animationStartOffset; switch (_lastAnswer) { case InsightAnnotation.YES: - // For [InsightAnnotation.YES]: Animation starts from bottom left. - animationStartOffset = const Offset(-1.0, 0); - break; + // For [InsightAnnotation.YES]: Animation starts from left side and goes right. + return const Offset(-1.0, 0); case InsightAnnotation.NO: - // For [InsightAnnotation.YES]: Animation starts from bottom right. - animationStartOffset = const Offset(1.0, 0); - break; + // For [InsightAnnotation.NO]: Animation starts from right side and goes left. + return const Offset(1.0, 0); case InsightAnnotation.MAYBE: case null: - animationStartOffset = const Offset(0, 1); + // For [InsightAnnotation.MAYBE]: Animation starts from bottom and goes up. + return const Offset(0, 1); } - return animationStartOffset; } Widget _buildWidget(BuildContext context, int currentQuestionIndex) { @@ -111,7 +110,10 @@ class _QuestionCardState extends State return Column( children: [ _buildQuestionCard( - context, widget.product, questions[currentQuestionIndex]), + context, + widget.product, + questions[currentQuestionIndex], + ), _buildAnswerOptions( context, questions, @@ -187,8 +189,7 @@ class _QuestionCardState extends State Widget _buildAnswerOptions( BuildContext context, List questions, {required int currentQuestionIndex}) { - final Size screenSize = MediaQuery.of(context).size; - final double yesNoButtonWidth = screenSize.width / 3; + final double yesNoButtonWidth = MediaQuery.of(context).size.width / 3; final RobotoffQuestion question = questions[currentQuestionIndex]; return Column( children: [ @@ -198,7 +199,7 @@ class _QuestionCardState extends State SizedBox( width: yesNoButtonWidth, height: yesNoButtonWidth / 1.25, - child: _buildInsightAnnotationButton( + child: _buildAnswerButton( insightId: question.insightId, insightAnnotation: InsightAnnotation.NO, backgroundColor: Colors.redAccent, @@ -209,7 +210,7 @@ class _QuestionCardState extends State SizedBox( width: yesNoButtonWidth, height: yesNoButtonWidth / 1.25, - child: _buildInsightAnnotationButton( + child: _buildAnswerButton( insightId: question.insightId, insightAnnotation: InsightAnnotation.YES, backgroundColor: Colors.lightGreen, @@ -221,7 +222,7 @@ class _QuestionCardState extends State ), AspectRatio( aspectRatio: 8, - child: _buildInsightAnnotationButton( + child: _buildAnswerButton( insightId: question.insightId, insightAnnotation: InsightAnnotation.MAYBE, backgroundColor: Colors.white, @@ -233,12 +234,13 @@ class _QuestionCardState extends State ); } - Widget _buildInsightAnnotationButton( - {required String? insightId, - required InsightAnnotation insightAnnotation, - required Color backgroundColor, - required Color contentColor, - required int currentQuestionIndex}) { + Widget _buildAnswerButton({ + required String? insightId, + required InsightAnnotation insightAnnotation, + required Color backgroundColor, + required Color contentColor, + required int currentQuestionIndex, + }) { final AppLocalizations appLocalizations = AppLocalizations.of(context)!; String buttonText; IconData? icon; @@ -266,7 +268,9 @@ class _QuestionCardState extends State elevation: 4, color: backgroundColor, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(VERY_LARGE_SPACE)), + borderRadius: BorderRadius.all( + Radius.circular(VERY_LARGE_SPACE), + ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 11631d24e20..139597fcfaf 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -176,7 +176,7 @@ }, "thanks_for_contributing": "Thanks for contributing", "@contributors": { - "description": "Text shown to the user after they have contributed to the project." + "description": "Text shown to the user after they have contributed to Open food facts." }, "contributors": "Contributors", "@contributors": { @@ -229,6 +229,14 @@ "@contribute_translate_text": {}, "contribute_translate_text_2": "Translations is one of the key tasks of the project", "@contribute_translate_text_2": {}, + "tap_to_answer": "Tap here to answer questions", + "@tap_to_answer": { + "description": "Button label shown on a product, clicking the button opens a card with unanswered product questions, users can answer these to contribute to Open food facts and gain rewards." + }, + "contribute_to_get_rewards": "Help food transparency and get reward badges", + "@contribute_to_get_rewards": { + "description": "Button description shown on a product, clicking the button opens a card with unanswered product questions, users can answer these to contribute to Open food facts and gain rewards." + }, "@Personal preferences": {}, "myPreferences": "My preferences", "@myPreferences": { diff --git a/packages/smooth_app/lib/pages/product/summary_card.dart b/packages/smooth_app/lib/pages/product/summary_card.dart index 2750e3232d2..f7e4b90b44c 100644 --- a/packages/smooth_app/lib/pages/product/summary_card.dart +++ b/packages/smooth_app/lib/pages/product/summary_card.dart @@ -378,13 +378,16 @@ class _SummaryCardState extends State { } Widget _buildProductQuestionsWidget() { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; if (widget.productQuestions == null) { return EMPTY_WIDGET; } return FutureBuilder>( future: widget.productQuestions, - builder: (BuildContext context, - AsyncSnapshot> snapshot) { + builder: ( + BuildContext context, + AsyncSnapshot> snapshot, + ) { final List questions = snapshot.data ?? []; if (questions.isNotEmpty) { @@ -411,11 +414,11 @@ class _SummaryCardState extends State { width: double.infinity, child: Column( children: [ - const Text('🏅 Tap here to answer questions'), + // TODO(jasmeet): Use Material icon or SVG (after consulting UX). + Text('🏅 ${appLocalizations.tap_to_answer}'), Container( padding: const EdgeInsets.only(top: SMALL_SPACE), - child: const Text( - 'Help food transparency and get reward badges'), + child: Text(appLocalizations.contribute_to_get_rewards), ), ], ),