From f53938c8e9b62a0f24f0121bb6217a7f3bb68156 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Sun, 29 Dec 2024 22:58:48 +0100 Subject: [PATCH] feat: Improve KP tables layout (#6080) * Improve KP tables * Cache horizontalDivider * Some comments * Improvements for dropdown * no message --- .../lib/helpers/html_extension.dart | 15 + .../knowledge_panel_table_card.dart | 343 ++++++++++++++---- 2 files changed, 280 insertions(+), 78 deletions(-) create mode 100644 packages/smooth_app/lib/helpers/html_extension.dart diff --git a/packages/smooth_app/lib/helpers/html_extension.dart b/packages/smooth_app/lib/helpers/html_extension.dart new file mode 100644 index 000000000000..a3c24743c648 --- /dev/null +++ b/packages/smooth_app/lib/helpers/html_extension.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart'; + +extension AlignmentDirectionalExtension on AlignmentDirectional { + String toHTMLTextAlign() { + if (start == -1.0) { + return 'start'; + } else if (start == 0.0) { + return 'center'; + } else if (start == 1.0) { + return 'end'; + } else { + return 'match-parent'; + } + } +} 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 814cc8841e96..94ad36e30595 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 @@ -1,23 +1,30 @@ -import 'dart:math'; +import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/smooth_html_widget.dart'; +import 'package:smooth_app/helpers/html_extension.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; import 'package:smooth_app/knowledge_panel/knowledge_panels/knowledge_panel_card.dart'; import 'package:smooth_app/pages/product/portion_calculator.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. +import 'package:smooth_app/themes/smooth_theme.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.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; -// Minimum length of a cell, without this a column may look unnaturally small -// when put next to larger columns. -const int kMinCellLengthInARow = 20; +/// Minimum length of a cell, without this a column may look unnaturally small +/// when put next to larger columns. +const int kMinCellLengthInARow = 2; +// Min length when the cell has values (eg: percent or 100g) +const int kMinCellLengthInARowValue = 4; /// 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 @@ -77,6 +84,7 @@ class KnowledgePanelTableCard extends StatefulWidget { class _KnowledgePanelTableCardState extends State { final List _columnGroups = []; final List _columnsMaxLength = []; + final List<_TableCellType> _columnsType = <_TableCellType>[]; @override void initState() { @@ -89,20 +97,21 @@ class _KnowledgePanelTableCardState extends State { Widget build(BuildContext context) { final bool withPortionCalculator = widget.tableElement.id == KnowledgePanelCard.PANEL_NUTRITION_TABLE_ID; - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { + + return LayoutBuilder(builder: ( + BuildContext context, + BoxConstraints constraints, + ) { final List> rowsWidgets = _buildRowWidgets(_buildRowCells(), constraints); + return Column( children: [ for (final List row in rowsWidgets) Semantics( excludeSemantics: true, value: _buildSemanticsValue(row), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: row, - ), + child: IntrinsicHeight(child: Row(children: row)), ), if (withPortionCalculator) const Divider(), if (withPortionCalculator) PortionCalculator(widget.product) @@ -125,20 +134,22 @@ class _KnowledgePanelTableCardState extends State { case KnowledgePanelColumnType.TEXT: rows[0].add( TableCell( - text: text, - color: Colors.grey, - isHeader: true, - columnGroup: columnGroup), + 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), + text: text, + color: Colors.grey, + isHeader: true, + columnGroup: columnGroup, + ), ); break; } @@ -175,27 +186,76 @@ class _KnowledgePanelTableCardState extends State { _columnsMaxLength.reduce((int sum, int width) => sum + width); final List> rowsWidgets = >[]; + final Widget verticalDivider = _verticalDivider; + final Widget horizontalDivider = _horizontalDivider; + for (final List row in rows) { final List rowWidgets = []; int index = 0; for (final TableCell cell in row) { - final double cellWidth = - availableWidth / totalMaxColumnWidth * _columnsMaxLength[index++]; + final int cellWidth = + (availableWidth / totalMaxColumnWidth * _columnsMaxLength[index++]) + .toInt(); + + Widget tableCellWidget = _TableCellWidget( + cell: cell, + cellType: _columnsType[index - 1], + cellWidthPercent: cellWidth, + tableElement: widget.tableElement, + rebuildTable: setState, + isInitiallyExpanded: widget.isInitiallyExpanded, + ); + + /// Add a divider below the header cell + if (cell.isHeader) { + tableCellWidget = Column( + children: [ + Expanded(child: tableCellWidget), + horizontalDivider, + ], + ); + } + rowWidgets.add( - TableCellWidget( - cell: cell, - cellWidth: cellWidth, - tableElement: widget.tableElement, - rebuildTable: setState, - isInitiallyExpanded: widget.isInitiallyExpanded, + Expanded( + flex: cellWidth, + child: tableCellWidget, ), ); + + if (index < row.length) { + rowWidgets.add(verticalDivider); + } } rowsWidgets.add(rowWidgets); } return rowsWidgets; } + Widget get _verticalDivider { + final SmoothColorsThemeExtension extension = + context.extension(); + + return VerticalDivider( + width: 1.0, + color: context.lightTheme() + ? extension.primaryMedium + : extension.primarySemiDark, + ); + } + + Widget get _horizontalDivider { + final SmoothColorsThemeExtension extension = + context.extension(); + + return Divider( + height: 1.0, + color: context.lightTheme() + ? extension.primaryMedium + : extension.primarySemiDark, + ); + } + void _initColumnGroups() { int index = 0; // Used to locate [columnGroup] for a given [column.columnGroupId]. @@ -239,31 +299,71 @@ class _KnowledgePanelTableCardState extends State { 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) { + _TableCellType type; + if (cell.text == '100g') { + type = _TableCellType.PER_100G; + } else if (cell.text == '%') { + type = _TableCellType.PERCENT; + } else { + type = _TableCellType.TEXT; + } + // Set value for the header row. - _columnsMaxLength.add(cell.text.length); + _columnsMaxLength.add( + math.max( + cell.text.length, + type != _TableCellType.TEXT + ? kMinCellLengthInARowValue + : kMinCellLengthInARow), + ); + + _columnsType.add(type); } else { - if (cell.text.length > _columnsMaxLength[index]) { - _columnsMaxLength[index] = max(kMinCellLengthInARow, - min(kMaxCellLengthInARow, cell.text.length)); + /// When there is content, ensure the first word is fully visible + if (_columnsType[index] != _TableCellType.TEXT) { + _columnsMaxLength[index] = math.max( + _columnsMaxLength[index], + cell.text.split('(')[0].length, + ); + } else { + _columnsMaxLength[index] = math.max( + _columnsMaxLength[index], + cell.text.split(' ')[0].length, + ); } } index++; } } + + /// Ensure the columns are not too wide or too narrow. + final int sum = _columnsMaxLength.sum; + final int maxWidth = (sum ~/ _columnsMaxLength.length) - 4; + final int minWidth = maxWidth ~/ 4; + + for (int i = 0; i < _columnsMaxLength.length; i++) { + if (_columnsType[i] == _TableCellType.PERCENT) { + _columnsMaxLength[i] = math.max(_columnsMaxLength[i] + 2, minWidth); + } else if (_columnsType[i] == _TableCellType.PER_100G) { + _columnsMaxLength[i] = math.max(_columnsMaxLength[i], minWidth); + } else if (_columnsMaxLength[i] > maxWidth) { + _columnsMaxLength[i] = maxWidth; + } else if (_columnsMaxLength[i] < minWidth) { + _columnsMaxLength[i] = minWidth; + } + } } String _buildSemanticsValue(List row) { final StringBuffer buffer = StringBuffer(); for (final Widget widget in row) { - if (widget is TableCellWidget && widget.cell.text.isNotEmpty) { + if (widget is _TableCellWidget && widget.cell.text.isNotEmpty) { if (buffer.isNotEmpty) { buffer.write(' - '); } @@ -283,26 +383,28 @@ class _KnowledgePanelTableCardState extends State { } } -class TableCellWidget extends StatefulWidget { - const TableCellWidget({ +class _TableCellWidget extends StatefulWidget { + const _TableCellWidget({ required this.cell, - required this.cellWidth, + required this.cellType, + required this.cellWidthPercent, required this.tableElement, required this.rebuildTable, required this.isInitiallyExpanded, }); final TableCell cell; - final double cellWidth; + final _TableCellType cellType; + final int cellWidthPercent; final KnowledgePanelTableElement tableElement; final void Function(VoidCallback fn) rebuildTable; final bool isInitiallyExpanded; @override - State createState() => _TableCellWidgetState(); + State<_TableCellWidget> createState() => _TableCellWidgetState(); } -class _TableCellWidgetState extends State { +class _TableCellWidgetState extends State<_TableCellWidget> { late bool _isExpanded; @override @@ -313,47 +415,99 @@ class _TableCellWidgetState extends State { @override Widget build(BuildContext context) { - EdgeInsetsGeometry padding = - const EdgeInsetsDirectional.only(bottom: VERY_SMALL_SPACE); - // header cells get a bigger vertical padding. + final SmoothColorsThemeExtension extension = + context.extension(); + + final EdgeInsetsGeometry padding; + if (widget.cell.isHeader) { - padding = const EdgeInsets.symmetric(vertical: SMALL_SPACE); + padding = const EdgeInsetsDirectional.symmetric( + vertical: SMALL_SPACE, + horizontal: VERY_SMALL_SPACE, + ); + } else { + padding = const EdgeInsetsDirectional.symmetric( + vertical: 6.0, + horizontal: VERY_SMALL_SPACE, + ); } + TextStyle style = Theme.of(context).textTheme.bodyMedium!; if (widget.cell.color != null) { - style = style.apply(color: widget.cell.color); + /// Override the default color + if (widget.cell.isHeader && + widget.cell.color!.intValue == const Color(0xFF9e9e9e).intValue) { + if (context.lightTheme()) { + style = style.apply(color: extension.primaryDark); + } else { + style = style.apply(color: extension.primaryMedium); + } + } else { + style = style.apply(color: widget.cell.color); + } } + if (widget.cell.isHeader && widget.cell.columnGroup!.columns.length == 1) { - return _buildHtmlCell(padding, style, isSelectable: false); + return SizedBox( + height: double.infinity, + child: _buildHtmlCell( + padding, + style.copyWith(fontWeight: FontWeight.w600), + isSelectable: false, + alignment: widget.cellType.headerTextAlignment, + backgroundColor: context.lightTheme() + ? extension.primaryLight + : extension.primaryUltraBlack, + ), + ); } else if (!widget.cell.isHeader || widget.cell.columnGroup!.columns.length == 1) { - return _buildHtmlCell(padding, style, isSelectable: true); + return _buildHtmlCell( + padding, + style, + isSelectable: true, + alignment: widget.cellType.contentTextAlignment, + ); } - return _buildDropDownColumnHeader(padding, style); + return _buildDropDownColumnHeader( + padding, + widget.cellWidthPercent, + style.copyWith(fontWeight: FontWeight.w600), + backgroundColor: context.lightTheme() + ? extension.primaryLight + : extension.primaryUltraBlack, + ); } Widget _buildHtmlCell( EdgeInsetsGeometry padding, TextStyle style, { required bool isSelectable, + required AlignmentDirectional alignment, + Color? backgroundColor, }) { - String cellText = widget.cell.text; + final StringBuffer styleBuilder = + StringBuffer('text-align:${alignment.toHTMLTextAlign()}'); + if (!_isExpanded) { - const String htmlStyle = ''' - "text-overflow: ellipsis; - overflow: hidden; - max-lines: 2;" - '''; - cellText = '
${widget.cell.text}
'; + styleBuilder.write(''' + text-overflow: ellipsis; + overflow: hidden; + max-lines: 2; + '''); } - return GestureDetector( + + final String cellText = + '
${widget.cell.text}
'; + + final Widget child = GestureDetector( onTap: () => setState(() { _isExpanded = true; }), child: Padding( padding: padding, - child: SizedBox( - width: widget.cellWidth, + child: Align( + alignment: alignment, child: SmoothHtmlWidget( cellText, textStyle: style, @@ -362,17 +516,28 @@ class _TableCellWidgetState extends State { ), ), ); + + if (backgroundColor != null) { + return ColoredBox( + color: backgroundColor, + child: child, + ); + } else { + return child; + } } Widget _buildDropDownColumnHeader( EdgeInsetsGeometry padding, - TextStyle style, - ) { + int width, + TextStyle style, { + required Color backgroundColor, + }) { // Now we finally render [ColumnGroup]s as drop down menus. - return Padding( - padding: padding, - child: SizedBox( - width: widget.cellWidth, + return ColoredBox( + color: backgroundColor, + child: Padding( + padding: padding, child: DropdownButtonHideUnderline( child: ButtonTheme( child: DropdownButton( @@ -381,14 +546,16 @@ class _TableCellWidgetState extends State { .map((KnowledgePanelTableColumn column) { return DropdownMenuItem( value: column, - child: Container( - // 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), + child: SizedBox( + width: width.toDouble() - 21.0 - padding.horizontal, + child: Text( + column.textForSmallScreens ?? column.text, + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), ), ); - }).toList(), + }).toList(growable: false), onChanged: (KnowledgePanelTableColumn? selectedColumn) { setState(() { widget.cell.columnGroup!.currentColumn = selectedColumn; @@ -417,8 +584,28 @@ class _TableCellWidgetState extends State { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('expanded', widget.isInitiallyExpanded)); - properties.add(DoubleProperty('cellWidth', widget.cellWidth)); + properties.add( + DiagnosticsProperty('expanded', widget.isInitiallyExpanded), + ); } } + +enum _TableCellType { + TEXT( + headerTextAlignment: AlignmentDirectional.centerStart, + contentTextAlignment: AlignmentDirectional.centerStart), + PERCENT( + headerTextAlignment: AlignmentDirectional.center, + contentTextAlignment: AlignmentDirectional.centerStart), + PER_100G( + headerTextAlignment: AlignmentDirectional.center, + contentTextAlignment: AlignmentDirectional.center); + + const _TableCellType({ + required this.headerTextAlignment, + required this.contentTextAlignment, + }); + + final AlignmentDirectional headerTextAlignment; + final AlignmentDirectional contentTextAlignment; +}