From 143ba2485b4b7658bba308e53fc8353592a9f10d Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 14 May 2020 21:12:42 +0300 Subject: [PATCH] Update table row selection style --- lib/ui/app/tables/app_data_table.dart | 966 ++++++++++++++++++ lib/ui/app/tables/app_data_table_source.dart | 62 ++ .../app/tables/app_paginated_data_table.dart | 498 +++++++++ lib/ui/app/tables/entity_datatable.dart | 38 +- lib/ui/app/tables/entity_list.dart | 6 +- 5 files changed, 1554 insertions(+), 16 deletions(-) create mode 100644 lib/ui/app/tables/app_data_table.dart create mode 100644 lib/ui/app/tables/app_data_table_source.dart create mode 100644 lib/ui/app/tables/app_paginated_data_table.dart diff --git a/lib/ui/app/tables/app_data_table.dart b/lib/ui/app/tables/app_data_table.dart new file mode 100644 index 000000000..2f16e54c0 --- /dev/null +++ b/lib/ui/app/tables/app_data_table.dart @@ -0,0 +1,966 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Signature for [DataColumn.onSort] callback. +typedef DataColumnSortCallback = void Function(int columnIndex, bool ascending); + +/// Column configuration for a [DataTable]. +/// +/// One column configuration must be provided for each column to +/// display in the table. The list of [DataColumn] objects is passed +/// as the `columns` argument to the [new DataTable] constructor. +@immutable +class DataColumn { + /// Creates the configuration for a column of a [DataTable]. + /// + /// The [label] argument must not be null. + const DataColumn({ + @required this.label, + this.tooltip, + this.numeric = false, + this.onSort, + }) : assert(label != null); + + /// The column heading. + /// + /// Typically, this will be a [Text] widget. It could also be an + /// [Icon] (typically using size 18), or a [Row] with an icon and + /// some text. + /// + /// By default, this widget will only occupy the minimal space. If you want + /// it to take the entire remaining space, e.g. when you want to use [Center], + /// you can wrap it with an [Expanded]. + /// + /// The label should not include the sort indicator. + final Widget label; + + /// The column heading's tooltip. + /// + /// This is a longer description of the column heading, for cases + /// where the heading might have been abbreviated to keep the column + /// width to a reasonable size. + final String tooltip; + + /// Whether this column represents numeric data or not. + /// + /// The contents of cells of columns containing numeric data are + /// right-aligned. + final bool numeric; + + /// Called when the user asks to sort the table using this column. + /// + /// If null, the column will not be considered sortable. + /// + /// See [DataTable.sortColumnIndex] and [DataTable.sortAscending]. + final DataColumnSortCallback onSort; + + bool get _debugInteractive => onSort != null; +} + +/// Row configuration and cell data for a [DataTable]. +/// +/// One row configuration must be provided for each row to +/// display in the table. The list of [DataRow] objects is passed +/// as the `rows` argument to the [new DataTable] constructor. +/// +/// The data for this row of the table is provided in the [cells] +/// property of the [DataRow] object. +@immutable +class DataRow { + /// Creates the configuration for a row of a [DataTable]. + /// + /// The [cells] argument must not be null. + const DataRow({ + this.key, + this.selected = false, + this.onSelectChanged, + @required this.cells, + }) : assert(cells != null); + + /// Creates the configuration for a row of a [DataTable], deriving + /// the key from a row index. + /// + /// The [cells] argument must not be null. + DataRow.byIndex({ + int index, + this.selected = false, + this.onSelectChanged, + @required this.cells, + }) : assert(cells != null), + key = ValueKey(index); + + /// A [Key] that uniquely identifies this row. This is used to + /// ensure that if a row is added or removed, any stateful widgets + /// related to this row (e.g. an in-progress checkbox animation) + /// remain on the right row visually. + /// + /// If the table never changes once created, no key is necessary. + final LocalKey key; + + /// Called when the user selects or unselects a selectable row. + /// + /// If this is not null, then the row is selectable. The current + /// selection state of the row is given by [selected]. + /// + /// If any row is selectable, then the table's heading row will have + /// a checkbox that can be checked to select all selectable rows + /// (and which is checked if all the rows are selected), and each + /// subsequent row will have a checkbox to toggle just that row. + /// + /// A row whose [onSelectChanged] callback is null is ignored for + /// the purposes of determining the state of the "all" checkbox, + /// and its checkbox is disabled. + final ValueChanged onSelectChanged; + + /// Whether the row is selected. + /// + /// If [onSelectChanged] is non-null for any row in the table, then + /// a checkbox is shown at the start of each row. If the row is + /// selected (true), the checkbox will be checked and the row will + /// be highlighted. + /// + /// Otherwise, the checkbox, if present, will not be checked. + final bool selected; + + /// The data for this row. + /// + /// There must be exactly as many cells as there are columns in the + /// table. + final List cells; + + bool get _debugInteractive => + onSelectChanged != null || + cells.any((DataCell cell) => cell._debugInteractive); +} + +/// The data for a cell of a [DataTable]. +/// +/// One list of [DataCell] objects must be provided for each [DataRow] +/// in the [DataTable], in the [new DataRow] constructor's `cells` +/// argument. +@immutable +class DataCell { + /// Creates an object to hold the data for a cell in a [DataTable]. + /// + /// The first argument is the widget to show for the cell, typically + /// a [Text] or [DropdownButton] widget; this becomes the [child] + /// property and must not be null. + /// + /// If the cell has no data, then a [Text] widget with placeholder + /// text should be provided instead, and then the [placeholder] + /// argument should be set to true. + const DataCell( + this.child, { + this.placeholder = false, + this.showEditIcon = false, + this.onTap, + this.backgroundColor, + }) : assert(child != null); + + /// A cell that has no content and has zero width and height. + static const DataCell empty = DataCell(SizedBox(width: 0.0, height: 0.0)); + + /// The data for the row. + /// + /// Typically a [Text] widget or a [DropdownButton] widget. + /// + /// If the cell has no data, then a [Text] widget with placeholder + /// text should be provided instead, and [placeholder] should be set + /// to true. + /// + /// {@macro flutter.widgets.child} + final Widget child; + + /// Whether the [child] is actually a placeholder. + /// + /// If this is true, the default text style for the cell is changed + /// to be appropriate for placeholder text. + final bool placeholder; + + /// Whether to show an edit icon at the end of the cell. + /// + /// This does not make the cell actually editable; the caller must + /// implement editing behavior if desired (initiated from the + /// [onTap] callback). + /// + /// If this is set, [onTap] should also be set, otherwise tapping + /// the icon will have no effect. + final bool showEditIcon; + + /// Called if the cell is tapped. + /// + /// If non-null, tapping the cell will call this callback. If + /// null, tapping the cell will attempt to select the row (if + /// [DataRow.onSelectChanged] is provided). + final VoidCallback onTap; + + final Color backgroundColor; + + bool get _debugInteractive => onTap != null; +} + +/// A material design data table. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ktTajqbhIcY} +/// +/// Displaying data in a table is expensive, because to lay out the +/// table all the data must be measured twice, once to negotiate the +/// dimensions to use for each column, and once to actually lay out +/// the table given the results of the negotiation. +/// +/// For this reason, if you have a lot of data (say, more than a dozen +/// rows with a dozen columns, though the precise limits depend on the +/// target device), it is suggested that you use a +/// [PaginatedDataTable] which automatically splits the data into +/// multiple pages. +/// +/// {@tool dartpad --template=stateless_widget_scaffold} +/// +/// This sample shows how to display a [DataTable] with three columns: name, age, and +/// role. The columns are defined by three [DataColumn] objects. The table +/// contains three rows of data for three example users, the data for which +/// is defined by three [DataRow] objects. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/data_table.png) +/// +/// ```dart +/// Widget build(BuildContext context) { +/// return DataTable( +/// columns: const [ +/// DataColumn( +/// label: Text( +/// 'Name', +/// style: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// ), +/// DataColumn( +/// label: Text( +/// 'Age', +/// style: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// ), +/// DataColumn( +/// label: Text( +/// 'Role', +/// style: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// ), +/// ], +/// rows: const [ +/// DataRow( +/// cells: [ +/// DataCell(Text('Sarah')), +/// DataCell(Text('19')), +/// DataCell(Text('Student')), +/// ], +/// ), +/// DataRow( +/// cells: [ +/// DataCell(Text('Janine')), +/// DataCell(Text('43')), +/// DataCell(Text('Professor')), +/// ], +/// ), +/// DataRow( +/// cells: [ +/// DataCell(Text('William')), +/// DataCell(Text('27')), +/// DataCell(Text('Associate Professor')), +/// ], +/// ), +/// ], +/// ); +/// } +/// ``` +/// +/// {@end-tool} +/// +/// See also: +/// +/// * [DataColumn], which describes a column in the data table. +/// * [DataRow], which contains the data for a row in the data table. +/// * [DataCell], which contains the data for a single cell in the data table. +/// * [PaginatedDataTable], which shows part of the data in a data table and +/// provides controls for paging through the remainder of the data. +/// * +class AppDataTable extends StatelessWidget { + /// Creates a widget describing a data table. + /// + /// The [columns] argument must be a list of as many [DataColumn] + /// objects as the table is to have columns, ignoring the leading + /// checkbox column if any. The [columns] argument must have a + /// length greater than zero and must not be null. + /// + /// The [rows] argument must be a list of as many [DataRow] objects + /// as the table is to have rows, ignoring the leading heading row + /// that contains the column headings (derived from the [columns] + /// argument). There may be zero rows, but the rows argument must + /// not be null. + /// + /// Each [DataRow] object in [rows] must have as many [DataCell] + /// objects in the [DataRow.cells] list as the table has columns. + /// + /// If the table is sorted, the column that provides the current + /// primary key should be specified by index in [sortColumnIndex], 0 + /// meaning the first column in [columns], 1 being the next one, and + /// so forth. + /// + /// The actual sort order can be specified using [sortAscending]; if + /// the sort order is ascending, this should be true (the default), + /// otherwise it should be false. + AppDataTable({ + Key key, + @required this.columns, + this.sortColumnIndex, + this.sortAscending = true, + this.onSelectAll, + this.dataRowHeight = kMinInteractiveDimension, + this.headingRowHeight = 56.0, + this.horizontalMargin = 24.0, + this.columnSpacing = 56.0, + this.showCheckboxColumn = true, + this.dividerThickness = 1.0, + @required this.rows, + }) : assert(columns != null), + assert(columns.isNotEmpty), + assert(sortColumnIndex == null || + (sortColumnIndex >= 0 && sortColumnIndex < columns.length)), + assert(sortAscending != null), + assert(dataRowHeight != null), + assert(headingRowHeight != null), + assert(horizontalMargin != null), + assert(columnSpacing != null), + assert(showCheckboxColumn != null), + assert(rows != null), + assert(!rows.any((DataRow row) => row.cells.length != columns.length)), + assert(dividerThickness != null && dividerThickness >= 0), + _onlyTextColumn = _initOnlyTextColumn(columns), + super(key: key); + + /// The configuration and labels for the columns in the table. + final List columns; + + /// The current primary sort key's column. + /// + /// If non-null, indicates that the indicated column is the column + /// by which the data is sorted. The number must correspond to the + /// index of the relevant column in [columns]. + /// + /// Setting this will cause the relevant column to have a sort + /// indicator displayed. + /// + /// When this is null, it implies that the table's sort order does + /// not correspond to any of the columns. + final int sortColumnIndex; + + /// Whether the column mentioned in [sortColumnIndex], if any, is sorted + /// in ascending order. + /// + /// If true, the order is ascending (meaning the rows with the + /// smallest values for the current sort column are first in the + /// table). + /// + /// If false, the order is descending (meaning the rows with the + /// smallest values for the current sort column are last in the + /// table). + final bool sortAscending; + + /// Invoked when the user selects or unselects every row, using the + /// checkbox in the heading row. + /// + /// If this is null, then the [DataRow.onSelectChanged] callback of + /// every row in the table is invoked appropriately instead. + /// + /// To control whether a particular row is selectable or not, see + /// [DataRow.onSelectChanged]. This callback is only relevant if any + /// row is selectable. + final ValueSetter onSelectAll; + + /// The height of each row (excluding the row that contains column headings). + /// + /// This value defaults to kMinInteractiveDimension to adhere to the Material + /// Design specifications. + final double dataRowHeight; + + /// The height of the heading row. + /// + /// This value defaults to 56.0 to adhere to the Material Design specifications. + final double headingRowHeight; + + /// The horizontal margin between the edges of the table and the content + /// in the first and last cells of each row. + /// + /// When a checkbox is displayed, it is also the margin between the checkbox + /// the content in the first data column. + /// + /// This value defaults to 24.0 to adhere to the Material Design specifications. + final double horizontalMargin; + + /// The horizontal margin between the contents of each data column. + /// + /// This value defaults to 56.0 to adhere to the Material Design specifications. + final double columnSpacing; + + /// {@template flutter.material.dataTable.showCheckboxColumn} + /// Whether the widget should display checkboxes for selectable rows. + /// + /// If true, a [CheckBox] will be placed at the beginning of each row that is + /// selectable. However, if [DataRow.onSelectChanged] is not set for any row, + /// checkboxes will not be placed, even if this value is true. + /// + /// If false, all rows will not display a [CheckBox]. + /// {@endtemplate} + final bool showCheckboxColumn; + + /// The data to show in each row (excluding the row that contains + /// the column headings). + /// + /// Must be non-null, but may be empty. + final List rows; + + // Set by the constructor to the index of the only Column that is + // non-numeric, if there is exactly one, otherwise null. + final int _onlyTextColumn; + + static int _initOnlyTextColumn(List columns) { + int result; + for (int index = 0; index < columns.length; index += 1) { + final DataColumn column = columns[index]; + if (!column.numeric) { + if (result != null) return null; + result = index; + } + } + return result; + } + + bool get _debugInteractive { + return columns.any((DataColumn column) => column._debugInteractive) || + rows.any((DataRow row) => row._debugInteractive); + } + + static final LocalKey _headingRowKey = UniqueKey(); + + void _handleSelectAll(bool checked) { + if (onSelectAll != null) { + onSelectAll(checked); + } else { + for (final DataRow row in rows) { + if ((row.onSelectChanged != null) && (row.selected != checked)) + row.onSelectChanged(checked); + } + } + } + + static const double _sortArrowPadding = 2.0; + static const double _headingFontSize = 12.0; + static const Duration _sortArrowAnimationDuration = + Duration(milliseconds: 150); + static const Color _grey100Opacity = + Color(0x0A000000); // Grey 100 as opacity instead of solid color + static const Color _grey300Opacity = + Color(0x1E000000); // Dark theme variant is just a guess. + + /// The width of the divider that appears between [TableRow]s. + /// + /// Must be non-null and greater than or equal to zero. + /// This value defaults to 1.0. + final double dividerThickness; + + Widget _buildCheckbox({ + Color color, + bool checked, + VoidCallback onRowTap, + ValueChanged onCheckboxChanged, + }) { + Widget contents = Semantics( + container: true, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: horizontalMargin, end: horizontalMargin / 2.0), + child: Center( + child: Checkbox( + activeColor: color, + value: checked, + onChanged: onCheckboxChanged, + ), + ), + ), + ); + if (onRowTap != null) { + contents = TableRowInkWell( + onTap: onRowTap, + child: contents, + ); + } + return TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: contents, + ); + } + + Widget _buildHeadingCell({ + BuildContext context, + EdgeInsetsGeometry padding, + Widget label, + String tooltip, + bool numeric, + VoidCallback onSort, + bool sorted, + bool ascending, + }) { + List arrowWithPadding() { + return onSort == null + ? const [] + : [ + _SortArrow( + visible: sorted, + down: sorted ? ascending : null, + duration: _sortArrowAnimationDuration, + ), + const SizedBox(width: _sortArrowPadding), + ]; + } + + label = Row( + textDirection: numeric ? TextDirection.rtl : null, + children: [ + label, + ...arrowWithPadding(), + ], + ); + label = Container( + padding: padding, + height: headingRowHeight, + alignment: + numeric ? Alignment.centerRight : AlignmentDirectional.centerStart, + child: AnimatedDefaultTextStyle( + style: TextStyle( + // TODO(hansmuller): This should use the information provided by + // textTheme/DataTableTheme, https://github.com/flutter/flutter/issues/56079 + fontWeight: FontWeight.w500, + fontSize: _headingFontSize, + height: math.min(1.0, headingRowHeight / _headingFontSize), + color: (Theme.of(context).brightness == Brightness.light) + ? ((onSort != null && sorted) ? Colors.black87 : Colors.black54) + : ((onSort != null && sorted) ? Colors.white : Colors.white70), + ), + softWrap: false, + duration: _sortArrowAnimationDuration, + child: label, + ), + ); + if (tooltip != null) { + label = Tooltip( + message: tooltip, + child: label, + ); + } + // TODO(dkwingsmt): Only wrap Inkwell if onSort != null. Blocked by + // https://github.com/flutter/flutter/issues/51152 + label = InkWell( + onTap: onSort, + child: label, + ); + return label; + } + + Widget _buildDataCell({ + BuildContext context, + EdgeInsetsGeometry padding, + Widget label, + bool numeric, + bool placeholder, + bool showEditIcon, + Color backgroundColor, + VoidCallback onTap, + VoidCallback onSelectChanged, + }) { + final bool isLightTheme = Theme.of(context).brightness == Brightness.light; + if (showEditIcon) { + const Widget icon = Icon(Icons.edit, size: 18.0); + label = Expanded(child: label); + label = Row( + textDirection: numeric ? TextDirection.rtl : null, + children: [label, icon], + ); + } + label = Container( + padding: padding, + height: dataRowHeight, + alignment: + numeric ? Alignment.centerRight : AlignmentDirectional.centerStart, + child: DefaultTextStyle( + style: TextStyle( + // TODO(hansmuller): This should use the information provided by + // textTheme/DataTableTheme, https://github.com/flutter/flutter/issues/56079 + fontSize: 13.0, + color: isLightTheme + ? (placeholder ? Colors.black38 : Colors.black87) + : (placeholder ? Colors.white38 : Colors.white70), + ), + child: IconTheme.merge( + data: IconThemeData( + color: isLightTheme ? Colors.black54 : Colors.white70, + ), + child: DropdownButtonHideUnderline(child: label), + ), + ), + ); + if (onTap != null) { + label = InkWell( + onTap: onTap, + child: label, + ); + } else if (onSelectChanged != null) { + label = TableRowInkWell( + onTap: onSelectChanged, + child: label, + ); + } + return DecoratedBox( + child: label, + decoration: BoxDecoration(color: backgroundColor), + ); + } + + @override + Widget build(BuildContext context) { + assert(!_debugInteractive || debugCheckHasMaterial(context)); + + final ThemeData theme = Theme.of(context); + final BoxDecoration _kSelectedDecoration = BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: dividerThickness)), + // The backgroundColor has to be transparent so you can see the ink on the material + color: (Theme.of(context).brightness == Brightness.light) + ? _grey100Opacity + : _grey300Opacity, + ); + final BoxDecoration _kUnselectedDecoration = BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: dividerThickness)), + ); + + final bool displayCheckboxColumn = showCheckboxColumn && + rows.any((DataRow row) => row.onSelectChanged != null); + final bool allChecked = displayCheckboxColumn && + !rows + .any((DataRow row) => row.onSelectChanged != null && !row.selected); + + final List tableColumns = List( + columns.length + (displayCheckboxColumn ? 1 : 0)); + final List tableRows = List.generate( + rows.length + 1, // the +1 is for the header row + (int index) { + return TableRow( + key: index == 0 ? _headingRowKey : rows[index - 1].key, + decoration: index > 0 && rows[index - 1].selected + ? _kSelectedDecoration + : _kUnselectedDecoration, + children: List(tableColumns.length), + ); + }, + ); + + int rowIndex; + + int displayColumnIndex = 0; + if (displayCheckboxColumn) { + tableColumns[0] = FixedColumnWidth( + horizontalMargin + Checkbox.width + horizontalMargin / 2.0); + tableRows[0].children[0] = _buildCheckbox( + color: theme.accentColor, + checked: allChecked, + onCheckboxChanged: _handleSelectAll, + ); + rowIndex = 1; + for (final DataRow row in rows) { + tableRows[rowIndex].children[0] = _buildCheckbox( + color: theme.accentColor, + checked: row.selected, + onRowTap: () => row.onSelectChanged != null + ? row.onSelectChanged(!row.selected) + : null, + onCheckboxChanged: row.onSelectChanged, + ); + rowIndex += 1; + } + displayColumnIndex += 1; + } + + for (int dataColumnIndex = 0; + dataColumnIndex < columns.length; + dataColumnIndex += 1) { + final DataColumn column = columns[dataColumnIndex]; + + double paddingStart; + if (dataColumnIndex == 0 && displayCheckboxColumn) { + paddingStart = horizontalMargin / 2.0; + } else if (dataColumnIndex == 0 && !displayCheckboxColumn) { + paddingStart = horizontalMargin; + } else { + paddingStart = columnSpacing / 2.0; + } + + double paddingEnd; + if (dataColumnIndex == columns.length - 1) { + paddingEnd = horizontalMargin; + } else { + paddingEnd = columnSpacing / 2.0; + } + + final EdgeInsetsDirectional padding = EdgeInsetsDirectional.only( + start: paddingStart, + end: paddingEnd, + ); + if (dataColumnIndex == _onlyTextColumn) { + tableColumns[displayColumnIndex] = + const IntrinsicColumnWidth(flex: 1.0); + } else { + tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(); + } + tableRows[0].children[displayColumnIndex] = _buildHeadingCell( + context: context, + padding: padding, + label: column.label, + tooltip: column.tooltip, + numeric: column.numeric, + onSort: column.onSort != null + ? () => column.onSort(dataColumnIndex, + sortColumnIndex != dataColumnIndex || !sortAscending) + : null, + sorted: dataColumnIndex == sortColumnIndex, + ascending: sortAscending, + ); + rowIndex = 1; + for (final DataRow row in rows) { + final DataCell cell = row.cells[dataColumnIndex]; + tableRows[rowIndex].children[displayColumnIndex] = _buildDataCell( + context: context, + padding: padding, + label: cell.child, + numeric: column.numeric, + placeholder: cell.placeholder, + showEditIcon: cell.showEditIcon, + onTap: cell.onTap, + backgroundColor: cell.backgroundColor, + onSelectChanged: () => row.onSelectChanged != null + ? row.onSelectChanged(!row.selected) + : null, + ); + rowIndex += 1; + } + displayColumnIndex += 1; + } + + return Table( + columnWidths: tableColumns.asMap(), + children: tableRows, + ); + } +} + +/// A rectangular area of a Material that responds to touch but clips +/// its ink splashes to the current table row of the nearest table. +/// +/// Must have an ancestor [Material] widget in which to cause ink +/// reactions and an ancestor [Table] widget to establish a row. +/// +/// The [TableRowInkWell] must be in the same coordinate space (modulo +/// translations) as the [Table]. If it's rotated or scaled or +/// otherwise transformed, it will not be able to describe the +/// rectangle of the row in its own coordinate system as a [Rect], and +/// thus the splash will not occur. (In general, this is easy to +/// achieve: just put the [TableRowInkWell] as the direct child of the +/// [Table], and put the other contents of the cell inside it.) +class TableRowInkWell extends InkResponse { + /// Creates an ink well for a table row. + const TableRowInkWell({ + Key key, + Widget child, + GestureTapCallback onTap, + GestureTapCallback onDoubleTap, + GestureLongPressCallback onLongPress, + ValueChanged onHighlightChanged, + }) : super( + key: key, + child: child, + onTap: onTap, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + onHighlightChanged: onHighlightChanged, + containedInkWell: true, + highlightShape: BoxShape.rectangle, + ); + + @override + RectCallback getRectCallback(RenderBox referenceBox) { + return () { + RenderObject cell = referenceBox; + AbstractNode table = cell.parent; + final Matrix4 transform = Matrix4.identity(); + while (table is RenderObject && table is! RenderTable) { + final RenderObject parentBox = table as RenderObject; + parentBox.applyPaintTransform(cell, transform); + assert(table == cell.parent); + cell = parentBox; + table = table.parent; + } + if (table is RenderTable) { + final TableCellParentData cellParentData = + cell.parentData as TableCellParentData; + assert(cellParentData.y != null); + final Rect rect = table.getRowBox(cellParentData.y); + // The rect is in the table's coordinate space. We need to change it to the + // TableRowInkWell's coordinate space. + table.applyPaintTransform(cell, transform); + final Offset offset = MatrixUtils.getAsTranslation(transform); + if (offset != null) return rect.shift(-offset); + } + return Rect.zero; + }; + } + + @override + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasTable(context)); + return super.debugCheckContext(context); + } +} + +class _SortArrow extends StatefulWidget { + const _SortArrow({ + Key key, + this.visible, + this.down, + this.duration, + }) : super(key: key); + + final bool visible; + + final bool down; + + final Duration duration; + + @override + _SortArrowState createState() => _SortArrowState(); +} + +class _SortArrowState extends State<_SortArrow> with TickerProviderStateMixin { + AnimationController _opacityController; + Animation _opacityAnimation; + + AnimationController _orientationController; + Animation _orientationAnimation; + double _orientationOffset = 0.0; + + bool _down; + + static final Animatable _turnTween = + Tween(begin: 0.0, end: math.pi) + .chain(CurveTween(curve: Curves.easeIn)); + + @override + void initState() { + super.initState(); + _opacityAnimation = CurvedAnimation( + parent: _opacityController = AnimationController( + duration: widget.duration, + vsync: this, + ), + curve: Curves.fastOutSlowIn, + )..addListener(_rebuild); + _opacityController.value = widget.visible ? 1.0 : 0.0; + _orientationController = AnimationController( + duration: widget.duration, + vsync: this, + ); + _orientationAnimation = _orientationController.drive(_turnTween) + ..addListener(_rebuild) + ..addStatusListener(_resetOrientationAnimation); + if (widget.visible) _orientationOffset = widget.down ? 0.0 : math.pi; + } + + void _rebuild() { + setState(() { + // The animations changed, so we need to rebuild. + }); + } + + void _resetOrientationAnimation(AnimationStatus status) { + if (status == AnimationStatus.completed) { + assert(_orientationAnimation.value == math.pi); + _orientationOffset += math.pi; + _orientationController.value = + 0.0; // TODO(ianh): This triggers a pointless rebuild. + } + } + + @override + void didUpdateWidget(_SortArrow oldWidget) { + super.didUpdateWidget(oldWidget); + bool skipArrow = false; + final bool newDown = widget.down ?? _down; + if (oldWidget.visible != widget.visible) { + if (widget.visible && + (_opacityController.status == AnimationStatus.dismissed)) { + _orientationController.stop(); + _orientationController.value = 0.0; + _orientationOffset = newDown ? 0.0 : math.pi; + skipArrow = true; + } + if (widget.visible) { + _opacityController.forward(); + } else { + _opacityController.reverse(); + } + } + if ((_down != newDown) && !skipArrow) { + if (_orientationController.status == AnimationStatus.dismissed) { + _orientationController.forward(); + } else { + _orientationController.reverse(); + } + } + _down = newDown; + } + + @override + void dispose() { + _opacityController.dispose(); + _orientationController.dispose(); + super.dispose(); + } + + static const double _arrowIconBaselineOffset = -1.5; + static const double _arrowIconSize = 16.0; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: _opacityAnimation.value, + child: Transform( + transform: + Matrix4.rotationZ(_orientationOffset + _orientationAnimation.value) + ..setTranslationRaw(0.0, _arrowIconBaselineOffset, 0.0), + alignment: Alignment.center, + child: Icon( + Icons.arrow_downward, + size: _arrowIconSize, + color: (Theme.of(context).brightness == Brightness.light) + ? Colors.black87 + : Colors.white70, + ), + ), + ); + } +} diff --git a/lib/ui/app/tables/app_data_table_source.dart b/lib/ui/app/tables/app_data_table_source.dart new file mode 100644 index 000000000..1259bb70c --- /dev/null +++ b/lib/ui/app/tables/app_data_table_source.dart @@ -0,0 +1,62 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:invoiceninja_flutter/ui/app/tables/app_data_table.dart'; + +/// A data source for obtaining row data for [PaginatedDataTable] objects. +/// +/// A data table source provides two main pieces of information: +/// +/// * The number of rows in the data table ([rowCount]). +/// * The data for each row (indexed from `0` to `rowCount - 1`). +/// +/// It also provides a listener API ([addListener]/[removeListener]) so that +/// consumers of the data can be notified when it changes. When the data +/// changes, call [notifyListeners] to send the notifications. +/// +/// DataTableSource objects are expected to be long-lived, not recreated with +/// each build. +abstract class AppDataTableSource extends ChangeNotifier { + /// Called to obtain the data about a particular row. + /// + /// The [new DataRow.byIndex] constructor provides a convenient way to construct + /// [DataRow] objects for this callback's purposes without having to worry about + /// independently keying each row. + /// + /// If the given index does not correspond to a row, or if no data is yet + /// available for a row, then return null. The row will be left blank and a + /// loading indicator will be displayed over the table. Once data is available + /// or once it is firmly established that the row index in question is beyond + /// the end of the table, call [notifyListeners]. + /// + /// Data returned from this method must be consistent for the lifetime of the + /// object. If the row count changes, then a new delegate must be provided. + DataRow getRow(int index); + + /// Called to obtain the number of rows to tell the user are available. + /// + /// If [isRowCountApproximate] is false, then this must be an accurate number, + /// and [getRow] must return a non-null value for all indices in the range 0 + /// to one less than the row count. + /// + /// If [isRowCountApproximate] is true, then the user will be allowed to + /// attempt to display rows up to this [rowCount], and the display will + /// indicate that the count is approximate. The row count should therefore be + /// greater than the actual number of rows if at all possible. + /// + /// If the row count changes, call [notifyListeners]. + int get rowCount; + + /// Called to establish if [rowCount] is a precise number or might be an + /// over-estimate. If this returns true (i.e. the count is approximate), and + /// then later the exact number becomes available, then call + /// [notifyListeners]. + bool get isRowCountApproximate; + + /// Called to obtain the number of rows that are currently selected. + /// + /// If the selected row count changes, call [notifyListeners]. + int get selectedRowCount; +} diff --git a/lib/ui/app/tables/app_paginated_data_table.dart b/lib/ui/app/tables/app_paginated_data_table.dart new file mode 100644 index 000000000..4f57e9220 --- /dev/null +++ b/lib/ui/app/tables/app_paginated_data_table.dart @@ -0,0 +1,498 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/material.dart' hide DataRow, DataCell, DataColumn; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:invoiceninja_flutter/ui/app/tables/app_data_table.dart'; +import 'package:invoiceninja_flutter/ui/app/tables/app_data_table_source.dart'; + + +/// A material design data table that shows data using multiple pages. +/// +/// A paginated data table shows [rowsPerPage] rows of data per page and +/// provides controls for showing other pages. +/// +/// Data is read lazily from from a [DataTableSource]. The widget is presented +/// as a [Card]. +/// +/// See also: +/// +/// * [DataTable], which is not paginated. +/// * +class AppPaginatedDataTable extends StatefulWidget { + /// Creates a widget describing a paginated [DataTable] on a [Card]. + /// + /// The [header] should give the card's header, typically a [Text] widget. It + /// must not be null. + /// + /// The [columns] argument must be a list of as many [DataColumn] objects as + /// the table is to have columns, ignoring the leading checkbox column if any. + /// The [columns] argument must have a length greater than zero and cannot be + /// null. + /// + /// If the table is sorted, the column that provides the current primary key + /// should be specified by index in [sortColumnIndex], 0 meaning the first + /// column in [columns], 1 being the next one, and so forth. + /// + /// The actual sort order can be specified using [sortAscending]; if the sort + /// order is ascending, this should be true (the default), otherwise it should + /// be false. + /// + /// The [source] must not be null. The [source] should be a long-lived + /// [DataTableSource]. The same source should be provided each time a + /// particular [PaginatedDataTable] widget is created; avoid creating a new + /// [DataTableSource] with each new instance of the [PaginatedDataTable] + /// widget unless the data table really is to now show entirely different + /// data from a new source. + /// + /// The [rowsPerPage] and [availableRowsPerPage] must not be null (they + /// both have defaults, though, so don't have to be specified). + AppPaginatedDataTable({ + Key key, + @required this.header, + this.actions, + @required this.columns, + this.sortColumnIndex, + this.sortAscending = true, + this.onSelectAll, + this.dataRowHeight = kMinInteractiveDimension, + this.headingRowHeight = 56.0, + this.horizontalMargin = 24.0, + this.columnSpacing = 56.0, + this.showCheckboxColumn = true, + this.initialFirstRowIndex = 0, + this.onPageChanged, + this.rowsPerPage = defaultRowsPerPage, + this.availableRowsPerPage = const [defaultRowsPerPage, defaultRowsPerPage * 2, defaultRowsPerPage * 5, defaultRowsPerPage * 10], + this.onRowsPerPageChanged, + this.dragStartBehavior = DragStartBehavior.start, + @required this.source, + }) : assert(header != null), + assert(columns != null), + assert(dragStartBehavior != null), + assert(columns.isNotEmpty), + assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)), + assert(sortAscending != null), + assert(dataRowHeight != null), + assert(headingRowHeight != null), + assert(horizontalMargin != null), + assert(columnSpacing != null), + assert(showCheckboxColumn != null), + assert(rowsPerPage != null), + assert(rowsPerPage > 0), + assert(() { + if (onRowsPerPageChanged != null) + assert(availableRowsPerPage != null && availableRowsPerPage.contains(rowsPerPage)); + return true; + }()), + assert(source != null), + super(key: key); + + /// The table card's header. + /// + /// This is typically a [Text] widget, but can also be a [ButtonBar] with + /// [FlatButton]s. Suitable defaults are automatically provided for the font, + /// button color, button padding, and so forth. + /// + /// If items in the table are selectable, then, when the selection is not + /// empty, the header is replaced by a count of the selected items. + final Widget header; + + /// Icon buttons to show at the top right of the table. + /// + /// Typically, the exact actions included in this list will vary based on + /// whether any rows are selected or not. + /// + /// These should be size 24.0 with default padding (8.0). + final List actions; + + /// The configuration and labels for the columns in the table. + final List columns; + + /// The current primary sort key's column. + /// + /// See [DataTable.sortColumnIndex]. + final int sortColumnIndex; + + /// Whether the column mentioned in [sortColumnIndex], if any, is sorted + /// in ascending order. + /// + /// See [DataTable.sortAscending]. + final bool sortAscending; + + /// Invoked when the user selects or unselects every row, using the + /// checkbox in the heading row. + /// + /// See [DataTable.onSelectAll]. + final ValueSetter onSelectAll; + + /// The height of each row (excluding the row that contains column headings). + /// + /// This value is optional and defaults to kMinInteractiveDimension if not + /// specified. + final double dataRowHeight; + + /// The height of the heading row. + /// + /// This value is optional and defaults to 56.0 if not specified. + final double headingRowHeight; + + /// The horizontal margin between the edges of the table and the content + /// in the first and last cells of each row. + /// + /// When a checkbox is displayed, it is also the margin between the checkbox + /// the content in the first data column. + /// + /// This value defaults to 24.0 to adhere to the Material Design specifications. + final double horizontalMargin; + + /// The horizontal margin between the contents of each data column. + /// + /// This value defaults to 56.0 to adhere to the Material Design specifications. + final double columnSpacing; + + /// {@macro flutter.material.dataTable.showCheckboxColumn} + final bool showCheckboxColumn; + + /// The index of the first row to display when the widget is first created. + final int initialFirstRowIndex; + + /// Invoked when the user switches to another page. + /// + /// The value is the index of the first row on the currently displayed page. + final ValueChanged onPageChanged; + + /// The number of rows to show on each page. + /// + /// See also: + /// + /// * [onRowsPerPageChanged] + /// * [defaultRowsPerPage] + final int rowsPerPage; + + /// The default value for [rowsPerPage]. + /// + /// Useful when initializing the field that will hold the current + /// [rowsPerPage], when implemented [onRowsPerPageChanged]. + static const int defaultRowsPerPage = 10; + + /// The options to offer for the rowsPerPage. + /// + /// The current [rowsPerPage] must be a value in this list. + /// + /// The values in this list should be sorted in ascending order. + final List availableRowsPerPage; + + /// Invoked when the user selects a different number of rows per page. + /// + /// If this is null, then the value given by [rowsPerPage] will be used + /// and no affordance will be provided to change the value. + final ValueChanged onRowsPerPageChanged; + + /// The data source which provides data to show in each row. Must be non-null. + /// + /// This object should generally have a lifetime longer than the + /// [PaginatedDataTable] widget itself; it should be reused each time the + /// [PaginatedDataTable] constructor is called. + final AppDataTableSource source; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + @override + AppPaginatedDataTableState createState() => AppPaginatedDataTableState(); +} + +/// Holds the state of a [PaginatedDataTable]. +/// +/// The table can be programmatically paged using the [pageTo] method. +class AppPaginatedDataTableState extends State { + int _firstRowIndex; + int _rowCount; + bool _rowCountApproximate; + int _selectedRowCount; + final Map _rows = {}; + + @override + void initState() { + super.initState(); + _firstRowIndex = PageStorage.of(context)?.readState(context) as int ?? widget.initialFirstRowIndex ?? 0; + widget.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + + @override + void didUpdateWidget(AppPaginatedDataTable oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.source != widget.source) { + oldWidget.source.removeListener(_handleDataSourceChanged); + widget.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + } + + @override + void dispose() { + widget.source.removeListener(_handleDataSourceChanged); + super.dispose(); + } + + void _handleDataSourceChanged() { + setState(() { + _rowCount = widget.source.rowCount; + _rowCountApproximate = widget.source.isRowCountApproximate; + _selectedRowCount = widget.source.selectedRowCount; + _rows.clear(); + }); + } + + /// Ensures that the given row is visible. + void pageTo(int rowIndex) { + final int oldFirstRowIndex = _firstRowIndex; + setState(() { + final int rowsPerPage = widget.rowsPerPage; + _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage; + }); + if ((widget.onPageChanged != null) && + (oldFirstRowIndex != _firstRowIndex)) + widget.onPageChanged(_firstRowIndex); + } + + DataRow _getBlankRowFor(int index) { + return DataRow.byIndex( + index: index, + cells: widget.columns.map((DataColumn column) => DataCell.empty).toList(), + ); + } + + DataRow _getProgressIndicatorRowFor(int index) { + bool haveProgressIndicator = false; + final List cells = widget.columns.map((DataColumn column) { + if (!column.numeric) { + haveProgressIndicator = true; + return const DataCell(CircularProgressIndicator()); + } + return DataCell.empty; + }).toList(); + if (!haveProgressIndicator) { + haveProgressIndicator = true; + cells[0] = const DataCell(CircularProgressIndicator()); + } + return DataRow.byIndex( + index: index, + cells: cells, + ); + } + + List _getRows(int firstRowIndex, int rowsPerPage) { + final List result = []; + final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage; + bool haveProgressIndicator = false; + for (int index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) { + DataRow row; + if (index < _rowCount || _rowCountApproximate) { + row = _rows.putIfAbsent(index, () => widget.source.getRow(index)); + if (row == null && !haveProgressIndicator) { + row ??= _getProgressIndicatorRowFor(index); + haveProgressIndicator = true; + } + } + row ??= _getBlankRowFor(index); + result.add(row); + } + return result; + } + + void _handlePrevious() { + pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0)); + } + + void _handleNext() { + pageTo(_firstRowIndex + widget.rowsPerPage); + } + + final GlobalKey _tableKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + // TODO(ianh): This whole build function doesn't handle RTL yet. + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData themeData = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + // HEADER + final List headerWidgets = []; + double startPadding = 24.0; + if (_selectedRowCount == 0) { + headerWidgets.add(Expanded(child: widget.header)); + if (widget.header is ButtonBar) { + // We adjust the padding when a button bar is present, because the + // ButtonBar introduces 2 pixels of outside padding, plus 2 pixels + // around each button on each side, and the button itself will have 8 + // pixels internally on each side, yet we want the left edge of the + // inside of the button to line up with the 24.0 left inset. + startPadding = 12.0; + } + } else { + headerWidgets.add(Expanded( + child: Text(localizations.selectedRowCountTitle(_selectedRowCount)), + )); + } + if (widget.actions != null) { + headerWidgets.addAll( + widget.actions.map((Widget action) { + return Padding( + // 8.0 is the default padding of an icon button + padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0), + child: action, + ); + }).toList() + ); + } + + // FOOTER + final TextStyle footerTextStyle = themeData.textTheme.caption; + final List footerWidgets = []; + if (widget.onRowsPerPageChanged != null) { + final List availableRowsPerPage = widget.availableRowsPerPage + .where((int value) => value <= _rowCount || value == widget.rowsPerPage) + .map>((int value) { + return DropdownMenuItem( + value: value, + child: Text('$value'), + ); + }) + .toList(); + footerWidgets.addAll([ + Container(width: 14.0), // to match trailing padding in case we overflow and end up scrolling + Text(localizations.rowsPerPageTitle), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 64.0), // 40.0 for the text, 24.0 for the icon + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DropdownButtonHideUnderline( + child: DropdownButton( + items: availableRowsPerPage.cast>(), + value: widget.rowsPerPage, + onChanged: widget.onRowsPerPageChanged, + style: footerTextStyle, + iconSize: 24.0, + ), + ), + ), + ), + ]); + } + footerWidgets.addAll([ + Container(width: 32.0), + Text( + localizations.pageRowsInfoTitle( + _firstRowIndex + 1, + _firstRowIndex + widget.rowsPerPage, + _rowCount, + _rowCountApproximate, + ), + ), + Container(width: 32.0), + IconButton( + icon: const Icon(Icons.chevron_left), + padding: EdgeInsets.zero, + tooltip: localizations.previousPageTooltip, + onPressed: _firstRowIndex <= 0 ? null : _handlePrevious, + ), + Container(width: 24.0), + IconButton( + icon: const Icon(Icons.chevron_right), + padding: EdgeInsets.zero, + tooltip: localizations.nextPageTooltip, + onPressed: (!_rowCountApproximate && (_firstRowIndex + widget.rowsPerPage >= _rowCount)) ? null : _handleNext, + ), + Container(width: 14.0), + ]); + + // CARD + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Card( + semanticContainer: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + container: true, + child: DefaultTextStyle( + // These typographic styles aren't quite the regular ones. We pick the closest ones from the regular + // list and then tweak them appropriately. + // See https://material.io/design/components/data-tables.html#tables-within-cards + style: _selectedRowCount > 0 ? themeData.textTheme.subtitle1.copyWith(color: themeData.accentColor) + : themeData.textTheme.headline6.copyWith(fontWeight: FontWeight.w400), + child: IconTheme.merge( + data: const IconThemeData( + opacity: 0.54 + ), + child: Ink( + height: 64.0, + color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null, + child: Padding( + padding: EdgeInsetsDirectional.only(start: startPadding, end: 14.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: headerWidgets, + ), + ), + ), + ), + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + dragStartBehavior: widget.dragStartBehavior, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.minWidth), + child: AppDataTable( + key: _tableKey, + columns: widget.columns, + sortColumnIndex: widget.sortColumnIndex, + sortAscending: widget.sortAscending, + onSelectAll: widget.onSelectAll, + dataRowHeight: widget.dataRowHeight, + headingRowHeight: widget.headingRowHeight, + horizontalMargin: widget.horizontalMargin, + columnSpacing: widget.columnSpacing, + showCheckboxColumn: widget.showCheckboxColumn, + rows: _getRows(_firstRowIndex, widget.rowsPerPage), + ), + ), + ), + DefaultTextStyle( + style: footerTextStyle, + child: IconTheme.merge( + data: const IconThemeData( + opacity: 0.54 + ), + child: Container( + // TODO(bkonyi): this won't handle text zoom correctly, + // https://github.com/flutter/flutter/issues/48522 + height: 56.0, + child: SingleChildScrollView( + dragStartBehavior: widget.dragStartBehavior, + scrollDirection: Axis.horizontal, + reverse: true, + child: Row( + children: footerWidgets, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/ui/app/tables/entity_datatable.dart b/lib/ui/app/tables/entity_datatable.dart index 61a1b17a4..ddbd9be67 100644 --- a/lib/ui/app/tables/entity_datatable.dart +++ b/lib/ui/app/tables/entity_datatable.dart @@ -1,15 +1,20 @@ import 'package:built_collection/built_collection.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide DataRow, DataCell, DataColumn; import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/entities.dart'; import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/ui/app/actions_menu_button.dart'; import 'package:invoiceninja_flutter/ui/app/lists/list_filter.dart'; +import 'package:invoiceninja_flutter/ui/app/lists/selected_indicator.dart'; import 'package:invoiceninja_flutter/ui/app/presenters/entity_presenter.dart'; +import 'package:invoiceninja_flutter/ui/app/tables/app_data_table.dart'; +import 'package:invoiceninja_flutter/ui/app/tables/app_data_table_source.dart'; +import 'package:invoiceninja_flutter/utils/colors.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; -class EntityDataTableSource extends DataTableSource { +class EntityDataTableSource extends AppDataTableSource { EntityDataTableSource( {@required this.context, @required this.editingId, @@ -59,6 +64,21 @@ class EntityDataTableSource extends DataTableSource { ]); } + bool isSelected = false; + if (state.prefState.isPreviewVisible || state.uiState.isEditing) { + if (state.uiState.isEditing + ? entity.id == editingId + : entity.id == uIState.selectedId) { + isSelected = true; + } + } + + final backgroundColor = isSelected + ? convertHexStringToColor(state.prefState.enableDarkMode + ? kDefaultDarkSelectedColor + : kDefaultLightSelectedColor) + : null; + return DataRow( selected: (listState.selectedIds ?? []).contains(entity.id), onSelectChanged: @@ -68,18 +88,6 @@ class EntityDataTableSource extends DataTableSource { DataCell( Row( children: [ - if (state.prefState.isPreviewVisible || state.uiState.isEditing) - Text( - '•', - style: TextStyle( - color: (state.uiState.isEditing - ? entity.id == editingId - : entity.id == uIState.selectedId) - ? Theme.of(context).accentColor - : Colors.transparent, - fontSize: 30, - fontWeight: FontWeight.bold), - ), ActionMenuButton( entityActions: entity.getActions( userCompany: state.userCompany, @@ -96,11 +104,13 @@ class EntityDataTableSource extends DataTableSource { ], ), onTap: () => onTap(entity), + backgroundColor: backgroundColor, ), ...tableColumns.map( (field) => DataCell( entityPresenter.getField(field: field, context: context), onTap: () => onTap(entity), + backgroundColor: backgroundColor, ), ) ], diff --git a/lib/ui/app/tables/entity_list.dart b/lib/ui/app/tables/entity_list.dart index 2b72c5793..2147c6f51 100644 --- a/lib/ui/app/tables/entity_list.dart +++ b/lib/ui/app/tables/entity_list.dart @@ -1,5 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide DataRow, DataCell, DataColumn; import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; @@ -10,6 +10,8 @@ import 'package:invoiceninja_flutter/ui/app/lists/list_divider.dart'; import 'package:invoiceninja_flutter/ui/app/lists/list_filter.dart'; import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart'; import 'package:invoiceninja_flutter/ui/app/presenters/entity_presenter.dart'; +import 'package:invoiceninja_flutter/ui/app/tables/app_data_table.dart'; +import 'package:invoiceninja_flutter/ui/app/tables/app_paginated_data_table.dart'; import 'package:invoiceninja_flutter/ui/app/tables/entity_datatable.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; @@ -143,7 +145,7 @@ class _EntityListState extends State { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), - child: PaginatedDataTable( + child: AppPaginatedDataTable( onSelectAll: (value) { final entities = entityList .map((String entityId) => entityMap[entityId])