From f6909deb9c7d4ebc19adc1c4303e7ed4b3871da6 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:31:28 +0100 Subject: [PATCH 01/15] Added custom loading indicator screen --- .../custom_loading_indicator_screen.dart | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 demo/lib/screen/feature/custom_loading_indicator_screen.dart diff --git a/demo/lib/screen/feature/custom_loading_indicator_screen.dart b/demo/lib/screen/feature/custom_loading_indicator_screen.dart new file mode 100644 index 00000000..9e6017b0 --- /dev/null +++ b/demo/lib/screen/feature/custom_loading_indicator_screen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +import '../../dummy_data/development.dart'; + +class CustomLoadingIndicatorScreen extends StatefulWidget { + static const routeName = 'empty'; + + const CustomLoadingIndicatorScreen({super.key}); + + @override + _CustomLoadingIndicatorScreenState createState() => _CustomLoadingIndicatorScreenState(); +} + +class _CustomLoadingIndicatorScreenState extends State { + late List columns; + + late List rows; + + late PlutoGridStateManager stateManager; + + @override + void initState() { + super.initState(); + + columns = [ + PlutoColumn( + title: 'column1', + field: 'column1', + type: PlutoColumnType.text(), + ), + PlutoColumn( + title: 'column2', + field: 'column2', + type: PlutoColumnType.text(), + ), + PlutoColumn( + title: 'column3', + field: 'column3', + type: PlutoColumnType.text(), + ), + ]; + + rows = DummyData.rowsByColumns(length: 10, columns: columns); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Container( + padding: const EdgeInsets.all(8), + child: PlutoGrid( + columns: columns, + rows: rows, + onChanged: (PlutoGridOnChangedEvent event) { + print(event); + }, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + configuration: const PlutoGridConfiguration(), + ), + ), + ), + ); + } +} From f0e1674eaf19a26f8e164e221dcb3605c29a69f0 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:32:17 +0100 Subject: [PATCH 02/15] Added CustomLoadingindicatorScreen to routes --- demo/lib/main.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/demo/lib/main.dart b/demo/lib/main.dart index 82070b82..694dc7a8 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -110,6 +110,7 @@ class MyApp extends StatelessWidget { const TimeTypeColumnScreen(), ValueFormatterScreen.routeName: (context) => const ValueFormatterScreen(), + CustomLoadingIndicator.routeName: (context) => const CustomLoadingIndicatorScreen(), // only development EmptyScreen.routeName: (context) => const EmptyScreen(), DevelopmentScreen.routeName: (context) => const DevelopmentScreen(), From a44c9c802a2ff730478cd9a852f00213880adbaf Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:32:22 +0100 Subject: [PATCH 03/15] Added me --- demo/lib/screen/home_screen.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/demo/lib/screen/home_screen.dart b/demo/lib/screen/home_screen.dart index 8daa879f..229c6905 100644 --- a/demo/lib/screen/home_screen.dart +++ b/demo/lib/screen/home_screen.dart @@ -713,6 +713,13 @@ class PlutoContributors extends StatelessWidget { launchUrl('https://github.com/coruscant187'); }, ), + PlutoContributorTile( + name: 'sten435', + linkTitle: 'Github', + onTapLink: () { + launchUrl('https://github.com/sten435'); + }, + ), PlutoContributorTile.invisible( name: 'And you.', linkTitle: 'Github', From 5ac246ee0e909cb6eefdeb37c2fc8f0f5d5b4a89 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:36:51 +0100 Subject: [PATCH 04/15] Added imports --- demo/lib/main.dart | 48 +++++++------------ .../custom_loading_indicator_screen.dart | 2 +- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/demo/lib/main.dart b/demo/lib/main.dart index 694dc7a8..7e816b6c 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:demo/screen/feature/custom_loading_indicator_screen.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -58,59 +59,44 @@ class MyApp extends StatelessWidget { kReleaseMode ? HomeScreen.routeName : DevelopmentScreen.routeName, routes: { HomeScreen.routeName: (context) => const HomeScreen(), - AddAndRemoveColumnRowScreen.routeName: (context) => - const AddAndRemoveColumnRowScreen(), - AddRowsAsynchronouslyScreen.routeName: (context) => - const AddRowsAsynchronouslyScreen(), + AddAndRemoveColumnRowScreen.routeName: (context) => const AddAndRemoveColumnRowScreen(), + AddRowsAsynchronouslyScreen.routeName: (context) => const AddRowsAsynchronouslyScreen(), CellRendererScreen.routeName: (context) => const CellRendererScreen(), CellSelectionScreen.routeName: (context) => const CellSelectionScreen(), RTLScreen.routeName: (context) => const RTLScreen(), - ColumnFilteringScreen.routeName: (context) => - const ColumnFilteringScreen(), + ColumnFilteringScreen.routeName: (context) => const ColumnFilteringScreen(), ColumnFooterScreen.routeName: (context) => const ColumnFooterScreen(), - ColumnFreezingScreen.routeName: (context) => - const ColumnFreezingScreen(), + ColumnFreezingScreen.routeName: (context) => const ColumnFreezingScreen(), ColumnGroupScreen.routeName: (context) => const ColumnGroupScreen(), ColumnHidingScreen.routeName: (context) => const ColumnHidingScreen(), ColumnMenuScreen.routeName: (context) => const ColumnMenuScreen(), ColumnMovingScreen.routeName: (context) => const ColumnMovingScreen(), - ColumnResizingScreen.routeName: (context) => - const ColumnResizingScreen(), + ColumnResizingScreen.routeName: (context) => const ColumnResizingScreen(), ColumnSortingScreen.routeName: (context) => const ColumnSortingScreen(), CopyAndPasteScreen.routeName: (context) => const CopyAndPasteScreen(), - CurrencyTypeColumnScreen.routeName: (context) => - const CurrencyTypeColumnScreen(), + CurrencyTypeColumnScreen.routeName: (context) => const CurrencyTypeColumnScreen(), DarkModeScreen.routeName: (context) => const DarkModeScreen(), - DateTypeColumnScreen.routeName: (context) => - const DateTypeColumnScreen(), + DateTypeColumnScreen.routeName: (context) => const DateTypeColumnScreen(), DualModeScreen.routeName: (context) => const DualModeScreen(), EditingStateScreen.routeName: (context) => const EditingStateScreen(), ExportScreen.routeName: (context) => const ExportScreen(), GridAsPopupScreen.routeName: (context) => const GridAsPopupScreen(), ListingModeScreen.routeName: (context) => const ListingModeScreen(), MovingScreen.routeName: (context) => const MovingScreen(), - NumberTypeColumnScreen.routeName: (context) => - const NumberTypeColumnScreen(), + NumberTypeColumnScreen.routeName: (context) => const NumberTypeColumnScreen(), RowColorScreen.routeName: (context) => const RowColorScreen(), RowGroupScreen.routeName: (context) => const RowGroupScreen(), - RowInfinityScrollScreen.routeName: (context) => - const RowInfinityScrollScreen(), - RowLazyPaginationScreen.routeName: (context) => - const RowLazyPaginationScreen(), + RowInfinityScrollScreen.routeName: (context) => const RowInfinityScrollScreen(), + RowLazyPaginationScreen.routeName: (context) => const RowLazyPaginationScreen(), RowMovingScreen.routeName: (context) => const RowMovingScreen(), RowPaginationScreen.routeName: (context) => const RowPaginationScreen(), RowSelectionScreen.routeName: (context) => const RowSelectionScreen(), - RowWithCheckboxScreen.routeName: (context) => - const RowWithCheckboxScreen(), - SelectionTypeColumnScreen.routeName: (context) => - const SelectionTypeColumnScreen(), - TextTypeColumnScreen.routeName: (context) => - const TextTypeColumnScreen(), - TimeTypeColumnScreen.routeName: (context) => - const TimeTypeColumnScreen(), - ValueFormatterScreen.routeName: (context) => - const ValueFormatterScreen(), - CustomLoadingIndicator.routeName: (context) => const CustomLoadingIndicatorScreen(), + RowWithCheckboxScreen.routeName: (context) => const RowWithCheckboxScreen(), + SelectionTypeColumnScreen.routeName: (context) => const SelectionTypeColumnScreen(), + TextTypeColumnScreen.routeName: (context) => const TextTypeColumnScreen(), + TimeTypeColumnScreen.routeName: (context) => const TimeTypeColumnScreen(), + ValueFormatterScreen.routeName: (context) => const ValueFormatterScreen(), + CustomLoadingIndicatorScreen.routeName: (context) => const CustomLoadingIndicatorScreen(), // only development EmptyScreen.routeName: (context) => const EmptyScreen(), DevelopmentScreen.routeName: (context) => const DevelopmentScreen(), diff --git a/demo/lib/screen/feature/custom_loading_indicator_screen.dart b/demo/lib/screen/feature/custom_loading_indicator_screen.dart index 9e6017b0..7ce175bf 100644 --- a/demo/lib/screen/feature/custom_loading_indicator_screen.dart +++ b/demo/lib/screen/feature/custom_loading_indicator_screen.dart @@ -4,7 +4,7 @@ import 'package:pluto_grid_plus/pluto_grid_plus.dart'; import '../../dummy_data/development.dart'; class CustomLoadingIndicatorScreen extends StatefulWidget { - static const routeName = 'empty'; + static const routeName = 'feature/custom-loading-indicator'; const CustomLoadingIndicatorScreen({super.key}); From d47e3d3c09fe73a2dd3a4e6772b89faf22793e30 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:38:43 +0100 Subject: [PATCH 05/15] Added PlutoListTile for custom loading indicator --- demo/lib/screen/home_screen.dart | 51 ++++++++----------- .../ephemeral/.plugin_symlinks/file_saver | 2 +- .../.plugin_symlinks/path_provider_linux | 2 +- .../ephemeral/.plugin_symlinks/printing | 2 +- .../.plugin_symlinks/url_launcher_linux | 2 +- 5 files changed, 26 insertions(+), 33 deletions(-) diff --git a/demo/lib/screen/home_screen.dart b/demo/lib/screen/home_screen.dart index 229c6905..ed80daa8 100644 --- a/demo/lib/screen/home_screen.dart +++ b/demo/lib/screen/home_screen.dart @@ -1,6 +1,6 @@ import 'dart:math'; - import 'package:demo/screen/empty_screen.dart'; +import 'package:demo/screen/feature/custom_loading_indicator_screen.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -215,8 +215,7 @@ class PlutoFeatures extends StatelessWidget { children: [ PlutoListTile( title: 'Column moving', - description: - 'Dragging the column heading left or right moves the column left and right.', + description: 'Dragging the column heading left or right moves the column left and right.', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnMovingScreen.routeName); }, @@ -237,16 +236,14 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Column resizing', - description: - 'Dragging the icon to the right of the column title left or right changes the width of the column.', + description: 'Dragging the icon to the right of the column title left or right changes the width of the column.', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnResizingScreen.routeName); }, ), PlutoListTile( title: 'Column sorting', - description: - 'Ascending or Descending by clicking on the column heading.', + description: 'Ascending or Descending by clicking on the column heading.', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnSortingScreen.routeName); }, @@ -274,8 +271,7 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Column footer', - description: - 'Display each column fixed at the bottom. (For outputting data sum, average, etc.)', + description: 'Display each column fixed at the bottom. (For outputting data sum, average, etc.)', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnFooterScreen.routeName); }, @@ -340,8 +336,7 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Row selection', - description: - 'In Row selection mode, Shift + tap or long tap and then move or Control + tap to select a row.', + description: 'In Row selection mode, Shift + tap or long tap and then move or Control + tap to select a row.', onTapLiveDemo: () { Navigator.pushNamed(context, RowSelectionScreen.routeName); }, @@ -362,8 +357,7 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Row lazy pagination', - description: - 'Implement pagination in the form of fetching data from the server.', + description: 'Implement pagination in the form of fetching data from the server.', onTapLiveDemo: () { Navigator.pushNamed(context, RowLazyPaginationScreen.routeName); }, @@ -403,32 +397,28 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Cell selection', - description: - 'In cell selection mode, Shift + tap or long tap and then move to select cells.', + description: 'In cell selection mode, Shift + tap or long tap and then move to select cells.', onTapLiveDemo: () { Navigator.pushNamed(context, CellSelectionScreen.routeName); }, ), PlutoListTile( title: 'Cell renderer', - description: - 'You can change the widget of the cell through the renderer.', + description: 'You can change the widget of the cell through the renderer.', onTapLiveDemo: () { Navigator.pushNamed(context, CellRendererScreen.routeName); }, ), PlutoListTile( title: 'Copy and Paste', - description: - 'Copy and paste are operated depending on the cell and row selection status.', + description: 'Copy and paste are operated depending on the cell and row selection status.', onTapLiveDemo: () { Navigator.pushNamed(context, CopyAndPasteScreen.routeName); }, ), PlutoListTile( title: 'Moving', - description: - 'Change the current cell position with the arrow keys, enter key, and tab key.', + description: 'Change the current cell position with the arrow keys, enter key, and tab key.', onTapLiveDemo: () { Navigator.pushNamed(context, MovingScreen.routeName); }, @@ -451,22 +441,19 @@ class PlutoFeatures extends StatelessWidget { title: 'Add and Remove Columns, Rows', description: 'You can add or delete columns, rows.', onTapLiveDemo: () { - Navigator.pushNamed( - context, AddAndRemoveColumnRowScreen.routeName); + Navigator.pushNamed(context, AddAndRemoveColumnRowScreen.routeName); }, ), PlutoListTile( title: 'Dual mode', - description: - 'Place the grid on the left and right and move or edit with the keyboard.', + description: 'Place the grid on the left and right and move or edit with the keyboard.', onTapLiveDemo: () { Navigator.pushNamed(context, DualModeScreen.routeName); }, ), PlutoListTile( title: 'Grid as Popup', - description: - 'You can call the grid by popping up with the TextField.', + description: 'You can call the grid by popping up with the TextField.', onTapLiveDemo: () { Navigator.pushNamed(context, GridAsPopupScreen.routeName); }, @@ -492,6 +479,13 @@ class PlutoFeatures extends StatelessWidget { Navigator.pushNamed(context, DarkModeScreen.routeName); }, ), + PlutoListTile( + title: 'Custom Loading Indicator', + description: 'Define a custom loading indicator.', + onTapLiveDemo: () { + Navigator.pushNamed(context, CustomLoadingIndicatorScreen.routeName); + }, + ), PlutoListTile.amber( title: 'Empty', description: @@ -502,8 +496,7 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile.amber( title: 'Development', - description: - 'This screen is used during development, and various functions can be tested.', + description: 'This screen is used during development, and various functions can be tested.', onTapLiveDemo: () { Navigator.pushNamed(context, DevelopmentScreen.routeName); }, diff --git a/demo/linux/flutter/ephemeral/.plugin_symlinks/file_saver b/demo/linux/flutter/ephemeral/.plugin_symlinks/file_saver index 265ba5f4..c933a2ee 120000 --- a/demo/linux/flutter/ephemeral/.plugin_symlinks/file_saver +++ b/demo/linux/flutter/ephemeral/.plugin_symlinks/file_saver @@ -1 +1 @@ -C:/Users/DOONF/AppData/Local/Pub/Cache/hosted/pub.dev/file_saver-0.2.10/ \ No newline at end of file +C:/Users/stanp/AppData/Local/Pub/Cache/hosted/pub.dev/file_saver-0.2.14/ \ No newline at end of file diff --git a/demo/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux b/demo/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux index f9f3220b..c3766d49 120000 --- a/demo/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux +++ b/demo/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux @@ -1 +1 @@ -C:/Users/DOONF/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file +C:/Users/stanp/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file diff --git a/demo/linux/flutter/ephemeral/.plugin_symlinks/printing b/demo/linux/flutter/ephemeral/.plugin_symlinks/printing index 08c478a3..79b0dd17 120000 --- a/demo/linux/flutter/ephemeral/.plugin_symlinks/printing +++ b/demo/linux/flutter/ephemeral/.plugin_symlinks/printing @@ -1 +1 @@ -C:/Users/DOONF/AppData/Local/Pub/Cache/hosted/pub.dev/printing-5.11.1/ \ No newline at end of file +C:/Users/stanp/AppData/Local/Pub/Cache/hosted/pub.dev/printing-5.13.4/ \ No newline at end of file diff --git a/demo/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux b/demo/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux index debad87a..a5652a59 120000 --- a/demo/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux +++ b/demo/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux @@ -1 +1 @@ -C:/Users/DOONF/AppData/Local/Pub/Cache/hosted/pub.dev/url_launcher_linux-3.1.1/ \ No newline at end of file +C:/Users/stanp/AppData/Local/Pub/Cache/hosted/pub.dev/url_launcher_linux-3.2.1/ \ No newline at end of file From 841370f06d90dddf8562e61e2fcb7ee04c6116bb Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 3 Dec 2024 23:48:49 +0100 Subject: [PATCH 06/15] Fix merge conflict --- demo/lib/screen/home_screen.dart | 45 +++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/demo/lib/screen/home_screen.dart b/demo/lib/screen/home_screen.dart index ed80daa8..fc41fbbd 100644 --- a/demo/lib/screen/home_screen.dart +++ b/demo/lib/screen/home_screen.dart @@ -215,7 +215,8 @@ class PlutoFeatures extends StatelessWidget { children: [ PlutoListTile( title: 'Column moving', - description: 'Dragging the column heading left or right moves the column left and right.', + description: + 'Dragging the column heading left or right moves the column left and right.', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnMovingScreen.routeName); }, @@ -236,14 +237,16 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Column resizing', - description: 'Dragging the icon to the right of the column title left or right changes the width of the column.', + description: + 'Dragging the icon to the right of the column title left or right changes the width of the column.', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnResizingScreen.routeName); }, ), PlutoListTile( title: 'Column sorting', - description: 'Ascending or Descending by clicking on the column heading.', + description: + 'Ascending or Descending by clicking on the column heading.', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnSortingScreen.routeName); }, @@ -271,7 +274,8 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Column footer', - description: 'Display each column fixed at the bottom. (For outputting data sum, average, etc.)', + description: + 'Display each column fixed at the bottom. (For outputting data sum, average, etc.)', onTapLiveDemo: () { Navigator.pushNamed(context, ColumnFooterScreen.routeName); }, @@ -336,7 +340,8 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Row selection', - description: 'In Row selection mode, Shift + tap or long tap and then move or Control + tap to select a row.', + description: + 'In Row selection mode, Shift + tap or long tap and then move or Control + tap to select a row.', onTapLiveDemo: () { Navigator.pushNamed(context, RowSelectionScreen.routeName); }, @@ -357,7 +362,8 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Row lazy pagination', - description: 'Implement pagination in the form of fetching data from the server.', + description: + 'Implement pagination in the form of fetching data from the server.', onTapLiveDemo: () { Navigator.pushNamed(context, RowLazyPaginationScreen.routeName); }, @@ -397,28 +403,32 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile( title: 'Cell selection', - description: 'In cell selection mode, Shift + tap or long tap and then move to select cells.', + description: + 'In cell selection mode, Shift + tap or long tap and then move to select cells.', onTapLiveDemo: () { Navigator.pushNamed(context, CellSelectionScreen.routeName); }, ), PlutoListTile( title: 'Cell renderer', - description: 'You can change the widget of the cell through the renderer.', + description: + 'You can change the widget of the cell through the renderer.', onTapLiveDemo: () { Navigator.pushNamed(context, CellRendererScreen.routeName); }, ), PlutoListTile( title: 'Copy and Paste', - description: 'Copy and paste are operated depending on the cell and row selection status.', + description: + 'Copy and paste are operated depending on the cell and row selection status.', onTapLiveDemo: () { Navigator.pushNamed(context, CopyAndPasteScreen.routeName); }, ), PlutoListTile( title: 'Moving', - description: 'Change the current cell position with the arrow keys, enter key, and tab key.', + description: + 'Change the current cell position with the arrow keys, enter key, and tab key.', onTapLiveDemo: () { Navigator.pushNamed(context, MovingScreen.routeName); }, @@ -441,19 +451,22 @@ class PlutoFeatures extends StatelessWidget { title: 'Add and Remove Columns, Rows', description: 'You can add or delete columns, rows.', onTapLiveDemo: () { - Navigator.pushNamed(context, AddAndRemoveColumnRowScreen.routeName); + Navigator.pushNamed( + context, AddAndRemoveColumnRowScreen.routeName); }, ), PlutoListTile( title: 'Dual mode', - description: 'Place the grid on the left and right and move or edit with the keyboard.', + description: + 'Place the grid on the left and right and move or edit with the keyboard.', onTapLiveDemo: () { Navigator.pushNamed(context, DualModeScreen.routeName); }, ), PlutoListTile( title: 'Grid as Popup', - description: 'You can call the grid by popping up with the TextField.', + description: + 'You can call the grid by popping up with the TextField.', onTapLiveDemo: () { Navigator.pushNamed(context, GridAsPopupScreen.routeName); }, @@ -483,7 +496,8 @@ class PlutoFeatures extends StatelessWidget { title: 'Custom Loading Indicator', description: 'Define a custom loading indicator.', onTapLiveDemo: () { - Navigator.pushNamed(context, CustomLoadingIndicatorScreen.routeName); + Navigator.pushNamed( + context, CustomLoadingIndicatorScreen.routeName); }, ), PlutoListTile.amber( @@ -496,7 +510,8 @@ class PlutoFeatures extends StatelessWidget { ), PlutoListTile.amber( title: 'Development', - description: 'This screen is used during development, and various functions can be tested.', + description: + 'This screen is used during development, and various functions can be tested.', onTapLiveDemo: () { Navigator.pushNamed(context, DevelopmentScreen.routeName); }, From 365bbfeec8ed2fcfa8ce23658be86909076ec072 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 3 Dec 2024 23:48:58 +0100 Subject: [PATCH 07/15] WIP --- .../shortcut/pluto_grid_shortcut_action.dart | 73 ++++++------------- 1 file changed, 23 insertions(+), 50 deletions(-) diff --git a/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart b/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart index e2284058..38545b5e 100644 --- a/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart +++ b/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart @@ -80,8 +80,7 @@ class PlutoGridActionMoveCellFocus extends PlutoGridShortcutAction { required PlutoKeyManagerEvent keyEvent, required PlutoGridStateManager stateManager, }) { - bool force = keyEvent.isHorizontal && - stateManager.configuration.enableMoveHorizontalInEditing == true; + bool force = keyEvent.isHorizontal && stateManager.configuration.enableMoveHorizontalInEditing == true; if (stateManager.currentCell == null) { stateManager.setCurrentCell(stateManager.firstCell, 0); @@ -138,8 +137,7 @@ class PlutoGridActionMoveCellFocusByPage extends PlutoGridShortcutAction { final previousPosition = stateManager.currentCellPosition; - int toPage = - direction.isLeft ? stateManager.page - 1 : stateManager.page + 1; + int toPage = direction.isLeft ? stateManager.page - 1 : stateManager.page + 1; if (toPage < 1) { toPage = 1; @@ -158,9 +156,7 @@ class PlutoGridActionMoveCellFocusByPage extends PlutoGridShortcutAction { break; case PlutoMoveDirection.up: case PlutoMoveDirection.down: - final int moveCount = - (stateManager.rowContainerHeight / stateManager.rowTotalHeight) - .floor(); + final int moveCount = (stateManager.rowContainerHeight / stateManager.rowTotalHeight).floor(); int rowIdx = stateManager.currentRowIdx!; @@ -199,8 +195,7 @@ class PlutoGridActionMoveCellFocusByPage extends PlutoGridShortcutAction { /// /// When [direction] is left or right, no action is taken. /// {@endtemplate} -class PlutoGridActionMoveSelectedCellFocusByPage - extends PlutoGridShortcutAction { +class PlutoGridActionMoveSelectedCellFocusByPage extends PlutoGridShortcutAction { const PlutoGridActionMoveSelectedCellFocusByPage(this.direction); final PlutoMoveDirection direction; @@ -212,12 +207,9 @@ class PlutoGridActionMoveSelectedCellFocusByPage }) { if (direction.horizontal) return; - final int moveCount = - (stateManager.rowContainerHeight / stateManager.rowTotalHeight).floor(); + final int moveCount = (stateManager.rowContainerHeight / stateManager.rowTotalHeight).floor(); - int rowIdx = stateManager.currentSelectingPosition?.rowIdx ?? - stateManager.currentCellPosition?.rowIdx ?? - 0; + int rowIdx = stateManager.currentSelectingPosition?.rowIdx ?? stateManager.currentCellPosition?.rowIdx ?? 0; rowIdx += direction.isUp ? -moveCount : moveCount; @@ -250,16 +242,13 @@ class PlutoGridActionDefaultTab extends PlutoGridShortcutAction { final saveIsEditing = stateManager.isEditing; - keyEvent.isShiftPressed - ? _moveCellPrevious(stateManager) - : _moveCellNext(stateManager); + keyEvent.isShiftPressed ? _moveCellPrevious(stateManager) : _moveCellNext(stateManager); stateManager.setEditing(stateManager.autoEditing || saveIsEditing); } void _moveCellPrevious(PlutoGridStateManager stateManager) { - if (_willMoveToPreviousRow( - stateManager.currentCellPosition, stateManager)) { + if (_willMoveToPreviousRow(stateManager.currentCellPosition, stateManager)) { _moveCellToPreviousRow(stateManager); } else { stateManager.moveCurrentCell(PlutoMoveDirection.left, force: true); @@ -278,9 +267,7 @@ class PlutoGridActionDefaultTab extends PlutoGridShortcutAction { PlutoGridCellPosition? position, PlutoGridStateManager stateManager, ) { - if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || - position == null || - !position.hasPosition) { + if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || position == null || !position.hasPosition) { return false; } @@ -291,14 +278,11 @@ class PlutoGridActionDefaultTab extends PlutoGridShortcutAction { PlutoGridCellPosition? position, PlutoGridStateManager stateManager, ) { - if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || - position == null || - !position.hasPosition) { + if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || position == null || !position.hasPosition) { return false; } - return position.rowIdx! < stateManager.refRows.length - 1 && - position.columnIdx == stateManager.refColumns.length - 1; + return position.rowIdx! < stateManager.refRows.length - 1 && position.columnIdx == stateManager.refColumns.length - 1; } void _moveCellToPreviousRow(PlutoGridStateManager stateManager) { @@ -351,9 +335,7 @@ class PlutoGridActionDefaultEnterKey extends PlutoGridShortcutAction { row: stateManager.currentRow, rowIdx: stateManager.currentRowIdx, cell: stateManager.currentCell, - selectedRows: stateManager.mode.isMultiSelectMode - ? stateManager.currentSelectingRows - : null, + selectedRows: stateManager.mode.isMultiSelectMode ? stateManager.currentSelectingRows : null, )); return; } @@ -370,8 +352,7 @@ class PlutoGridActionDefaultEnterKey extends PlutoGridShortcutAction { if (stateManager.configuration.enterKeyAction.isToggleEditing) { stateManager.toggleEditing(notify: false); } else { - if (stateManager.isEditing == true || - stateManager.currentColumn?.enableEditingMode == false) { + if (stateManager.isEditing == true || stateManager.currentColumn?.enableEditingMode == false) { final saveIsEditing = stateManager.isEditing; _moveCell(keyEvent, stateManager); @@ -390,11 +371,7 @@ class PlutoGridActionDefaultEnterKey extends PlutoGridShortcutAction { } bool _isExpandableCell(PlutoGridStateManager stateManager) { - return stateManager.currentCell != null && - stateManager.enabledRowGroups && - stateManager.rowGroupDelegate - ?.isExpandableCell(stateManager.currentCell!) == - true; + return stateManager.currentCell != null && stateManager.enabledRowGroups && stateManager.rowGroupDelegate?.isExpandableCell(stateManager.currentCell!) == true; } void _moveCell( @@ -454,8 +431,7 @@ class PlutoGridActionDefaultEscapeKey extends PlutoGridShortcutAction { required PlutoKeyManagerEvent keyEvent, required PlutoGridStateManager stateManager, }) { - if (stateManager.mode.isSelectMode || - (stateManager.mode.isPopup && !stateManager.isEditing)) { + if (stateManager.mode.isSelectMode || (stateManager.mode.isPopup && !stateManager.isEditing)) { if (stateManager.onSelected != null) { stateManager.clearCurrentSelecting(); stateManager.onSelected!(const PlutoGridOnSelectedEvent()); @@ -499,8 +475,7 @@ class PlutoGridActionMoveCellFocusToEdge extends PlutoGridShortcutAction { /// Moves the selected focus to the end of the [direction] direction /// in the cell or row selection state. /// {@endtemplate} -class PlutoGridActionMoveSelectedCellFocusToEdge - extends PlutoGridShortcutAction { +class PlutoGridActionMoveSelectedCellFocusToEdge extends PlutoGridShortcutAction { const PlutoGridActionMoveSelectedCellFocusToEdge(this.direction); final PlutoMoveDirection direction; @@ -599,9 +574,7 @@ class PlutoGridActionToggleColumnSort extends PlutoGridShortcutAction { PlutoGridCellPosition? previousPosition, bool ignore = false, }) { - if (ignore || - currentColumn == null || - previousPosition?.hasPosition != true) { + if (ignore || currentColumn == null || previousPosition?.hasPosition != true) { return; } @@ -657,12 +630,12 @@ class PlutoGridActionPasteValues extends PlutoGridShortcutAction { return; } - Clipboard.getData('text/plain').then((value) { - if (value == null) { - return; - } - List> textList = - PlutoClipboardTransformation.stringToList(value!.text!); + Clipboard.getData('text/plain').then((ClipboardData? data) { + final text = data?.text; + + if (text == null) return; + + List> textList = PlutoClipboardTransformation.stringToList(text); stateManager.pasteCellValue(textList); }); From 79fd44797e0c4f3a6004edc9e9781dd646c2652e Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Wed, 4 Dec 2024 00:09:05 +0100 Subject: [PATCH 08/15] Removed print --- .../custom_loading_indicator_screen.dart | 102 +++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/demo/lib/screen/feature/custom_loading_indicator_screen.dart b/demo/lib/screen/feature/custom_loading_indicator_screen.dart index 7ce175bf..c549cea6 100644 --- a/demo/lib/screen/feature/custom_loading_indicator_screen.dart +++ b/demo/lib/screen/feature/custom_loading_indicator_screen.dart @@ -17,7 +17,7 @@ class _CustomLoadingIndicatorScreenState extends State rows; - late PlutoGridStateManager stateManager; + PlutoGridStateManager? stateManager; @override void initState() { @@ -46,23 +46,93 @@ class _CustomLoadingIndicatorScreenState extends State stateManager?.setLoadingLevel(level), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: stateManager?.loadingLevel == level + ? Theme.of(context).colorScheme.primary + : Colors.transparent), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8), + child: Text(level.name), + ), + ), + ), + ); + }).toList(), + ), + persistentFooterButtons: [ + TextButton( + onPressed: () { + stateManager?.setShowLoading(stateManager?.showLoading == false, + level: stateManager!.loadingLevel); + }, + child: const Text('Toggle Loading'), + ) + ], + body: SafeArea( + child: Container( + padding: const EdgeInsets.all(8), + child: PlutoGrid( + columns: columns, + rows: rows, + mode: PlutoGridMode.readOnly, + onChanged: (PlutoGridOnChangedEvent event) { + print(event); + }, + onLoaded: (PlutoGridOnLoadedEvent event) { + setState(() => stateManager = event.stateManager); + + stateManager?.setCustomLoadingIndicator( + (context) => CustomLoadingIndicator()); + }, + configuration: const PlutoGridConfiguration( + columnSize: PlutoGridColumnSizeConfig( + autoSizeMode: PlutoAutoSizeMode.scale)), + ), + ), + ), + ); + }, + ); + } +} + +class CustomLoadingIndicator extends StatefulWidget { + const CustomLoadingIndicator({super.key}); + + @override + State createState() => _CustomLoadingIndicatorState(); +} + +class _CustomLoadingIndicatorState extends State { + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: ColoredBox( + color: Theme.of(context).colorScheme.onPrimary.withOpacity(.6), ), ), - ), + Center(child: CircularProgressIndicator.adaptive()), + ], ); } } From 34b8072392a718895dd5eee8c194c29112a80e7b Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 17 Dec 2024 01:13:41 +0100 Subject: [PATCH 09/15] Fixed new flutter version issues --- .../custom_loading_indicator_screen.dart | 3 +- lib/src/helper/pluto_move_direction.dart | 2 - lib/src/helper/show_column_menu.dart | 404 +- .../event/pluto_grid_cell_gesture_event.dart | 1 - lib/src/manager/state/layout_state.dart | 2 + lib/src/manager/state/selecting_state.dart | 1423 ++++--- lib/src/widgets/pluto_loading.dart | 36 + lib/src/widgets/pluto_scrollbar.dart | 2878 +++++++------- lib/src/widgets/pluto_shadow_container.dart | 122 +- lib/src/widgets/pluto_shadow_line.dart | 82 +- test/src/pluto_grid_test.dart | 3463 ++++++++--------- 11 files changed, 4226 insertions(+), 4190 deletions(-) diff --git a/demo/lib/screen/feature/custom_loading_indicator_screen.dart b/demo/lib/screen/feature/custom_loading_indicator_screen.dart index c549cea6..e1c8f94c 100644 --- a/demo/lib/screen/feature/custom_loading_indicator_screen.dart +++ b/demo/lib/screen/feature/custom_loading_indicator_screen.dart @@ -128,7 +128,8 @@ class _CustomLoadingIndicatorState extends State { children: [ Positioned.fill( child: ColoredBox( - color: Theme.of(context).colorScheme.onPrimary.withOpacity(.6), + color: + Theme.of(context).colorScheme.onPrimary.withValues(alpha: .6), ), ), Center(child: CircularProgressIndicator.adaptive()), diff --git a/lib/src/helper/pluto_move_direction.dart b/lib/src/helper/pluto_move_direction.dart index b99c3f68..51b8a1ac 100644 --- a/lib/src/helper/pluto_move_direction.dart +++ b/lib/src/helper/pluto_move_direction.dart @@ -32,8 +32,6 @@ enum PlutoMoveDirection { case PlutoMoveDirection.right: case PlutoMoveDirection.down: return 1; - default: - return 0; } } diff --git a/lib/src/helper/show_column_menu.dart b/lib/src/helper/show_column_menu.dart index 585a65f1..faeacb22 100644 --- a/lib/src/helper/show_column_menu.dart +++ b/lib/src/helper/show_column_menu.dart @@ -1,202 +1,202 @@ -import 'package:flutter/material.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -abstract class PlutoColumnMenuDelegate { - List> buildMenuItems({ - required PlutoGridStateManager stateManager, - required PlutoColumn column, - }); - - void onSelected({ - required BuildContext context, - required PlutoGridStateManager stateManager, - required PlutoColumn column, - required bool mounted, - required T? selected, - }); -} - -class PlutoColumnMenuDelegateDefault - implements PlutoColumnMenuDelegate { - const PlutoColumnMenuDelegateDefault(); - - @override - List> buildMenuItems({ - required PlutoGridStateManager stateManager, - required PlutoColumn column, - }) { - return _getDefaultColumnMenuItems( - stateManager: stateManager, - column: column, - ); - } - - @override - void onSelected({ - required BuildContext context, - required PlutoGridStateManager stateManager, - required PlutoColumn column, - required bool mounted, - required PlutoGridColumnMenuItem? selected, - }) { - switch (selected) { - case PlutoGridColumnMenuItem.unfreeze: - stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.none); - break; - case PlutoGridColumnMenuItem.freezeToStart: - stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.start); - break; - case PlutoGridColumnMenuItem.freezeToEnd: - stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.end); - break; - case PlutoGridColumnMenuItem.autoFit: - if (!mounted) return; - stateManager.autoFitColumn(context, column); - stateManager.notifyResizingListeners(); - break; - case PlutoGridColumnMenuItem.hideColumn: - stateManager.hideColumn(column, true); - break; - case PlutoGridColumnMenuItem.setColumns: - if (!mounted) return; - stateManager.showSetColumnsPopup(context); - break; - case PlutoGridColumnMenuItem.setFilter: - if (!mounted) return; - stateManager.showFilterPopup(context, calledColumn: column); - break; - case PlutoGridColumnMenuItem.resetFilter: - stateManager.setFilter(null); - break; - default: - break; - } - } -} - -/// Open the context menu on the right side of the column. -Future? showColumnMenu({ - required BuildContext context, - required Offset position, - required List> items, - Color backgroundColor = Colors.white, -}) { - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - - return showMenu( - context: context, - color: backgroundColor, - position: RelativeRect.fromLTRB( - position.dx, - position.dy, - position.dx + overlay.size.width, - position.dy + overlay.size.height, - ), - items: items, - useRootNavigator: true, - ); -} - -List> _getDefaultColumnMenuItems({ - required PlutoGridStateManager stateManager, - required PlutoColumn column, -}) { - final Color textColor = stateManager.style.cellTextStyle.color!; - - final Color disableTextColor = textColor.withOpacity(0.5); - - final bool enoughFrozenColumnsWidth = stateManager.enoughFrozenColumnsWidth( - stateManager.maxWidth! - column.width, - ); - - final localeText = stateManager.localeText; - - return [ - if (column.frozen.isFrozen == true) - _buildMenuItem( - value: PlutoGridColumnMenuItem.unfreeze, - text: localeText.unfreezeColumn, - textColor: textColor, - ), - if (column.frozen.isFrozen != true) ...[ - _buildMenuItem( - value: PlutoGridColumnMenuItem.freezeToStart, - enabled: enoughFrozenColumnsWidth, - text: localeText.freezeColumnToStart, - textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, - ), - _buildMenuItem( - value: PlutoGridColumnMenuItem.freezeToEnd, - enabled: enoughFrozenColumnsWidth, - text: localeText.freezeColumnToEnd, - textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, - ), - ], - const PopupMenuDivider(), - _buildMenuItem( - value: PlutoGridColumnMenuItem.autoFit, - text: localeText.autoFitColumn, - textColor: textColor, - ), - if (column.enableHideColumnMenuItem == true) - _buildMenuItem( - value: PlutoGridColumnMenuItem.hideColumn, - text: localeText.hideColumn, - textColor: textColor, - enabled: stateManager.refColumns.length > 1, - ), - if (column.enableSetColumnsMenuItem == true) - _buildMenuItem( - value: PlutoGridColumnMenuItem.setColumns, - text: localeText.setColumns, - textColor: textColor, - ), - if (column.enableFilterMenuItem == true) ...[ - const PopupMenuDivider(), - _buildMenuItem( - value: PlutoGridColumnMenuItem.setFilter, - text: localeText.setFilter, - textColor: textColor, - ), - _buildMenuItem( - value: PlutoGridColumnMenuItem.resetFilter, - text: localeText.resetFilter, - textColor: textColor, - enabled: stateManager.hasFilter, - ), - ], - ]; -} - -PopupMenuItem _buildMenuItem({ - required String text, - required Color? textColor, - bool enabled = true, - PlutoGridColumnMenuItem? value, -}) { - return PopupMenuItem( - value: value, - height: 36, - enabled: enabled, - child: Text( - text, - style: TextStyle( - color: enabled ? textColor : textColor!.withOpacity(0.5), - fontSize: 13, - ), - ), - ); -} - -/// Items in the context menu on the right side of the column -enum PlutoGridColumnMenuItem { - unfreeze, - freezeToStart, - freezeToEnd, - hideColumn, - setColumns, - autoFit, - setFilter, - resetFilter, -} +import 'package:flutter/material.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +abstract class PlutoColumnMenuDelegate { + List> buildMenuItems({ + required PlutoGridStateManager stateManager, + required PlutoColumn column, + }); + + void onSelected({ + required BuildContext context, + required PlutoGridStateManager stateManager, + required PlutoColumn column, + required bool mounted, + required T? selected, + }); +} + +class PlutoColumnMenuDelegateDefault + implements PlutoColumnMenuDelegate { + const PlutoColumnMenuDelegateDefault(); + + @override + List> buildMenuItems({ + required PlutoGridStateManager stateManager, + required PlutoColumn column, + }) { + return _getDefaultColumnMenuItems( + stateManager: stateManager, + column: column, + ); + } + + @override + void onSelected({ + required BuildContext context, + required PlutoGridStateManager stateManager, + required PlutoColumn column, + required bool mounted, + required PlutoGridColumnMenuItem? selected, + }) { + switch (selected) { + case PlutoGridColumnMenuItem.unfreeze: + stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.none); + break; + case PlutoGridColumnMenuItem.freezeToStart: + stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.start); + break; + case PlutoGridColumnMenuItem.freezeToEnd: + stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.end); + break; + case PlutoGridColumnMenuItem.autoFit: + if (!mounted) return; + stateManager.autoFitColumn(context, column); + stateManager.notifyResizingListeners(); + break; + case PlutoGridColumnMenuItem.hideColumn: + stateManager.hideColumn(column, true); + break; + case PlutoGridColumnMenuItem.setColumns: + if (!mounted) return; + stateManager.showSetColumnsPopup(context); + break; + case PlutoGridColumnMenuItem.setFilter: + if (!mounted) return; + stateManager.showFilterPopup(context, calledColumn: column); + break; + case PlutoGridColumnMenuItem.resetFilter: + stateManager.setFilter(null); + break; + default: + break; + } + } +} + +/// Open the context menu on the right side of the column. +Future? showColumnMenu({ + required BuildContext context, + required Offset position, + required List> items, + Color backgroundColor = Colors.white, +}) { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + + return showMenu( + context: context, + color: backgroundColor, + position: RelativeRect.fromLTRB( + position.dx, + position.dy, + position.dx + overlay.size.width, + position.dy + overlay.size.height, + ), + items: items, + useRootNavigator: true, + ); +} + +List> _getDefaultColumnMenuItems({ + required PlutoGridStateManager stateManager, + required PlutoColumn column, +}) { + final Color textColor = stateManager.style.cellTextStyle.color!; + + final Color disableTextColor = textColor.withValues(alpha: 0.5); + + final bool enoughFrozenColumnsWidth = stateManager.enoughFrozenColumnsWidth( + stateManager.maxWidth! - column.width, + ); + + final localeText = stateManager.localeText; + + return [ + if (column.frozen.isFrozen == true) + _buildMenuItem( + value: PlutoGridColumnMenuItem.unfreeze, + text: localeText.unfreezeColumn, + textColor: textColor, + ), + if (column.frozen.isFrozen != true) ...[ + _buildMenuItem( + value: PlutoGridColumnMenuItem.freezeToStart, + enabled: enoughFrozenColumnsWidth, + text: localeText.freezeColumnToStart, + textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, + ), + _buildMenuItem( + value: PlutoGridColumnMenuItem.freezeToEnd, + enabled: enoughFrozenColumnsWidth, + text: localeText.freezeColumnToEnd, + textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, + ), + ], + const PopupMenuDivider(), + _buildMenuItem( + value: PlutoGridColumnMenuItem.autoFit, + text: localeText.autoFitColumn, + textColor: textColor, + ), + if (column.enableHideColumnMenuItem == true) + _buildMenuItem( + value: PlutoGridColumnMenuItem.hideColumn, + text: localeText.hideColumn, + textColor: textColor, + enabled: stateManager.refColumns.length > 1, + ), + if (column.enableSetColumnsMenuItem == true) + _buildMenuItem( + value: PlutoGridColumnMenuItem.setColumns, + text: localeText.setColumns, + textColor: textColor, + ), + if (column.enableFilterMenuItem == true) ...[ + const PopupMenuDivider(), + _buildMenuItem( + value: PlutoGridColumnMenuItem.setFilter, + text: localeText.setFilter, + textColor: textColor, + ), + _buildMenuItem( + value: PlutoGridColumnMenuItem.resetFilter, + text: localeText.resetFilter, + textColor: textColor, + enabled: stateManager.hasFilter, + ), + ], + ]; +} + +PopupMenuItem _buildMenuItem({ + required String text, + required Color? textColor, + bool enabled = true, + PlutoGridColumnMenuItem? value, +}) { + return PopupMenuItem( + value: value, + height: 36, + enabled: enabled, + child: Text( + text, + style: TextStyle( + color: enabled ? textColor : textColor!.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + ); +} + +/// Items in the context menu on the right side of the column +enum PlutoGridColumnMenuItem { + unfreeze, + freezeToStart, + freezeToEnd, + hideColumn, + setColumns, + autoFit, + setFilter, + resetFilter, +} diff --git a/lib/src/manager/event/pluto_grid_cell_gesture_event.dart b/lib/src/manager/event/pluto_grid_cell_gesture_event.dart index 10b23f1f..38f0a1a4 100644 --- a/lib/src/manager/event/pluto_grid_cell_gesture_event.dart +++ b/lib/src/manager/event/pluto_grid_cell_gesture_event.dart @@ -38,7 +38,6 @@ class PlutoGridCellGestureEvent extends PlutoGridEvent { case PlutoGridGestureType.onSecondaryTap: _onSecondaryTap(stateManager); break; - default: } } diff --git a/lib/src/manager/state/layout_state.dart b/lib/src/manager/state/layout_state.dart index 339dd710..2331d572 100644 --- a/lib/src/manager/state/layout_state.dart +++ b/lib/src/manager/state/layout_state.dart @@ -43,6 +43,8 @@ abstract class ILayoutState { PlutoGridLoadingLevel get loadingLevel; + WidgetBuilder? get customLoadingIndicator; + bool get hasLeftFrozenColumns; bool get hasRightFrozenColumns; diff --git a/lib/src/manager/state/selecting_state.dart b/lib/src/manager/state/selecting_state.dart index dfcd9597..0bf5d813 100644 --- a/lib/src/manager/state/selecting_state.dart +++ b/lib/src/manager/state/selecting_state.dart @@ -1,712 +1,711 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -abstract class ISelectingState { - /// Multi-selection state. - bool get isSelecting; - - /// [selectingMode] - PlutoGridSelectingMode get selectingMode; - - /// Current position of multi-select cell. - /// Calculate the currently selected cell and its multi-selection range. - PlutoGridCellPosition? get currentSelectingPosition; - - /// Position list of currently selected. - /// Only valid in [PlutoGridSelectingMode.cell]. - /// - /// ```dart - /// stateManager.currentSelectingPositionList.forEach((element) { - /// final cellValue = stateManager.rows[element.rowIdx].cells[element.field].value; - /// }); - /// ``` - List get currentSelectingPositionList; - - bool get hasCurrentSelectingPosition; - - /// Rows of currently selected. - /// Only valid in [PlutoGridSelectingMode.row]. - List get currentSelectingRows; - - /// String of multi-selected cells. - /// Preserves the structure of the cells selected by the tabs and the enter key. - String get currentSelectingText; - - /// Change Multi-Select Status. - void setSelecting(bool flag, {bool notify = true}); - - /// Set the mode to select cells or rows. - /// - /// If [PlutoGrid.mode] is [PlutoGridMode.select] or [PlutoGridMode.selectWithOneTap] - /// Coerced to [PlutoGridSelectingMode.none] regardless of [selectingMode] value. - /// - /// When [PlutoGrid.mode] is [PlutoGridMode.multiSelect] - /// Coerced to [PlutoGridSelectingMode.row] regardless of [selectingMode] value. - void setSelectingMode( - PlutoGridSelectingMode selectingMode, { - bool notify = true, - }); - - void setAllCurrentSelecting(); - - /// Sets the position of a multi-selected cell. - void setCurrentSelectingPosition({ - PlutoGridCellPosition? cellPosition, - bool notify = true, - }); - - void setCurrentSelectingPositionByCellKey( - Key? cellKey, { - bool notify = true, - }); - - /// Sets the position of a multi-selected cell. - void setCurrentSelectingPositionWithOffset(Offset offset); - - /// Sets the currentSelectingRows by range. - /// [from] rowIdx of rows. - /// [to] rowIdx of rows. - void setCurrentSelectingRowsByRange(int from, int to, {bool notify = true}); - - /// Resets currently selected rows and cells. - void clearCurrentSelecting({bool notify = true}); - - /// Select or unselect a row. - void toggleSelectingRow(int rowIdx, {bool notify = true}); - - bool isSelectingInteraction(); - - bool isSelectedRow(Key rowKey); - - /// Whether the cell is the currently multi selected cell. - bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx); - - /// The action that is selected in the Select dialog - /// and processed after the dialog is closed. - void handleAfterSelectingRow(PlutoCell cell, dynamic value); -} - -class _State { - bool _isSelecting = false; - - PlutoGridSelectingMode _selectingMode = PlutoGridSelectingMode.cell; - - List _currentSelectingRows = []; - - PlutoGridCellPosition? _currentSelectingPosition; -} - -mixin SelectingState implements IPlutoGridState { - final _State _state = _State(); - - @override - bool get isSelecting => _state._isSelecting; - - @override - PlutoGridSelectingMode get selectingMode => _state._selectingMode; - - @override - PlutoGridCellPosition? get currentSelectingPosition => - _state._currentSelectingPosition; - - @override - List get currentSelectingPositionList { - if (currentCellPosition == null || currentSelectingPosition == null) { - return []; - } - - switch (selectingMode) { - case PlutoGridSelectingMode.cell: - return _selectingCells(); - case PlutoGridSelectingMode.horizontal: - return _selectingCellsHorizontally(); - case PlutoGridSelectingMode.row: - case PlutoGridSelectingMode.none: - return []; - } - } - - @override - bool get hasCurrentSelectingPosition => currentSelectingPosition != null; - - @override - List get currentSelectingRows => _state._currentSelectingRows; - - @override - String get currentSelectingText { - final bool fromSelectingRows = - selectingMode.isRow && currentSelectingRows.isNotEmpty; - - final bool fromSelectingPosition = - currentCellPosition != null && currentSelectingPosition != null; - - final bool fromCurrentCell = currentCellPosition != null; - - if (fromSelectingRows) { - return _selectingTextFromSelectingRows(); - } else if (fromSelectingPosition) { - return _selectingTextFromSelectingPosition(); - } else if (fromCurrentCell) { - return _selectingTextFromCurrentCell(); - } - - return ''; - } - - @override - void setSelecting(bool flag, {bool notify = true}) { - if (selectingMode.isNone) { - return; - } - - if (currentCell == null || isSelecting == flag) { - return; - } - - _state._isSelecting = flag; - - if (isEditing == true) { - setEditing(false, notify: false); - } - - // Invalidates the previously selected row. - if (isSelecting) { - clearCurrentSelecting(notify: false); - } - - notifyListeners(notify, setSelecting.hashCode); - } - - @override - void setSelectingMode( - PlutoGridSelectingMode selectingMode, { - bool notify = true, - }) { - if (mode.isSingleSelectMode) { - selectingMode = PlutoGridSelectingMode.none; - } else if (mode.isMultiSelectMode) { - selectingMode = PlutoGridSelectingMode.row; - } - - if (_state._selectingMode == selectingMode) { - return; - } - - _state._currentSelectingRows = []; - - _state._currentSelectingPosition = null; - - _state._selectingMode = selectingMode; - - notifyListeners(notify, setSelectingMode.hashCode); - } - - @override - void setAllCurrentSelecting() { - if (refRows.isEmpty) { - return; - } - - switch (selectingMode) { - case PlutoGridSelectingMode.cell: - case PlutoGridSelectingMode.horizontal: - _setFistCellAsCurrent(); - - setCurrentSelectingPosition( - cellPosition: PlutoGridCellPosition( - columnIdx: refColumns.length - 1, - rowIdx: refRows.length - 1, - ), - ); - break; - case PlutoGridSelectingMode.row: - if (currentCell == null) { - _setFistCellAsCurrent(); - } - - _state._currentSelectingPosition = PlutoGridCellPosition( - columnIdx: refColumns.length - 1, - rowIdx: refRows.length - 1, - ); - - setCurrentSelectingRowsByRange(0, refRows.length - 1); - break; - case PlutoGridSelectingMode.none: - default: - break; - } - } - - @override - void setCurrentSelectingPosition({ - PlutoGridCellPosition? cellPosition, - bool notify = true, - }) { - if (selectingMode.isNone) { - return; - } - - if (currentSelectingPosition == cellPosition) { - return; - } - - _state._currentSelectingPosition = - isInvalidCellPosition(cellPosition) ? null : cellPosition; - - if (currentSelectingPosition != null && selectingMode.isRow) { - setCurrentSelectingRowsByRange( - currentRowIdx, - currentSelectingPosition!.rowIdx, - notify: false, - ); - } - - notifyListeners(notify, setCurrentSelectingPosition.hashCode); - } - - @override - void setCurrentSelectingPositionByCellKey( - Key? cellKey, { - bool notify = true, - }) { - if (cellKey == null) { - return; - } - - setCurrentSelectingPosition( - cellPosition: cellPositionByCellKey(cellKey), - notify: notify, - ); - } - - @override - void setCurrentSelectingPositionWithOffset(Offset? offset) { - if (currentCell == null) { - return; - } - - final double gridBodyOffsetDy = gridGlobalOffset!.dy + - gridBorderWidth + - headerHeight + - columnGroupHeight + - columnHeight + - columnFilterHeight; - - double currentCellOffsetDy = (currentRowIdx! * rowTotalHeight) + - gridBodyOffsetDy - - scroll.vertical!.offset; - - if (gridBodyOffsetDy > offset!.dy) { - return; - } - - int rowIdx = (((currentCellOffsetDy - offset.dy) / rowTotalHeight).ceil() - - currentRowIdx!) - .abs(); - - int? columnIdx; - - final directionalOffset = toDirectionalOffset(offset); - double currentWidth = isLTR ? gridGlobalOffset!.dx : 0.0; - - final columnIndexes = columnIndexesByShowFrozen; - - final savedRightBlankOffset = rightBlankOffset; - final savedHorizontalScrollOffset = scroll.horizontal!.offset; - - for (int i = 0; i < columnIndexes.length; i += 1) { - final column = refColumns[columnIndexes[i]]; - - currentWidth += column.width; - - final rightFrozenColumnOffset = - column.frozen.isEnd && showFrozenColumn ? savedRightBlankOffset : 0; - - if (currentWidth + rightFrozenColumnOffset > - directionalOffset.dx + savedHorizontalScrollOffset) { - columnIdx = i; - break; - } - } - - if (columnIdx == null) { - return; - } - - setCurrentSelectingPosition( - cellPosition: PlutoGridCellPosition( - columnIdx: columnIdx, - rowIdx: rowIdx, - ), - ); - } - - @override - void setCurrentSelectingRowsByRange(int? from, int? to, - {bool notify = true}) { - if (!selectingMode.isRow) { - return; - } - - final maxFrom = min(from!, to!); - - final maxTo = max(from, to) + 1; - - if (maxFrom < 0 || maxTo > refRows.length) { - return; - } - - _state._currentSelectingRows = refRows.getRange(maxFrom, maxTo).toList(); - - notifyListeners(notify, setCurrentSelectingRowsByRange.hashCode); - } - - @override - void clearCurrentSelecting({bool notify = true}) { - _clearCurrentSelectingPosition(notify: false); - - _clearCurrentSelectingRows(notify: false); - - notifyListeners(notify, clearCurrentSelecting.hashCode); - } - - @override - void toggleSelectingRow(int? rowIdx, {notify = true}) { - if (!selectingMode.isRow) { - return; - } - - if (rowIdx == null || rowIdx < 0 || rowIdx > refRows.length - 1) { - return; - } - - final PlutoRow row = refRows[rowIdx]; - - final keys = Set.from(currentSelectingRows.map((e) => e.key)); - - if (keys.contains(row.key)) { - currentSelectingRows.removeWhere((element) => element.key == row.key); - } else { - currentSelectingRows.add(row); - } - - notifyListeners(notify, toggleSelectingRow.hashCode); - } - - @override - bool isSelectingInteraction() { - return !selectingMode.isNone && - (keyPressed.shift || keyPressed.ctrl) && - currentCell != null; - } - - @override - bool isSelectedRow(Key? rowKey) { - if (rowKey == null || - !selectingMode.isRow || - currentSelectingRows.isEmpty) { - return false; - } - - return currentSelectingRows.firstWhereOrNull( - (element) => element.key == rowKey, - ) != - null; - } - - // todo : code cleanup - @override - bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx) { - if (selectingMode.isNone) { - return false; - } - - if (currentCellPosition == null) { - return false; - } - - if (currentSelectingPosition == null) { - return false; - } - - if (selectingMode.isCell) { - final bool inRangeOfRows = min( - currentCellPosition!.rowIdx as num, - currentSelectingPosition!.rowIdx as num, - ) <= - rowIdx && - rowIdx <= - max( - currentCellPosition!.rowIdx!, - currentSelectingPosition!.rowIdx!, - ); - - if (inRangeOfRows == false) { - return false; - } - - final int? columnIdx = columnIndex(column); - - if (columnIdx == null) { - return false; - } - - final bool inRangeOfColumns = min( - currentCellPosition!.columnIdx as num, - currentSelectingPosition!.columnIdx as num, - ) <= - columnIdx && - columnIdx <= - max( - currentCellPosition!.columnIdx!, - currentSelectingPosition!.columnIdx!, - ); - - if (inRangeOfColumns == false) { - return false; - } - - return true; - } else if (selectingMode.isHorizontal) { - int startRowIdx = min( - currentCellPosition!.rowIdx!, - currentSelectingPosition!.rowIdx!, - ); - - int endRowIdx = max( - currentCellPosition!.rowIdx!, - currentSelectingPosition!.rowIdx!, - ); - - final int? columnIdx = columnIndex(column); - - if (columnIdx == null) { - return false; - } - - int? startColumnIdx; - - int? endColumnIdx; - - if (currentCellPosition!.rowIdx! < currentSelectingPosition!.rowIdx!) { - startColumnIdx = currentCellPosition!.columnIdx; - endColumnIdx = currentSelectingPosition!.columnIdx; - } else if (currentCellPosition!.rowIdx! > - currentSelectingPosition!.rowIdx!) { - startColumnIdx = currentSelectingPosition!.columnIdx; - endColumnIdx = currentCellPosition!.columnIdx; - } else { - startColumnIdx = min( - currentCellPosition!.columnIdx!, - currentSelectingPosition!.columnIdx!, - ); - endColumnIdx = max( - currentCellPosition!.columnIdx!, - currentSelectingPosition!.columnIdx!, - ); - } - - if (rowIdx == startRowIdx && startRowIdx == endRowIdx) { - return !(columnIdx < startColumnIdx! || columnIdx > endColumnIdx!); - } else if (rowIdx == startRowIdx && columnIdx >= startColumnIdx!) { - return true; - } else if (rowIdx == endRowIdx && columnIdx <= endColumnIdx!) { - return true; - } else if (rowIdx > startRowIdx && rowIdx < endRowIdx) { - return true; - } - - return false; - } else if (selectingMode.isRow) { - return false; - } else { - throw Exception('selectingMode is not handled'); - } - } - - @override - void handleAfterSelectingRow(PlutoCell cell, dynamic value) { - changeCellValue(cell, value, notify: false); - - if (configuration.enableMoveDownAfterSelecting) { - moveCurrentCell(PlutoMoveDirection.down, notify: false); - - setEditing(true, notify: false); - } - - setKeepFocus(true, notify: false); - - notifyListeners(true, handleAfterSelectingRow.hashCode); - } - - List _selectingCells() { - final List positions = []; - - final columnIndexes = columnIndexesByShowFrozen; - - int columnStartIdx = min( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int columnEndIdx = max( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int rowStartIdx = - min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - int rowEndIdx = - max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { - for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { - final String field = refColumns[columnIndexes[j]].field; - - positions.add(PlutoGridSelectingCellPosition( - rowIdx: i, - field: field, - )); - } - } - - return positions; - } - - List _selectingCellsHorizontally() { - final List positions = []; - - final columnIndexes = columnIndexesByShowFrozen; - - final bool firstCurrent = currentCellPosition!.rowIdx! < - currentSelectingPosition!.rowIdx! || - (currentCellPosition!.rowIdx! == currentSelectingPosition!.rowIdx! && - currentCellPosition!.columnIdx! <= - currentSelectingPosition!.columnIdx!); - - PlutoGridCellPosition startCell = - firstCurrent ? currentCellPosition! : currentSelectingPosition!; - - PlutoGridCellPosition endCell = - !firstCurrent ? currentCellPosition! : currentSelectingPosition!; - - int columnStartIdx = startCell.columnIdx!; - - int columnEndIdx = endCell.columnIdx!; - - int rowStartIdx = startCell.rowIdx!; - - int rowEndIdx = endCell.rowIdx!; - - final length = columnIndexes.length; - - for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { - for (int j = 0; j < length; j += 1) { - if (i == rowStartIdx && j < columnStartIdx) { - continue; - } - - final String field = refColumns[columnIndexes[j]].field; - - positions.add(PlutoGridSelectingCellPosition( - rowIdx: i, - field: field, - )); - - if (i == rowEndIdx && j == columnEndIdx) { - break; - } - } - } - - return positions; - } - - String _selectingTextFromSelectingRows() { - final columnIndexes = columnIndexesByShowFrozen; - - List rowText = []; - - for (final row in currentSelectingRows) { - List columnText = []; - - for (int i = 0; i < columnIndexes.length; i += 1) { - final String field = refColumns[columnIndexes[i]].field; - - columnText.add(row.cells[field]!.value.toString()); - } - - rowText.add(columnText.join('\t')); - } - - return rowText.join('\n'); - } - - String _selectingTextFromSelectingPosition() { - final columnIndexes = columnIndexesByShowFrozen; - - List rowText = []; - - int columnStartIdx = min( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int columnEndIdx = max( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int rowStartIdx = - min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - int rowEndIdx = - max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { - List columnText = []; - - for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { - final String field = refColumns[columnIndexes[j]].field; - - columnText.add(refRows[i].cells[field]!.value.toString()); - } - - rowText.add(columnText.join('\t')); - } - - return rowText.join('\n'); - } - - String _selectingTextFromCurrentCell() { - return currentCell!.value.toString(); - } - - void _setFistCellAsCurrent() { - setCurrentCell(firstCell, 0, notify: false); - - if (isEditing == true) { - setEditing(false, notify: false); - } - } - - void _clearCurrentSelectingPosition({bool notify = true}) { - if (currentSelectingPosition == null) { - return; - } - - _state._currentSelectingPosition = null; - - if (notify) { - notifyListeners(); - } - } - - void _clearCurrentSelectingRows({bool notify = true}) { - if (currentSelectingRows.isEmpty) { - return; - } - - _state._currentSelectingRows = []; - - if (notify) { - notifyListeners(); - } - } -} +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +abstract class ISelectingState { + /// Multi-selection state. + bool get isSelecting; + + /// [selectingMode] + PlutoGridSelectingMode get selectingMode; + + /// Current position of multi-select cell. + /// Calculate the currently selected cell and its multi-selection range. + PlutoGridCellPosition? get currentSelectingPosition; + + /// Position list of currently selected. + /// Only valid in [PlutoGridSelectingMode.cell]. + /// + /// ```dart + /// stateManager.currentSelectingPositionList.forEach((element) { + /// final cellValue = stateManager.rows[element.rowIdx].cells[element.field].value; + /// }); + /// ``` + List get currentSelectingPositionList; + + bool get hasCurrentSelectingPosition; + + /// Rows of currently selected. + /// Only valid in [PlutoGridSelectingMode.row]. + List get currentSelectingRows; + + /// String of multi-selected cells. + /// Preserves the structure of the cells selected by the tabs and the enter key. + String get currentSelectingText; + + /// Change Multi-Select Status. + void setSelecting(bool flag, {bool notify = true}); + + /// Set the mode to select cells or rows. + /// + /// If [PlutoGrid.mode] is [PlutoGridMode.select] or [PlutoGridMode.selectWithOneTap] + /// Coerced to [PlutoGridSelectingMode.none] regardless of [selectingMode] value. + /// + /// When [PlutoGrid.mode] is [PlutoGridMode.multiSelect] + /// Coerced to [PlutoGridSelectingMode.row] regardless of [selectingMode] value. + void setSelectingMode( + PlutoGridSelectingMode selectingMode, { + bool notify = true, + }); + + void setAllCurrentSelecting(); + + /// Sets the position of a multi-selected cell. + void setCurrentSelectingPosition({ + PlutoGridCellPosition? cellPosition, + bool notify = true, + }); + + void setCurrentSelectingPositionByCellKey( + Key? cellKey, { + bool notify = true, + }); + + /// Sets the position of a multi-selected cell. + void setCurrentSelectingPositionWithOffset(Offset offset); + + /// Sets the currentSelectingRows by range. + /// [from] rowIdx of rows. + /// [to] rowIdx of rows. + void setCurrentSelectingRowsByRange(int from, int to, {bool notify = true}); + + /// Resets currently selected rows and cells. + void clearCurrentSelecting({bool notify = true}); + + /// Select or unselect a row. + void toggleSelectingRow(int rowIdx, {bool notify = true}); + + bool isSelectingInteraction(); + + bool isSelectedRow(Key rowKey); + + /// Whether the cell is the currently multi selected cell. + bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx); + + /// The action that is selected in the Select dialog + /// and processed after the dialog is closed. + void handleAfterSelectingRow(PlutoCell cell, dynamic value); +} + +class _State { + bool _isSelecting = false; + + PlutoGridSelectingMode _selectingMode = PlutoGridSelectingMode.cell; + + List _currentSelectingRows = []; + + PlutoGridCellPosition? _currentSelectingPosition; +} + +mixin SelectingState implements IPlutoGridState { + final _State _state = _State(); + + @override + bool get isSelecting => _state._isSelecting; + + @override + PlutoGridSelectingMode get selectingMode => _state._selectingMode; + + @override + PlutoGridCellPosition? get currentSelectingPosition => + _state._currentSelectingPosition; + + @override + List get currentSelectingPositionList { + if (currentCellPosition == null || currentSelectingPosition == null) { + return []; + } + + switch (selectingMode) { + case PlutoGridSelectingMode.cell: + return _selectingCells(); + case PlutoGridSelectingMode.horizontal: + return _selectingCellsHorizontally(); + case PlutoGridSelectingMode.row: + case PlutoGridSelectingMode.none: + return []; + } + } + + @override + bool get hasCurrentSelectingPosition => currentSelectingPosition != null; + + @override + List get currentSelectingRows => _state._currentSelectingRows; + + @override + String get currentSelectingText { + final bool fromSelectingRows = + selectingMode.isRow && currentSelectingRows.isNotEmpty; + + final bool fromSelectingPosition = + currentCellPosition != null && currentSelectingPosition != null; + + final bool fromCurrentCell = currentCellPosition != null; + + if (fromSelectingRows) { + return _selectingTextFromSelectingRows(); + } else if (fromSelectingPosition) { + return _selectingTextFromSelectingPosition(); + } else if (fromCurrentCell) { + return _selectingTextFromCurrentCell(); + } + + return ''; + } + + @override + void setSelecting(bool flag, {bool notify = true}) { + if (selectingMode.isNone) { + return; + } + + if (currentCell == null || isSelecting == flag) { + return; + } + + _state._isSelecting = flag; + + if (isEditing == true) { + setEditing(false, notify: false); + } + + // Invalidates the previously selected row. + if (isSelecting) { + clearCurrentSelecting(notify: false); + } + + notifyListeners(notify, setSelecting.hashCode); + } + + @override + void setSelectingMode( + PlutoGridSelectingMode selectingMode, { + bool notify = true, + }) { + if (mode.isSingleSelectMode) { + selectingMode = PlutoGridSelectingMode.none; + } else if (mode.isMultiSelectMode) { + selectingMode = PlutoGridSelectingMode.row; + } + + if (_state._selectingMode == selectingMode) { + return; + } + + _state._currentSelectingRows = []; + + _state._currentSelectingPosition = null; + + _state._selectingMode = selectingMode; + + notifyListeners(notify, setSelectingMode.hashCode); + } + + @override + void setAllCurrentSelecting() { + if (refRows.isEmpty) { + return; + } + + switch (selectingMode) { + case PlutoGridSelectingMode.cell: + case PlutoGridSelectingMode.horizontal: + _setFistCellAsCurrent(); + + setCurrentSelectingPosition( + cellPosition: PlutoGridCellPosition( + columnIdx: refColumns.length - 1, + rowIdx: refRows.length - 1, + ), + ); + break; + case PlutoGridSelectingMode.row: + if (currentCell == null) { + _setFistCellAsCurrent(); + } + + _state._currentSelectingPosition = PlutoGridCellPosition( + columnIdx: refColumns.length - 1, + rowIdx: refRows.length - 1, + ); + + setCurrentSelectingRowsByRange(0, refRows.length - 1); + break; + case PlutoGridSelectingMode.none: + break; + } + } + + @override + void setCurrentSelectingPosition({ + PlutoGridCellPosition? cellPosition, + bool notify = true, + }) { + if (selectingMode.isNone) { + return; + } + + if (currentSelectingPosition == cellPosition) { + return; + } + + _state._currentSelectingPosition = + isInvalidCellPosition(cellPosition) ? null : cellPosition; + + if (currentSelectingPosition != null && selectingMode.isRow) { + setCurrentSelectingRowsByRange( + currentRowIdx, + currentSelectingPosition!.rowIdx, + notify: false, + ); + } + + notifyListeners(notify, setCurrentSelectingPosition.hashCode); + } + + @override + void setCurrentSelectingPositionByCellKey( + Key? cellKey, { + bool notify = true, + }) { + if (cellKey == null) { + return; + } + + setCurrentSelectingPosition( + cellPosition: cellPositionByCellKey(cellKey), + notify: notify, + ); + } + + @override + void setCurrentSelectingPositionWithOffset(Offset? offset) { + if (currentCell == null) { + return; + } + + final double gridBodyOffsetDy = gridGlobalOffset!.dy + + gridBorderWidth + + headerHeight + + columnGroupHeight + + columnHeight + + columnFilterHeight; + + double currentCellOffsetDy = (currentRowIdx! * rowTotalHeight) + + gridBodyOffsetDy - + scroll.vertical!.offset; + + if (gridBodyOffsetDy > offset!.dy) { + return; + } + + int rowIdx = (((currentCellOffsetDy - offset.dy) / rowTotalHeight).ceil() - + currentRowIdx!) + .abs(); + + int? columnIdx; + + final directionalOffset = toDirectionalOffset(offset); + double currentWidth = isLTR ? gridGlobalOffset!.dx : 0.0; + + final columnIndexes = columnIndexesByShowFrozen; + + final savedRightBlankOffset = rightBlankOffset; + final savedHorizontalScrollOffset = scroll.horizontal!.offset; + + for (int i = 0; i < columnIndexes.length; i += 1) { + final column = refColumns[columnIndexes[i]]; + + currentWidth += column.width; + + final rightFrozenColumnOffset = + column.frozen.isEnd && showFrozenColumn ? savedRightBlankOffset : 0; + + if (currentWidth + rightFrozenColumnOffset > + directionalOffset.dx + savedHorizontalScrollOffset) { + columnIdx = i; + break; + } + } + + if (columnIdx == null) { + return; + } + + setCurrentSelectingPosition( + cellPosition: PlutoGridCellPosition( + columnIdx: columnIdx, + rowIdx: rowIdx, + ), + ); + } + + @override + void setCurrentSelectingRowsByRange(int? from, int? to, + {bool notify = true}) { + if (!selectingMode.isRow) { + return; + } + + final maxFrom = min(from!, to!); + + final maxTo = max(from, to) + 1; + + if (maxFrom < 0 || maxTo > refRows.length) { + return; + } + + _state._currentSelectingRows = refRows.getRange(maxFrom, maxTo).toList(); + + notifyListeners(notify, setCurrentSelectingRowsByRange.hashCode); + } + + @override + void clearCurrentSelecting({bool notify = true}) { + _clearCurrentSelectingPosition(notify: false); + + _clearCurrentSelectingRows(notify: false); + + notifyListeners(notify, clearCurrentSelecting.hashCode); + } + + @override + void toggleSelectingRow(int? rowIdx, {notify = true}) { + if (!selectingMode.isRow) { + return; + } + + if (rowIdx == null || rowIdx < 0 || rowIdx > refRows.length - 1) { + return; + } + + final PlutoRow row = refRows[rowIdx]; + + final keys = Set.from(currentSelectingRows.map((e) => e.key)); + + if (keys.contains(row.key)) { + currentSelectingRows.removeWhere((element) => element.key == row.key); + } else { + currentSelectingRows.add(row); + } + + notifyListeners(notify, toggleSelectingRow.hashCode); + } + + @override + bool isSelectingInteraction() { + return !selectingMode.isNone && + (keyPressed.shift || keyPressed.ctrl) && + currentCell != null; + } + + @override + bool isSelectedRow(Key? rowKey) { + if (rowKey == null || + !selectingMode.isRow || + currentSelectingRows.isEmpty) { + return false; + } + + return currentSelectingRows.firstWhereOrNull( + (element) => element.key == rowKey, + ) != + null; + } + + // todo : code cleanup + @override + bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx) { + if (selectingMode.isNone) { + return false; + } + + if (currentCellPosition == null) { + return false; + } + + if (currentSelectingPosition == null) { + return false; + } + + if (selectingMode.isCell) { + final bool inRangeOfRows = min( + currentCellPosition!.rowIdx as num, + currentSelectingPosition!.rowIdx as num, + ) <= + rowIdx && + rowIdx <= + max( + currentCellPosition!.rowIdx!, + currentSelectingPosition!.rowIdx!, + ); + + if (inRangeOfRows == false) { + return false; + } + + final int? columnIdx = columnIndex(column); + + if (columnIdx == null) { + return false; + } + + final bool inRangeOfColumns = min( + currentCellPosition!.columnIdx as num, + currentSelectingPosition!.columnIdx as num, + ) <= + columnIdx && + columnIdx <= + max( + currentCellPosition!.columnIdx!, + currentSelectingPosition!.columnIdx!, + ); + + if (inRangeOfColumns == false) { + return false; + } + + return true; + } else if (selectingMode.isHorizontal) { + int startRowIdx = min( + currentCellPosition!.rowIdx!, + currentSelectingPosition!.rowIdx!, + ); + + int endRowIdx = max( + currentCellPosition!.rowIdx!, + currentSelectingPosition!.rowIdx!, + ); + + final int? columnIdx = columnIndex(column); + + if (columnIdx == null) { + return false; + } + + int? startColumnIdx; + + int? endColumnIdx; + + if (currentCellPosition!.rowIdx! < currentSelectingPosition!.rowIdx!) { + startColumnIdx = currentCellPosition!.columnIdx; + endColumnIdx = currentSelectingPosition!.columnIdx; + } else if (currentCellPosition!.rowIdx! > + currentSelectingPosition!.rowIdx!) { + startColumnIdx = currentSelectingPosition!.columnIdx; + endColumnIdx = currentCellPosition!.columnIdx; + } else { + startColumnIdx = min( + currentCellPosition!.columnIdx!, + currentSelectingPosition!.columnIdx!, + ); + endColumnIdx = max( + currentCellPosition!.columnIdx!, + currentSelectingPosition!.columnIdx!, + ); + } + + if (rowIdx == startRowIdx && startRowIdx == endRowIdx) { + return !(columnIdx < startColumnIdx! || columnIdx > endColumnIdx!); + } else if (rowIdx == startRowIdx && columnIdx >= startColumnIdx!) { + return true; + } else if (rowIdx == endRowIdx && columnIdx <= endColumnIdx!) { + return true; + } else if (rowIdx > startRowIdx && rowIdx < endRowIdx) { + return true; + } + + return false; + } else if (selectingMode.isRow) { + return false; + } else { + throw Exception('selectingMode is not handled'); + } + } + + @override + void handleAfterSelectingRow(PlutoCell cell, dynamic value) { + changeCellValue(cell, value, notify: false); + + if (configuration.enableMoveDownAfterSelecting) { + moveCurrentCell(PlutoMoveDirection.down, notify: false); + + setEditing(true, notify: false); + } + + setKeepFocus(true, notify: false); + + notifyListeners(true, handleAfterSelectingRow.hashCode); + } + + List _selectingCells() { + final List positions = []; + + final columnIndexes = columnIndexesByShowFrozen; + + int columnStartIdx = min( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int columnEndIdx = max( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int rowStartIdx = + min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + int rowEndIdx = + max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { + for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { + final String field = refColumns[columnIndexes[j]].field; + + positions.add(PlutoGridSelectingCellPosition( + rowIdx: i, + field: field, + )); + } + } + + return positions; + } + + List _selectingCellsHorizontally() { + final List positions = []; + + final columnIndexes = columnIndexesByShowFrozen; + + final bool firstCurrent = currentCellPosition!.rowIdx! < + currentSelectingPosition!.rowIdx! || + (currentCellPosition!.rowIdx! == currentSelectingPosition!.rowIdx! && + currentCellPosition!.columnIdx! <= + currentSelectingPosition!.columnIdx!); + + PlutoGridCellPosition startCell = + firstCurrent ? currentCellPosition! : currentSelectingPosition!; + + PlutoGridCellPosition endCell = + !firstCurrent ? currentCellPosition! : currentSelectingPosition!; + + int columnStartIdx = startCell.columnIdx!; + + int columnEndIdx = endCell.columnIdx!; + + int rowStartIdx = startCell.rowIdx!; + + int rowEndIdx = endCell.rowIdx!; + + final length = columnIndexes.length; + + for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { + for (int j = 0; j < length; j += 1) { + if (i == rowStartIdx && j < columnStartIdx) { + continue; + } + + final String field = refColumns[columnIndexes[j]].field; + + positions.add(PlutoGridSelectingCellPosition( + rowIdx: i, + field: field, + )); + + if (i == rowEndIdx && j == columnEndIdx) { + break; + } + } + } + + return positions; + } + + String _selectingTextFromSelectingRows() { + final columnIndexes = columnIndexesByShowFrozen; + + List rowText = []; + + for (final row in currentSelectingRows) { + List columnText = []; + + for (int i = 0; i < columnIndexes.length; i += 1) { + final String field = refColumns[columnIndexes[i]].field; + + columnText.add(row.cells[field]!.value.toString()); + } + + rowText.add(columnText.join('\t')); + } + + return rowText.join('\n'); + } + + String _selectingTextFromSelectingPosition() { + final columnIndexes = columnIndexesByShowFrozen; + + List rowText = []; + + int columnStartIdx = min( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int columnEndIdx = max( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int rowStartIdx = + min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + int rowEndIdx = + max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { + List columnText = []; + + for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { + final String field = refColumns[columnIndexes[j]].field; + + columnText.add(refRows[i].cells[field]!.value.toString()); + } + + rowText.add(columnText.join('\t')); + } + + return rowText.join('\n'); + } + + String _selectingTextFromCurrentCell() { + return currentCell!.value.toString(); + } + + void _setFistCellAsCurrent() { + setCurrentCell(firstCell, 0, notify: false); + + if (isEditing == true) { + setEditing(false, notify: false); + } + } + + void _clearCurrentSelectingPosition({bool notify = true}) { + if (currentSelectingPosition == null) { + return; + } + + _state._currentSelectingPosition = null; + + if (notify) { + notifyListeners(); + } + } + + void _clearCurrentSelectingRows({bool notify = true}) { + if (currentSelectingRows.isEmpty) { + return; + } + + _state._currentSelectingRows = []; + + if (notify) { + notifyListeners(); + } + } +} diff --git a/lib/src/widgets/pluto_loading.dart b/lib/src/widgets/pluto_loading.dart index 6a662a8a..4684e3fd 100644 --- a/lib/src/widgets/pluto_loading.dart +++ b/lib/src/widgets/pluto_loading.dart @@ -94,3 +94,39 @@ class _GridLoading extends StatelessWidget { ); } } + +class _CustomLoading extends StatelessWidget { + const _CustomLoading({required this.stateManager}); + + final PlutoGridStateManager stateManager; + + @override + Widget build(BuildContext context) { + return stateManager.customLoadingIndicator?.call(context) ?? + CustomLoadingIndicator(); + } +} + +class CustomLoadingIndicator extends StatefulWidget { + const CustomLoadingIndicator({super.key}); + + @override + State createState() => _CustomLoadingIndicatorState(); +} + +class _CustomLoadingIndicatorState extends State { + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: ColoredBox( + color: + Theme.of(context).colorScheme.onPrimary.withValues(alpha: .6), + ), + ), + Center(child: CircularProgressIndicator.adaptive()), + ], + ); + } +} diff --git a/lib/src/widgets/pluto_scrollbar.dart b/lib/src/widgets/pluto_scrollbar.dart index 89446855..dd72dbf2 100644 --- a/lib/src/widgets/pluto_scrollbar.dart +++ b/lib/src/widgets/pluto_scrollbar.dart @@ -1,1438 +1,1440 @@ -/* - * This widget modifies [CupertinoScrollbar] a little, - * so that the horizontal and vertical scroll controllers work together. -*/ - -// All values eyeballed. -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; - -const double _kScrollbarMinLength = 36.0; -const double _kScrollbarMinOverscrollLength = 8.0; -const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); -const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); -const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); -const Duration _kScrollbarLongPressDuration = Duration(milliseconds: 100); - -// Extracted from iOS 13.1 beta using Debug View Hierarchy. -const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( - color: Color(0x59000000), - darkColor: Color(0x80FFFFFF), -); -const Color _kTrackColor = Color(0x00000000); -// This is the amount of space from the top of a vertical scrollbar to the -// top edge of the scrollable, measured when the vertical scrollbar overscrolls -// to the top. -// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 -const double _kScrollbarMainAxisMargin = 3.0; -const double _kScrollbarCrossAxisMargin = 3.0; - -class PlutoScrollbar extends StatefulWidget { - const PlutoScrollbar({ - super.key, - this.horizontalController, - this.verticalController, - this.isAlwaysShown = false, - this.onlyDraggingThumb = true, - this.enableHover = true, - this.enableScrollAfterDragEnd = true, - this.thickness = defaultThickness, - this.thicknessWhileDragging = defaultThicknessWhileDragging, - this.hoverWidth = defaultScrollbarHoverWidth, - double? mainAxisMargin, - double? crossAxisMargin, - Color? scrollBarColor, - Color? scrollBarTrackColor, - Duration? longPressDuration, - this.radius = defaultRadius, - this.radiusWhileDragging = defaultRadiusWhileDragging, - required this.child, - }) : assert(thickness < double.infinity), - assert(thicknessWhileDragging < double.infinity), - assert(!isAlwaysShown || - (horizontalController != null || verticalController != null)), - mainAxisMargin = mainAxisMargin ?? _kScrollbarMainAxisMargin, - crossAxisMargin = crossAxisMargin ?? _kScrollbarCrossAxisMargin, - scrollBarColor = scrollBarColor ?? _kScrollbarColor, - scrollBarTrackColor = scrollBarTrackColor ?? _kTrackColor, - longPressDuration = longPressDuration ?? _kScrollbarLongPressDuration; - final ScrollController? horizontalController; - - final ScrollController? verticalController; - - final bool isAlwaysShown; - - final bool onlyDraggingThumb; - - final bool enableHover; - - final bool enableScrollAfterDragEnd; - - final Duration longPressDuration; - - final double thickness; - - final double thicknessWhileDragging; - - final double hoverWidth; - - final double mainAxisMargin; - - final double crossAxisMargin; - - final Color scrollBarColor; - - final Color scrollBarTrackColor; - - final Radius radius; - - final Radius radiusWhileDragging; - - final Widget child; - - static const double defaultThickness = 3; - - static const double defaultThicknessWhileDragging = 8.0; - - static const double defaultScrollbarHoverWidth = 16.0; - - static const Radius defaultRadius = Radius.circular(1.5); - - static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); - - @override - PlutoGridCupertinoScrollbarState createState() => - PlutoGridCupertinoScrollbarState(); -} - -class PlutoGridCupertinoScrollbarState extends State - with TickerProviderStateMixin { - final GlobalKey _customPaintKey = GlobalKey(); - _ScrollbarPainter? _painter; - - late TextDirection _textDirection; - late AnimationController _fadeoutAnimationController; - late Animation _fadeoutOpacityAnimation; - late AnimationController _thicknessAnimationController; - Timer? _fadeoutTimer; - double? _dragScrollbarAxisPosition; - Drag? _drag; - - double get _thickness { - return widget.thickness + - _thicknessAnimationController.value * - (widget.thicknessWhileDragging - widget.thickness); - } - - Radius? get _radius { - return Radius.lerp(widget.radius, widget.radiusWhileDragging, - _thicknessAnimationController.value); - } - - ScrollController? _currentController; - - ScrollController? get _controller { - if (_currentAxis == null) { - return widget.verticalController ?? - widget.horizontalController ?? - PrimaryScrollController.of(context); - } - - return _currentAxis == Axis.vertical - ? widget.verticalController - : widget.horizontalController; - } - - Axis? _currentAxis; - - _HoverAxis _currentHoverAxis = _HoverAxis.none; - - @override - void initState() { - super.initState(); - _fadeoutAnimationController = AnimationController( - vsync: this, - duration: _kScrollbarFadeDuration, - ); - _fadeoutOpacityAnimation = CurvedAnimation( - parent: _fadeoutAnimationController, - curve: Curves.fastOutSlowIn, - ); - _thicknessAnimationController = AnimationController( - vsync: this, - duration: _kScrollbarResizeDuration, - ); - _thicknessAnimationController.addListener(() { - _painter!.updateThickness(_thickness, _radius!); - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _textDirection = Directionality.of(context); - if (_painter == null) { - _painter = _buildCupertinoScrollbarPainter(context); - } else { - _painter! - ..textDirection = _textDirection - ..color = CupertinoDynamicColor.resolve(widget.scrollBarColor, context) - ..padding = MediaQuery.of(context).padding; - } - _triggerScrollbar(); - } - - @override - void didUpdateWidget(PlutoScrollbar oldWidget) { - super.didUpdateWidget(oldWidget); - assert(_painter != null); - _painter!.updateThickness(_thickness, _radius!); - if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { - if (widget.isAlwaysShown == true) { - _triggerScrollbar(); - _fadeoutAnimationController.animateTo(1.0); - } else { - _fadeoutAnimationController.reverse(); - } - } - } - - /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar. - _ScrollbarPainter _buildCupertinoScrollbarPainter(BuildContext context) { - return _ScrollbarPainter( - trackColor: - CupertinoDynamicColor.resolve(widget.scrollBarTrackColor, context), - color: CupertinoDynamicColor.resolve(widget.scrollBarColor, context), - textDirection: Directionality.of(context), - thickness: _thickness, - fadeoutOpacityAnimation: _fadeoutOpacityAnimation, - mainAxisMargin: widget.mainAxisMargin, - crossAxisMargin: widget.crossAxisMargin, - radius: _radius, - padding: MediaQuery.of(context).padding, - minLength: _kScrollbarMinLength, - minOverscrollLength: _kScrollbarMinOverscrollLength, - ); - } - - // Wait one frame and cause an empty scroll event. This allows the thumb to - // show immediately when isAlwaysShown is true. A scroll event is required in - // order to paint the thumb. - void _triggerScrollbar() { - WidgetsBinding.instance.addPostFrameCallback((Duration duration) { - if (widget.isAlwaysShown) { - _fadeoutTimer?.cancel(); - if (widget.verticalController!.hasClients) { - widget.verticalController!.position.didUpdateScrollPositionBy(0); - } - } - }); - } - - // Handle a gesture that drags the scrollbar by the given amount. - void _dragScrollbar(double primaryDelta) { - assert(_currentController != null); - - // Convert primaryDelta, the amount that the scrollbar moved since the last - // time _dragScrollbar was called, into the coordinate space of the scroll - // position, and create/update the drag event with that position. - final double scrollOffsetLocal = _painter!.getTrackToScroll(primaryDelta); - final double scrollOffsetGlobal = - scrollOffsetLocal + _currentController!.position.pixels; - final Axis direction = _currentController!.position.axis; - - if (_drag == null) { - _drag = _currentController!.position.drag( - DragStartDetails( - globalPosition: direction == Axis.vertical - ? Offset(0.0, scrollOffsetGlobal) - : Offset(scrollOffsetGlobal, 0.0), - ), - () {}, - ); - } else { - _drag!.update(DragUpdateDetails( - globalPosition: direction == Axis.vertical - ? Offset(0.0, scrollOffsetGlobal) - : Offset(scrollOffsetGlobal, 0.0), - delta: direction == Axis.vertical - ? Offset(0.0, -scrollOffsetLocal) - : Offset(-scrollOffsetLocal, 0.0), - primaryDelta: -scrollOffsetLocal, - )); - } - } - - void _startFadeoutTimer() { - if (!widget.isAlwaysShown) { - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { - _fadeoutAnimationController.reverse(); - _fadeoutTimer = null; - }); - } - } - - Axis? _getDirection() { - try { - return _currentController!.position.axis; - } catch (_) { - // Ignore the gesture if we cannot determine the direction. - return null; - } - } - - double _pressStartAxisPosition = 0.0; - - // Long press event callbacks handle the gesture where the user long presses - // on the scrollbar thumb and then drags the scrollbar without releasing. - void _handleLongPressStart(LongPressStartDetails details) { - _currentController = _controller; - final Axis? direction = _getDirection(); - if (direction == null) { - return; - } - _fadeoutTimer?.cancel(); - _fadeoutAnimationController.forward(); - switch (direction) { - case Axis.vertical: - _pressStartAxisPosition = details.localPosition.dy; - _dragScrollbar(details.localPosition.dy); - _dragScrollbarAxisPosition = details.localPosition.dy; - break; - case Axis.horizontal: - _pressStartAxisPosition = details.localPosition.dx; - _dragScrollbar(details.localPosition.dx); - _dragScrollbarAxisPosition = details.localPosition.dx; - break; - } - } - - void _handleLongPress() { - if (_getDirection() == null) { - return; - } - _fadeoutTimer?.cancel(); - _thicknessAnimationController.forward().then( - (_) => HapticFeedback.mediumImpact(), - ); - } - - void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - final Axis? direction = _getDirection(); - if (direction == null) { - return; - } - switch (direction) { - case Axis.vertical: - _dragScrollbar(details.localPosition.dy - _dragScrollbarAxisPosition!); - _dragScrollbarAxisPosition = details.localPosition.dy; - break; - case Axis.horizontal: - _dragScrollbar(details.localPosition.dx - _dragScrollbarAxisPosition!); - _dragScrollbarAxisPosition = details.localPosition.dx; - break; - } - } - - void _handleLongPressEnd(LongPressEndDetails details) { - final Axis? direction = _getDirection(); - if (direction == null) { - return; - } - switch (direction) { - case Axis.vertical: - _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction); - if (details.velocity.pixelsPerSecond.dy.abs() < 10 && - (details.localPosition.dy - _pressStartAxisPosition).abs() > 0) { - HapticFeedback.mediumImpact(); - } - break; - case Axis.horizontal: - _handleDragScrollEnd(details.velocity.pixelsPerSecond.dx, direction); - if (details.velocity.pixelsPerSecond.dx.abs() < 10 && - (details.localPosition.dx - _pressStartAxisPosition).abs() > 0) { - HapticFeedback.mediumImpact(); - } - break; - } - _currentController = null; - } - - void _handleDragScrollEnd(double trackVelocity, Axis direction) { - _startFadeoutTimer(); - _thicknessAnimationController.reverse(); - _dragScrollbarAxisPosition = null; - final double scrollVelocity = widget.enableScrollAfterDragEnd - ? _painter!.getTrackToScroll(trackVelocity) - : 0; - _drag?.end(DragEndDetails( - primaryVelocity: -scrollVelocity, - velocity: Velocity( - pixelsPerSecond: direction == Axis.vertical - ? Offset(0.0, -scrollVelocity) - : Offset(-scrollVelocity, 0.0), - ), - )); - _drag = null; - } - - bool _handleScrollNotification(ScrollNotification notification) { - final ScrollMetrics metrics = notification.metrics; - if (metrics.maxScrollExtent <= metrics.minScrollExtent) { - return false; - } - - _currentAxis = axisDirectionToAxis(metrics.axisDirection); - - if (notification is ScrollUpdateNotification || - notification is OverscrollNotification || - notification is UserScrollNotification) { - // Any movements always makes the scrollbar start showing up. - if (_fadeoutAnimationController.status != AnimationStatus.forward) { - _fadeoutAnimationController.forward(); - } - - _fadeoutTimer?.cancel(); - _painter!.update(metrics, metrics.axisDirection); - - // Call ScrollController.jumpTo on keyboard move. - // An error where the Thumb does not disappear - // because UserScrollNotification is called - // after ScrollEndNotification when the horizontal axis is moved. - if ((notification is UserScrollNotification) && - notification.direction == ScrollDirection.idle) { - _callFadeoutTimer(); - } - } else if (notification is ScrollEndNotification) { - // On iOS, the scrollbar can only go away once the user lifted the finger. - _callFadeoutTimer(); - } - - return false; - } - - void _callFadeoutTimer() { - if (_dragScrollbarAxisPosition == null) { - _startFadeoutTimer(); - } - } - - // Get the GestureRecognizerFactories used to detect gestures on the scrollbar - // thumb. - Map get _gestures { - final Map gestures = - {}; - - gestures[_ThumbPressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( - () => _ThumbPressGestureRecognizer( - customPaintKey: _customPaintKey, - debugOwner: this, - duration: widget.longPressDuration, - onlyDraggingThumb: widget.onlyDraggingThumb, - ), - (_ThumbPressGestureRecognizer instance) { - instance - ..onLongPressStart = _handleLongPressStart - ..onLongPress = _handleLongPress - ..onLongPressMoveUpdate = _handleLongPressMoveUpdate - ..onLongPressEnd = _handleLongPressEnd; - }, - ); - - return gestures; - } - - @override - void dispose() { - _fadeoutAnimationController.dispose(); - _thicknessAnimationController.dispose(); - _fadeoutTimer?.cancel(); - _painter!.dispose(); - super.dispose(); - } - - bool _needUpdatePainterByHover(Axis axis) { - switch (_painter?._lastAxisDirection) { - case AxisDirection.up: - case AxisDirection.down: - return axis != Axis.vertical; - case AxisDirection.left: - case AxisDirection.right: - return axis != Axis.horizontal; - default: - return true; - } - } - - void _handleHoverExit(PointerExitEvent event) { - _callFadeoutTimer(); - } - - void _handleHover(PointerHoverEvent event) { - final hoverAxis = _getHoverAxis(event.position, event.kind, forHover: true); - if (hoverAxis == _currentHoverAxis) return; - _currentHoverAxis = hoverAxis; - - ScrollMetrics? metrics; - bool needUpdate = false; - - switch (hoverAxis) { - case _HoverAxis.vertical: - _currentAxis = Axis.vertical; - _currentController = widget.verticalController; - needUpdate = _needUpdatePainterByHover(Axis.vertical); - if (needUpdate) { - metrics = FixedScrollMetrics( - minScrollExtent: - widget.verticalController?.position.minScrollExtent, - maxScrollExtent: - widget.verticalController?.position.maxScrollExtent, - pixels: widget.verticalController?.position.pixels, - viewportDimension: - widget.verticalController?.position.viewportDimension, - axisDirection: widget.verticalController?.position.axisDirection ?? - AxisDirection.down, - devicePixelRatio: 1.0, - ); - } - break; - case _HoverAxis.horizontal: - _currentAxis = Axis.horizontal; - _currentController = widget.horizontalController; - needUpdate = _needUpdatePainterByHover(Axis.horizontal); - if (needUpdate) { - metrics = FixedScrollMetrics( - minScrollExtent: - widget.horizontalController?.position.minScrollExtent, - maxScrollExtent: - widget.horizontalController?.position.maxScrollExtent, - pixels: widget.horizontalController?.position.pixels, - viewportDimension: - widget.horizontalController?.position.viewportDimension, - axisDirection: - widget.horizontalController?.position.axisDirection ?? - AxisDirection.right, - devicePixelRatio: 1.0, - ); - } - break; - case _HoverAxis.none: - _callFadeoutTimer(); - return; - } - - if (_fadeoutAnimationController.status != AnimationStatus.forward) { - _fadeoutAnimationController.forward(); - } - - _fadeoutTimer?.cancel(); - - if (needUpdate) { - _painter!.update(metrics!, metrics.axisDirection); - } - } - - _HoverAxis _getHoverAxis( - Offset position, - PointerDeviceKind kind, { - bool forHover = false, - }) { - if (_customPaintKey.currentContext == null || _painter == null) { - return _HoverAxis.none; - } - - final RenderBox renderBox = - _customPaintKey.currentContext!.findRenderObject()! as RenderBox; - final localOffset = renderBox.globalToLocal(position); - final trackSize = renderBox.size; - final isRTL = _textDirection == TextDirection.rtl; - final hoverWidth = widget.hoverWidth; - - if (Rect.fromLTRB( - isRTL ? 0 : trackSize.width - hoverWidth, - 0, - isRTL ? hoverWidth : trackSize.width, - trackSize.height, - ).contains(localOffset)) { - return _HoverAxis.vertical; - } - - if (Rect.fromLTRB( - 0, - trackSize.height - hoverWidth, - trackSize.width, - trackSize.height, - ).contains(localOffset)) { - return _HoverAxis.horizontal; - } - - return _HoverAxis.none; - } - - @override - Widget build(BuildContext context) { - Widget child = CustomPaint( - key: _customPaintKey, - foregroundPainter: _painter, - child: RepaintBoundary(child: widget.child), - ); - - if (widget.enableHover) { - child = MouseRegion( - onExit: (PointerExitEvent event) { - switch (event.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.trackpad: - _handleHoverExit(event); - break; - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - case PointerDeviceKind.touch: - break; - } - }, - onHover: (PointerHoverEvent event) { - switch (event.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.trackpad: - _handleHover(event); - break; - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - case PointerDeviceKind.touch: - break; - } - }, - child: child, - ); - } - - return NotificationListener( - onNotification: _handleScrollNotification, - child: RepaintBoundary( - child: RawGestureDetector( - gestures: _gestures, - child: child, - ), - ), - ); - } -} - -const double _kMinInteractiveSize = 48.0; -const double _kScrollbarThickness = 6.0; -const double _kMinThumbExtent = 18.0; - -class _ScrollbarPainter extends ChangeNotifier implements CustomPainter { - /// Creates a scrollbar with customizations given by construction arguments. - _ScrollbarPainter({ - required Color color, - required this.fadeoutOpacityAnimation, - required Color trackColor, - Color trackBorderColor = const Color(0x00000000), - TextDirection? textDirection, - double thickness = _kScrollbarThickness, - EdgeInsets padding = EdgeInsets.zero, - double mainAxisMargin = 0.0, - double crossAxisMargin = 0.0, - Radius? radius, - Radius? trackRadius, - OutlinedBorder? shape, - double minLength = _kMinThumbExtent, - double? minOverscrollLength, - ScrollbarOrientation? scrollbarOrientation, - bool ignorePointer = false, - }) : assert(radius == null || shape == null), - assert(minLength >= 0), - assert(minOverscrollLength == null || minOverscrollLength <= minLength), - assert(minOverscrollLength == null || minOverscrollLength >= 0), - assert(padding.isNonNegative), - _color = color, - _textDirection = textDirection, - _thickness = thickness, - _radius = radius, - _shape = shape, - _padding = padding, - _mainAxisMargin = mainAxisMargin, - _crossAxisMargin = crossAxisMargin, - _minLength = minLength, - _trackColor = trackColor, - _trackBorderColor = trackBorderColor, - _trackRadius = trackRadius, - _scrollbarOrientation = scrollbarOrientation, - _minOverscrollLength = minOverscrollLength ?? minLength, - _ignorePointer = ignorePointer { - fadeoutOpacityAnimation.addListener(notifyListeners); - } - - /// [Color] of the thumb. Mustn't be null. - Color get color => _color; - Color _color; - set color(Color value) { - if (color == value) return; - - _color = value; - notifyListeners(); - } - - /// [Color] of the track. Mustn't be null. - Color get trackColor => _trackColor; - Color _trackColor; - set trackColor(Color value) { - if (trackColor == value) return; - - _trackColor = value; - notifyListeners(); - } - - /// [Color] of the track border. Mustn't be null. - Color get trackBorderColor => _trackBorderColor; - Color _trackBorderColor; - set trackBorderColor(Color value) { - if (trackBorderColor == value) return; - - _trackBorderColor = value; - notifyListeners(); - } - - /// [Radius] of corners of the Scrollbar's track. - /// - /// Scrollbar's track will be rectangular if [trackRadius] is null. - Radius? get trackRadius => _trackRadius; - Radius? _trackRadius; - set trackRadius(Radius? value) { - if (trackRadius == value) return; - - _trackRadius = value; - notifyListeners(); - } - - /// [TextDirection] of the [BuildContext] which dictates the side of the - /// screen the scrollbar appears in (the trailing side). Must be set prior to - /// calling paint. - TextDirection? get textDirection => _textDirection; - TextDirection? _textDirection; - set textDirection(TextDirection? value) { - assert(value != null); - if (textDirection == value) return; - - _textDirection = value; - notifyListeners(); - } - - /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. - double get thickness => _thickness; - double _thickness; - set thickness(double value) { - if (thickness == value) return; - - _thickness = value; - notifyListeners(); - } - - /// An opacity [Animation] that dictates the opacity of the thumb. - /// Changes in value of this [Listenable] will automatically trigger repaints. - /// Mustn't be null. - final Animation fadeoutOpacityAnimation; - - /// Distance from the scrollbar's start and end to the edge of the viewport - /// in logical pixels. It affects the amount of available paint area. - /// - /// Mustn't be null and defaults to 0. - double get mainAxisMargin => _mainAxisMargin; - double _mainAxisMargin; - set mainAxisMargin(double value) { - if (mainAxisMargin == value) return; - - _mainAxisMargin = value; - notifyListeners(); - } - - /// Distance from the scrollbar thumb to the nearest cross axis edge - /// in logical pixels. - /// - /// Must not be null and defaults to 0. - double get crossAxisMargin => _crossAxisMargin; - double _crossAxisMargin; - set crossAxisMargin(double value) { - if (crossAxisMargin == value) return; - - _crossAxisMargin = value; - notifyListeners(); - } - - /// [Radius] of corners if the scrollbar should have rounded corners. - /// - /// Scrollbar will be rectangular if [radius] is null. - Radius? get radius => _radius; - Radius? _radius; - set radius(Radius? value) { - assert(shape == null || value == null); - if (radius == value) return; - - _radius = value; - notifyListeners(); - } - - /// The [OutlinedBorder] of the scrollbar's thumb. - /// - /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, - /// it's simplest to just specify [radius]. By default, the scrollbar thumb's - /// shape is a simple rectangle. - /// - /// If [shape] is specified, the thumb will take the shape of the passed - /// [OutlinedBorder] and fill itself with [color] (or grey if it - /// is unspecified). - /// - OutlinedBorder? get shape => _shape; - OutlinedBorder? _shape; - set shape(OutlinedBorder? value) { - assert(radius == null || value == null); - if (shape == value) return; - - _shape = value; - notifyListeners(); - } - - /// The amount of space by which to inset the scrollbar's start and end, as - /// well as its side to the nearest edge, in logical pixels. - /// - /// This is typically set to the current [MediaQueryData.padding] to avoid - /// partial obstructions such as display notches. If you only want additional - /// margins around the scrollbar, see [mainAxisMargin]. - /// - /// Defaults to [EdgeInsets.zero]. Must not be null and offsets from all four - /// directions must be greater than or equal to zero. - EdgeInsets get padding => _padding; - EdgeInsets _padding; - set padding(EdgeInsets value) { - if (padding == value) return; - - _padding = value; - notifyListeners(); - } - - /// The preferred smallest size the scrollbar thumb can shrink to when the total - /// scrollable extent is large, the current visible viewport is small, and the - /// viewport is not overscrolled. - /// - /// The size of the scrollbar may shrink to a smaller size than [minLength] to - /// fit in the available paint area. E.g., when [minLength] is - /// `double.infinity`, it will not be respected if - /// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. - /// - /// Mustn't be null and the value has to be greater or equal to - /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. - double get minLength => _minLength; - double _minLength; - set minLength(double value) { - if (minLength == value) return; - - _minLength = value; - notifyListeners(); - } - - /// The preferred smallest size the scrollbar thumb can shrink to when viewport is - /// overscrolled. - /// - /// When overscrolling, the size of the scrollbar may shrink to a smaller size - /// than [minOverscrollLength] to fit in the available paint area. E.g., when - /// [minOverscrollLength] is `double.infinity`, it will not be respected if - /// the [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. - /// - /// The value is less than or equal to [minLength] and greater than or equal to 0. - /// When null, it will default to the value of [minLength]. - double get minOverscrollLength => _minOverscrollLength; - double _minOverscrollLength; - set minOverscrollLength(double value) { - if (minOverscrollLength == value) return; - - _minOverscrollLength = value; - notifyListeners(); - } - - /// {@template flutter.widgets.Scrollbar.scrollbarOrientation} - /// Dictates the orientation of the scrollbar. - /// - /// [ScrollbarOrientation.top] places the scrollbar on top of the screen. - /// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen. - /// [ScrollbarOrientation.left] places the scrollbar on the left of the screen. - /// [ScrollbarOrientation.right] places the scrollbar on the right of the screen. - /// - /// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be - /// used with a vertical scroll. - /// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be - /// used with a horizontal scroll. - /// - /// For a vertical scroll the orientation defaults to - /// [ScrollbarOrientation.right] for [TextDirection.ltr] and - /// [ScrollbarOrientation.left] for [TextDirection.rtl]. - /// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom]. - /// {@endtemplate} - ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation; - ScrollbarOrientation? _scrollbarOrientation; - set scrollbarOrientation(ScrollbarOrientation? value) { - if (scrollbarOrientation == value) return; - - _scrollbarOrientation = value; - notifyListeners(); - } - - /// Whether the painter will be ignored during hit testing. - bool get ignorePointer => _ignorePointer; - bool _ignorePointer; - set ignorePointer(bool value) { - if (ignorePointer == value) return; - - _ignorePointer = value; - notifyListeners(); - } - - void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { - assert( - (_isVertical && _isVerticalOrientation(orientation)) || - (!_isVertical && !_isVerticalOrientation(orientation)), - 'The given ScrollbarOrientation: $orientation is incompatible with the current AxisDirection: $_lastAxisDirection.'); - } - - /// Check whether given scrollbar orientation is vertical - bool _isVerticalOrientation(ScrollbarOrientation orientation) => - orientation == ScrollbarOrientation.left || - orientation == ScrollbarOrientation.right; - - ScrollMetrics? _lastMetrics; - AxisDirection? _lastAxisDirection; - - ScrollMetrics? _lastVerticalMetrics; - AxisDirection? _lastVerticalAxisDirection; - - ScrollMetrics? _lastHorizontalMetrics; - AxisDirection? _lastHorizontalAxisDirection; - - Rect? _thumbRect; - Rect? _trackRect; - late double _thumbOffset; - - /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will - /// show and redraw itself based on these new metrics. - /// - /// The scrollbar will remain on screen. - void update( - ScrollMetrics metrics, - AxisDirection axisDirection, - ) { - final bool vertical = axisDirection == AxisDirection.up || - axisDirection == AxisDirection.down; - - if (vertical) { - if (_lastVerticalMetrics != null && - _lastVerticalMetrics!.extentBefore == metrics.extentBefore && - _lastVerticalMetrics!.extentInside == metrics.extentInside && - _lastVerticalMetrics!.extentAfter == metrics.extentAfter && - _lastVerticalAxisDirection == axisDirection && - _lastAxisDirection == axisDirection) { - return; - } - - _lastVerticalMetrics = metrics; - _lastVerticalAxisDirection = axisDirection; - } else { - if (_lastHorizontalMetrics != null && - _lastHorizontalMetrics!.extentBefore == metrics.extentBefore && - _lastHorizontalMetrics!.extentInside == metrics.extentInside && - _lastHorizontalMetrics!.extentAfter == metrics.extentAfter && - _lastHorizontalAxisDirection == axisDirection && - _lastAxisDirection == axisDirection) { - return; - } - - _lastHorizontalMetrics = metrics; - _lastHorizontalAxisDirection = axisDirection; - } - - final ScrollMetrics? oldMetrics = - vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; - - _lastMetrics = vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; - _lastAxisDirection = - vertical ? _lastVerticalAxisDirection : _lastHorizontalAxisDirection; - - bool needPaint(ScrollMetrics? metrics) => - metrics != null && metrics.maxScrollExtent > metrics.minScrollExtent; - if (!needPaint(oldMetrics) && !needPaint(metrics)) return; - - notifyListeners(); - } - - /// Update and redraw with new scrollbar thickness and radius. - void updateThickness(double nextThickness, Radius nextRadius) { - thickness = nextThickness; - radius = nextRadius; - } - - Paint get _paintThumb { - return Paint() - ..color = - color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); - } - - Paint _paintTrack({bool isBorder = false}) { - if (isBorder) { - return Paint() - ..color = trackBorderColor.withOpacity( - trackBorderColor.opacity * fadeoutOpacityAnimation.value) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.0; - } - return Paint() - ..color = trackColor - .withOpacity(trackColor.opacity * fadeoutOpacityAnimation.value); - } - - void _paintScrollbar( - Canvas canvas, Size size, double thumbExtent, AxisDirection direction) { - assert( - textDirection != null, - 'A TextDirection must be provided before a Scrollbar can be painted.', - ); - - final ScrollbarOrientation resolvedOrientation; - - if (scrollbarOrientation == null) { - if (_isVertical) { - resolvedOrientation = textDirection == TextDirection.ltr - ? ScrollbarOrientation.right - : ScrollbarOrientation.left; - } else { - resolvedOrientation = ScrollbarOrientation.bottom; - } - } else { - resolvedOrientation = scrollbarOrientation!; - } - - final double x, y; - final Size thumbSize, trackSize; - final Offset trackOffset, borderStart, borderEnd; - - _debugAssertIsValidOrientation(resolvedOrientation); - - switch (resolvedOrientation) { - case ScrollbarOrientation.left: - thumbSize = Size(thickness, thumbExtent); - trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); - x = crossAxisMargin + padding.left; - y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); - borderStart = trackOffset + Offset(trackSize.width, 0.0); - borderEnd = Offset( - trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); - break; - case ScrollbarOrientation.right: - thumbSize = Size(thickness, thumbExtent); - trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); - x = size.width - thickness - crossAxisMargin - padding.right; - y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); - borderStart = trackOffset; - borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); - break; - case ScrollbarOrientation.top: - thumbSize = Size(thumbExtent, thickness); - trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); - x = _thumbOffset; - y = crossAxisMargin + padding.top; - trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); - borderStart = trackOffset + Offset(0.0, trackSize.height); - borderEnd = Offset( - trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); - break; - case ScrollbarOrientation.bottom: - thumbSize = Size(thumbExtent, thickness); - trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); - x = _thumbOffset; - y = size.height - thickness - crossAxisMargin - padding.bottom; - trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); - borderStart = trackOffset; - borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); - break; - } - - // Whether we paint or not, calculating these rects allows us to hit test - // when the scrollbar is transparent. - _trackRect = trackOffset & trackSize; - _thumbRect = Offset(x, y) & thumbSize; - - // Paint if the opacity dictates visibility - if (fadeoutOpacityAnimation.value != 0.0) { - // Track - if (trackRadius == null) { - canvas.drawRect(_trackRect!, _paintTrack()); - } else { - canvas.drawRRect( - RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack()); - } - // Track Border - canvas.drawLine(borderStart, borderEnd, _paintTrack(isBorder: true)); - if (radius != null) { - // Rounded rect thumb - canvas.drawRRect( - RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); - return; - } - if (shape == null) { - // Square thumb - canvas.drawRect(_thumbRect!, _paintThumb); - return; - } - // Custom-shaped thumb - final Path outerPath = shape!.getOuterPath(_thumbRect!); - canvas.drawPath(outerPath, _paintThumb); - shape!.paint(canvas, _thumbRect!); - } - } - - double _thumbExtent() { - // Thumb extent reflects fraction of content visible, as long as this - // isn't less than the absolute minimum size. - // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 - final double fractionVisible = - ((_lastMetrics!.extentInside - _mainAxisPadding) / - (_totalContentExtent - _mainAxisPadding)) - .clamp(0.0, 1.0); - - final double thumbExtent = math.max( - math.min(_trackExtent, minOverscrollLength), - _trackExtent * fractionVisible, - ); - - final double fractionOverscrolled = - 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; - final double safeMinLength = math.min(minLength, _trackExtent); - final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) - // Thumb extent is no smaller than minLength if scrolling normally. - ? safeMinLength - // User is overscrolling. Thumb extent can be less than minLength - // but no smaller than minOverscrollLength. We can't use the - // fractionVisible to produce intermediate values between minLength and - // minOverscrollLength when the user is transitioning from regular - // scrolling to overscrolling, so we instead use the percentage of the - // content that is still in the viewport to determine the size of the - // thumb. iOS behavior appears to have the thumb reach its minimum size - // with ~20% of overscroll. We map the percentage of minLength from - // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce - // values for the thumb that range between minLength and the smallest - // possible value, minOverscrollLength. - : safeMinLength * (1.0 - fractionOverscrolled.clamp(0.0, 0.2) / 0.2); - - // The `thumbExtent` should be no greater than `trackSize`, otherwise - // the scrollbar may scroll towards the wrong direction. - return thumbExtent.clamp(newMinLength, _trackExtent); - } - - @override - void dispose() { - fadeoutOpacityAnimation.removeListener(notifyListeners); - super.dispose(); - } - - bool get _isVertical => - _lastAxisDirection == AxisDirection.down || - _lastAxisDirection == AxisDirection.up; - bool get _isReversed => - _lastAxisDirection == AxisDirection.up || - _lastAxisDirection == AxisDirection.left; - // The amount of scroll distance before and after the current position. - double get _beforeExtent => - _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; - double get _afterExtent => - _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; - // Padding of the thumb track. - double get _mainAxisPadding => - _isVertical ? padding.vertical : padding.horizontal; - // The size of the thumb track. - double get _trackExtent => - _lastMetrics!.viewportDimension - 2 * mainAxisMargin - _mainAxisPadding; - - // The total size of the scrollable content. - double get _totalContentExtent { - return _lastMetrics!.maxScrollExtent - - _lastMetrics!.minScrollExtent + - _lastMetrics!.viewportDimension; - } - - /// Convert between a thumb track position and the corresponding scroll - /// position. - /// - /// thumbOffsetLocal is a position in the thumb track. Cannot be null. - double getTrackToScroll(double thumbOffsetLocal) { - final double scrollableExtent = - _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; - final double thumbMovableExtent = _trackExtent - _thumbExtent(); - - return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; - } - - // Converts between a scroll position and the corresponding position in the - // thumb track. - double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { - final double scrollableExtent = - metrics.maxScrollExtent - metrics.minScrollExtent; - - final double fractionPast = (scrollableExtent > 0) - ? ((metrics.pixels - metrics.minScrollExtent) / scrollableExtent) - .clamp(0.0, 1.0) - : 0; - - return (_isReversed ? 1 - fractionPast : fractionPast) * - (_trackExtent - thumbExtent); - } - - @override - void paint(Canvas canvas, Size size) { - if (_lastAxisDirection == null || - _lastMetrics == null || - _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) return; - - // Skip painting if there's not enough space. - if (_lastMetrics!.viewportDimension <= _mainAxisPadding || - _trackExtent <= 0) { - return; - } - - final double beforePadding = _isVertical ? padding.top : padding.left; - final double thumbExtent = _thumbExtent(); - final double thumbOffsetLocal = - _getScrollToTrack(_lastMetrics!, thumbExtent); - _thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding; - - // Do not paint a scrollbar if the scroll view is infinitely long. - // TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434 - if (_lastMetrics!.maxScrollExtent.isInfinite) return; - - return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!); - } - - bool get _lastMetricsAreScrollable => - _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; - - /// Same as hitTest, but includes some padding when the [PointerEvent] is - /// caused by [PointerDeviceKind.touch] to make sure that the region - /// isn't too small to be interacted with by the user. - /// - /// The hit test area for hovering with [PointerDeviceKind.mouse] over the - /// scrollbar also uses this extra padding. This is to make it easier to - /// interact with the scrollbar by presenting it to the mouse for interaction - /// based on proximity. When `forHover` is true, the larger hit test area will - /// be used. - bool hitTestInteractive(Offset position, PointerDeviceKind kind, - {bool forHover = false}) { - if (_trackRect == null) { - // We have not computed the scrollbar position yet. - return false; - } - if (ignorePointer) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - final Rect interactiveRect = _trackRect!; - final Rect paddedRect = interactiveRect.expandToInclude( - Rect.fromCircle( - center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), - ); - - // The scrollbar is not able to be hit when transparent - except when - // hovering with a mouse. This should bring the scrollbar into view so the - // mouse can interact with it. - if (fadeoutOpacityAnimation.value == 0.0) { - if (forHover && kind == PointerDeviceKind.mouse) { - return paddedRect.contains(position); - } - return false; - } - - switch (kind) { - case PointerDeviceKind.touch: - return paddedRect.contains(position); - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] - // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 - return interactiveRect.contains(position); - } - } - - /// Same as hitTestInteractive, but excludes the track portion of the scrollbar. - /// Used to evaluate interactions with only the scrollbar thumb. - bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) { - if (_thumbRect == null) { - return false; - } - if (ignorePointer) { - return false; - } - // The thumb is not able to be hit when transparent. - if (fadeoutOpacityAnimation.value == 0.0) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - switch (kind) { - case PointerDeviceKind.touch: - final Rect touchThumbRect = _thumbRect!.expandToInclude( - Rect.fromCircle( - center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), - ); - return touchThumbRect.contains(position); - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] - // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 - return _thumbRect!.contains(position); - } - } - - // Scrollbars are interactive. - @override - bool? hitTest(Offset? position) { - if (_thumbRect == null) { - return null; - } - if (ignorePointer) { - return false; - } - - // The thumb is not able to be hit when transparent. - if (fadeoutOpacityAnimation.value == 0.0) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - return _trackRect!.contains(position!); - } - - @override - bool shouldRepaint(_ScrollbarPainter oldDelegate) { - // Should repaint if any properties changed. - return color != oldDelegate.color || - trackColor != oldDelegate.trackColor || - trackBorderColor != oldDelegate.trackBorderColor || - textDirection != oldDelegate.textDirection || - thickness != oldDelegate.thickness || - fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation || - mainAxisMargin != oldDelegate.mainAxisMargin || - crossAxisMargin != oldDelegate.crossAxisMargin || - radius != oldDelegate.radius || - trackRadius != oldDelegate.trackRadius || - shape != oldDelegate.shape || - padding != oldDelegate.padding || - minLength != oldDelegate.minLength || - minOverscrollLength != oldDelegate.minOverscrollLength || - scrollbarOrientation != oldDelegate.scrollbarOrientation || - ignorePointer != oldDelegate.ignorePointer; - } - - @override - bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; - - @override - SemanticsBuilderCallback? get semanticsBuilder => null; - - @override - String toString() => describeIdentity(this); -} - -String describeIdentity(Object? object) => - '${objectRuntimeType(object, '')}#${shortHash(object)}'; - -String objectRuntimeType(Object? object, String optimizedValue) { - assert(() { - optimizedValue = object.runtimeType.toString(); - return true; - }()); - return optimizedValue; -} - -String shortHash(Object? object) { - return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0'); -} - -// A longpress gesture detector that only responds to events on the scrollbar's -// thumb and ignores everything else. -class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { - _ThumbPressGestureRecognizer({ - required GlobalKey customPaintKey, - required Object super.debugOwner, - required Duration super.duration, - this.onlyDraggingThumb = false, - }) : _customPaintKey = customPaintKey; - - final GlobalKey _customPaintKey; - final bool onlyDraggingThumb; - - @override - bool isPointerAllowed(PointerDownEvent event) { - if (!_hitTestInteractive( - _customPaintKey, event.position, event.kind, onlyDraggingThumb)) { - return false; - } - return super.isPointerAllowed(event); - } -} - -// foregroundPainter also hit tests its children by default, but the -// scrollbar should only respond to a gesture directly on its thumb, so -// manually check for a hit on the thumb here. -bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, - PointerDeviceKind kind, bool onlyDraggingThumb) { - if (customPaintKey.currentContext == null) { - return false; - } - final CustomPaint customPaint = - customPaintKey.currentContext!.widget as CustomPaint; - final _ScrollbarPainter painter = - customPaint.foregroundPainter! as _ScrollbarPainter; - final Offset localOffset = _getLocalOffset(customPaintKey, offset); - // We can only receive track taps that are on the thumb. - return onlyDraggingThumb - ? painter.hitTestOnlyThumbInteractive(localOffset, kind) - : painter.hitTestInteractive(localOffset, kind); -} - -Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { - final RenderBox renderBox = - scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; - return renderBox.globalToLocal(position); -} - -enum _HoverAxis { - vertical, - horizontal, - none; - - bool get isVertical => this == _HoverAxis.vertical; - bool get isHorizontal => this == _HoverAxis.horizontal; - bool get isNone => this == _HoverAxis.none; -} +/* + * This widget modifies [CupertinoScrollbar] a little, + * so that the horizontal and vertical scroll controllers work together. +*/ + +// All values eyeballed. +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +const double _kScrollbarMinLength = 36.0; +const double _kScrollbarMinOverscrollLength = 8.0; +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); +const Duration _kScrollbarLongPressDuration = Duration(milliseconds: 100); + +// Extracted from iOS 13.1 beta using Debug View Hierarchy. +const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: Color(0x59000000), + darkColor: Color(0x80FFFFFF), +); +const Color _kTrackColor = Color(0x00000000); +// This is the amount of space from the top of a vertical scrollbar to the +// top edge of the scrollable, measured when the vertical scrollbar overscrolls +// to the top. +// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 3.0; + +class PlutoScrollbar extends StatefulWidget { + const PlutoScrollbar({ + super.key, + this.horizontalController, + this.verticalController, + this.isAlwaysShown = false, + this.onlyDraggingThumb = true, + this.enableHover = true, + this.enableScrollAfterDragEnd = true, + this.thickness = defaultThickness, + this.thicknessWhileDragging = defaultThicknessWhileDragging, + this.hoverWidth = defaultScrollbarHoverWidth, + double? mainAxisMargin, + double? crossAxisMargin, + Color? scrollBarColor, + Color? scrollBarTrackColor, + Duration? longPressDuration, + this.radius = defaultRadius, + this.radiusWhileDragging = defaultRadiusWhileDragging, + required this.child, + }) : assert(thickness < double.infinity), + assert(thicknessWhileDragging < double.infinity), + assert(!isAlwaysShown || + (horizontalController != null || verticalController != null)), + mainAxisMargin = mainAxisMargin ?? _kScrollbarMainAxisMargin, + crossAxisMargin = crossAxisMargin ?? _kScrollbarCrossAxisMargin, + scrollBarColor = scrollBarColor ?? _kScrollbarColor, + scrollBarTrackColor = scrollBarTrackColor ?? _kTrackColor, + longPressDuration = longPressDuration ?? _kScrollbarLongPressDuration; + final ScrollController? horizontalController; + + final ScrollController? verticalController; + + final bool isAlwaysShown; + + final bool onlyDraggingThumb; + + final bool enableHover; + + final bool enableScrollAfterDragEnd; + + final Duration longPressDuration; + + final double thickness; + + final double thicknessWhileDragging; + + final double hoverWidth; + + final double mainAxisMargin; + + final double crossAxisMargin; + + final Color scrollBarColor; + + final Color scrollBarTrackColor; + + final Radius radius; + + final Radius radiusWhileDragging; + + final Widget child; + + static const double defaultThickness = 3; + + static const double defaultThicknessWhileDragging = 8.0; + + static const double defaultScrollbarHoverWidth = 16.0; + + static const Radius defaultRadius = Radius.circular(1.5); + + static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); + + @override + PlutoGridCupertinoScrollbarState createState() => + PlutoGridCupertinoScrollbarState(); +} + +class PlutoGridCupertinoScrollbarState extends State + with TickerProviderStateMixin { + final GlobalKey _customPaintKey = GlobalKey(); + _ScrollbarPainter? _painter; + + late TextDirection _textDirection; + late AnimationController _fadeoutAnimationController; + late Animation _fadeoutOpacityAnimation; + late AnimationController _thicknessAnimationController; + Timer? _fadeoutTimer; + double? _dragScrollbarAxisPosition; + Drag? _drag; + + double get _thickness { + return widget.thickness + + _thicknessAnimationController.value * + (widget.thicknessWhileDragging - widget.thickness); + } + + Radius? get _radius { + return Radius.lerp(widget.radius, widget.radiusWhileDragging, + _thicknessAnimationController.value); + } + + ScrollController? _currentController; + + ScrollController? get _controller { + if (_currentAxis == null) { + return widget.verticalController ?? + widget.horizontalController ?? + PrimaryScrollController.of(context); + } + + return _currentAxis == Axis.vertical + ? widget.verticalController + : widget.horizontalController; + } + + Axis? _currentAxis; + + _HoverAxis _currentHoverAxis = _HoverAxis.none; + + @override + void initState() { + super.initState(); + _fadeoutAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarFadeDuration, + ); + _fadeoutOpacityAnimation = CurvedAnimation( + parent: _fadeoutAnimationController, + curve: Curves.fastOutSlowIn, + ); + _thicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _thicknessAnimationController.addListener(() { + _painter!.updateThickness(_thickness, _radius!); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _textDirection = Directionality.of(context); + if (_painter == null) { + _painter = _buildCupertinoScrollbarPainter(context); + } else { + _painter! + ..textDirection = _textDirection + ..color = CupertinoDynamicColor.resolve(widget.scrollBarColor, context) + ..padding = MediaQuery.of(context).padding; + } + _triggerScrollbar(); + } + + @override + void didUpdateWidget(PlutoScrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + assert(_painter != null); + _painter!.updateThickness(_thickness, _radius!); + if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { + if (widget.isAlwaysShown == true) { + _triggerScrollbar(); + _fadeoutAnimationController.animateTo(1.0); + } else { + _fadeoutAnimationController.reverse(); + } + } + } + + /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar. + _ScrollbarPainter _buildCupertinoScrollbarPainter(BuildContext context) { + return _ScrollbarPainter( + trackColor: + CupertinoDynamicColor.resolve(widget.scrollBarTrackColor, context), + color: CupertinoDynamicColor.resolve(widget.scrollBarColor, context), + textDirection: Directionality.of(context), + thickness: _thickness, + fadeoutOpacityAnimation: _fadeoutOpacityAnimation, + mainAxisMargin: widget.mainAxisMargin, + crossAxisMargin: widget.crossAxisMargin, + radius: _radius, + padding: MediaQuery.of(context).padding, + minLength: _kScrollbarMinLength, + minOverscrollLength: _kScrollbarMinOverscrollLength, + ); + } + + // Wait one frame and cause an empty scroll event. This allows the thumb to + // show immediately when isAlwaysShown is true. A scroll event is required in + // order to paint the thumb. + void _triggerScrollbar() { + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + if (widget.isAlwaysShown) { + _fadeoutTimer?.cancel(); + if (widget.verticalController!.hasClients) { + widget.verticalController!.position.didUpdateScrollPositionBy(0); + } + } + }); + } + + // Handle a gesture that drags the scrollbar by the given amount. + void _dragScrollbar(double primaryDelta) { + assert(_currentController != null); + + // Convert primaryDelta, the amount that the scrollbar moved since the last + // time _dragScrollbar was called, into the coordinate space of the scroll + // position, and create/update the drag event with that position. + final double scrollOffsetLocal = _painter!.getTrackToScroll(primaryDelta); + final double scrollOffsetGlobal = + scrollOffsetLocal + _currentController!.position.pixels; + final Axis direction = _currentController!.position.axis; + + if (_drag == null) { + _drag = _currentController!.position.drag( + DragStartDetails( + globalPosition: direction == Axis.vertical + ? Offset(0.0, scrollOffsetGlobal) + : Offset(scrollOffsetGlobal, 0.0), + ), + () {}, + ); + } else { + _drag!.update(DragUpdateDetails( + globalPosition: direction == Axis.vertical + ? Offset(0.0, scrollOffsetGlobal) + : Offset(scrollOffsetGlobal, 0.0), + delta: direction == Axis.vertical + ? Offset(0.0, -scrollOffsetLocal) + : Offset(-scrollOffsetLocal, 0.0), + primaryDelta: -scrollOffsetLocal, + )); + } + } + + void _startFadeoutTimer() { + if (!widget.isAlwaysShown) { + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } + + Axis? _getDirection() { + try { + return _currentController!.position.axis; + } catch (_) { + // Ignore the gesture if we cannot determine the direction. + return null; + } + } + + double _pressStartAxisPosition = 0.0; + + // Long press event callbacks handle the gesture where the user long presses + // on the scrollbar thumb and then drags the scrollbar without releasing. + void _handleLongPressStart(LongPressStartDetails details) { + _currentController = _controller; + final Axis? direction = _getDirection(); + if (direction == null) { + return; + } + _fadeoutTimer?.cancel(); + _fadeoutAnimationController.forward(); + switch (direction) { + case Axis.vertical: + _pressStartAxisPosition = details.localPosition.dy; + _dragScrollbar(details.localPosition.dy); + _dragScrollbarAxisPosition = details.localPosition.dy; + break; + case Axis.horizontal: + _pressStartAxisPosition = details.localPosition.dx; + _dragScrollbar(details.localPosition.dx); + _dragScrollbarAxisPosition = details.localPosition.dx; + break; + } + } + + void _handleLongPress() { + if (_getDirection() == null) { + return; + } + _fadeoutTimer?.cancel(); + _thicknessAnimationController.forward().then( + (_) => HapticFeedback.mediumImpact(), + ); + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + final Axis? direction = _getDirection(); + if (direction == null) { + return; + } + switch (direction) { + case Axis.vertical: + _dragScrollbar(details.localPosition.dy - _dragScrollbarAxisPosition!); + _dragScrollbarAxisPosition = details.localPosition.dy; + break; + case Axis.horizontal: + _dragScrollbar(details.localPosition.dx - _dragScrollbarAxisPosition!); + _dragScrollbarAxisPosition = details.localPosition.dx; + break; + } + } + + void _handleLongPressEnd(LongPressEndDetails details) { + final Axis? direction = _getDirection(); + if (direction == null) { + return; + } + switch (direction) { + case Axis.vertical: + _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction); + if (details.velocity.pixelsPerSecond.dy.abs() < 10 && + (details.localPosition.dy - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + break; + case Axis.horizontal: + _handleDragScrollEnd(details.velocity.pixelsPerSecond.dx, direction); + if (details.velocity.pixelsPerSecond.dx.abs() < 10 && + (details.localPosition.dx - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + break; + } + _currentController = null; + } + + void _handleDragScrollEnd(double trackVelocity, Axis direction) { + _startFadeoutTimer(); + _thicknessAnimationController.reverse(); + _dragScrollbarAxisPosition = null; + final double scrollVelocity = widget.enableScrollAfterDragEnd + ? _painter!.getTrackToScroll(trackVelocity) + : 0; + _drag?.end(DragEndDetails( + primaryVelocity: -scrollVelocity, + velocity: Velocity( + pixelsPerSecond: direction == Axis.vertical + ? Offset(0.0, -scrollVelocity) + : Offset(-scrollVelocity, 0.0), + ), + )); + _drag = null; + } + + bool _handleScrollNotification(ScrollNotification notification) { + final ScrollMetrics metrics = notification.metrics; + if (metrics.maxScrollExtent <= metrics.minScrollExtent) { + return false; + } + + _currentAxis = axisDirectionToAxis(metrics.axisDirection); + + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification || + notification is UserScrollNotification) { + // Any movements always makes the scrollbar start showing up. + if (_fadeoutAnimationController.status != AnimationStatus.forward) { + _fadeoutAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + _painter!.update(metrics, metrics.axisDirection); + + // Call ScrollController.jumpTo on keyboard move. + // An error where the Thumb does not disappear + // because UserScrollNotification is called + // after ScrollEndNotification when the horizontal axis is moved. + if ((notification is UserScrollNotification) && + notification.direction == ScrollDirection.idle) { + _callFadeoutTimer(); + } + } else if (notification is ScrollEndNotification) { + // On iOS, the scrollbar can only go away once the user lifted the finger. + _callFadeoutTimer(); + } + + return false; + } + + void _callFadeoutTimer() { + if (_dragScrollbarAxisPosition == null) { + _startFadeoutTimer(); + } + } + + // Get the GestureRecognizerFactories used to detect gestures on the scrollbar + // thumb. + Map get _gestures { + final Map gestures = + {}; + + gestures[_ThumbPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( + () => _ThumbPressGestureRecognizer( + customPaintKey: _customPaintKey, + debugOwner: this, + duration: widget.longPressDuration, + onlyDraggingThumb: widget.onlyDraggingThumb, + ), + (_ThumbPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPress = _handleLongPress + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + + return gestures; + } + + @override + void dispose() { + _fadeoutAnimationController.dispose(); + _thicknessAnimationController.dispose(); + _fadeoutTimer?.cancel(); + _painter!.dispose(); + super.dispose(); + } + + bool _needUpdatePainterByHover(Axis axis) { + switch (_painter?._lastAxisDirection) { + case AxisDirection.up: + case AxisDirection.down: + return axis != Axis.vertical; + case AxisDirection.left: + case AxisDirection.right: + return axis != Axis.horizontal; + default: + return true; + } + } + + void _handleHoverExit(PointerExitEvent event) { + _callFadeoutTimer(); + } + + void _handleHover(PointerHoverEvent event) { + final hoverAxis = _getHoverAxis(event.position, event.kind, forHover: true); + if (hoverAxis == _currentHoverAxis) return; + _currentHoverAxis = hoverAxis; + + ScrollMetrics? metrics; + bool needUpdate = false; + + switch (hoverAxis) { + case _HoverAxis.vertical: + _currentAxis = Axis.vertical; + _currentController = widget.verticalController; + needUpdate = _needUpdatePainterByHover(Axis.vertical); + if (needUpdate) { + metrics = FixedScrollMetrics( + minScrollExtent: + widget.verticalController?.position.minScrollExtent, + maxScrollExtent: + widget.verticalController?.position.maxScrollExtent, + pixels: widget.verticalController?.position.pixels, + viewportDimension: + widget.verticalController?.position.viewportDimension, + axisDirection: widget.verticalController?.position.axisDirection ?? + AxisDirection.down, + devicePixelRatio: 1.0, + ); + } + break; + case _HoverAxis.horizontal: + _currentAxis = Axis.horizontal; + _currentController = widget.horizontalController; + needUpdate = _needUpdatePainterByHover(Axis.horizontal); + if (needUpdate) { + metrics = FixedScrollMetrics( + minScrollExtent: + widget.horizontalController?.position.minScrollExtent, + maxScrollExtent: + widget.horizontalController?.position.maxScrollExtent, + pixels: widget.horizontalController?.position.pixels, + viewportDimension: + widget.horizontalController?.position.viewportDimension, + axisDirection: + widget.horizontalController?.position.axisDirection ?? + AxisDirection.right, + devicePixelRatio: 1.0, + ); + } + break; + case _HoverAxis.none: + _callFadeoutTimer(); + return; + } + + if (_fadeoutAnimationController.status != AnimationStatus.forward) { + _fadeoutAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + + if (needUpdate) { + _painter!.update(metrics!, metrics.axisDirection); + } + } + + _HoverAxis _getHoverAxis( + Offset position, + PointerDeviceKind kind, { + bool forHover = false, + }) { + if (_customPaintKey.currentContext == null || _painter == null) { + return _HoverAxis.none; + } + + final RenderBox renderBox = + _customPaintKey.currentContext!.findRenderObject()! as RenderBox; + final localOffset = renderBox.globalToLocal(position); + final trackSize = renderBox.size; + final isRTL = _textDirection == TextDirection.rtl; + final hoverWidth = widget.hoverWidth; + + if (Rect.fromLTRB( + isRTL ? 0 : trackSize.width - hoverWidth, + 0, + isRTL ? hoverWidth : trackSize.width, + trackSize.height, + ).contains(localOffset)) { + return _HoverAxis.vertical; + } + + if (Rect.fromLTRB( + 0, + trackSize.height - hoverWidth, + trackSize.width, + trackSize.height, + ).contains(localOffset)) { + return _HoverAxis.horizontal; + } + + return _HoverAxis.none; + } + + @override + Widget build(BuildContext context) { + Widget child = CustomPaint( + key: _customPaintKey, + foregroundPainter: _painter, + child: RepaintBoundary(child: widget.child), + ); + + if (widget.enableHover) { + child = MouseRegion( + onExit: (PointerExitEvent event) { + switch (event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + _handleHoverExit(event); + break; + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + onHover: (PointerHoverEvent event) { + switch (event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + _handleHover(event); + break; + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + child: child, + ); + } + + return NotificationListener( + onNotification: _handleScrollNotification, + child: RepaintBoundary( + child: RawGestureDetector( + gestures: _gestures, + child: child, + ), + ), + ); + } +} + +const double _kMinInteractiveSize = 48.0; +const double _kScrollbarThickness = 6.0; +const double _kMinThumbExtent = 18.0; + +class _ScrollbarPainter extends ChangeNotifier implements CustomPainter { + /// Creates a scrollbar with customizations given by construction arguments. + _ScrollbarPainter({ + required Color color, + required this.fadeoutOpacityAnimation, + required Color trackColor, + Color trackBorderColor = const Color(0x00000000), + TextDirection? textDirection, + double thickness = _kScrollbarThickness, + EdgeInsets padding = EdgeInsets.zero, + double mainAxisMargin = 0.0, + double crossAxisMargin = 0.0, + Radius? radius, + Radius? trackRadius, + OutlinedBorder? shape, + double minLength = _kMinThumbExtent, + double? minOverscrollLength, + ScrollbarOrientation? scrollbarOrientation, + bool ignorePointer = false, + }) : assert(radius == null || shape == null), + assert(minLength >= 0), + assert(minOverscrollLength == null || minOverscrollLength <= minLength), + assert(minOverscrollLength == null || minOverscrollLength >= 0), + assert(padding.isNonNegative), + _color = color, + _textDirection = textDirection, + _thickness = thickness, + _radius = radius, + _shape = shape, + _padding = padding, + _mainAxisMargin = mainAxisMargin, + _crossAxisMargin = crossAxisMargin, + _minLength = minLength, + _trackColor = trackColor, + _trackBorderColor = trackBorderColor, + _trackRadius = trackRadius, + _scrollbarOrientation = scrollbarOrientation, + _minOverscrollLength = minOverscrollLength ?? minLength, + _ignorePointer = ignorePointer { + fadeoutOpacityAnimation.addListener(notifyListeners); + } + + /// [Color] of the thumb. Mustn't be null. + Color get color => _color; + Color _color; + set color(Color value) { + if (color == value) return; + + _color = value; + notifyListeners(); + } + + /// [Color] of the track. Mustn't be null. + Color get trackColor => _trackColor; + Color _trackColor; + set trackColor(Color value) { + if (trackColor == value) return; + + _trackColor = value; + notifyListeners(); + } + + /// [Color] of the track border. Mustn't be null. + Color get trackBorderColor => _trackBorderColor; + Color _trackBorderColor; + set trackBorderColor(Color value) { + if (trackBorderColor == value) return; + + _trackBorderColor = value; + notifyListeners(); + } + + /// [Radius] of corners of the Scrollbar's track. + /// + /// Scrollbar's track will be rectangular if [trackRadius] is null. + Radius? get trackRadius => _trackRadius; + Radius? _trackRadius; + set trackRadius(Radius? value) { + if (trackRadius == value) return; + + _trackRadius = value; + notifyListeners(); + } + + /// [TextDirection] of the [BuildContext] which dictates the side of the + /// screen the scrollbar appears in (the trailing side). Must be set prior to + /// calling paint. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + assert(value != null); + if (textDirection == value) return; + + _textDirection = value; + notifyListeners(); + } + + /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. + double get thickness => _thickness; + double _thickness; + set thickness(double value) { + if (thickness == value) return; + + _thickness = value; + notifyListeners(); + } + + /// An opacity [Animation] that dictates the opacity of the thumb. + /// Changes in value of this [Listenable] will automatically trigger repaints. + /// Mustn't be null. + final Animation fadeoutOpacityAnimation; + + /// Distance from the scrollbar's start and end to the edge of the viewport + /// in logical pixels. It affects the amount of available paint area. + /// + /// Mustn't be null and defaults to 0. + double get mainAxisMargin => _mainAxisMargin; + double _mainAxisMargin; + set mainAxisMargin(double value) { + if (mainAxisMargin == value) return; + + _mainAxisMargin = value; + notifyListeners(); + } + + /// Distance from the scrollbar thumb to the nearest cross axis edge + /// in logical pixels. + /// + /// Must not be null and defaults to 0. + double get crossAxisMargin => _crossAxisMargin; + double _crossAxisMargin; + set crossAxisMargin(double value) { + if (crossAxisMargin == value) return; + + _crossAxisMargin = value; + notifyListeners(); + } + + /// [Radius] of corners if the scrollbar should have rounded corners. + /// + /// Scrollbar will be rectangular if [radius] is null. + Radius? get radius => _radius; + Radius? _radius; + set radius(Radius? value) { + assert(shape == null || value == null); + if (radius == value) return; + + _radius = value; + notifyListeners(); + } + + /// The [OutlinedBorder] of the scrollbar's thumb. + /// + /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, + /// it's simplest to just specify [radius]. By default, the scrollbar thumb's + /// shape is a simple rectangle. + /// + /// If [shape] is specified, the thumb will take the shape of the passed + /// [OutlinedBorder] and fill itself with [color] (or grey if it + /// is unspecified). + /// + OutlinedBorder? get shape => _shape; + OutlinedBorder? _shape; + set shape(OutlinedBorder? value) { + assert(radius == null || value == null); + if (shape == value) return; + + _shape = value; + notifyListeners(); + } + + /// The amount of space by which to inset the scrollbar's start and end, as + /// well as its side to the nearest edge, in logical pixels. + /// + /// This is typically set to the current [MediaQueryData.padding] to avoid + /// partial obstructions such as display notches. If you only want additional + /// margins around the scrollbar, see [mainAxisMargin]. + /// + /// Defaults to [EdgeInsets.zero]. Must not be null and offsets from all four + /// directions must be greater than or equal to zero. + EdgeInsets get padding => _padding; + EdgeInsets _padding; + set padding(EdgeInsets value) { + if (padding == value) return; + + _padding = value; + notifyListeners(); + } + + /// The preferred smallest size the scrollbar thumb can shrink to when the total + /// scrollable extent is large, the current visible viewport is small, and the + /// viewport is not overscrolled. + /// + /// The size of the scrollbar may shrink to a smaller size than [minLength] to + /// fit in the available paint area. E.g., when [minLength] is + /// `double.infinity`, it will not be respected if + /// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. + /// + /// Mustn't be null and the value has to be greater or equal to + /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. + double get minLength => _minLength; + double _minLength; + set minLength(double value) { + if (minLength == value) return; + + _minLength = value; + notifyListeners(); + } + + /// The preferred smallest size the scrollbar thumb can shrink to when viewport is + /// overscrolled. + /// + /// When overscrolling, the size of the scrollbar may shrink to a smaller size + /// than [minOverscrollLength] to fit in the available paint area. E.g., when + /// [minOverscrollLength] is `double.infinity`, it will not be respected if + /// the [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. + /// + /// The value is less than or equal to [minLength] and greater than or equal to 0. + /// When null, it will default to the value of [minLength]. + double get minOverscrollLength => _minOverscrollLength; + double _minOverscrollLength; + set minOverscrollLength(double value) { + if (minOverscrollLength == value) return; + + _minOverscrollLength = value; + notifyListeners(); + } + + /// {@template flutter.widgets.Scrollbar.scrollbarOrientation} + /// Dictates the orientation of the scrollbar. + /// + /// [ScrollbarOrientation.top] places the scrollbar on top of the screen. + /// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen. + /// [ScrollbarOrientation.left] places the scrollbar on the left of the screen. + /// [ScrollbarOrientation.right] places the scrollbar on the right of the screen. + /// + /// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be + /// used with a vertical scroll. + /// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be + /// used with a horizontal scroll. + /// + /// For a vertical scroll the orientation defaults to + /// [ScrollbarOrientation.right] for [TextDirection.ltr] and + /// [ScrollbarOrientation.left] for [TextDirection.rtl]. + /// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom]. + /// {@endtemplate} + ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation; + ScrollbarOrientation? _scrollbarOrientation; + set scrollbarOrientation(ScrollbarOrientation? value) { + if (scrollbarOrientation == value) return; + + _scrollbarOrientation = value; + notifyListeners(); + } + + /// Whether the painter will be ignored during hit testing. + bool get ignorePointer => _ignorePointer; + bool _ignorePointer; + set ignorePointer(bool value) { + if (ignorePointer == value) return; + + _ignorePointer = value; + notifyListeners(); + } + + void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { + assert( + (_isVertical && _isVerticalOrientation(orientation)) || + (!_isVertical && !_isVerticalOrientation(orientation)), + 'The given ScrollbarOrientation: $orientation is incompatible with the current AxisDirection: $_lastAxisDirection.'); + } + + /// Check whether given scrollbar orientation is vertical + bool _isVerticalOrientation(ScrollbarOrientation orientation) => + orientation == ScrollbarOrientation.left || + orientation == ScrollbarOrientation.right; + + ScrollMetrics? _lastMetrics; + AxisDirection? _lastAxisDirection; + + ScrollMetrics? _lastVerticalMetrics; + AxisDirection? _lastVerticalAxisDirection; + + ScrollMetrics? _lastHorizontalMetrics; + AxisDirection? _lastHorizontalAxisDirection; + + Rect? _thumbRect; + Rect? _trackRect; + late double _thumbOffset; + + /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will + /// show and redraw itself based on these new metrics. + /// + /// The scrollbar will remain on screen. + void update( + ScrollMetrics metrics, + AxisDirection axisDirection, + ) { + final bool vertical = axisDirection == AxisDirection.up || + axisDirection == AxisDirection.down; + + if (vertical) { + if (_lastVerticalMetrics != null && + _lastVerticalMetrics!.extentBefore == metrics.extentBefore && + _lastVerticalMetrics!.extentInside == metrics.extentInside && + _lastVerticalMetrics!.extentAfter == metrics.extentAfter && + _lastVerticalAxisDirection == axisDirection && + _lastAxisDirection == axisDirection) { + return; + } + + _lastVerticalMetrics = metrics; + _lastVerticalAxisDirection = axisDirection; + } else { + if (_lastHorizontalMetrics != null && + _lastHorizontalMetrics!.extentBefore == metrics.extentBefore && + _lastHorizontalMetrics!.extentInside == metrics.extentInside && + _lastHorizontalMetrics!.extentAfter == metrics.extentAfter && + _lastHorizontalAxisDirection == axisDirection && + _lastAxisDirection == axisDirection) { + return; + } + + _lastHorizontalMetrics = metrics; + _lastHorizontalAxisDirection = axisDirection; + } + + final ScrollMetrics? oldMetrics = + vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; + + _lastMetrics = vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; + _lastAxisDirection = + vertical ? _lastVerticalAxisDirection : _lastHorizontalAxisDirection; + + bool needPaint(ScrollMetrics? metrics) => + metrics != null && metrics.maxScrollExtent > metrics.minScrollExtent; + if (!needPaint(oldMetrics) && !needPaint(metrics)) return; + + notifyListeners(); + } + + /// Update and redraw with new scrollbar thickness and radius. + void updateThickness(double nextThickness, Radius nextRadius) { + thickness = nextThickness; + radius = nextRadius; + } + + Paint get _paintThumb { + return Paint() + ..color = + color.withValues(alpha: color.a * fadeoutOpacityAnimation.value); + } + + Paint _paintTrack({bool isBorder = false}) { + if (isBorder) { + return Paint() + ..color = trackBorderColor.withValues( + alpha: trackBorderColor.a * fadeoutOpacityAnimation.value) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + } + return Paint() + ..color = trackColor.withValues( + alpha: trackColor.a * fadeoutOpacityAnimation.value); + } + + void _paintScrollbar( + Canvas canvas, Size size, double thumbExtent, AxisDirection direction) { + assert( + textDirection != null, + 'A TextDirection must be provided before a Scrollbar can be painted.', + ); + + final ScrollbarOrientation resolvedOrientation; + + if (scrollbarOrientation == null) { + if (_isVertical) { + resolvedOrientation = textDirection == TextDirection.ltr + ? ScrollbarOrientation.right + : ScrollbarOrientation.left; + } else { + resolvedOrientation = ScrollbarOrientation.bottom; + } + } else { + resolvedOrientation = scrollbarOrientation!; + } + + final double x, y; + final Size thumbSize, trackSize; + final Offset trackOffset, borderStart, borderEnd; + + _debugAssertIsValidOrientation(resolvedOrientation); + + switch (resolvedOrientation) { + case ScrollbarOrientation.left: + thumbSize = Size(thickness, thumbExtent); + trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); + x = crossAxisMargin + padding.left; + y = _thumbOffset; + trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); + borderStart = trackOffset + Offset(trackSize.width, 0.0); + borderEnd = Offset( + trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); + break; + case ScrollbarOrientation.right: + thumbSize = Size(thickness, thumbExtent); + trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); + x = size.width - thickness - crossAxisMargin - padding.right; + y = _thumbOffset; + trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); + borderStart = trackOffset; + borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); + break; + case ScrollbarOrientation.top: + thumbSize = Size(thumbExtent, thickness); + trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); + x = _thumbOffset; + y = crossAxisMargin + padding.top; + trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); + borderStart = trackOffset + Offset(0.0, trackSize.height); + borderEnd = Offset( + trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); + break; + case ScrollbarOrientation.bottom: + thumbSize = Size(thumbExtent, thickness); + trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); + x = _thumbOffset; + y = size.height - thickness - crossAxisMargin - padding.bottom; + trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); + borderStart = trackOffset; + borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); + break; + } + + // Whether we paint or not, calculating these rects allows us to hit test + // when the scrollbar is transparent. + _trackRect = trackOffset & trackSize; + _thumbRect = Offset(x, y) & thumbSize; + + // Paint if the opacity dictates visibility + if (fadeoutOpacityAnimation.value != 0.0) { + // Track + if (trackRadius == null) { + canvas.drawRect(_trackRect!, _paintTrack()); + } else { + canvas.drawRRect( + RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack()); + } + // Track Border + canvas.drawLine(borderStart, borderEnd, _paintTrack(isBorder: true)); + if (radius != null) { + // Rounded rect thumb + canvas.drawRRect( + RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); + return; + } + if (shape == null) { + // Square thumb + canvas.drawRect(_thumbRect!, _paintThumb); + return; + } + // Custom-shaped thumb + final Path outerPath = shape!.getOuterPath(_thumbRect!); + canvas.drawPath(outerPath, _paintThumb); + shape!.paint(canvas, _thumbRect!); + } + } + + double _thumbExtent() { + // Thumb extent reflects fraction of content visible, as long as this + // isn't less than the absolute minimum size. + // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 + final double fractionVisible = + ((_lastMetrics!.extentInside - _mainAxisPadding) / + (_totalContentExtent - _mainAxisPadding)) + .clamp(0.0, 1.0); + + final double thumbExtent = math.max( + math.min(_trackExtent, minOverscrollLength), + _trackExtent * fractionVisible, + ); + + final double fractionOverscrolled = + 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; + final double safeMinLength = math.min(minLength, _trackExtent); + final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) + // Thumb extent is no smaller than minLength if scrolling normally. + ? safeMinLength + // User is overscrolling. Thumb extent can be less than minLength + // but no smaller than minOverscrollLength. We can't use the + // fractionVisible to produce intermediate values between minLength and + // minOverscrollLength when the user is transitioning from regular + // scrolling to overscrolling, so we instead use the percentage of the + // content that is still in the viewport to determine the size of the + // thumb. iOS behavior appears to have the thumb reach its minimum size + // with ~20% of overscroll. We map the percentage of minLength from + // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce + // values for the thumb that range between minLength and the smallest + // possible value, minOverscrollLength. + : safeMinLength * (1.0 - fractionOverscrolled.clamp(0.0, 0.2) / 0.2); + + // The `thumbExtent` should be no greater than `trackSize`, otherwise + // the scrollbar may scroll towards the wrong direction. + return thumbExtent.clamp(newMinLength, _trackExtent); + } + + @override + void dispose() { + fadeoutOpacityAnimation.removeListener(notifyListeners); + super.dispose(); + } + + bool get _isVertical => + _lastAxisDirection == AxisDirection.down || + _lastAxisDirection == AxisDirection.up; + bool get _isReversed => + _lastAxisDirection == AxisDirection.up || + _lastAxisDirection == AxisDirection.left; + // The amount of scroll distance before and after the current position. + double get _beforeExtent => + _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; + double get _afterExtent => + _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; + // Padding of the thumb track. + double get _mainAxisPadding => + _isVertical ? padding.vertical : padding.horizontal; + // The size of the thumb track. + double get _trackExtent => + _lastMetrics!.viewportDimension - 2 * mainAxisMargin - _mainAxisPadding; + + // The total size of the scrollable content. + double get _totalContentExtent { + return _lastMetrics!.maxScrollExtent - + _lastMetrics!.minScrollExtent + + _lastMetrics!.viewportDimension; + } + + /// Convert between a thumb track position and the corresponding scroll + /// position. + /// + /// thumbOffsetLocal is a position in the thumb track. Cannot be null. + double getTrackToScroll(double thumbOffsetLocal) { + final double scrollableExtent = + _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; + final double thumbMovableExtent = _trackExtent - _thumbExtent(); + + return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; + } + + // Converts between a scroll position and the corresponding position in the + // thumb track. + double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { + final double scrollableExtent = + metrics.maxScrollExtent - metrics.minScrollExtent; + + final double fractionPast = (scrollableExtent > 0) + ? ((metrics.pixels - metrics.minScrollExtent) / scrollableExtent) + .clamp(0.0, 1.0) + : 0; + + return (_isReversed ? 1 - fractionPast : fractionPast) * + (_trackExtent - thumbExtent); + } + + @override + void paint(Canvas canvas, Size size) { + if (_lastAxisDirection == null || + _lastMetrics == null || + _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { + return; + } + + // Skip painting if there's not enough space. + if (_lastMetrics!.viewportDimension <= _mainAxisPadding || + _trackExtent <= 0) { + return; + } + + final double beforePadding = _isVertical ? padding.top : padding.left; + final double thumbExtent = _thumbExtent(); + final double thumbOffsetLocal = + _getScrollToTrack(_lastMetrics!, thumbExtent); + _thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding; + + // Do not paint a scrollbar if the scroll view is infinitely long. + // TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434 + if (_lastMetrics!.maxScrollExtent.isInfinite) return; + + return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!); + } + + bool get _lastMetricsAreScrollable => + _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; + + /// Same as hitTest, but includes some padding when the [PointerEvent] is + /// caused by [PointerDeviceKind.touch] to make sure that the region + /// isn't too small to be interacted with by the user. + /// + /// The hit test area for hovering with [PointerDeviceKind.mouse] over the + /// scrollbar also uses this extra padding. This is to make it easier to + /// interact with the scrollbar by presenting it to the mouse for interaction + /// based on proximity. When `forHover` is true, the larger hit test area will + /// be used. + bool hitTestInteractive(Offset position, PointerDeviceKind kind, + {bool forHover = false}) { + if (_trackRect == null) { + // We have not computed the scrollbar position yet. + return false; + } + if (ignorePointer) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + final Rect interactiveRect = _trackRect!; + final Rect paddedRect = interactiveRect.expandToInclude( + Rect.fromCircle( + center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), + ); + + // The scrollbar is not able to be hit when transparent - except when + // hovering with a mouse. This should bring the scrollbar into view so the + // mouse can interact with it. + if (fadeoutOpacityAnimation.value == 0.0) { + if (forHover && kind == PointerDeviceKind.mouse) { + return paddedRect.contains(position); + } + return false; + } + + switch (kind) { + case PointerDeviceKind.touch: + return paddedRect.contains(position); + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] + // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 + return interactiveRect.contains(position); + } + } + + /// Same as hitTestInteractive, but excludes the track portion of the scrollbar. + /// Used to evaluate interactions with only the scrollbar thumb. + bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) { + if (_thumbRect == null) { + return false; + } + if (ignorePointer) { + return false; + } + // The thumb is not able to be hit when transparent. + if (fadeoutOpacityAnimation.value == 0.0) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + switch (kind) { + case PointerDeviceKind.touch: + final Rect touchThumbRect = _thumbRect!.expandToInclude( + Rect.fromCircle( + center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), + ); + return touchThumbRect.contains(position); + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] + // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 + return _thumbRect!.contains(position); + } + } + + // Scrollbars are interactive. + @override + bool? hitTest(Offset? position) { + if (_thumbRect == null) { + return null; + } + if (ignorePointer) { + return false; + } + + // The thumb is not able to be hit when transparent. + if (fadeoutOpacityAnimation.value == 0.0) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + return _trackRect!.contains(position!); + } + + @override + bool shouldRepaint(_ScrollbarPainter oldDelegate) { + // Should repaint if any properties changed. + return color != oldDelegate.color || + trackColor != oldDelegate.trackColor || + trackBorderColor != oldDelegate.trackBorderColor || + textDirection != oldDelegate.textDirection || + thickness != oldDelegate.thickness || + fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation || + mainAxisMargin != oldDelegate.mainAxisMargin || + crossAxisMargin != oldDelegate.crossAxisMargin || + radius != oldDelegate.radius || + trackRadius != oldDelegate.trackRadius || + shape != oldDelegate.shape || + padding != oldDelegate.padding || + minLength != oldDelegate.minLength || + minOverscrollLength != oldDelegate.minOverscrollLength || + scrollbarOrientation != oldDelegate.scrollbarOrientation || + ignorePointer != oldDelegate.ignorePointer; + } + + @override + bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + String toString() => describeIdentity(this); +} + +String describeIdentity(Object? object) => + '${objectRuntimeType(object, '')}#${shortHash(object)}'; + +String objectRuntimeType(Object? object, String optimizedValue) { + assert(() { + optimizedValue = object.runtimeType.toString(); + return true; + }()); + return optimizedValue; +} + +String shortHash(Object? object) { + return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0'); +} + +// A longpress gesture detector that only responds to events on the scrollbar's +// thumb and ignores everything else. +class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { + _ThumbPressGestureRecognizer({ + required GlobalKey customPaintKey, + required Object super.debugOwner, + required Duration super.duration, + this.onlyDraggingThumb = false, + }) : _customPaintKey = customPaintKey; + + final GlobalKey _customPaintKey; + final bool onlyDraggingThumb; + + @override + bool isPointerAllowed(PointerDownEvent event) { + if (!_hitTestInteractive( + _customPaintKey, event.position, event.kind, onlyDraggingThumb)) { + return false; + } + return super.isPointerAllowed(event); + } +} + +// foregroundPainter also hit tests its children by default, but the +// scrollbar should only respond to a gesture directly on its thumb, so +// manually check for a hit on the thumb here. +bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, + PointerDeviceKind kind, bool onlyDraggingThumb) { + if (customPaintKey.currentContext == null) { + return false; + } + final CustomPaint customPaint = + customPaintKey.currentContext!.widget as CustomPaint; + final _ScrollbarPainter painter = + customPaint.foregroundPainter! as _ScrollbarPainter; + final Offset localOffset = _getLocalOffset(customPaintKey, offset); + // We can only receive track taps that are on the thumb. + return onlyDraggingThumb + ? painter.hitTestOnlyThumbInteractive(localOffset, kind) + : painter.hitTestInteractive(localOffset, kind); +} + +Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { + final RenderBox renderBox = + scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + return renderBox.globalToLocal(position); +} + +enum _HoverAxis { + vertical, + horizontal, + none; + + bool get isVertical => this == _HoverAxis.vertical; + bool get isHorizontal => this == _HoverAxis.horizontal; + bool get isNone => this == _HoverAxis.none; +} diff --git a/lib/src/widgets/pluto_shadow_container.dart b/lib/src/widgets/pluto_shadow_container.dart index c68087ec..7f1e414a 100644 --- a/lib/src/widgets/pluto_shadow_container.dart +++ b/lib/src/widgets/pluto_shadow_container.dart @@ -1,61 +1,61 @@ -import 'package:flutter/material.dart'; - -class PlutoShadowContainer extends StatelessWidget { - final double width; - - final double height; - - final EdgeInsetsGeometry padding; - - final Color backgroundColor; - - final Color borderColor; - - final AlignmentGeometry alignment; - - final Widget child; - - const PlutoShadowContainer({ - super.key, - required this.width, - required this.height, - required this.child, - this.padding = const EdgeInsets.symmetric( - horizontal: 10, - ), - this.backgroundColor = Colors.white, - this.borderColor = const Color(0xFFA1A5AE), - this.alignment = Alignment.centerLeft, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: width, - height: height, - child: DecoratedBox( - decoration: BoxDecoration( - color: backgroundColor, - border: Border.all( - color: borderColor, - ), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.5), - spreadRadius: 5, - blurRadius: 7, - offset: const Offset(0, 3), // changes position of shadow - ), - ], - ), - child: Padding( - padding: padding, - child: Align( - alignment: alignment, - child: child, - ), - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +class PlutoShadowContainer extends StatelessWidget { + final double width; + + final double height; + + final EdgeInsetsGeometry padding; + + final Color backgroundColor; + + final Color borderColor; + + final AlignmentGeometry alignment; + + final Widget child; + + const PlutoShadowContainer({ + super.key, + required this.width, + required this.height, + required this.child, + this.padding = const EdgeInsets.symmetric( + horizontal: 10, + ), + this.backgroundColor = Colors.white, + this.borderColor = const Color(0xFFA1A5AE), + this.alignment = Alignment.centerLeft, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + color: borderColor, + ), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.5), + spreadRadius: 5, + blurRadius: 7, + offset: const Offset(0, 3), // changes position of shadow + ), + ], + ), + child: Padding( + padding: padding, + child: Align( + alignment: alignment, + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/pluto_shadow_line.dart b/lib/src/widgets/pluto_shadow_line.dart index 0496cccb..5ab0b03a 100644 --- a/lib/src/widgets/pluto_shadow_line.dart +++ b/lib/src/widgets/pluto_shadow_line.dart @@ -1,41 +1,41 @@ -import 'package:flutter/material.dart'; - -class PlutoShadowLine extends StatelessWidget { - final Axis? axis; - final bool? reverse; - final Color? color; - final bool? shadow; - - const PlutoShadowLine({ - this.axis, - this.reverse, - this.color, - this.shadow, - super.key, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: axis == Axis.vertical ? 1 : 0, - height: axis == Axis.horizontal ? 1 : 0, - child: DecoratedBox( - decoration: BoxDecoration( - color: color ?? Colors.black, - boxShadow: shadow == true - ? [ - BoxShadow( - color: Colors.grey.withOpacity(0.15), - spreadRadius: 1, - blurRadius: 3, - offset: reverse == true - ? const Offset(-3, -3) - : const Offset(3, 3), // changes position of shadow - ), - ] - : [], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +class PlutoShadowLine extends StatelessWidget { + final Axis? axis; + final bool? reverse; + final Color? color; + final bool? shadow; + + const PlutoShadowLine({ + this.axis, + this.reverse, + this.color, + this.shadow, + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: axis == Axis.vertical ? 1 : 0, + height: axis == Axis.horizontal ? 1 : 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: color ?? Colors.black, + boxShadow: shadow == true + ? [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.15), + spreadRadius: 1, + blurRadius: 3, + offset: reverse == true + ? const Offset(-3, -3) + : const Offset(3, 3), // changes position of shadow + ), + ] + : [], + ), + ), + ); + } +} diff --git a/test/src/pluto_grid_test.dart b/test/src/pluto_grid_test.dart index f7d1a62d..303831cb 100644 --- a/test/src/pluto_grid_test.dart +++ b/test/src/pluto_grid_test.dart @@ -1,1732 +1,1731 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -import '../helper/column_helper.dart'; -import '../helper/row_helper.dart'; -import '../helper/test_helper_util.dart'; -import '../matcher/pluto_object_matcher.dart'; -import '../mock/mock_methods.dart'; - -void main() { - const columnWidth = PlutoGridSettings.columnWidth; - - const ValueKey sortableGestureKey = ValueKey( - 'ColumnTitleSortableGesture', - ); - - testWidgets( - 'Directionality 가 rtl 인 경우 rtl 상태가 적용 되어야 한다.', - (WidgetTester tester) async { - // given - late final PlutoGridStateManager stateManager; - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Directionality( - textDirection: TextDirection.rtl, - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(stateManager.isLTR, false); - expect(stateManager.isRTL, true); - }, - ); - - testWidgets( - 'Directionality 가 rtl 인 경우 컬럼의 frozen 에 따라 방향에 맞게 위치해야 한다.', - (WidgetTester tester) async { - // given - await TestHelperUtil.changeWidth( - tester: tester, - width: 1400, - height: 600, - ); - final columns = ColumnHelper.textColumn('header', count: 6); - final rows = RowHelper.count(3, columns); - - columns[0].frozen = PlutoColumnFrozen.start; - columns[1].frozen = PlutoColumnFrozen.end; - columns[2].frozen = PlutoColumnFrozen.start; - columns[3].frozen = PlutoColumnFrozen.end; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Directionality( - textDirection: TextDirection.rtl, - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - final firstStartColumn = find.text('header0'); - final secondStartColumn = find.text('header2'); - final firstBodyColumn = find.text('header4'); - final secondBodyColumn = find.text('header5'); - final firstEndColumn = find.text('header1'); - final secondEndColumn = find.text('header3'); - - final firstStartColumnDx = tester.getTopRight(firstStartColumn).dx; - final secondStartColumnDx = tester.getTopRight(secondStartColumn).dx; - final firstBodyColumnDx = tester.getTopRight(firstBodyColumn).dx; - final secondBodyColumnDx = tester.getTopRight(secondBodyColumn).dx; - // frozen.end 컬럼은 전체 넓이로 인해 중앙 빈공간이 있어 좌측에서 위치 확인 - final firstEndColumnDx = tester.getTopLeft(firstEndColumn).dx; - final secondEndColumnDx = tester.getTopLeft(secondEndColumn).dx; - - double expectOffset = columnWidth; - expect(firstStartColumnDx - secondStartColumnDx, expectOffset); - - expectOffset = columnWidth + PlutoGridSettings.gridBorderWidth; - expect(secondStartColumnDx - firstBodyColumnDx, expectOffset); - - expectOffset = columnWidth; - expect(firstBodyColumnDx - secondBodyColumnDx, expectOffset); - - // end 컬럼은 중앙 컬럼보다 좌측에 위치해야 한다. - expect(firstEndColumnDx, lessThan(secondBodyColumnDx - columnWidth)); - - expectOffset = columnWidth; - expect(firstEndColumnDx - secondEndColumnDx, expectOffset); - }, - ); - - testWidgets('createFooter 를 설정 한 경우 footer 가 출력 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - createFooter: (stateManager) { - return const Text('Footer widget.'); - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final footer = find.text('Footer widget.'); - expect(footer, findsOneWidget); - }); - - testWidgets( - 'header 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - createHeader: (stateManager) { - return PlutoPagination(stateManager); - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final found = find.byType(PlutoPagination); - expect(found, findsOneWidget); - }); - - testWidgets( - 'footer 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - createFooter: (stateManager) { - return PlutoPagination(stateManager); - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final found = find.byType(PlutoPagination); - expect(found, findsOneWidget); - }); - - testWidgets('cell 값이 출력 되어야 한다.', (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final cell1 = find.text('header0 value 0'); - expect(cell1, findsOneWidget); - - final cell2 = find.text('header0 value 1'); - expect(cell2, findsOneWidget); - - final cell3 = find.text('header0 value 2'); - expect(cell3, findsOneWidget); - }); - - testWidgets('header 탭 후 정렬 되어야 한다.', (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - Finder sortableGesture = find.descendant( - of: find.byKey(columns.first.key), - matching: find.byKey(sortableGestureKey), - ); - - // then - await tester.tap(sortableGesture); - // Ascending - expect(rows[0].cells['header0']!.value, 'header0 value 0'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - - await tester.tap(sortableGesture); - // Descending - expect(rows[0].cells['header0']!.value, 'header0 value 2'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 0'); - - await tester.tap(sortableGesture); - // Original - expect(rows[0].cells['header0']!.value, 'header0 value 0'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - }); - - testWidgets('셀 값 변경 후 헤더를 탭하면 변경 된 값에 맞게 정렬 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - Finder firstCell = find.byKey(rows.first.cells['header0']!.key); - - // 셀 선택 - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - expect(stateManager!.isEditing, false); - - // 수정 상태로 변경 - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - // 수정 상태 확인 - expect(stateManager!.isEditing, true); - - // TODO : 셀 값 변경 (1) 안되서 (2) 강제로 - // (1) - // await tester.pump(Duration(milliseconds:800)); - // - // await tester.enterText( - // find.descendant(of: firstCell, matching: find.byType(TextField)), - // 'cell value4'); - // (2) - stateManager! - .changeCellValue(stateManager!.currentCell!, 'header0 value 4'); - - // 다음 행으로 이동 - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - - expect(rows[0].cells['header0']!.value, 'header0 value 4'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - - Finder sortableGesture = find.descendant( - of: find.byKey(columns.first.key), - matching: find.byKey(sortableGestureKey), - ); - - await tester.tap(sortableGesture); - // Ascending - expect(rows[0].cells['header0']!.value, 'header0 value 1'); - expect(rows[1].cells['header0']!.value, 'header0 value 2'); - expect(rows[2].cells['header0']!.value, 'header0 value 4'); - - await tester.tap(sortableGesture); - // Descending - expect(rows[0].cells['header0']!.value, 'header0 value 4'); - expect(rows[1].cells['header0']!.value, 'header0 value 2'); - expect(rows[2].cells['header0']!.value, 'header0 value 1'); - - await tester.tap(sortableGesture); - // Original - expect(rows[0].cells['header0']!.value, 'header0 value 4'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - }); - - testWidgets( - 'WHEN selecting a specific cell without grid header' - 'THEN That cell should be selected.', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 10), - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - // first cell of first column - Finder firstCell = find.byKey(rows.first.cells['header0']!.key); - - // select first cell - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - Offset selectedCellOffset = - tester.getCenter(find.byKey(rows[7].cells['header3']!.key)); - - stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); - - // then - expect(stateManager!.currentSelectingPosition!.rowIdx, 7); - expect(stateManager!.currentSelectingPosition!.columnIdx, 3); - }); - - testWidgets( - 'WHEN selecting a specific cell with grid header' - 'THEN That cell should be selected.', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 10), - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - createHeader: (stateManager) => const Text('grid header'), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - // first cell of first column - Finder firstCell = find.byKey(rows.first.cells['header0']!.key); - - // select first cell - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - Offset selectedCellOffset = - tester.getCenter(find.byKey(rows[5].cells['header3']!.key)); - - stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); - - // then - expect(stateManager!.currentSelectingPosition!.rowIdx, 5); - expect(stateManager!.currentSelectingPosition!.columnIdx, 3); - }); - - group('applyColumnRowOnInit', () { - testWidgets( - 'number column' - 'WHEN applyFormatOnInit value of Column is true(default value)' - 'THEN cell value of the column should be changed to format.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), - PlutoRow(cells: {'header': PlutoCell(value: 12)}), - PlutoRow(cells: {'header': PlutoCell(value: '12')}), - PlutoRow(cells: {'header': PlutoCell(value: -10)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), - PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 0); - expect(stateManager!.rows[1].cells['header']!.value, 12); - expect(stateManager!.rows[2].cells['header']!.value, 12); - expect(stateManager!.rows[3].cells['header']!.value, -10); - expect(stateManager!.rows[4].cells['header']!.value, 1234567); - expect(stateManager!.rows[5].cells['header']!.value, 12); - }); - - testWidgets( - 'number column' - 'WHEN applyFormatOnInit value of Column is false' - 'THEN cell value of the column should not be changed to format.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(applyFormatOnInit: false), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), - PlutoRow(cells: {'header': PlutoCell(value: 12)}), - PlutoRow(cells: {'header': PlutoCell(value: '12')}), - PlutoRow(cells: {'header': PlutoCell(value: -10)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), - PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 'not a number'); - expect(stateManager!.rows[1].cells['header']!.value, 12); - expect(stateManager!.rows[2].cells['header']!.value, '12'); - expect(stateManager!.rows[3].cells['header']!.value, -10); - expect(stateManager!.rows[4].cells['header']!.value, 1234567); - expect(stateManager!.rows[5].cells['header']!.value, 12.12345); - }); - - testWidgets( - 'number column' - 'WHEN format allows prime numbers' - 'THEN cell value should be displayed as a decimal number according to the number of digits in the format.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(format: '#,###.#####'), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567.1234)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567.12345)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567.123456)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 1234567); - expect(stateManager!.rows[1].cells['header']!.value, 1234567.1234); - expect(stateManager!.rows[2].cells['header']!.value, 1234567.12345); - expect(stateManager!.rows[3].cells['header']!.value, 1234567.12346); - }); - - testWidgets( - 'number column' - 'WHEN negative is false' - 'THEN negative numbers should not be displayed in the cell value.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(negative: false), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 12345)}), - PlutoRow(cells: {'header': PlutoCell(value: -12345)}), - PlutoRow(cells: {'header': PlutoCell(value: 333.333)}), - PlutoRow(cells: {'header': PlutoCell(value: -333.333)}), - PlutoRow(cells: {'header': PlutoCell(value: 0)}), - PlutoRow(cells: {'header': PlutoCell(value: -0)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 12345); - expect(stateManager!.rows[1].cells['header']!.value, 0); - expect(stateManager!.rows[2].cells['header']!.value, 333); - expect(stateManager!.rows[3].cells['header']!.value, 0); - expect(stateManager!.rows[4].cells['header']!.value, 0); - expect(stateManager!.rows[5].cells['header']!.value, 0); - }); - - testWidgets( - 'WHEN Row does not have sortIdx' - 'THEN sortIdx must be set in Row', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 1), - ]; - final rows = [ - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - createHeader: (stateManager) => const Text('grid header'), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].sortIdx, 0); - expect(stateManager!.rows[1].sortIdx, 1); - expect(stateManager!.rows[2].sortIdx, 2); - expect(stateManager!.rows[3].sortIdx, 3); - expect(stateManager!.rows[4].sortIdx, 4); - }); - - testWidgets( - 'WHEN Row has sortIdx' - 'THEN sortIdx is reset.', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 1), - ]; - final rows = [ - PlutoRow(sortIdx: 5, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 6, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 7, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 8, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 9, cells: {'header0': PlutoCell(value: 'value')}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - createHeader: (stateManager) => const Text('grid header'), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].sortIdx, 0); - expect(stateManager!.rows[1].sortIdx, 1); - expect(stateManager!.rows[2].sortIdx, 2); - expect(stateManager!.rows[3].sortIdx, 3); - expect(stateManager!.rows[4].sortIdx, 4); - }); - }); - - group('moveColumn', () { - testWidgets( - '고정 컬럼이 없는 상태에서 ' - '0번 컬럼을 2번 컬럼으로 이동.', (WidgetTester tester) async { - // given - List columns = [ - ...ColumnHelper.textColumn('body', count: 10, width: 100), - ]; - - List rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - stateManager!.moveColumn(column: columns[0], targetColumn: columns[2]); - - // then - expect(columns[0].title, 'body1'); - expect(columns[1].title, 'body2'); - expect(columns[2].title, 'body0'); - }); - - testWidgets( - '고정 컬럼이 없는 상태에서 ' - '9번 컬럼을 0번 컬럼으로 이동.', (WidgetTester tester) async { - // given - List columns = [ - ...ColumnHelper.textColumn('body', count: 10, width: 100), - ]; - - List rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - stateManager!.moveColumn(column: columns[9], targetColumn: columns[0]); - - // then - expect(columns[0].title, 'body9'); - expect(columns[1].title, 'body0'); - expect(columns[2].title, 'body1'); - expect(columns[3].title, 'body2'); - expect(columns[4].title, 'body3'); - expect(columns[5].title, 'body4'); - expect(columns[6].title, 'body5'); - expect(columns[7].title, 'body6'); - expect(columns[8].title, 'body7'); - expect(columns[9].title, 'body8'); - }); - - testWidgets('넓이가 충분하지 않은 상태에서 고정 컬럼으로 설정하면 설정 되지 않아야 한다.', - (WidgetTester tester) async { - // given - List columns = [ - ...ColumnHelper.textColumn('body', count: 10, width: 100), - ]; - - List rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SizedBox( - width: 50, - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ), - ); - - stateManager! - .setLayout(const BoxConstraints(maxWidth: 50, maxHeight: 300)); - - // when - stateManager!.toggleFrozenColumn(columns[3], PlutoColumnFrozen.start); - - await tester.pumpAndSettle(const Duration(seconds: 1)); - - // then - expect(columns[0].title, 'body0'); - expect(columns[1].title, 'body1'); - expect(columns[2].title, 'body2'); - expect(columns[3].title, 'body3'); - expect(columns[3].frozen, PlutoColumnFrozen.none); - expect(columns[4].title, 'body4'); - expect(columns[5].title, 'body5'); - expect(columns[6].title, 'body6'); - expect(columns[7].title, 'body7'); - expect(columns[8].title, 'body8'); - expect(columns[9].title, 'body9'); - }); - }); - - testWidgets('editing 상태에서 shift + 우측 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태에서 shift + 좌측 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태에서 shift + 위쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태에서 shift + 아래쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 우측 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - await tester.pumpAndSettle(); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 3); - expect(stateManager!.currentSelectingPosition!.rowIdx, 1); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 좌측 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 1); - expect(stateManager!.currentSelectingPosition!.rowIdx, 1); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 위쪽 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 2); - expect(stateManager!.currentSelectingPosition!.rowIdx, 0); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 아래쪽 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 2); - expect(stateManager!.currentSelectingPosition!.rowIdx, 2); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('showLoading 을 호출 하면 Loading 위젯이 나타나야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - stateManager.setShowLoading(true); - - await tester.pump(); - - expect(find.byType(PlutoLoading), findsOneWidget); - }); - - testWidgets( - 'showLoading 을 rows 레벨로 호출 하면 LinearProgressIndicator 위젯이 나타나야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - stateManager.setShowLoading(true, level: PlutoGridLoadingLevel.rows); - - await tester.pump(); - - expect(find.byType(LinearProgressIndicator), findsOneWidget); - }); - - testWidgets( - 'showLoading 을 rowsBottomCircular 레벨로 호출 하면 CircularProgressIndicator 위젯이 나타나야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - stateManager.setShowLoading( - true, - level: PlutoGridLoadingLevel.rowsBottomCircular, - ); - - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }); - - testWidgets('showLoading 을 호출 하지 않으면 Loading 위젯이 나타나지 않아야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ); - - await tester.pump(); - - expect(find.byType(PlutoLoading), findsNothing); - }); - - testWidgets('select 모드에서 첫번째 숨김 컬럼이 있는 경우 두번째 컬럼이 현재 컬럼으로 첫 셀이 선택 되어야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - - columns.first.hide = true; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - mode: PlutoGridMode.select, - onLoaded: (e) => stateManager = e.stateManager, - ), - ), - ), - ); - - await tester.pump(); - - expect(stateManager.currentColumn!.title, 'column1'); - expect(stateManager.currentCell!.value, 'column1 value 0'); - }); - - testWidgets('normal 모드에서 readOnly 모드로 변경 하면 셀이 편집 불가 상태가 되어야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - const ValueKey buttonKey = ValueKey('setReadOnly'); - PlutoGridMode mode = PlutoGridMode.normal; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: StatefulBuilder( - builder: (context, setState) { - return PlutoGrid( - columns: columns, - rows: rows, - mode: mode, - onLoaded: (e) => stateManager = e.stateManager, - createHeader: (s) => TextButton( - key: buttonKey, - onPressed: () { - setState(() { - mode = PlutoGridMode.readOnly; - }); - }, - child: const Text('set readOnly'), - ), - ); - }, - ), - ), - ), - ); - - await tester.pump(); - - await tester.tap(find.text('column0 value 0')); - await tester.pump(); - await tester.tap(find.text('column0 value 0')); - await tester.pump(); - expect(stateManager.isEditing, true); - - await tester.tap(find.byKey(buttonKey)); - await tester.pumpAndSettle(); - expect(stateManager.mode, PlutoGridMode.readOnly); - expect(stateManager.isEditing, false); - }); - - testWidgets('셀 값을 변경하면 onChanged 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onChanged: mock.oneParamReturnVoid, - ), - ), - ), - ); - - await tester.pump(); - - final sampleCell = find.text('column1 value 2'); - - await tester.tap(sampleCell); - await tester.pump(); - await tester.tap(sampleCell); - await tester.pump(); - - await tester.enterText(sampleCell, 'text'); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.row == rows[2] && - e.column == columns[1] && - e.rowIdx == 2 && - e.columnIdx == 1 && - e.value == 'text' && - e.oldValue == 'column1 value 2'; - }))).called(1); - }); - - testWidgets('컬럼을 좌측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - onColumnsMoved: - mock.oneParamReturnVoid, - ), - ), - ), - ); - - stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.start); - await tester.pump(); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.idx == 1 && e.visualIdx == 0 && e.columns.length == 1; - }))).called(1); - }); - - testWidgets('컬럼을 우측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - onColumnsMoved: - mock.oneParamReturnVoid, - ), - ), - ), - ); - - stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.end); - await tester.pump(); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.idx == 1 && e.visualIdx == 9 && e.columns.length == 1; - }))).called(1); - }); - - testWidgets('컬럼을 드래그하여 이동하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onColumnsMoved: - mock.oneParamReturnVoid, - ), - ), - ), - ); - - final sampleColumn = find.text('column1'); - - await tester.drag(sampleColumn, const Offset(400, 0)); - - await tester.pumpAndSettle(const Duration(milliseconds: 300)); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.idx == 3 && e.visualIdx == 3 && e.columns.length == 1; - }))).called(1); - }); - - group('noRowsWidget', () { - testWidgets('행이 없는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = []; - const noRowsWidget = Center( - key: ValueKey('NoRowsWidget'), - child: Text('There are no rows.'), - ); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - noRowsWidget: noRowsWidget, - ), - ), - ), - ); - - expect(find.byKey(noRowsWidget.key!), findsOneWidget); - }); - - testWidgets('행을 삭제하는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - const noRowsWidget = Center( - key: ValueKey('NoRowsWidget'), - child: Text('There are no rows.'), - ); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - noRowsWidget: noRowsWidget, - ), - ), - ), - ); - - expect(find.byKey(noRowsWidget.key!), findsNothing); - - stateManager.removeAllRows(); - - await tester.pump(const Duration(milliseconds: 350)); - - expect(find.byKey(noRowsWidget.key!), findsOneWidget); - }); - - testWidgets('행을 추가하는 경우 noRowsWidget 이 렌더링 되지 않아야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = []; - late final PlutoGridStateManager stateManager; - const noRowsWidget = Center( - key: ValueKey('NoRowsWidget'), - child: Text('There are no rows.'), - ); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - noRowsWidget: noRowsWidget, - ), - ), - ), - ); - - expect(find.byKey(noRowsWidget.key!), findsOneWidget); - - stateManager.appendNewRows(); - - await tester.pumpAndSettle(const Duration(milliseconds: 350)); - - expect(find.byKey(noRowsWidget.key!), findsNothing); - }); - }); -} +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +import '../helper/column_helper.dart'; +import '../helper/row_helper.dart'; +import '../helper/test_helper_util.dart'; +import '../matcher/pluto_object_matcher.dart'; +import '../mock/mock_methods.dart'; + +void main() { + const columnWidth = PlutoGridSettings.columnWidth; + + const ValueKey sortableGestureKey = ValueKey( + 'ColumnTitleSortableGesture', + ); + + testWidgets( + 'Directionality 가 rtl 인 경우 rtl 상태가 적용 되어야 한다.', + (WidgetTester tester) async { + // given + late final PlutoGridStateManager stateManager; + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(stateManager.isLTR, false); + expect(stateManager.isRTL, true); + }, + ); + + testWidgets( + 'Directionality 가 rtl 인 경우 컬럼의 frozen 에 따라 방향에 맞게 위치해야 한다.', + (WidgetTester tester) async { + // given + await TestHelperUtil.changeWidth( + tester: tester, + width: 1400, + height: 600, + ); + final columns = ColumnHelper.textColumn('header', count: 6); + final rows = RowHelper.count(3, columns); + + columns[0].frozen = PlutoColumnFrozen.start; + columns[1].frozen = PlutoColumnFrozen.end; + columns[2].frozen = PlutoColumnFrozen.start; + columns[3].frozen = PlutoColumnFrozen.end; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final firstStartColumn = find.text('header0'); + final secondStartColumn = find.text('header2'); + final firstBodyColumn = find.text('header4'); + final secondBodyColumn = find.text('header5'); + final firstEndColumn = find.text('header1'); + final secondEndColumn = find.text('header3'); + + final firstStartColumnDx = tester.getTopRight(firstStartColumn).dx; + final secondStartColumnDx = tester.getTopRight(secondStartColumn).dx; + final firstBodyColumnDx = tester.getTopRight(firstBodyColumn).dx; + final secondBodyColumnDx = tester.getTopRight(secondBodyColumn).dx; + // frozen.end 컬럼은 전체 넓이로 인해 중앙 빈공간이 있어 좌측에서 위치 확인 + final firstEndColumnDx = tester.getTopLeft(firstEndColumn).dx; + final secondEndColumnDx = tester.getTopLeft(secondEndColumn).dx; + + double expectOffset = columnWidth; + expect(firstStartColumnDx - secondStartColumnDx, expectOffset); + + expectOffset = columnWidth + PlutoGridSettings.gridBorderWidth; + expect(secondStartColumnDx - firstBodyColumnDx, expectOffset); + + expectOffset = columnWidth; + expect(firstBodyColumnDx - secondBodyColumnDx, expectOffset); + + // end 컬럼은 중앙 컬럼보다 좌측에 위치해야 한다. + expect(firstEndColumnDx, lessThan(secondBodyColumnDx - columnWidth)); + + expectOffset = columnWidth; + expect(firstEndColumnDx - secondEndColumnDx, expectOffset); + }, + ); + + testWidgets('createFooter 를 설정 한 경우 footer 가 출력 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + createFooter: (stateManager) { + return const Text('Footer widget.'); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final footer = find.text('Footer widget.'); + expect(footer, findsOneWidget); + }); + + testWidgets( + 'header 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + createHeader: (stateManager) { + return PlutoPagination(stateManager); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final found = find.byType(PlutoPagination); + expect(found, findsOneWidget); + }); + + testWidgets( + 'footer 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + createFooter: (stateManager) { + return PlutoPagination(stateManager); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final found = find.byType(PlutoPagination); + expect(found, findsOneWidget); + }); + + testWidgets('cell 값이 출력 되어야 한다.', (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final cell1 = find.text('header0 value 0'); + expect(cell1, findsOneWidget); + + final cell2 = find.text('header0 value 1'); + expect(cell2, findsOneWidget); + + final cell3 = find.text('header0 value 2'); + expect(cell3, findsOneWidget); + }); + + testWidgets('header 탭 후 정렬 되어야 한다.', (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + Finder sortableGesture = find.descendant( + of: find.byKey(columns.first.key), + matching: find.byKey(sortableGestureKey), + ); + + // then + await tester.tap(sortableGesture); + // Ascending + expect(rows[0].cells['header0']!.value, 'header0 value 0'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + + await tester.tap(sortableGesture); + // Descending + expect(rows[0].cells['header0']!.value, 'header0 value 2'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 0'); + + await tester.tap(sortableGesture); + // Original + expect(rows[0].cells['header0']!.value, 'header0 value 0'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + }); + + testWidgets('셀 값 변경 후 헤더를 탭하면 변경 된 값에 맞게 정렬 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + Finder firstCell = find.byKey(rows.first.cells['header0']!.key); + + // 셀 선택 + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + expect(stateManager!.isEditing, false); + + // 수정 상태로 변경 + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + // 수정 상태 확인 + expect(stateManager!.isEditing, true); + + // (1) + // await tester.pump(Duration(milliseconds:800)); + // + // await tester.enterText( + // find.descendant(of: firstCell, matching: find.byType(TextField)), + // 'cell value4'); + // (2) + stateManager! + .changeCellValue(stateManager!.currentCell!, 'header0 value 4'); + + // 다음 행으로 이동 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(rows[0].cells['header0']!.value, 'header0 value 4'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + + Finder sortableGesture = find.descendant( + of: find.byKey(columns.first.key), + matching: find.byKey(sortableGestureKey), + ); + + await tester.tap(sortableGesture); + // Ascending + expect(rows[0].cells['header0']!.value, 'header0 value 1'); + expect(rows[1].cells['header0']!.value, 'header0 value 2'); + expect(rows[2].cells['header0']!.value, 'header0 value 4'); + + await tester.tap(sortableGesture); + // Descending + expect(rows[0].cells['header0']!.value, 'header0 value 4'); + expect(rows[1].cells['header0']!.value, 'header0 value 2'); + expect(rows[2].cells['header0']!.value, 'header0 value 1'); + + await tester.tap(sortableGesture); + // Original + expect(rows[0].cells['header0']!.value, 'header0 value 4'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + }); + + testWidgets( + 'WHEN selecting a specific cell without grid header' + 'THEN That cell should be selected.', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 10), + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + // first cell of first column + Finder firstCell = find.byKey(rows.first.cells['header0']!.key); + + // select first cell + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + Offset selectedCellOffset = + tester.getCenter(find.byKey(rows[7].cells['header3']!.key)); + + stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); + + // then + expect(stateManager!.currentSelectingPosition!.rowIdx, 7); + expect(stateManager!.currentSelectingPosition!.columnIdx, 3); + }); + + testWidgets( + 'WHEN selecting a specific cell with grid header' + 'THEN That cell should be selected.', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 10), + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + createHeader: (stateManager) => const Text('grid header'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + // first cell of first column + Finder firstCell = find.byKey(rows.first.cells['header0']!.key); + + // select first cell + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + Offset selectedCellOffset = + tester.getCenter(find.byKey(rows[5].cells['header3']!.key)); + + stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); + + // then + expect(stateManager!.currentSelectingPosition!.rowIdx, 5); + expect(stateManager!.currentSelectingPosition!.columnIdx, 3); + }); + + group('applyColumnRowOnInit', () { + testWidgets( + 'number column' + 'WHEN applyFormatOnInit value of Column is true(default value)' + 'THEN cell value of the column should be changed to format.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), + PlutoRow(cells: {'header': PlutoCell(value: 12)}), + PlutoRow(cells: {'header': PlutoCell(value: '12')}), + PlutoRow(cells: {'header': PlutoCell(value: -10)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), + PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 0); + expect(stateManager!.rows[1].cells['header']!.value, 12); + expect(stateManager!.rows[2].cells['header']!.value, 12); + expect(stateManager!.rows[3].cells['header']!.value, -10); + expect(stateManager!.rows[4].cells['header']!.value, 1234567); + expect(stateManager!.rows[5].cells['header']!.value, 12); + }); + + testWidgets( + 'number column' + 'WHEN applyFormatOnInit value of Column is false' + 'THEN cell value of the column should not be changed to format.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(applyFormatOnInit: false), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), + PlutoRow(cells: {'header': PlutoCell(value: 12)}), + PlutoRow(cells: {'header': PlutoCell(value: '12')}), + PlutoRow(cells: {'header': PlutoCell(value: -10)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), + PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 'not a number'); + expect(stateManager!.rows[1].cells['header']!.value, 12); + expect(stateManager!.rows[2].cells['header']!.value, '12'); + expect(stateManager!.rows[3].cells['header']!.value, -10); + expect(stateManager!.rows[4].cells['header']!.value, 1234567); + expect(stateManager!.rows[5].cells['header']!.value, 12.12345); + }); + + testWidgets( + 'number column' + 'WHEN format allows prime numbers' + 'THEN cell value should be displayed as a decimal number according to the number of digits in the format.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(format: '#,###.#####'), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567.1234)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567.12345)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567.123456)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 1234567); + expect(stateManager!.rows[1].cells['header']!.value, 1234567.1234); + expect(stateManager!.rows[2].cells['header']!.value, 1234567.12345); + expect(stateManager!.rows[3].cells['header']!.value, 1234567.12346); + }); + + testWidgets( + 'number column' + 'WHEN negative is false' + 'THEN negative numbers should not be displayed in the cell value.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(negative: false), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 12345)}), + PlutoRow(cells: {'header': PlutoCell(value: -12345)}), + PlutoRow(cells: {'header': PlutoCell(value: 333.333)}), + PlutoRow(cells: {'header': PlutoCell(value: -333.333)}), + PlutoRow(cells: {'header': PlutoCell(value: 0)}), + PlutoRow(cells: {'header': PlutoCell(value: -0)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 12345); + expect(stateManager!.rows[1].cells['header']!.value, 0); + expect(stateManager!.rows[2].cells['header']!.value, 333); + expect(stateManager!.rows[3].cells['header']!.value, 0); + expect(stateManager!.rows[4].cells['header']!.value, 0); + expect(stateManager!.rows[5].cells['header']!.value, 0); + }); + + testWidgets( + 'WHEN Row does not have sortIdx' + 'THEN sortIdx must be set in Row', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 1), + ]; + final rows = [ + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + createHeader: (stateManager) => const Text('grid header'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].sortIdx, 0); + expect(stateManager!.rows[1].sortIdx, 1); + expect(stateManager!.rows[2].sortIdx, 2); + expect(stateManager!.rows[3].sortIdx, 3); + expect(stateManager!.rows[4].sortIdx, 4); + }); + + testWidgets( + 'WHEN Row has sortIdx' + 'THEN sortIdx is reset.', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 1), + ]; + final rows = [ + PlutoRow(sortIdx: 5, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 6, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 7, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 8, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 9, cells: {'header0': PlutoCell(value: 'value')}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + createHeader: (stateManager) => const Text('grid header'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].sortIdx, 0); + expect(stateManager!.rows[1].sortIdx, 1); + expect(stateManager!.rows[2].sortIdx, 2); + expect(stateManager!.rows[3].sortIdx, 3); + expect(stateManager!.rows[4].sortIdx, 4); + }); + }); + + group('moveColumn', () { + testWidgets( + '고정 컬럼이 없는 상태에서 ' + '0번 컬럼을 2번 컬럼으로 이동.', (WidgetTester tester) async { + // given + List columns = [ + ...ColumnHelper.textColumn('body', count: 10, width: 100), + ]; + + List rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + stateManager!.moveColumn(column: columns[0], targetColumn: columns[2]); + + // then + expect(columns[0].title, 'body1'); + expect(columns[1].title, 'body2'); + expect(columns[2].title, 'body0'); + }); + + testWidgets( + '고정 컬럼이 없는 상태에서 ' + '9번 컬럼을 0번 컬럼으로 이동.', (WidgetTester tester) async { + // given + List columns = [ + ...ColumnHelper.textColumn('body', count: 10, width: 100), + ]; + + List rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + stateManager!.moveColumn(column: columns[9], targetColumn: columns[0]); + + // then + expect(columns[0].title, 'body9'); + expect(columns[1].title, 'body0'); + expect(columns[2].title, 'body1'); + expect(columns[3].title, 'body2'); + expect(columns[4].title, 'body3'); + expect(columns[5].title, 'body4'); + expect(columns[6].title, 'body5'); + expect(columns[7].title, 'body6'); + expect(columns[8].title, 'body7'); + expect(columns[9].title, 'body8'); + }); + + testWidgets('넓이가 충분하지 않은 상태에서 고정 컬럼으로 설정하면 설정 되지 않아야 한다.', + (WidgetTester tester) async { + // given + List columns = [ + ...ColumnHelper.textColumn('body', count: 10, width: 100), + ]; + + List rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 50, + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ), + ); + + stateManager! + .setLayout(const BoxConstraints(maxWidth: 50, maxHeight: 300)); + + // when + stateManager!.toggleFrozenColumn(columns[3], PlutoColumnFrozen.start); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // then + expect(columns[0].title, 'body0'); + expect(columns[1].title, 'body1'); + expect(columns[2].title, 'body2'); + expect(columns[3].title, 'body3'); + expect(columns[3].frozen, PlutoColumnFrozen.none); + expect(columns[4].title, 'body4'); + expect(columns[5].title, 'body5'); + expect(columns[6].title, 'body6'); + expect(columns[7].title, 'body7'); + expect(columns[8].title, 'body8'); + expect(columns[9].title, 'body9'); + }); + }); + + testWidgets('editing 상태에서 shift + 우측 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태에서 shift + 좌측 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태에서 shift + 위쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태에서 shift + 아래쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 우측 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + await tester.pumpAndSettle(); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 3); + expect(stateManager!.currentSelectingPosition!.rowIdx, 1); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 좌측 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 1); + expect(stateManager!.currentSelectingPosition!.rowIdx, 1); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 위쪽 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 2); + expect(stateManager!.currentSelectingPosition!.rowIdx, 0); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 아래쪽 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 2); + expect(stateManager!.currentSelectingPosition!.rowIdx, 2); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('showLoading 을 호출 하면 Loading 위젯이 나타나야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + stateManager.setShowLoading(true); + + await tester.pump(); + + expect(find.byType(PlutoLoading), findsOneWidget); + }); + + testWidgets( + 'showLoading 을 rows 레벨로 호출 하면 LinearProgressIndicator 위젯이 나타나야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + stateManager.setShowLoading(true, level: PlutoGridLoadingLevel.rows); + + await tester.pump(); + + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + + testWidgets( + 'showLoading 을 rowsBottomCircular 레벨로 호출 하면 CircularProgressIndicator 위젯이 나타나야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + stateManager.setShowLoading( + true, + level: PlutoGridLoadingLevel.rowsBottomCircular, + ); + + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('showLoading 을 호출 하지 않으면 Loading 위젯이 나타나지 않아야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + + await tester.pump(); + + expect(find.byType(PlutoLoading), findsNothing); + }); + + testWidgets('select 모드에서 첫번째 숨김 컬럼이 있는 경우 두번째 컬럼이 현재 컬럼으로 첫 셀이 선택 되어야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + + columns.first.hide = true; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + mode: PlutoGridMode.select, + onLoaded: (e) => stateManager = e.stateManager, + ), + ), + ), + ); + + await tester.pump(); + + expect(stateManager.currentColumn!.title, 'column1'); + expect(stateManager.currentCell!.value, 'column1 value 0'); + }); + + testWidgets('normal 모드에서 readOnly 모드로 변경 하면 셀이 편집 불가 상태가 되어야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + const ValueKey buttonKey = ValueKey('setReadOnly'); + PlutoGridMode mode = PlutoGridMode.normal; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (context, setState) { + return PlutoGrid( + columns: columns, + rows: rows, + mode: mode, + onLoaded: (e) => stateManager = e.stateManager, + createHeader: (s) => TextButton( + key: buttonKey, + onPressed: () { + setState(() { + mode = PlutoGridMode.readOnly; + }); + }, + child: const Text('set readOnly'), + ), + ); + }, + ), + ), + ), + ); + + await tester.pump(); + + await tester.tap(find.text('column0 value 0')); + await tester.pump(); + await tester.tap(find.text('column0 value 0')); + await tester.pump(); + expect(stateManager.isEditing, true); + + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + expect(stateManager.mode, PlutoGridMode.readOnly); + expect(stateManager.isEditing, false); + }); + + testWidgets('셀 값을 변경하면 onChanged 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onChanged: mock.oneParamReturnVoid, + ), + ), + ), + ); + + await tester.pump(); + + final sampleCell = find.text('column1 value 2'); + + await tester.tap(sampleCell); + await tester.pump(); + await tester.tap(sampleCell); + await tester.pump(); + + await tester.enterText(sampleCell, 'text'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.row == rows[2] && + e.column == columns[1] && + e.rowIdx == 2 && + e.columnIdx == 1 && + e.value == 'text' && + e.oldValue == 'column1 value 2'; + }))).called(1); + }); + + testWidgets('컬럼을 좌측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + onColumnsMoved: + mock.oneParamReturnVoid, + ), + ), + ), + ); + + stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.start); + await tester.pump(); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.idx == 1 && e.visualIdx == 0 && e.columns.length == 1; + }))).called(1); + }); + + testWidgets('컬럼을 우측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + onColumnsMoved: + mock.oneParamReturnVoid, + ), + ), + ), + ); + + stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.end); + await tester.pump(); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.idx == 1 && e.visualIdx == 9 && e.columns.length == 1; + }))).called(1); + }); + + testWidgets('컬럼을 드래그하여 이동하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onColumnsMoved: + mock.oneParamReturnVoid, + ), + ), + ), + ); + + final sampleColumn = find.text('column1'); + + await tester.drag(sampleColumn, const Offset(400, 0)); + + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.idx == 3 && e.visualIdx == 3 && e.columns.length == 1; + }))).called(1); + }); + + group('noRowsWidget', () { + testWidgets('행이 없는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = []; + const noRowsWidget = Center( + key: ValueKey('NoRowsWidget'), + child: Text('There are no rows.'), + ); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + noRowsWidget: noRowsWidget, + ), + ), + ), + ); + + expect(find.byKey(noRowsWidget.key!), findsOneWidget); + }); + + testWidgets('행을 삭제하는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + const noRowsWidget = Center( + key: ValueKey('NoRowsWidget'), + child: Text('There are no rows.'), + ); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + noRowsWidget: noRowsWidget, + ), + ), + ), + ); + + expect(find.byKey(noRowsWidget.key!), findsNothing); + + stateManager.removeAllRows(); + + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byKey(noRowsWidget.key!), findsOneWidget); + }); + + testWidgets('행을 추가하는 경우 noRowsWidget 이 렌더링 되지 않아야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = []; + late final PlutoGridStateManager stateManager; + const noRowsWidget = Center( + key: ValueKey('NoRowsWidget'), + child: Text('There are no rows.'), + ); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + noRowsWidget: noRowsWidget, + ), + ), + ), + ); + + expect(find.byKey(noRowsWidget.key!), findsOneWidget); + + stateManager.appendNewRows(); + + await tester.pumpAndSettle(const Duration(milliseconds: 350)); + + expect(find.byKey(noRowsWidget.key!), findsNothing); + }); + }); +} From 17870d6ccc46371a4e064c125171ab8f5e5b9755 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 17 Dec 2024 01:35:59 +0100 Subject: [PATCH 10/15] Added end of line seperator --- .vscode/settings.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8d4d525e..5f4ee0b7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "dart.lineLength": 80, -} \ No newline at end of file + "dart.lineLength": 80, // most used + "files.eol": "\r\n" // This makes sure, that the EOL (End of Line seperator) is always \r\n (CRLF) and not \n (LF) to avoid git difs errors +} From 08dae6ca4f5b245ca47b22df8b22579eeb2b6f8a Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 17 Dec 2024 03:22:37 +0100 Subject: [PATCH 11/15] Added missing parameter filterWidgetBuilder and onFilterSuffixTap --- lib/src/model/pluto_column.dart | 11 +++++++++++ lib/src/ui/columns/pluto_column_filter.dart | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/src/model/pluto_column.dart b/lib/src/model/pluto_column.dart index d0cadebc..ef2c42ca 100644 --- a/lib/src/model/pluto_column.dart +++ b/lib/src/model/pluto_column.dart @@ -191,6 +191,15 @@ class PlutoColumn { ///Set suffix icon for filter field Icon? filterSuffixIcon; + /// Set a custom on tap event for the filter suffix icon + Function( + FocusNode focusNode, + TextEditingController controller, + bool enabled, + void Function(String changed) handleOnChanged, + PlutoGridStateManager stateManager, + )? onFilterSuffixTap; + ///Set custom widget @Deprecated("Use new filterWidgetBuilder to provide some parameters") Widget? filterWidget; @@ -257,6 +266,8 @@ class PlutoColumn { this.filterSuffixIcon, @Deprecated("Use new filterWidgetBuilder to provide some parameters") this.filterWidget, + this.filterWidgetBuilder, + this.onFilterSuffixTap, this.enableHideColumnMenuItem = true, this.enableSetColumnsMenuItem = true, this.enableAutoEditing = false, diff --git a/lib/src/ui/columns/pluto_column_filter.dart b/lib/src/ui/columns/pluto_column_filter.dart index e669d515..2f64787d 100644 --- a/lib/src/ui/columns/pluto_column_filter.dart +++ b/lib/src/ui/columns/pluto_column_filter.dart @@ -267,7 +267,20 @@ class PlutoColumnFilterState extends PlutoStateWithChange { onChanged: _handleOnChanged, onEditingComplete: _handleOnEditingComplete, decoration: InputDecoration( - suffixIcon: widget.column.filterSuffixIcon, + suffixIcon: widget.column.filterSuffixIcon != null + ? GestureDetector( + onTap: () { + widget.column.onFilterSuffixTap?.call( + _focusNode, + _controller, + _enabled, + _handleOnChanged, + stateManager, + ); + }, + child: widget.column.filterSuffixIcon, + ) + : null, hintText: widget.column.filterHintText ?? (_enabled ? widget.column.defaultFilter.title : ''), filled: true, From d98338f6991620a16b225f7f274c057d598dee14 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:25:14 +0100 Subject: [PATCH 12/15] Added onClear and clearIcon param and filterWidgetDelegate to plutoColumn --- lib/src/model/pluto_column.dart | 96 +++++++++++++-------- lib/src/ui/columns/pluto_column_filter.dart | 68 +++++++++++---- 2 files changed, 108 insertions(+), 56 deletions(-) diff --git a/lib/src/model/pluto_column.dart b/lib/src/model/pluto_column.dart index ef2c42ca..20687f79 100644 --- a/lib/src/model/pluto_column.dart +++ b/lib/src/model/pluto_column.dart @@ -182,36 +182,6 @@ class PlutoColumn { /// Valid only when [enableContextMenu] is activated. bool enableFilterMenuItem; - ///Set hint text for filter field - String? filterHintText; - - ///Set hint text color for filter field - Color? filterHintTextColor; - - ///Set suffix icon for filter field - Icon? filterSuffixIcon; - - /// Set a custom on tap event for the filter suffix icon - Function( - FocusNode focusNode, - TextEditingController controller, - bool enabled, - void Function(String changed) handleOnChanged, - PlutoGridStateManager stateManager, - )? onFilterSuffixTap; - - ///Set custom widget - @Deprecated("Use new filterWidgetBuilder to provide some parameters") - Widget? filterWidget; - - Widget Function( - FocusNode focusNode, - TextEditingController controller, - bool enabled, - void Function(String changed) handleOnChanged, - PlutoGridStateManager stateManager, - )? filterWidgetBuilder; - /// Displays Hide column menu in the column context menu. /// Valid only when [enableContextMenu] is activated. bool enableHideColumnMenuItem; @@ -230,6 +200,9 @@ class PlutoColumn { LinearGradient? backgroundGradient; + /// The widget of the filter column, this can be customized with the multiple constructors, defaults to a [PlutoFilterColumnWidgetDelegate.initial()] + PlutoFilterColumnWidgetDelegate? filterWidgetDelegate; + PlutoColumn({ required this.title, required this.field, @@ -261,18 +234,13 @@ class PlutoColumn { this.enableContextMenu = true, this.enableDropToResize = true, this.enableFilterMenuItem = true, - this.filterHintText, - this.filterHintTextColor, - this.filterSuffixIcon, - @Deprecated("Use new filterWidgetBuilder to provide some parameters") - this.filterWidget, - this.filterWidgetBuilder, - this.onFilterSuffixTap, this.enableHideColumnMenuItem = true, this.enableSetColumnsMenuItem = true, this.enableAutoEditing = false, this.enableEditingMode = true, this.hide = false, + this.filterWidgetDelegate = + const PlutoFilterColumnWidgetDelegate.textField(), this.disableRowCheckboxWhen, }) : _key = UniqueKey(), _checkReadOnly = checkReadOnly; @@ -383,6 +351,60 @@ class PlutoColumn { } } +class PlutoFilterColumnWidgetDelegate { + /// This is the default filter widget delegate + const PlutoFilterColumnWidgetDelegate.textField({ + this.filterHintText, + this.filterHintTextColor, + this.filterSuffixIcon, + this.onFilterSuffixTap, + this.clearIcon = const Icon(Icons.clear), + this.onClear, + }) : filterWidgetBuilder = null; + + /// If you don't want a custom widget + const PlutoFilterColumnWidgetDelegate.builder({ + this.filterWidgetBuilder, + }) : filterSuffixIcon = null, + onFilterSuffixTap = null, + filterHintText = null, + filterHintTextColor = null, + clearIcon = const Icon(Icons.clear), + onClear = null; + + ///Set hint text for filter field + final String? filterHintText; + + ///Set hint text color for filter field + final Color? filterHintTextColor; + + ///Set suffix icon for filter field + final Widget? filterSuffixIcon; + + /// Clear icon in the text field, if onClear is null, this will not appear + final Widget clearIcon; + + /// If this is set, it will be called when the clear button is tapped, if this is null there won't be a clear icon + final Function? onClear; + + /// Set a custom on tap event for the filter suffix icon + final Function( + FocusNode focusNode, + TextEditingController controller, + bool enabled, + void Function(String changed) handleOnChanged, + PlutoGridStateManager stateManager, + )? onFilterSuffixTap; + + final Widget Function( + FocusNode focusNode, + TextEditingController controller, + bool enabled, + void Function(String changed) handleOnChanged, + PlutoGridStateManager stateManager, + )? filterWidgetBuilder; +} + class PlutoColumnRendererContext { final PlutoColumn column; diff --git a/lib/src/ui/columns/pluto_column_filter.dart b/lib/src/ui/columns/pluto_column_filter.dart index 2f64787d..d9345b32 100644 --- a/lib/src/ui/columns/pluto_column_filter.dart +++ b/lib/src/ui/columns/pluto_column_filter.dart @@ -241,6 +241,50 @@ class PlutoColumnFilterState extends PlutoStateWithChange { @override Widget build(BuildContext context) { final style = stateManager.style; + final filterDelegate = widget.column.filterWidgetDelegate; + + Widget? suffixIcon; + + if (filterDelegate?.filterSuffixIcon != null) { + suffixIcon = InkWell( + onTap: () { + filterDelegate?.onFilterSuffixTap?.call( + _focusNode, + _controller, + _enabled, + _handleOnChanged, + stateManager, + ); + }, + child: filterDelegate?.filterSuffixIcon, + ); + } + + final clearIcon = InkWell( + onTap: () { + _controller.clear(); + _handleOnChanged(_controller.text); + filterDelegate?.onClear?.call(); + }, + child: filterDelegate?.clearIcon, + ); + + if (filterDelegate?.onClear != null) { + if (suffixIcon == null) { + suffixIcon = clearIcon; + } else { + suffixIcon = Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + suffixIcon, + clearIcon, + SizedBox(width: 4), + ], + ); + } + } return SizedBox( height: stateManager.columnFilterHeight, @@ -255,9 +299,8 @@ class PlutoColumnFilterState extends PlutoStateWithChange { ), child: Padding( padding: _padding, - child: widget.column.filterWidget ?? - widget.column.filterWidgetBuilder?.call(_focusNode, _controller, - _enabled, _handleOnChanged, stateManager) ?? + child: filterDelegate?.filterWidgetBuilder?.call(_focusNode, + _controller, _enabled, _handleOnChanged, stateManager) ?? TextField( focusNode: _focusNode, controller: _controller, @@ -267,25 +310,12 @@ class PlutoColumnFilterState extends PlutoStateWithChange { onChanged: _handleOnChanged, onEditingComplete: _handleOnEditingComplete, decoration: InputDecoration( - suffixIcon: widget.column.filterSuffixIcon != null - ? GestureDetector( - onTap: () { - widget.column.onFilterSuffixTap?.call( - _focusNode, - _controller, - _enabled, - _handleOnChanged, - stateManager, - ); - }, - child: widget.column.filterSuffixIcon, - ) - : null, - hintText: widget.column.filterHintText ?? + suffixIcon: suffixIcon, + hintText: filterDelegate?.filterHintText ?? (_enabled ? widget.column.defaultFilter.title : ''), filled: true, hintStyle: - TextStyle(color: widget.column.filterHintTextColor), + TextStyle(color: filterDelegate?.filterHintTextColor), fillColor: _textFieldColor, border: _border, enabledBorder: _border, From 4a5141aa3e99e58ac5e470e2a472f1a70b2b03e6 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Tue, 17 Dec 2024 02:15:22 +0100 Subject: [PATCH 13/15] update-tests-github-actions --- .github/workflows/tests.yaml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f72b9453..6eb0f17c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,13 +1,26 @@ -name: Tests +name: Flutter Test on: pull_request: - branches: - - master + branches: [master] + + workflow_dispatch: + jobs: - tests: + build: runs-on: ubuntu-latest + steps: - - name: Install Flutter Dependencies + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '11' + - uses: subosito/flutter-action@v2.18.0 + with: + channel: stable # or: beta, master (or main) + + - name: Get all Flutter Packages run: flutter pub get - - name: Run Flutter Tests + + - name: Run Flutter Test run: flutter test From 4e35fd0f2a5023cc46eb62b04d51239b796b02ac Mon Sep 17 00:00:00 2001 From: Feras abdalrahman Date: Thu, 2 Jan 2025 11:57:12 +0300 Subject: [PATCH 14/15] use lf end of line --- .gitattributes | 1 + .github/workflows/tests.yaml | 52 +- demo/pubspec.yaml | 176 ++-- lib/src/helper/pluto_key_manager_event.dart | 482 +++++----- pubspec.yaml | 2 +- .../helper/pluto_aggregate_helper_test.dart | 824 +++++++++--------- .../helper/pluto_key_manager_event_test.dart | 486 +++++------ 7 files changed, 1012 insertions(+), 1011 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fcadb2cf --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6eb0f17c..10f39a21 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,26 +1,26 @@ -name: Flutter Test -on: - pull_request: - branches: [master] - - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v2 - with: - distribution: 'zulu' - java-version: '11' - - uses: subosito/flutter-action@v2.18.0 - with: - channel: stable # or: beta, master (or main) - - - name: Get all Flutter Packages - run: flutter pub get - - - name: Run Flutter Test - run: flutter test +name: Flutter Test +on: + pull_request: + branches: [master] + + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '11' + - uses: subosito/flutter-action@v2.18.0 + with: + channel: stable # or: beta, master (or main) + + - name: Get all Flutter Packages + run: flutter pub get + + - name: Run Flutter Test + run: flutter test diff --git a/demo/pubspec.yaml b/demo/pubspec.yaml index 06ae60b0..ddb9e408 100644 --- a/demo/pubspec.yaml +++ b/demo/pubspec.yaml @@ -1,88 +1,88 @@ -name: demo -description: PlutoGrid demo app. - -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 - -environment: - sdk: ^3.0.0 - -dependency_overrides: - pluto_grid_plus: - path: ../ - -dependencies: - flutter: - sdk: flutter - pluto_grid_plus: 8.4.3 - faker: ^2.1.0 - url_launcher: ^6.2.1 - font_awesome_flutter: ^10.6.0 - rainbow_color: ^2.0.1 - pluto_menu_bar: ^3.0.1 - file_saver: ^0.2.10 - pluto_grid_plus_export: 1.0.5 - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.6 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^5.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - assets: - - assets/images/ - - fonts: - - family: OpenSans - fonts: - - asset: assets/fonts/open_sans/OpenSans-ExtraBold.ttf - weight: 800 - - asset: assets/fonts/open_sans/OpenSans-ExtraBoldItalic.ttf - weight: 800 - style: italic - - asset: assets/fonts/open_sans/OpenSans-Bold.ttf - weight: 700 - - asset: assets/fonts/open_sans/OpenSans-BoldItalic.ttf - weight: 700 - style: italic - - asset: assets/fonts/open_sans/OpenSans-SemiBold.ttf - weight: 600 - - asset: assets/fonts/open_sans/OpenSans-SemiBoldItalic.ttf - weight: 600 - style: italic - - asset: assets/fonts/open_sans/OpenSans-Regular.ttf - weight: 400 - - asset: assets/fonts/open_sans/OpenSans-Italic.ttf - weight: 400 - style: italic - - asset: assets/fonts/open_sans/OpenSans-Light.ttf - weight: 300 - - asset: assets/fonts/open_sans/OpenSans-LightItalic.ttf - weight: 300 - style: italic +name: demo +description: PlutoGrid demo app. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ^3.0.0 + +dependency_overrides: + pluto_grid_plus: + path: ../ + +dependencies: + flutter: + sdk: flutter + pluto_grid_plus: 8.4.3 + faker: ^2.1.0 + url_launcher: ^6.2.1 + font_awesome_flutter: ^10.6.0 + rainbow_color: ^2.0.1 + pluto_menu_bar: ^3.0.1 + file_saver: ^0.2.10 + pluto_grid_plus_export: 1.0.5 + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ + + fonts: + - family: OpenSans + fonts: + - asset: assets/fonts/open_sans/OpenSans-ExtraBold.ttf + weight: 800 + - asset: assets/fonts/open_sans/OpenSans-ExtraBoldItalic.ttf + weight: 800 + style: italic + - asset: assets/fonts/open_sans/OpenSans-Bold.ttf + weight: 700 + - asset: assets/fonts/open_sans/OpenSans-BoldItalic.ttf + weight: 700 + style: italic + - asset: assets/fonts/open_sans/OpenSans-SemiBold.ttf + weight: 600 + - asset: assets/fonts/open_sans/OpenSans-SemiBoldItalic.ttf + weight: 600 + style: italic + - asset: assets/fonts/open_sans/OpenSans-Regular.ttf + weight: 400 + - asset: assets/fonts/open_sans/OpenSans-Italic.ttf + weight: 400 + style: italic + - asset: assets/fonts/open_sans/OpenSans-Light.ttf + weight: 300 + - asset: assets/fonts/open_sans/OpenSans-LightItalic.ttf + weight: 300 + style: italic diff --git a/lib/src/helper/pluto_key_manager_event.dart b/lib/src/helper/pluto_key_manager_event.dart index 855c39e1..a8dd300c 100644 --- a/lib/src/helper/pluto_key_manager_event.dart +++ b/lib/src/helper/pluto_key_manager_event.dart @@ -1,241 +1,241 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -class PlutoKeyManagerEvent { - FocusNode focusNode; - KeyEvent event; - bool Function(LogicalKeyboardKey key)? isLogicalKeyPressed; - - PlutoKeyManagerEvent({ - required this.focusNode, - required this.event, - this.isLogicalKeyPressed, - }); - - bool get needsThrottle => isMoving || isTab || isPageUp || isPageDown; - - bool get isKeyDownEvent => event.runtimeType == KeyDownEvent; - - bool get isKeyUpEvent => event.runtimeType == KeyUpEvent; - - bool get isMoving => isHorizontal || isVertical; - - bool get isHorizontal => isLeft || isRight; - - bool get isVertical => isUp || isDown; - - bool get isLeft => - event.logicalKey.keyId == LogicalKeyboardKey.arrowLeft.keyId; - - bool get isRight => - event.logicalKey.keyId == LogicalKeyboardKey.arrowRight.keyId; - - bool get isUp => event.logicalKey.keyId == LogicalKeyboardKey.arrowUp.keyId; - - bool get isDown => - event.logicalKey.keyId == LogicalKeyboardKey.arrowDown.keyId; - - bool get isHome => event.logicalKey.keyId == LogicalKeyboardKey.home.keyId; - - bool get isEnd => event.logicalKey.keyId == LogicalKeyboardKey.end.keyId; - - bool get isPageUp { - // windows 에서 pageUp keyId 가 0x10700000021. - return event.logicalKey.keyId == LogicalKeyboardKey.pageUp.keyId || - event.logicalKey.keyId == 0x10700000021; - } - - bool get isPageDown { - // windows 에서 pageDown keyId 가 0x10700000022. - return event.logicalKey.keyId == LogicalKeyboardKey.pageDown.keyId || - event.logicalKey.keyId == 0x10700000022; - } - - bool get isEsc => event.logicalKey.keyId == LogicalKeyboardKey.escape.keyId; - - bool get isEnter => - event.logicalKey.keyId == LogicalKeyboardKey.enter.keyId || - event.logicalKey.keyId == LogicalKeyboardKey.numpadEnter.keyId; - - bool get isTab => event.logicalKey.keyId == LogicalKeyboardKey.tab.keyId; - - bool get isF2 => event.logicalKey.keyId == LogicalKeyboardKey.f2.keyId; - - bool get isF3 => event.logicalKey.keyId == LogicalKeyboardKey.f3.keyId; - - bool get isF4 => event.logicalKey.keyId == LogicalKeyboardKey.f4.keyId; - - bool get isBackspace => - event.logicalKey.keyId == LogicalKeyboardKey.backspace.keyId; - - /// This can be: - /// - /// LogicalKeyboardKey.shift - /// LogicalKeyboardKey.shiftLeft - /// LogicalKeyboardKey.shiftRight - bool get isShift => [ - LogicalKeyboardKey.shift, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.shiftRight, - ].any((lKey) => lKey.keyId == event.logicalKey.keyId); - - bool get isLeftShift => [ - LogicalKeyboardKey.shiftLeft, - ].any((lKey) => lKey.keyId == event.logicalKey.keyId); - - bool get isRightShift => [ - LogicalKeyboardKey.shiftRight, - ].any((lKey) => lKey.keyId == event.logicalKey.keyId); - - /// This can be: - /// - /// LogicalKeyboardKey.control - /// LogicalKeyboardKey.controlLeft - /// LogicalKeyboardKey.controlRight - bool get isControl => [ - LogicalKeyboardKey.control, - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.controlRight, - ].any((lKey) => lKey.keyId == event.logicalKey.keyId); - - bool get isLeftControl => [ - LogicalKeyboardKey.controlLeft, - ].any((lKey) => lKey.keyId == event.logicalKey.keyId); - - bool get isRightControl => [ - LogicalKeyboardKey.controlRight, - ].any((lKey) => lKey.keyId == event.logicalKey.keyId); - - bool get isCharacter => _characters.contains(event.logicalKey.keyId); - - bool get isCtrlC { - return isCtrlPressed && - event.logicalKey.keyId == LogicalKeyboardKey.keyC.keyId; - } - - bool get isCtrlV { - return isCtrlPressed && - event.logicalKey.keyId == LogicalKeyboardKey.keyV.keyId; - } - - bool get isCtrlA { - return isCtrlPressed && - event.logicalKey.keyId == LogicalKeyboardKey.keyA.keyId; - } - - bool get isShiftPressed { - return HardwareKeyboard.instance.isShiftPressed; - } - - bool get isCtrlPressed { - return HardwareKeyboard.instance.isMetaPressed || - HardwareKeyboard.instance.isControlPressed; - } - - bool get isAltPressed { - return HardwareKeyboard.instance.isAltPressed; - } - - bool get isModifierPressed { - return isShiftPressed || isCtrlPressed || isAltPressed; - } -} - -const _characters = { - 0x0000000041, // keyA, - 0x0000000042, // keyB, - 0x0000000043, // keyC, - 0x0000000044, // keyD, - 0x0000000045, // keyE, - 0x0000000046, // keyF, - 0x0000000047, // keyG, - 0x0000000048, // keyH, - 0x0000000049, // keyI, - 0x000000004a, // keyJ, - 0x000000004b, // keyK, - 0x000000004c, // keyL, - 0x000000004d, // keyM, - 0x000000004e, // keyN, - 0x000000004f, // keyO, - 0x0000000050, // keyP, - 0x0000000051, // keyQ, - 0x0000000052, // keyR, - 0x0000000053, // keyS, - 0x0000000054, // keyT, - 0x0000000055, // keyU, - 0x0000000056, // keyV, - 0x0000000057, // keyW, - 0x0000000058, // keyX, - 0x0000000059, // keyY, - 0x000000005a, // keyZ, - 0x0000000061, // keyA, - 0x0000000062, // keyB, - 0x0000000063, // keyC, - 0x0000000064, // keyD, - 0x0000000065, // keyE, - 0x0000000066, // keyF, - 0x0000000067, // keyG, - 0x0000000068, // keyH, - 0x0000000069, // keyI, - 0x000000006a, // keyJ, - 0x000000006b, // keyK, - 0x000000006c, // keyL, - 0x000000006d, // keyM, - 0x000000006e, // keyN, - 0x000000006f, // keyO, - 0x0000000070, // keyP, - 0x0000000071, // keyQ, - 0x0000000072, // keyR, - 0x0000000073, // keyS, - 0x0000000074, // keyT, - 0x0000000075, // keyU, - 0x0000000076, // keyV, - 0x0000000077, // keyW, - 0x0000000078, // keyX, - 0x0000000079, // keyY, - 0x000000007a, // keyZ, - 0x0000000031, // digit1, - 0x0000000032, // digit2, - 0x0000000033, // digit3, - 0x0000000034, // digit4, - 0x0000000035, // digit5, - 0x0000000036, // digit6, - 0x0000000037, // digit7, - 0x0000000038, // digit8, - 0x0000000039, // digit9, - 0x0000000030, // digit0, - 0x0000000020, // space, - 0x000000002d, // minus, - 0x000000003d, // equal, - 0x000000005b, // bracketLeft, - 0x000000005d, // bracketRight, - 0x000000005c, // backslash, - 0x000000003b, // semicolon, - 0x0000000027, // quote, - 0x0000000060, // backquote, - 0x000000002c, // comma, - 0x000000002e, // period, - 0x000000002f, // slash, - 0x0100070054, // numpadDivide, - 0x0100070055, // numpadMultiply, - 0x0100070056, // numpadSubtract, - 0x0100070057, // numpadAdd, - 0x0100070059, // numpad1, - 0x010007005a, // numpad2, - 0x010007005b, // numpad3, - 0x010007005c, // numpad4, - 0x010007005d, // numpad5, - 0x010007005e, // numpad6, - 0x010007005f, // numpad7, - 0x0100070060, // numpad8, - 0x0100070061, // numpad9, - 0x0100070062, // numpad0, - 0x0100070063, // numpadDecimal, - 0x0100070064, // intlBackslash, - 0x0100070067, // numpadEqual, - 0x0100070085, // numpadComma, - 0x0100070087, // intlRo, - 0x0100070089, // intlYen, - 0x01000700b6, // numpadParenLeft, - 0x01000700b7, // numpadParenRight, -}; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class PlutoKeyManagerEvent { + FocusNode focusNode; + KeyEvent event; + bool Function(LogicalKeyboardKey key)? isLogicalKeyPressed; + + PlutoKeyManagerEvent({ + required this.focusNode, + required this.event, + this.isLogicalKeyPressed, + }); + + bool get needsThrottle => isMoving || isTab || isPageUp || isPageDown; + + bool get isKeyDownEvent => event.runtimeType == KeyDownEvent; + + bool get isKeyUpEvent => event.runtimeType == KeyUpEvent; + + bool get isMoving => isHorizontal || isVertical; + + bool get isHorizontal => isLeft || isRight; + + bool get isVertical => isUp || isDown; + + bool get isLeft => + event.logicalKey.keyId == LogicalKeyboardKey.arrowLeft.keyId; + + bool get isRight => + event.logicalKey.keyId == LogicalKeyboardKey.arrowRight.keyId; + + bool get isUp => event.logicalKey.keyId == LogicalKeyboardKey.arrowUp.keyId; + + bool get isDown => + event.logicalKey.keyId == LogicalKeyboardKey.arrowDown.keyId; + + bool get isHome => event.logicalKey.keyId == LogicalKeyboardKey.home.keyId; + + bool get isEnd => event.logicalKey.keyId == LogicalKeyboardKey.end.keyId; + + bool get isPageUp { + // windows 에서 pageUp keyId 가 0x10700000021. + return event.logicalKey.keyId == LogicalKeyboardKey.pageUp.keyId || + event.logicalKey.keyId == 0x10700000021; + } + + bool get isPageDown { + // windows 에서 pageDown keyId 가 0x10700000022. + return event.logicalKey.keyId == LogicalKeyboardKey.pageDown.keyId || + event.logicalKey.keyId == 0x10700000022; + } + + bool get isEsc => event.logicalKey.keyId == LogicalKeyboardKey.escape.keyId; + + bool get isEnter => + event.logicalKey.keyId == LogicalKeyboardKey.enter.keyId || + event.logicalKey.keyId == LogicalKeyboardKey.numpadEnter.keyId; + + bool get isTab => event.logicalKey.keyId == LogicalKeyboardKey.tab.keyId; + + bool get isF2 => event.logicalKey.keyId == LogicalKeyboardKey.f2.keyId; + + bool get isF3 => event.logicalKey.keyId == LogicalKeyboardKey.f3.keyId; + + bool get isF4 => event.logicalKey.keyId == LogicalKeyboardKey.f4.keyId; + + bool get isBackspace => + event.logicalKey.keyId == LogicalKeyboardKey.backspace.keyId; + + /// This can be: + /// + /// LogicalKeyboardKey.shift + /// LogicalKeyboardKey.shiftLeft + /// LogicalKeyboardKey.shiftRight + bool get isShift => [ + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + ].any((lKey) => lKey.keyId == event.logicalKey.keyId); + + bool get isLeftShift => [ + LogicalKeyboardKey.shiftLeft, + ].any((lKey) => lKey.keyId == event.logicalKey.keyId); + + bool get isRightShift => [ + LogicalKeyboardKey.shiftRight, + ].any((lKey) => lKey.keyId == event.logicalKey.keyId); + + /// This can be: + /// + /// LogicalKeyboardKey.control + /// LogicalKeyboardKey.controlLeft + /// LogicalKeyboardKey.controlRight + bool get isControl => [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + ].any((lKey) => lKey.keyId == event.logicalKey.keyId); + + bool get isLeftControl => [ + LogicalKeyboardKey.controlLeft, + ].any((lKey) => lKey.keyId == event.logicalKey.keyId); + + bool get isRightControl => [ + LogicalKeyboardKey.controlRight, + ].any((lKey) => lKey.keyId == event.logicalKey.keyId); + + bool get isCharacter => _characters.contains(event.logicalKey.keyId); + + bool get isCtrlC { + return isCtrlPressed && + event.logicalKey.keyId == LogicalKeyboardKey.keyC.keyId; + } + + bool get isCtrlV { + return isCtrlPressed && + event.logicalKey.keyId == LogicalKeyboardKey.keyV.keyId; + } + + bool get isCtrlA { + return isCtrlPressed && + event.logicalKey.keyId == LogicalKeyboardKey.keyA.keyId; + } + + bool get isShiftPressed { + return HardwareKeyboard.instance.isShiftPressed; + } + + bool get isCtrlPressed { + return HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isControlPressed; + } + + bool get isAltPressed { + return HardwareKeyboard.instance.isAltPressed; + } + + bool get isModifierPressed { + return isShiftPressed || isCtrlPressed || isAltPressed; + } +} + +const _characters = { + 0x0000000041, // keyA, + 0x0000000042, // keyB, + 0x0000000043, // keyC, + 0x0000000044, // keyD, + 0x0000000045, // keyE, + 0x0000000046, // keyF, + 0x0000000047, // keyG, + 0x0000000048, // keyH, + 0x0000000049, // keyI, + 0x000000004a, // keyJ, + 0x000000004b, // keyK, + 0x000000004c, // keyL, + 0x000000004d, // keyM, + 0x000000004e, // keyN, + 0x000000004f, // keyO, + 0x0000000050, // keyP, + 0x0000000051, // keyQ, + 0x0000000052, // keyR, + 0x0000000053, // keyS, + 0x0000000054, // keyT, + 0x0000000055, // keyU, + 0x0000000056, // keyV, + 0x0000000057, // keyW, + 0x0000000058, // keyX, + 0x0000000059, // keyY, + 0x000000005a, // keyZ, + 0x0000000061, // keyA, + 0x0000000062, // keyB, + 0x0000000063, // keyC, + 0x0000000064, // keyD, + 0x0000000065, // keyE, + 0x0000000066, // keyF, + 0x0000000067, // keyG, + 0x0000000068, // keyH, + 0x0000000069, // keyI, + 0x000000006a, // keyJ, + 0x000000006b, // keyK, + 0x000000006c, // keyL, + 0x000000006d, // keyM, + 0x000000006e, // keyN, + 0x000000006f, // keyO, + 0x0000000070, // keyP, + 0x0000000071, // keyQ, + 0x0000000072, // keyR, + 0x0000000073, // keyS, + 0x0000000074, // keyT, + 0x0000000075, // keyU, + 0x0000000076, // keyV, + 0x0000000077, // keyW, + 0x0000000078, // keyX, + 0x0000000079, // keyY, + 0x000000007a, // keyZ, + 0x0000000031, // digit1, + 0x0000000032, // digit2, + 0x0000000033, // digit3, + 0x0000000034, // digit4, + 0x0000000035, // digit5, + 0x0000000036, // digit6, + 0x0000000037, // digit7, + 0x0000000038, // digit8, + 0x0000000039, // digit9, + 0x0000000030, // digit0, + 0x0000000020, // space, + 0x000000002d, // minus, + 0x000000003d, // equal, + 0x000000005b, // bracketLeft, + 0x000000005d, // bracketRight, + 0x000000005c, // backslash, + 0x000000003b, // semicolon, + 0x0000000027, // quote, + 0x0000000060, // backquote, + 0x000000002c, // comma, + 0x000000002e, // period, + 0x000000002f, // slash, + 0x0100070054, // numpadDivide, + 0x0100070055, // numpadMultiply, + 0x0100070056, // numpadSubtract, + 0x0100070057, // numpadAdd, + 0x0100070059, // numpad1, + 0x010007005a, // numpad2, + 0x010007005b, // numpad3, + 0x010007005c, // numpad4, + 0x010007005d, // numpad5, + 0x010007005e, // numpad6, + 0x010007005f, // numpad7, + 0x0100070060, // numpad8, + 0x0100070061, // numpad9, + 0x0100070062, // numpad0, + 0x0100070063, // numpadDecimal, + 0x0100070064, // intlBackslash, + 0x0100070067, // numpadEqual, + 0x0100070085, // numpadComma, + 0x0100070087, // intlRo, + 0x0100070089, // intlYen, + 0x01000700b6, // numpadParenLeft, + 0x01000700b7, // numpadParenRight, +}; diff --git a/pubspec.yaml b/pubspec.yaml index 009593f5..852b8286 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: sdk: flutter # Follows the intl version included in Flutter. # https://github.com/flutter/flutter/blob/84a1e904f44f9b0e9c4510138010edcc653163f8/packages/flutter_localizations/pubspec.yaml#L11 - intl: ^0.20.0 + intl: ^0.19.0 rxdart: ^0.28.0 collection: ^1.18.0 diff --git a/test/src/helper/pluto_aggregate_helper_test.dart b/test/src/helper/pluto_aggregate_helper_test.dart index 080be252..38b84d96 100644 --- a/test/src/helper/pluto_aggregate_helper_test.dart +++ b/test/src/helper/pluto_aggregate_helper_test.dart @@ -1,412 +1,412 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -void main() { - group('sum', () { - test('숫자 컬럼이 아닌경우 0이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.text(), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), - PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), - PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), - PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), - PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), - ]; - - expect(PlutoAggregateHelper.sum(rows: rows, column: column), 0); - }); - - test('[양수] condition 이 없이 sum 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10)}), - PlutoRow(cells: {'column': PlutoCell(value: 20)}), - PlutoRow(cells: {'column': PlutoCell(value: 30)}), - PlutoRow(cells: {'column': PlutoCell(value: 40)}), - PlutoRow(cells: {'column': PlutoCell(value: 50)}), - ]; - - expect(PlutoAggregateHelper.sum(rows: rows, column: column), 150); - }); - - test('[음수] condition 이 없이 sum 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: -10)}), - PlutoRow(cells: {'column': PlutoCell(value: -20)}), - PlutoRow(cells: {'column': PlutoCell(value: -30)}), - PlutoRow(cells: {'column': PlutoCell(value: -40)}), - PlutoRow(cells: {'column': PlutoCell(value: -50)}), - ]; - - expect(PlutoAggregateHelper.sum(rows: rows, column: column), -150); - }); - - test('[소수] condition 이 없이 sum 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - ]; - - expect(PlutoAggregateHelper.sum(rows: rows, column: column), 50.005); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템의 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - ]; - - expect( - PlutoAggregateHelper.sum( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value == 10.001, - ), - 30.003, - ); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 0이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - ]; - - expect( - PlutoAggregateHelper.sum( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value == 10.003, - ), - null, - ); - }); - }); - - group('average', () { - test('[양수] condition 이 없이 average 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10)}), - PlutoRow(cells: {'column': PlutoCell(value: 20)}), - PlutoRow(cells: {'column': PlutoCell(value: 30)}), - PlutoRow(cells: {'column': PlutoCell(value: 40)}), - PlutoRow(cells: {'column': PlutoCell(value: 50)}), - ]; - - expect(PlutoAggregateHelper.average(rows: rows, column: column), 30); - }); - - test('[음수] condition 이 없이 average 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: -10)}), - PlutoRow(cells: {'column': PlutoCell(value: -20)}), - PlutoRow(cells: {'column': PlutoCell(value: -30)}), - PlutoRow(cells: {'column': PlutoCell(value: -40)}), - PlutoRow(cells: {'column': PlutoCell(value: -50)}), - ]; - - expect(PlutoAggregateHelper.average(rows: rows, column: column), -30); - }); - - test('[소수] condition 이 없이 average 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect(PlutoAggregateHelper.average(rows: rows, column: column), 10.003); - }); - }); - - group('min', () { - test('[양수] condition 이 없이 min 을 호출 한 경우 최소 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 101)}), - PlutoRow(cells: {'column': PlutoCell(value: 102)}), - PlutoRow(cells: {'column': PlutoCell(value: 103)}), - PlutoRow(cells: {'column': PlutoCell(value: 104)}), - PlutoRow(cells: {'column': PlutoCell(value: 105)}), - ]; - - expect(PlutoAggregateHelper.min(rows: rows, column: column), 101); - }); - - test('[음수] condition 이 없이 min 을 호출 한 경우 최소 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: -101)}), - PlutoRow(cells: {'column': PlutoCell(value: -102)}), - PlutoRow(cells: {'column': PlutoCell(value: -103)}), - PlutoRow(cells: {'column': PlutoCell(value: -104)}), - PlutoRow(cells: {'column': PlutoCell(value: -105)}), - ]; - - expect(PlutoAggregateHelper.min(rows: rows, column: column), -105); - }); - - test('[소수] condition 이 없이 min 을 호출 한 경우 최소 값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect(PlutoAggregateHelper.min(rows: rows, column: column), 10.001); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템이 있다면 조건내에서 최소값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect( - PlutoAggregateHelper.min( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value >= 10.003, - ), - 10.003, - ); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 null 이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - ]; - - expect( - PlutoAggregateHelper.min( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value == 10.003, - ), - null, - ); - }); - }); - - group('max', () { - test('condition 이 있는 경우 조건에 맞는 아이템이 있다면 조건내에서 최대값이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect( - PlutoAggregateHelper.max( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value >= 10.003, - ), - 10.005, - ); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 null 이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect( - PlutoAggregateHelper.max( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value >= 10.006, - ), - null, - ); - }); - }); - - group('count', () { - test('condition 이 없는 경우 전체 리스트 개수가 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect(PlutoAggregateHelper.count(rows: rows, column: column), 5); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템이 있다면 조건에 맞는 아이템 개수가 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect( - PlutoAggregateHelper.count( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value >= 10.003, - ), - 3, - ); - }); - - test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 0 이 리턴 되어야 한다.', () { - final column = PlutoColumn( - title: 'column', - field: 'column', - type: PlutoColumnType.number(format: '#,###.###'), - ); - - final rows = [ - PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), - PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), - ]; - - expect( - PlutoAggregateHelper.count( - rows: rows, - column: column, - filter: (PlutoCell cell) => cell.value >= 10.006, - ), - 0, - ); - }); - }); -} +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +void main() { + group('sum', () { + test('숫자 컬럼이 아닌경우 0이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.text(), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), + PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), + PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), + PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), + PlutoRow(cells: {'column': PlutoCell(value: '10.001')}), + ]; + + expect(PlutoAggregateHelper.sum(rows: rows, column: column), 0); + }); + + test('[양수] condition 이 없이 sum 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10)}), + PlutoRow(cells: {'column': PlutoCell(value: 20)}), + PlutoRow(cells: {'column': PlutoCell(value: 30)}), + PlutoRow(cells: {'column': PlutoCell(value: 40)}), + PlutoRow(cells: {'column': PlutoCell(value: 50)}), + ]; + + expect(PlutoAggregateHelper.sum(rows: rows, column: column), 150); + }); + + test('[음수] condition 이 없이 sum 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: -10)}), + PlutoRow(cells: {'column': PlutoCell(value: -20)}), + PlutoRow(cells: {'column': PlutoCell(value: -30)}), + PlutoRow(cells: {'column': PlutoCell(value: -40)}), + PlutoRow(cells: {'column': PlutoCell(value: -50)}), + ]; + + expect(PlutoAggregateHelper.sum(rows: rows, column: column), -150); + }); + + test('[소수] condition 이 없이 sum 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + ]; + + expect(PlutoAggregateHelper.sum(rows: rows, column: column), 50.005); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템의 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + ]; + + expect( + PlutoAggregateHelper.sum( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value == 10.001, + ), + 30.003, + ); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 0이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + ]; + + expect( + PlutoAggregateHelper.sum( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value == 10.003, + ), + null, + ); + }); + }); + + group('average', () { + test('[양수] condition 이 없이 average 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10)}), + PlutoRow(cells: {'column': PlutoCell(value: 20)}), + PlutoRow(cells: {'column': PlutoCell(value: 30)}), + PlutoRow(cells: {'column': PlutoCell(value: 40)}), + PlutoRow(cells: {'column': PlutoCell(value: 50)}), + ]; + + expect(PlutoAggregateHelper.average(rows: rows, column: column), 30); + }); + + test('[음수] condition 이 없이 average 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: -10)}), + PlutoRow(cells: {'column': PlutoCell(value: -20)}), + PlutoRow(cells: {'column': PlutoCell(value: -30)}), + PlutoRow(cells: {'column': PlutoCell(value: -40)}), + PlutoRow(cells: {'column': PlutoCell(value: -50)}), + ]; + + expect(PlutoAggregateHelper.average(rows: rows, column: column), -30); + }); + + test('[소수] condition 이 없이 average 을 호출 한 경우 전체 합계 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect(PlutoAggregateHelper.average(rows: rows, column: column), 10.003); + }); + }); + + group('min', () { + test('[양수] condition 이 없이 min 을 호출 한 경우 최소 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 101)}), + PlutoRow(cells: {'column': PlutoCell(value: 102)}), + PlutoRow(cells: {'column': PlutoCell(value: 103)}), + PlutoRow(cells: {'column': PlutoCell(value: 104)}), + PlutoRow(cells: {'column': PlutoCell(value: 105)}), + ]; + + expect(PlutoAggregateHelper.min(rows: rows, column: column), 101); + }); + + test('[음수] condition 이 없이 min 을 호출 한 경우 최소 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: -101)}), + PlutoRow(cells: {'column': PlutoCell(value: -102)}), + PlutoRow(cells: {'column': PlutoCell(value: -103)}), + PlutoRow(cells: {'column': PlutoCell(value: -104)}), + PlutoRow(cells: {'column': PlutoCell(value: -105)}), + ]; + + expect(PlutoAggregateHelper.min(rows: rows, column: column), -105); + }); + + test('[소수] condition 이 없이 min 을 호출 한 경우 최소 값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect(PlutoAggregateHelper.min(rows: rows, column: column), 10.001); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템이 있다면 조건내에서 최소값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect( + PlutoAggregateHelper.min( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value >= 10.003, + ), + 10.003, + ); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 null 이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + ]; + + expect( + PlutoAggregateHelper.min( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value == 10.003, + ), + null, + ); + }); + }); + + group('max', () { + test('condition 이 있는 경우 조건에 맞는 아이템이 있다면 조건내에서 최대값이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect( + PlutoAggregateHelper.max( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value >= 10.003, + ), + 10.005, + ); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 null 이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect( + PlutoAggregateHelper.max( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value >= 10.006, + ), + null, + ); + }); + }); + + group('count', () { + test('condition 이 없는 경우 전체 리스트 개수가 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect(PlutoAggregateHelper.count(rows: rows, column: column), 5); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템이 있다면 조건에 맞는 아이템 개수가 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect( + PlutoAggregateHelper.count( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value >= 10.003, + ), + 3, + ); + }); + + test('condition 이 있는 경우 조건에 맞는 아이템이 없다면 0 이 리턴 되어야 한다.', () { + final column = PlutoColumn( + title: 'column', + field: 'column', + type: PlutoColumnType.number(format: '#,###.###'), + ); + + final rows = [ + PlutoRow(cells: {'column': PlutoCell(value: 10.001)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.002)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.003)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.004)}), + PlutoRow(cells: {'column': PlutoCell(value: 10.005)}), + ]; + + expect( + PlutoAggregateHelper.count( + rows: rows, + column: column, + filter: (PlutoCell cell) => cell.value >= 10.006, + ), + 0, + ); + }); + }); +} diff --git a/test/src/helper/pluto_key_manager_event_test.dart b/test/src/helper/pluto_key_manager_event_test.dart index f91afa98..37bbd504 100644 --- a/test/src/helper/pluto_key_manager_event_test.dart +++ b/test/src/helper/pluto_key_manager_event_test.dart @@ -1,243 +1,243 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -void main() { - late FocusNode focusNode; - - late PlutoKeyManagerEvent? keyManagerEvent; - - KeyEventResult callback(FocusNode node, KeyEvent event) { - keyManagerEvent = PlutoKeyManagerEvent( - focusNode: node, - event: event, - isLogicalKeyPressed: HardwareKeyboard.instance.isLogicalKeyPressed, - ); - - return KeyEventResult.handled; - } - - setUp(() { - focusNode = FocusNode(); - }); - - tearDown(() { - keyManagerEvent = null; - }); - - Future buildWidget({ - required WidgetTester tester, - required FocusOnKeyEventCallback callback, - }) async { - await tester.pumpWidget(MaterialApp( - home: FocusScope( - autofocus: true, - onKeyEvent: callback, - child: Focus( - focusNode: focusNode, - child: const SizedBox(width: 100, height: 100), - ), - ), - )); - - focusNode.requestFocus(); - } - - testWidgets( - 'When any key is pressed, isKeyDownEvent must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.keyE; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isKeyDownEvent, true); - await tester.sendKeyUpEvent(key); - expect(keyManagerEvent!.isKeyDownEvent, false); - }, - ); - - testWidgets( - 'When the Home key is pressed, isHome must be `true`.', - (tester) async { - late PlutoKeyManagerEvent keyManagerEvent; - - KeyEventResult callback(FocusNode node, KeyEvent event) { - keyManagerEvent = PlutoKeyManagerEvent( - focusNode: node, - event: event, - ); - - return KeyEventResult.handled; - } - - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.home; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent.isHome, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the End key is pressed, isEnd must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.end; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isEnd, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the F4 key is pressed, isF4 must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.f4; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isF4, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the Backspace key is pressed, isBackspace must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.backspace; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isBackspace, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the Shift key is pressed, isShift must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.shift; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isShift, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the LeftShift key is pressed, isLeftShift must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.shiftLeft; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isLeftShift, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the RightShift key is pressed, isRightShift must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.shiftRight; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isRightShift, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the Control key is pressed, isControl must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.control; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isControl, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the LeftControl key is pressed, isLeftControl must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.controlLeft; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isLeftControl, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When the RightControl key is pressed, isRightControl must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.controlRight; - await tester.sendKeyDownEvent(key); - expect(keyManagerEvent!.isRightControl, true); - await tester.sendKeyUpEvent(key); - }, - ); - - testWidgets( - 'When Control + C keys are pressed, isCtrlC must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.control; - const key2 = LogicalKeyboardKey.keyC; - - await tester.sendKeyDownEvent(key); - await tester.sendKeyDownEvent(key2); - - expect(keyManagerEvent?.isCtrlC, true); - - await tester.sendKeyUpEvent(key); - await tester.sendKeyUpEvent(key2); - }, - ); - - testWidgets( - 'When Control + V keys are pressed, isCtrlV must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.control; - const key2 = LogicalKeyboardKey.keyV; - - await tester.sendKeyDownEvent(key); - await tester.sendKeyDownEvent(key2); - - expect(keyManagerEvent!.isCtrlV, true); - await tester.sendKeyUpEvent(key); - await tester.sendKeyUpEvent(key2); - }, - ); - - testWidgets( - 'When Control + A keys are pressed, isCtrlA must be `true`.', - (tester) async { - await buildWidget(tester: tester, callback: callback); - - const key = LogicalKeyboardKey.control; - const key2 = LogicalKeyboardKey.keyA; - - await tester.sendKeyDownEvent(key); - await tester.sendKeyDownEvent(key2); - - expect(keyManagerEvent!.isCtrlA, true); - - await tester.sendKeyUpEvent(key); - await tester.sendKeyUpEvent(key2); - }, - ); -} +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +void main() { + late FocusNode focusNode; + + late PlutoKeyManagerEvent? keyManagerEvent; + + KeyEventResult callback(FocusNode node, KeyEvent event) { + keyManagerEvent = PlutoKeyManagerEvent( + focusNode: node, + event: event, + isLogicalKeyPressed: HardwareKeyboard.instance.isLogicalKeyPressed, + ); + + return KeyEventResult.handled; + } + + setUp(() { + focusNode = FocusNode(); + }); + + tearDown(() { + keyManagerEvent = null; + }); + + Future buildWidget({ + required WidgetTester tester, + required FocusOnKeyEventCallback callback, + }) async { + await tester.pumpWidget(MaterialApp( + home: FocusScope( + autofocus: true, + onKeyEvent: callback, + child: Focus( + focusNode: focusNode, + child: const SizedBox(width: 100, height: 100), + ), + ), + )); + + focusNode.requestFocus(); + } + + testWidgets( + 'When any key is pressed, isKeyDownEvent must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.keyE; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isKeyDownEvent, true); + await tester.sendKeyUpEvent(key); + expect(keyManagerEvent!.isKeyDownEvent, false); + }, + ); + + testWidgets( + 'When the Home key is pressed, isHome must be `true`.', + (tester) async { + late PlutoKeyManagerEvent keyManagerEvent; + + KeyEventResult callback(FocusNode node, KeyEvent event) { + keyManagerEvent = PlutoKeyManagerEvent( + focusNode: node, + event: event, + ); + + return KeyEventResult.handled; + } + + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.home; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent.isHome, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the End key is pressed, isEnd must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.end; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isEnd, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the F4 key is pressed, isF4 must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.f4; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isF4, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the Backspace key is pressed, isBackspace must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.backspace; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isBackspace, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the Shift key is pressed, isShift must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.shift; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isShift, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the LeftShift key is pressed, isLeftShift must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.shiftLeft; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isLeftShift, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the RightShift key is pressed, isRightShift must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.shiftRight; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isRightShift, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the Control key is pressed, isControl must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.control; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isControl, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the LeftControl key is pressed, isLeftControl must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.controlLeft; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isLeftControl, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When the RightControl key is pressed, isRightControl must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.controlRight; + await tester.sendKeyDownEvent(key); + expect(keyManagerEvent!.isRightControl, true); + await tester.sendKeyUpEvent(key); + }, + ); + + testWidgets( + 'When Control + C keys are pressed, isCtrlC must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.control; + const key2 = LogicalKeyboardKey.keyC; + + await tester.sendKeyDownEvent(key); + await tester.sendKeyDownEvent(key2); + + expect(keyManagerEvent?.isCtrlC, true); + + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(key2); + }, + ); + + testWidgets( + 'When Control + V keys are pressed, isCtrlV must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.control; + const key2 = LogicalKeyboardKey.keyV; + + await tester.sendKeyDownEvent(key); + await tester.sendKeyDownEvent(key2); + + expect(keyManagerEvent!.isCtrlV, true); + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(key2); + }, + ); + + testWidgets( + 'When Control + A keys are pressed, isCtrlA must be `true`.', + (tester) async { + await buildWidget(tester: tester, callback: callback); + + const key = LogicalKeyboardKey.control; + const key2 = LogicalKeyboardKey.keyA; + + await tester.sendKeyDownEvent(key); + await tester.sendKeyDownEvent(key2); + + expect(keyManagerEvent!.isCtrlA, true); + + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(key2); + }, + ); +} From 412b997598ac559fec64fc4c6f3c4db8efee80a9 Mon Sep 17 00:00:00 2001 From: Stan Persoons <147701137+stan-at-work@users.noreply.github.com> Date: Sat, 11 Jan 2025 14:17:01 +0100 Subject: [PATCH 15/15] dart fix to remove change files --- demo/lib/main.dart | 48 +- example/lib/main.dart | 45 +- lib/src/helper/show_column_menu.dart | 404 +- .../event/pluto_grid_row_hover_event.dart | 3 +- .../shortcut/pluto_grid_shortcut_action.dart | 64 +- lib/src/manager/state/hovering_state.dart | 12 +- lib/src/manager/state/selecting_state.dart | 1422 +++---- .../plugin/pluto_infinity_scroll_rows.dart | 3 +- lib/src/plugin/pluto_lazy_pagination.dart | 2 +- lib/src/pluto_grid_configuration.dart | 7 +- lib/src/ui/cells/pluto_date_cell.dart | 3 +- lib/src/widgets/pluto_scrollbar.dart | 2880 +++++++------- lib/src/widgets/pluto_shadow_container.dart | 122 +- lib/src/widgets/pluto_shadow_line.dart | 82 +- .../state/hovering_row_state_test.dart | 8 +- test/src/pluto_grid_test.dart | 3462 ++++++++--------- 16 files changed, 4307 insertions(+), 4260 deletions(-) diff --git a/demo/lib/main.dart b/demo/lib/main.dart index 7e816b6c..0fc6d7ff 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -59,44 +59,60 @@ class MyApp extends StatelessWidget { kReleaseMode ? HomeScreen.routeName : DevelopmentScreen.routeName, routes: { HomeScreen.routeName: (context) => const HomeScreen(), - AddAndRemoveColumnRowScreen.routeName: (context) => const AddAndRemoveColumnRowScreen(), - AddRowsAsynchronouslyScreen.routeName: (context) => const AddRowsAsynchronouslyScreen(), + AddAndRemoveColumnRowScreen.routeName: (context) => + const AddAndRemoveColumnRowScreen(), + AddRowsAsynchronouslyScreen.routeName: (context) => + const AddRowsAsynchronouslyScreen(), CellRendererScreen.routeName: (context) => const CellRendererScreen(), CellSelectionScreen.routeName: (context) => const CellSelectionScreen(), RTLScreen.routeName: (context) => const RTLScreen(), - ColumnFilteringScreen.routeName: (context) => const ColumnFilteringScreen(), + ColumnFilteringScreen.routeName: (context) => + const ColumnFilteringScreen(), ColumnFooterScreen.routeName: (context) => const ColumnFooterScreen(), - ColumnFreezingScreen.routeName: (context) => const ColumnFreezingScreen(), + ColumnFreezingScreen.routeName: (context) => + const ColumnFreezingScreen(), ColumnGroupScreen.routeName: (context) => const ColumnGroupScreen(), ColumnHidingScreen.routeName: (context) => const ColumnHidingScreen(), ColumnMenuScreen.routeName: (context) => const ColumnMenuScreen(), ColumnMovingScreen.routeName: (context) => const ColumnMovingScreen(), - ColumnResizingScreen.routeName: (context) => const ColumnResizingScreen(), + ColumnResizingScreen.routeName: (context) => + const ColumnResizingScreen(), ColumnSortingScreen.routeName: (context) => const ColumnSortingScreen(), CopyAndPasteScreen.routeName: (context) => const CopyAndPasteScreen(), - CurrencyTypeColumnScreen.routeName: (context) => const CurrencyTypeColumnScreen(), + CurrencyTypeColumnScreen.routeName: (context) => + const CurrencyTypeColumnScreen(), DarkModeScreen.routeName: (context) => const DarkModeScreen(), - DateTypeColumnScreen.routeName: (context) => const DateTypeColumnScreen(), + DateTypeColumnScreen.routeName: (context) => + const DateTypeColumnScreen(), DualModeScreen.routeName: (context) => const DualModeScreen(), EditingStateScreen.routeName: (context) => const EditingStateScreen(), ExportScreen.routeName: (context) => const ExportScreen(), GridAsPopupScreen.routeName: (context) => const GridAsPopupScreen(), ListingModeScreen.routeName: (context) => const ListingModeScreen(), MovingScreen.routeName: (context) => const MovingScreen(), - NumberTypeColumnScreen.routeName: (context) => const NumberTypeColumnScreen(), + NumberTypeColumnScreen.routeName: (context) => + const NumberTypeColumnScreen(), RowColorScreen.routeName: (context) => const RowColorScreen(), RowGroupScreen.routeName: (context) => const RowGroupScreen(), - RowInfinityScrollScreen.routeName: (context) => const RowInfinityScrollScreen(), - RowLazyPaginationScreen.routeName: (context) => const RowLazyPaginationScreen(), + RowInfinityScrollScreen.routeName: (context) => + const RowInfinityScrollScreen(), + RowLazyPaginationScreen.routeName: (context) => + const RowLazyPaginationScreen(), RowMovingScreen.routeName: (context) => const RowMovingScreen(), RowPaginationScreen.routeName: (context) => const RowPaginationScreen(), RowSelectionScreen.routeName: (context) => const RowSelectionScreen(), - RowWithCheckboxScreen.routeName: (context) => const RowWithCheckboxScreen(), - SelectionTypeColumnScreen.routeName: (context) => const SelectionTypeColumnScreen(), - TextTypeColumnScreen.routeName: (context) => const TextTypeColumnScreen(), - TimeTypeColumnScreen.routeName: (context) => const TimeTypeColumnScreen(), - ValueFormatterScreen.routeName: (context) => const ValueFormatterScreen(), - CustomLoadingIndicatorScreen.routeName: (context) => const CustomLoadingIndicatorScreen(), + RowWithCheckboxScreen.routeName: (context) => + const RowWithCheckboxScreen(), + SelectionTypeColumnScreen.routeName: (context) => + const SelectionTypeColumnScreen(), + TextTypeColumnScreen.routeName: (context) => + const TextTypeColumnScreen(), + TimeTypeColumnScreen.routeName: (context) => + const TimeTypeColumnScreen(), + ValueFormatterScreen.routeName: (context) => + const ValueFormatterScreen(), + CustomLoadingIndicatorScreen.routeName: (context) => + const CustomLoadingIndicatorScreen(), // only development EmptyScreen.routeName: (context) => const EmptyScreen(), DevelopmentScreen.routeName: (context) => const DevelopmentScreen(), diff --git a/example/lib/main.dart b/example/lib/main.dart index dad9d280..78c17d30 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -170,29 +170,28 @@ class _PlutoGridExamplePageState extends State { body: Container( padding: const EdgeInsets.all(15), child: PlutoGrid( - columns: columns, - rows: rows, - columnGroups: columnGroups, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - stateManager.setShowColumnFilter(true); - }, - onChanged: (PlutoGridOnChangedEvent event) { - print(event); - }, - configuration: const PlutoGridConfiguration(), - selectDateCallback: (PlutoCell cell, PlutoColumn column) async { - return showDatePicker( - context: context, - initialDate: PlutoDateTimeHelper.parseOrNullWithFormat( - cell.value, - column.type.date.format, - ) ?? DateTime.now(), - firstDate: column.type.date.startDate ?? DateTime(0), - lastDate: column.type.date.endDate ?? DateTime(9999) - ); - } - ), + columns: columns, + rows: rows, + columnGroups: columnGroups, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + stateManager.setShowColumnFilter(true); + }, + onChanged: (PlutoGridOnChangedEvent event) { + print(event); + }, + configuration: const PlutoGridConfiguration(), + selectDateCallback: (PlutoCell cell, PlutoColumn column) async { + return showDatePicker( + context: context, + initialDate: PlutoDateTimeHelper.parseOrNullWithFormat( + cell.value, + column.type.date.format, + ) ?? + DateTime.now(), + firstDate: column.type.date.startDate ?? DateTime(0), + lastDate: column.type.date.endDate ?? DateTime(9999)); + }), ), ); } diff --git a/lib/src/helper/show_column_menu.dart b/lib/src/helper/show_column_menu.dart index faeacb22..96adc66d 100644 --- a/lib/src/helper/show_column_menu.dart +++ b/lib/src/helper/show_column_menu.dart @@ -1,202 +1,202 @@ -import 'package:flutter/material.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -abstract class PlutoColumnMenuDelegate { - List> buildMenuItems({ - required PlutoGridStateManager stateManager, - required PlutoColumn column, - }); - - void onSelected({ - required BuildContext context, - required PlutoGridStateManager stateManager, - required PlutoColumn column, - required bool mounted, - required T? selected, - }); -} - -class PlutoColumnMenuDelegateDefault - implements PlutoColumnMenuDelegate { - const PlutoColumnMenuDelegateDefault(); - - @override - List> buildMenuItems({ - required PlutoGridStateManager stateManager, - required PlutoColumn column, - }) { - return _getDefaultColumnMenuItems( - stateManager: stateManager, - column: column, - ); - } - - @override - void onSelected({ - required BuildContext context, - required PlutoGridStateManager stateManager, - required PlutoColumn column, - required bool mounted, - required PlutoGridColumnMenuItem? selected, - }) { - switch (selected) { - case PlutoGridColumnMenuItem.unfreeze: - stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.none); - break; - case PlutoGridColumnMenuItem.freezeToStart: - stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.start); - break; - case PlutoGridColumnMenuItem.freezeToEnd: - stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.end); - break; - case PlutoGridColumnMenuItem.autoFit: - if (!mounted) return; - stateManager.autoFitColumn(context, column); - stateManager.notifyResizingListeners(); - break; - case PlutoGridColumnMenuItem.hideColumn: - stateManager.hideColumn(column, true); - break; - case PlutoGridColumnMenuItem.setColumns: - if (!mounted) return; - stateManager.showSetColumnsPopup(context); - break; - case PlutoGridColumnMenuItem.setFilter: - if (!mounted) return; - stateManager.showFilterPopup(context, calledColumn: column); - break; - case PlutoGridColumnMenuItem.resetFilter: - stateManager.setFilter(null); - break; - default: - break; - } - } -} - -/// Open the context menu on the right side of the column. -Future? showColumnMenu({ - required BuildContext context, - required Offset position, - required List> items, - Color backgroundColor = Colors.white, -}) { - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - - return showMenu( - context: context, - color: backgroundColor, - position: RelativeRect.fromLTRB( - position.dx, - position.dy, - position.dx + overlay.size.width, - position.dy + overlay.size.height, - ), - items: items, - useRootNavigator: true, - ); -} - -List> _getDefaultColumnMenuItems({ - required PlutoGridStateManager stateManager, - required PlutoColumn column, -}) { - final Color textColor = stateManager.style.cellTextStyle.color!; - - final Color disableTextColor = textColor.withValues(alpha: 0.5); - - final bool enoughFrozenColumnsWidth = stateManager.enoughFrozenColumnsWidth( - stateManager.maxWidth! - column.width, - ); - - final localeText = stateManager.localeText; - - return [ - if (column.frozen.isFrozen == true) - _buildMenuItem( - value: PlutoGridColumnMenuItem.unfreeze, - text: localeText.unfreezeColumn, - textColor: textColor, - ), - if (column.frozen.isFrozen != true) ...[ - _buildMenuItem( - value: PlutoGridColumnMenuItem.freezeToStart, - enabled: enoughFrozenColumnsWidth, - text: localeText.freezeColumnToStart, - textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, - ), - _buildMenuItem( - value: PlutoGridColumnMenuItem.freezeToEnd, - enabled: enoughFrozenColumnsWidth, - text: localeText.freezeColumnToEnd, - textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, - ), - ], - const PopupMenuDivider(), - _buildMenuItem( - value: PlutoGridColumnMenuItem.autoFit, - text: localeText.autoFitColumn, - textColor: textColor, - ), - if (column.enableHideColumnMenuItem == true) - _buildMenuItem( - value: PlutoGridColumnMenuItem.hideColumn, - text: localeText.hideColumn, - textColor: textColor, - enabled: stateManager.refColumns.length > 1, - ), - if (column.enableSetColumnsMenuItem == true) - _buildMenuItem( - value: PlutoGridColumnMenuItem.setColumns, - text: localeText.setColumns, - textColor: textColor, - ), - if (column.enableFilterMenuItem == true) ...[ - const PopupMenuDivider(), - _buildMenuItem( - value: PlutoGridColumnMenuItem.setFilter, - text: localeText.setFilter, - textColor: textColor, - ), - _buildMenuItem( - value: PlutoGridColumnMenuItem.resetFilter, - text: localeText.resetFilter, - textColor: textColor, - enabled: stateManager.hasFilter, - ), - ], - ]; -} - -PopupMenuItem _buildMenuItem({ - required String text, - required Color? textColor, - bool enabled = true, - PlutoGridColumnMenuItem? value, -}) { - return PopupMenuItem( - value: value, - height: 36, - enabled: enabled, - child: Text( - text, - style: TextStyle( - color: enabled ? textColor : textColor!.withValues(alpha: 0.5), - fontSize: 13, - ), - ), - ); -} - -/// Items in the context menu on the right side of the column -enum PlutoGridColumnMenuItem { - unfreeze, - freezeToStart, - freezeToEnd, - hideColumn, - setColumns, - autoFit, - setFilter, - resetFilter, -} +import 'package:flutter/material.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +abstract class PlutoColumnMenuDelegate { + List> buildMenuItems({ + required PlutoGridStateManager stateManager, + required PlutoColumn column, + }); + + void onSelected({ + required BuildContext context, + required PlutoGridStateManager stateManager, + required PlutoColumn column, + required bool mounted, + required T? selected, + }); +} + +class PlutoColumnMenuDelegateDefault + implements PlutoColumnMenuDelegate { + const PlutoColumnMenuDelegateDefault(); + + @override + List> buildMenuItems({ + required PlutoGridStateManager stateManager, + required PlutoColumn column, + }) { + return _getDefaultColumnMenuItems( + stateManager: stateManager, + column: column, + ); + } + + @override + void onSelected({ + required BuildContext context, + required PlutoGridStateManager stateManager, + required PlutoColumn column, + required bool mounted, + required PlutoGridColumnMenuItem? selected, + }) { + switch (selected) { + case PlutoGridColumnMenuItem.unfreeze: + stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.none); + break; + case PlutoGridColumnMenuItem.freezeToStart: + stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.start); + break; + case PlutoGridColumnMenuItem.freezeToEnd: + stateManager.toggleFrozenColumn(column, PlutoColumnFrozen.end); + break; + case PlutoGridColumnMenuItem.autoFit: + if (!mounted) return; + stateManager.autoFitColumn(context, column); + stateManager.notifyResizingListeners(); + break; + case PlutoGridColumnMenuItem.hideColumn: + stateManager.hideColumn(column, true); + break; + case PlutoGridColumnMenuItem.setColumns: + if (!mounted) return; + stateManager.showSetColumnsPopup(context); + break; + case PlutoGridColumnMenuItem.setFilter: + if (!mounted) return; + stateManager.showFilterPopup(context, calledColumn: column); + break; + case PlutoGridColumnMenuItem.resetFilter: + stateManager.setFilter(null); + break; + default: + break; + } + } +} + +/// Open the context menu on the right side of the column. +Future? showColumnMenu({ + required BuildContext context, + required Offset position, + required List> items, + Color backgroundColor = Colors.white, +}) { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + + return showMenu( + context: context, + color: backgroundColor, + position: RelativeRect.fromLTRB( + position.dx, + position.dy, + position.dx + overlay.size.width, + position.dy + overlay.size.height, + ), + items: items, + useRootNavigator: true, + ); +} + +List> _getDefaultColumnMenuItems({ + required PlutoGridStateManager stateManager, + required PlutoColumn column, +}) { + final Color textColor = stateManager.style.cellTextStyle.color!; + + final Color disableTextColor = textColor.withValues(alpha: 0.5); + + final bool enoughFrozenColumnsWidth = stateManager.enoughFrozenColumnsWidth( + stateManager.maxWidth! - column.width, + ); + + final localeText = stateManager.localeText; + + return [ + if (column.frozen.isFrozen == true) + _buildMenuItem( + value: PlutoGridColumnMenuItem.unfreeze, + text: localeText.unfreezeColumn, + textColor: textColor, + ), + if (column.frozen.isFrozen != true) ...[ + _buildMenuItem( + value: PlutoGridColumnMenuItem.freezeToStart, + enabled: enoughFrozenColumnsWidth, + text: localeText.freezeColumnToStart, + textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, + ), + _buildMenuItem( + value: PlutoGridColumnMenuItem.freezeToEnd, + enabled: enoughFrozenColumnsWidth, + text: localeText.freezeColumnToEnd, + textColor: enoughFrozenColumnsWidth ? textColor : disableTextColor, + ), + ], + const PopupMenuDivider(), + _buildMenuItem( + value: PlutoGridColumnMenuItem.autoFit, + text: localeText.autoFitColumn, + textColor: textColor, + ), + if (column.enableHideColumnMenuItem == true) + _buildMenuItem( + value: PlutoGridColumnMenuItem.hideColumn, + text: localeText.hideColumn, + textColor: textColor, + enabled: stateManager.refColumns.length > 1, + ), + if (column.enableSetColumnsMenuItem == true) + _buildMenuItem( + value: PlutoGridColumnMenuItem.setColumns, + text: localeText.setColumns, + textColor: textColor, + ), + if (column.enableFilterMenuItem == true) ...[ + const PopupMenuDivider(), + _buildMenuItem( + value: PlutoGridColumnMenuItem.setFilter, + text: localeText.setFilter, + textColor: textColor, + ), + _buildMenuItem( + value: PlutoGridColumnMenuItem.resetFilter, + text: localeText.resetFilter, + textColor: textColor, + enabled: stateManager.hasFilter, + ), + ], + ]; +} + +PopupMenuItem _buildMenuItem({ + required String text, + required Color? textColor, + bool enabled = true, + PlutoGridColumnMenuItem? value, +}) { + return PopupMenuItem( + value: value, + height: 36, + enabled: enabled, + child: Text( + text, + style: TextStyle( + color: enabled ? textColor : textColor!.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + ); +} + +/// Items in the context menu on the right side of the column +enum PlutoGridColumnMenuItem { + unfreeze, + freezeToStart, + freezeToEnd, + hideColumn, + setColumns, + autoFit, + setFilter, + resetFilter, +} diff --git a/lib/src/manager/event/pluto_grid_row_hover_event.dart b/lib/src/manager/event/pluto_grid_row_hover_event.dart index 4987d459..6f9abbd7 100644 --- a/lib/src/manager/event/pluto_grid_row_hover_event.dart +++ b/lib/src/manager/event/pluto_grid_row_hover_event.dart @@ -12,7 +12,8 @@ class PlutoGridRowHoverEvent extends PlutoGridEvent { @override void handler(PlutoGridStateManager stateManager) { - bool enableRowHoverColor = stateManager.configuration.style.enableRowHoverColor; + bool enableRowHoverColor = + stateManager.configuration.style.enableRowHoverColor; // only change current hovered row index // if row hover color effect is enabled diff --git a/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart b/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart index 38545b5e..92d59da8 100644 --- a/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart +++ b/lib/src/manager/shortcut/pluto_grid_shortcut_action.dart @@ -80,7 +80,8 @@ class PlutoGridActionMoveCellFocus extends PlutoGridShortcutAction { required PlutoKeyManagerEvent keyEvent, required PlutoGridStateManager stateManager, }) { - bool force = keyEvent.isHorizontal && stateManager.configuration.enableMoveHorizontalInEditing == true; + bool force = keyEvent.isHorizontal && + stateManager.configuration.enableMoveHorizontalInEditing == true; if (stateManager.currentCell == null) { stateManager.setCurrentCell(stateManager.firstCell, 0); @@ -137,7 +138,8 @@ class PlutoGridActionMoveCellFocusByPage extends PlutoGridShortcutAction { final previousPosition = stateManager.currentCellPosition; - int toPage = direction.isLeft ? stateManager.page - 1 : stateManager.page + 1; + int toPage = + direction.isLeft ? stateManager.page - 1 : stateManager.page + 1; if (toPage < 1) { toPage = 1; @@ -156,7 +158,9 @@ class PlutoGridActionMoveCellFocusByPage extends PlutoGridShortcutAction { break; case PlutoMoveDirection.up: case PlutoMoveDirection.down: - final int moveCount = (stateManager.rowContainerHeight / stateManager.rowTotalHeight).floor(); + final int moveCount = + (stateManager.rowContainerHeight / stateManager.rowTotalHeight) + .floor(); int rowIdx = stateManager.currentRowIdx!; @@ -195,7 +199,8 @@ class PlutoGridActionMoveCellFocusByPage extends PlutoGridShortcutAction { /// /// When [direction] is left or right, no action is taken. /// {@endtemplate} -class PlutoGridActionMoveSelectedCellFocusByPage extends PlutoGridShortcutAction { +class PlutoGridActionMoveSelectedCellFocusByPage + extends PlutoGridShortcutAction { const PlutoGridActionMoveSelectedCellFocusByPage(this.direction); final PlutoMoveDirection direction; @@ -207,9 +212,12 @@ class PlutoGridActionMoveSelectedCellFocusByPage extends PlutoGridShortcutAction }) { if (direction.horizontal) return; - final int moveCount = (stateManager.rowContainerHeight / stateManager.rowTotalHeight).floor(); + final int moveCount = + (stateManager.rowContainerHeight / stateManager.rowTotalHeight).floor(); - int rowIdx = stateManager.currentSelectingPosition?.rowIdx ?? stateManager.currentCellPosition?.rowIdx ?? 0; + int rowIdx = stateManager.currentSelectingPosition?.rowIdx ?? + stateManager.currentCellPosition?.rowIdx ?? + 0; rowIdx += direction.isUp ? -moveCount : moveCount; @@ -242,13 +250,16 @@ class PlutoGridActionDefaultTab extends PlutoGridShortcutAction { final saveIsEditing = stateManager.isEditing; - keyEvent.isShiftPressed ? _moveCellPrevious(stateManager) : _moveCellNext(stateManager); + keyEvent.isShiftPressed + ? _moveCellPrevious(stateManager) + : _moveCellNext(stateManager); stateManager.setEditing(stateManager.autoEditing || saveIsEditing); } void _moveCellPrevious(PlutoGridStateManager stateManager) { - if (_willMoveToPreviousRow(stateManager.currentCellPosition, stateManager)) { + if (_willMoveToPreviousRow( + stateManager.currentCellPosition, stateManager)) { _moveCellToPreviousRow(stateManager); } else { stateManager.moveCurrentCell(PlutoMoveDirection.left, force: true); @@ -267,7 +278,9 @@ class PlutoGridActionDefaultTab extends PlutoGridShortcutAction { PlutoGridCellPosition? position, PlutoGridStateManager stateManager, ) { - if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || position == null || !position.hasPosition) { + if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || + position == null || + !position.hasPosition) { return false; } @@ -278,11 +291,14 @@ class PlutoGridActionDefaultTab extends PlutoGridShortcutAction { PlutoGridCellPosition? position, PlutoGridStateManager stateManager, ) { - if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || position == null || !position.hasPosition) { + if (!stateManager.configuration.tabKeyAction.isMoveToNextOnEdge || + position == null || + !position.hasPosition) { return false; } - return position.rowIdx! < stateManager.refRows.length - 1 && position.columnIdx == stateManager.refColumns.length - 1; + return position.rowIdx! < stateManager.refRows.length - 1 && + position.columnIdx == stateManager.refColumns.length - 1; } void _moveCellToPreviousRow(PlutoGridStateManager stateManager) { @@ -335,7 +351,9 @@ class PlutoGridActionDefaultEnterKey extends PlutoGridShortcutAction { row: stateManager.currentRow, rowIdx: stateManager.currentRowIdx, cell: stateManager.currentCell, - selectedRows: stateManager.mode.isMultiSelectMode ? stateManager.currentSelectingRows : null, + selectedRows: stateManager.mode.isMultiSelectMode + ? stateManager.currentSelectingRows + : null, )); return; } @@ -352,7 +370,8 @@ class PlutoGridActionDefaultEnterKey extends PlutoGridShortcutAction { if (stateManager.configuration.enterKeyAction.isToggleEditing) { stateManager.toggleEditing(notify: false); } else { - if (stateManager.isEditing == true || stateManager.currentColumn?.enableEditingMode == false) { + if (stateManager.isEditing == true || + stateManager.currentColumn?.enableEditingMode == false) { final saveIsEditing = stateManager.isEditing; _moveCell(keyEvent, stateManager); @@ -371,7 +390,11 @@ class PlutoGridActionDefaultEnterKey extends PlutoGridShortcutAction { } bool _isExpandableCell(PlutoGridStateManager stateManager) { - return stateManager.currentCell != null && stateManager.enabledRowGroups && stateManager.rowGroupDelegate?.isExpandableCell(stateManager.currentCell!) == true; + return stateManager.currentCell != null && + stateManager.enabledRowGroups && + stateManager.rowGroupDelegate + ?.isExpandableCell(stateManager.currentCell!) == + true; } void _moveCell( @@ -431,7 +454,8 @@ class PlutoGridActionDefaultEscapeKey extends PlutoGridShortcutAction { required PlutoKeyManagerEvent keyEvent, required PlutoGridStateManager stateManager, }) { - if (stateManager.mode.isSelectMode || (stateManager.mode.isPopup && !stateManager.isEditing)) { + if (stateManager.mode.isSelectMode || + (stateManager.mode.isPopup && !stateManager.isEditing)) { if (stateManager.onSelected != null) { stateManager.clearCurrentSelecting(); stateManager.onSelected!(const PlutoGridOnSelectedEvent()); @@ -475,7 +499,8 @@ class PlutoGridActionMoveCellFocusToEdge extends PlutoGridShortcutAction { /// Moves the selected focus to the end of the [direction] direction /// in the cell or row selection state. /// {@endtemplate} -class PlutoGridActionMoveSelectedCellFocusToEdge extends PlutoGridShortcutAction { +class PlutoGridActionMoveSelectedCellFocusToEdge + extends PlutoGridShortcutAction { const PlutoGridActionMoveSelectedCellFocusToEdge(this.direction); final PlutoMoveDirection direction; @@ -574,7 +599,9 @@ class PlutoGridActionToggleColumnSort extends PlutoGridShortcutAction { PlutoGridCellPosition? previousPosition, bool ignore = false, }) { - if (ignore || currentColumn == null || previousPosition?.hasPosition != true) { + if (ignore || + currentColumn == null || + previousPosition?.hasPosition != true) { return; } @@ -635,7 +662,8 @@ class PlutoGridActionPasteValues extends PlutoGridShortcutAction { if (text == null) return; - List> textList = PlutoClipboardTransformation.stringToList(text); + List> textList = + PlutoClipboardTransformation.stringToList(text); stateManager.pasteCellValue(textList); }); diff --git a/lib/src/manager/state/hovering_state.dart b/lib/src/manager/state/hovering_state.dart index 59b30b64..21abfe00 100644 --- a/lib/src/manager/state/hovering_state.dart +++ b/lib/src/manager/state/hovering_state.dart @@ -1,13 +1,9 @@ import 'package:pluto_grid_plus/pluto_grid_plus.dart'; abstract class IHoveringState { - int? get hoveredRowIdx; - void setHoveredRowIdx( - int? rowIdx, - {bool notify = true} - ); + void setHoveredRowIdx(int? rowIdx, {bool notify = true}); bool isRowIdxHovered(int rowIdx); } @@ -24,9 +20,9 @@ mixin HoveringState implements IPlutoGridState { @override void setHoveredRowIdx( - int? rowIdx, - {bool notify = true,} - ) { + int? rowIdx, { + bool notify = true, + }) { if (hoveredRowIdx == rowIdx) { return; } diff --git a/lib/src/manager/state/selecting_state.dart b/lib/src/manager/state/selecting_state.dart index 0bf5d813..89a59f82 100644 --- a/lib/src/manager/state/selecting_state.dart +++ b/lib/src/manager/state/selecting_state.dart @@ -1,711 +1,711 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -abstract class ISelectingState { - /// Multi-selection state. - bool get isSelecting; - - /// [selectingMode] - PlutoGridSelectingMode get selectingMode; - - /// Current position of multi-select cell. - /// Calculate the currently selected cell and its multi-selection range. - PlutoGridCellPosition? get currentSelectingPosition; - - /// Position list of currently selected. - /// Only valid in [PlutoGridSelectingMode.cell]. - /// - /// ```dart - /// stateManager.currentSelectingPositionList.forEach((element) { - /// final cellValue = stateManager.rows[element.rowIdx].cells[element.field].value; - /// }); - /// ``` - List get currentSelectingPositionList; - - bool get hasCurrentSelectingPosition; - - /// Rows of currently selected. - /// Only valid in [PlutoGridSelectingMode.row]. - List get currentSelectingRows; - - /// String of multi-selected cells. - /// Preserves the structure of the cells selected by the tabs and the enter key. - String get currentSelectingText; - - /// Change Multi-Select Status. - void setSelecting(bool flag, {bool notify = true}); - - /// Set the mode to select cells or rows. - /// - /// If [PlutoGrid.mode] is [PlutoGridMode.select] or [PlutoGridMode.selectWithOneTap] - /// Coerced to [PlutoGridSelectingMode.none] regardless of [selectingMode] value. - /// - /// When [PlutoGrid.mode] is [PlutoGridMode.multiSelect] - /// Coerced to [PlutoGridSelectingMode.row] regardless of [selectingMode] value. - void setSelectingMode( - PlutoGridSelectingMode selectingMode, { - bool notify = true, - }); - - void setAllCurrentSelecting(); - - /// Sets the position of a multi-selected cell. - void setCurrentSelectingPosition({ - PlutoGridCellPosition? cellPosition, - bool notify = true, - }); - - void setCurrentSelectingPositionByCellKey( - Key? cellKey, { - bool notify = true, - }); - - /// Sets the position of a multi-selected cell. - void setCurrentSelectingPositionWithOffset(Offset offset); - - /// Sets the currentSelectingRows by range. - /// [from] rowIdx of rows. - /// [to] rowIdx of rows. - void setCurrentSelectingRowsByRange(int from, int to, {bool notify = true}); - - /// Resets currently selected rows and cells. - void clearCurrentSelecting({bool notify = true}); - - /// Select or unselect a row. - void toggleSelectingRow(int rowIdx, {bool notify = true}); - - bool isSelectingInteraction(); - - bool isSelectedRow(Key rowKey); - - /// Whether the cell is the currently multi selected cell. - bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx); - - /// The action that is selected in the Select dialog - /// and processed after the dialog is closed. - void handleAfterSelectingRow(PlutoCell cell, dynamic value); -} - -class _State { - bool _isSelecting = false; - - PlutoGridSelectingMode _selectingMode = PlutoGridSelectingMode.cell; - - List _currentSelectingRows = []; - - PlutoGridCellPosition? _currentSelectingPosition; -} - -mixin SelectingState implements IPlutoGridState { - final _State _state = _State(); - - @override - bool get isSelecting => _state._isSelecting; - - @override - PlutoGridSelectingMode get selectingMode => _state._selectingMode; - - @override - PlutoGridCellPosition? get currentSelectingPosition => - _state._currentSelectingPosition; - - @override - List get currentSelectingPositionList { - if (currentCellPosition == null || currentSelectingPosition == null) { - return []; - } - - switch (selectingMode) { - case PlutoGridSelectingMode.cell: - return _selectingCells(); - case PlutoGridSelectingMode.horizontal: - return _selectingCellsHorizontally(); - case PlutoGridSelectingMode.row: - case PlutoGridSelectingMode.none: - return []; - } - } - - @override - bool get hasCurrentSelectingPosition => currentSelectingPosition != null; - - @override - List get currentSelectingRows => _state._currentSelectingRows; - - @override - String get currentSelectingText { - final bool fromSelectingRows = - selectingMode.isRow && currentSelectingRows.isNotEmpty; - - final bool fromSelectingPosition = - currentCellPosition != null && currentSelectingPosition != null; - - final bool fromCurrentCell = currentCellPosition != null; - - if (fromSelectingRows) { - return _selectingTextFromSelectingRows(); - } else if (fromSelectingPosition) { - return _selectingTextFromSelectingPosition(); - } else if (fromCurrentCell) { - return _selectingTextFromCurrentCell(); - } - - return ''; - } - - @override - void setSelecting(bool flag, {bool notify = true}) { - if (selectingMode.isNone) { - return; - } - - if (currentCell == null || isSelecting == flag) { - return; - } - - _state._isSelecting = flag; - - if (isEditing == true) { - setEditing(false, notify: false); - } - - // Invalidates the previously selected row. - if (isSelecting) { - clearCurrentSelecting(notify: false); - } - - notifyListeners(notify, setSelecting.hashCode); - } - - @override - void setSelectingMode( - PlutoGridSelectingMode selectingMode, { - bool notify = true, - }) { - if (mode.isSingleSelectMode) { - selectingMode = PlutoGridSelectingMode.none; - } else if (mode.isMultiSelectMode) { - selectingMode = PlutoGridSelectingMode.row; - } - - if (_state._selectingMode == selectingMode) { - return; - } - - _state._currentSelectingRows = []; - - _state._currentSelectingPosition = null; - - _state._selectingMode = selectingMode; - - notifyListeners(notify, setSelectingMode.hashCode); - } - - @override - void setAllCurrentSelecting() { - if (refRows.isEmpty) { - return; - } - - switch (selectingMode) { - case PlutoGridSelectingMode.cell: - case PlutoGridSelectingMode.horizontal: - _setFistCellAsCurrent(); - - setCurrentSelectingPosition( - cellPosition: PlutoGridCellPosition( - columnIdx: refColumns.length - 1, - rowIdx: refRows.length - 1, - ), - ); - break; - case PlutoGridSelectingMode.row: - if (currentCell == null) { - _setFistCellAsCurrent(); - } - - _state._currentSelectingPosition = PlutoGridCellPosition( - columnIdx: refColumns.length - 1, - rowIdx: refRows.length - 1, - ); - - setCurrentSelectingRowsByRange(0, refRows.length - 1); - break; - case PlutoGridSelectingMode.none: - break; - } - } - - @override - void setCurrentSelectingPosition({ - PlutoGridCellPosition? cellPosition, - bool notify = true, - }) { - if (selectingMode.isNone) { - return; - } - - if (currentSelectingPosition == cellPosition) { - return; - } - - _state._currentSelectingPosition = - isInvalidCellPosition(cellPosition) ? null : cellPosition; - - if (currentSelectingPosition != null && selectingMode.isRow) { - setCurrentSelectingRowsByRange( - currentRowIdx, - currentSelectingPosition!.rowIdx, - notify: false, - ); - } - - notifyListeners(notify, setCurrentSelectingPosition.hashCode); - } - - @override - void setCurrentSelectingPositionByCellKey( - Key? cellKey, { - bool notify = true, - }) { - if (cellKey == null) { - return; - } - - setCurrentSelectingPosition( - cellPosition: cellPositionByCellKey(cellKey), - notify: notify, - ); - } - - @override - void setCurrentSelectingPositionWithOffset(Offset? offset) { - if (currentCell == null) { - return; - } - - final double gridBodyOffsetDy = gridGlobalOffset!.dy + - gridBorderWidth + - headerHeight + - columnGroupHeight + - columnHeight + - columnFilterHeight; - - double currentCellOffsetDy = (currentRowIdx! * rowTotalHeight) + - gridBodyOffsetDy - - scroll.vertical!.offset; - - if (gridBodyOffsetDy > offset!.dy) { - return; - } - - int rowIdx = (((currentCellOffsetDy - offset.dy) / rowTotalHeight).ceil() - - currentRowIdx!) - .abs(); - - int? columnIdx; - - final directionalOffset = toDirectionalOffset(offset); - double currentWidth = isLTR ? gridGlobalOffset!.dx : 0.0; - - final columnIndexes = columnIndexesByShowFrozen; - - final savedRightBlankOffset = rightBlankOffset; - final savedHorizontalScrollOffset = scroll.horizontal!.offset; - - for (int i = 0; i < columnIndexes.length; i += 1) { - final column = refColumns[columnIndexes[i]]; - - currentWidth += column.width; - - final rightFrozenColumnOffset = - column.frozen.isEnd && showFrozenColumn ? savedRightBlankOffset : 0; - - if (currentWidth + rightFrozenColumnOffset > - directionalOffset.dx + savedHorizontalScrollOffset) { - columnIdx = i; - break; - } - } - - if (columnIdx == null) { - return; - } - - setCurrentSelectingPosition( - cellPosition: PlutoGridCellPosition( - columnIdx: columnIdx, - rowIdx: rowIdx, - ), - ); - } - - @override - void setCurrentSelectingRowsByRange(int? from, int? to, - {bool notify = true}) { - if (!selectingMode.isRow) { - return; - } - - final maxFrom = min(from!, to!); - - final maxTo = max(from, to) + 1; - - if (maxFrom < 0 || maxTo > refRows.length) { - return; - } - - _state._currentSelectingRows = refRows.getRange(maxFrom, maxTo).toList(); - - notifyListeners(notify, setCurrentSelectingRowsByRange.hashCode); - } - - @override - void clearCurrentSelecting({bool notify = true}) { - _clearCurrentSelectingPosition(notify: false); - - _clearCurrentSelectingRows(notify: false); - - notifyListeners(notify, clearCurrentSelecting.hashCode); - } - - @override - void toggleSelectingRow(int? rowIdx, {notify = true}) { - if (!selectingMode.isRow) { - return; - } - - if (rowIdx == null || rowIdx < 0 || rowIdx > refRows.length - 1) { - return; - } - - final PlutoRow row = refRows[rowIdx]; - - final keys = Set.from(currentSelectingRows.map((e) => e.key)); - - if (keys.contains(row.key)) { - currentSelectingRows.removeWhere((element) => element.key == row.key); - } else { - currentSelectingRows.add(row); - } - - notifyListeners(notify, toggleSelectingRow.hashCode); - } - - @override - bool isSelectingInteraction() { - return !selectingMode.isNone && - (keyPressed.shift || keyPressed.ctrl) && - currentCell != null; - } - - @override - bool isSelectedRow(Key? rowKey) { - if (rowKey == null || - !selectingMode.isRow || - currentSelectingRows.isEmpty) { - return false; - } - - return currentSelectingRows.firstWhereOrNull( - (element) => element.key == rowKey, - ) != - null; - } - - // todo : code cleanup - @override - bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx) { - if (selectingMode.isNone) { - return false; - } - - if (currentCellPosition == null) { - return false; - } - - if (currentSelectingPosition == null) { - return false; - } - - if (selectingMode.isCell) { - final bool inRangeOfRows = min( - currentCellPosition!.rowIdx as num, - currentSelectingPosition!.rowIdx as num, - ) <= - rowIdx && - rowIdx <= - max( - currentCellPosition!.rowIdx!, - currentSelectingPosition!.rowIdx!, - ); - - if (inRangeOfRows == false) { - return false; - } - - final int? columnIdx = columnIndex(column); - - if (columnIdx == null) { - return false; - } - - final bool inRangeOfColumns = min( - currentCellPosition!.columnIdx as num, - currentSelectingPosition!.columnIdx as num, - ) <= - columnIdx && - columnIdx <= - max( - currentCellPosition!.columnIdx!, - currentSelectingPosition!.columnIdx!, - ); - - if (inRangeOfColumns == false) { - return false; - } - - return true; - } else if (selectingMode.isHorizontal) { - int startRowIdx = min( - currentCellPosition!.rowIdx!, - currentSelectingPosition!.rowIdx!, - ); - - int endRowIdx = max( - currentCellPosition!.rowIdx!, - currentSelectingPosition!.rowIdx!, - ); - - final int? columnIdx = columnIndex(column); - - if (columnIdx == null) { - return false; - } - - int? startColumnIdx; - - int? endColumnIdx; - - if (currentCellPosition!.rowIdx! < currentSelectingPosition!.rowIdx!) { - startColumnIdx = currentCellPosition!.columnIdx; - endColumnIdx = currentSelectingPosition!.columnIdx; - } else if (currentCellPosition!.rowIdx! > - currentSelectingPosition!.rowIdx!) { - startColumnIdx = currentSelectingPosition!.columnIdx; - endColumnIdx = currentCellPosition!.columnIdx; - } else { - startColumnIdx = min( - currentCellPosition!.columnIdx!, - currentSelectingPosition!.columnIdx!, - ); - endColumnIdx = max( - currentCellPosition!.columnIdx!, - currentSelectingPosition!.columnIdx!, - ); - } - - if (rowIdx == startRowIdx && startRowIdx == endRowIdx) { - return !(columnIdx < startColumnIdx! || columnIdx > endColumnIdx!); - } else if (rowIdx == startRowIdx && columnIdx >= startColumnIdx!) { - return true; - } else if (rowIdx == endRowIdx && columnIdx <= endColumnIdx!) { - return true; - } else if (rowIdx > startRowIdx && rowIdx < endRowIdx) { - return true; - } - - return false; - } else if (selectingMode.isRow) { - return false; - } else { - throw Exception('selectingMode is not handled'); - } - } - - @override - void handleAfterSelectingRow(PlutoCell cell, dynamic value) { - changeCellValue(cell, value, notify: false); - - if (configuration.enableMoveDownAfterSelecting) { - moveCurrentCell(PlutoMoveDirection.down, notify: false); - - setEditing(true, notify: false); - } - - setKeepFocus(true, notify: false); - - notifyListeners(true, handleAfterSelectingRow.hashCode); - } - - List _selectingCells() { - final List positions = []; - - final columnIndexes = columnIndexesByShowFrozen; - - int columnStartIdx = min( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int columnEndIdx = max( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int rowStartIdx = - min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - int rowEndIdx = - max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { - for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { - final String field = refColumns[columnIndexes[j]].field; - - positions.add(PlutoGridSelectingCellPosition( - rowIdx: i, - field: field, - )); - } - } - - return positions; - } - - List _selectingCellsHorizontally() { - final List positions = []; - - final columnIndexes = columnIndexesByShowFrozen; - - final bool firstCurrent = currentCellPosition!.rowIdx! < - currentSelectingPosition!.rowIdx! || - (currentCellPosition!.rowIdx! == currentSelectingPosition!.rowIdx! && - currentCellPosition!.columnIdx! <= - currentSelectingPosition!.columnIdx!); - - PlutoGridCellPosition startCell = - firstCurrent ? currentCellPosition! : currentSelectingPosition!; - - PlutoGridCellPosition endCell = - !firstCurrent ? currentCellPosition! : currentSelectingPosition!; - - int columnStartIdx = startCell.columnIdx!; - - int columnEndIdx = endCell.columnIdx!; - - int rowStartIdx = startCell.rowIdx!; - - int rowEndIdx = endCell.rowIdx!; - - final length = columnIndexes.length; - - for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { - for (int j = 0; j < length; j += 1) { - if (i == rowStartIdx && j < columnStartIdx) { - continue; - } - - final String field = refColumns[columnIndexes[j]].field; - - positions.add(PlutoGridSelectingCellPosition( - rowIdx: i, - field: field, - )); - - if (i == rowEndIdx && j == columnEndIdx) { - break; - } - } - } - - return positions; - } - - String _selectingTextFromSelectingRows() { - final columnIndexes = columnIndexesByShowFrozen; - - List rowText = []; - - for (final row in currentSelectingRows) { - List columnText = []; - - for (int i = 0; i < columnIndexes.length; i += 1) { - final String field = refColumns[columnIndexes[i]].field; - - columnText.add(row.cells[field]!.value.toString()); - } - - rowText.add(columnText.join('\t')); - } - - return rowText.join('\n'); - } - - String _selectingTextFromSelectingPosition() { - final columnIndexes = columnIndexesByShowFrozen; - - List rowText = []; - - int columnStartIdx = min( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int columnEndIdx = max( - currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); - - int rowStartIdx = - min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - int rowEndIdx = - max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); - - for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { - List columnText = []; - - for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { - final String field = refColumns[columnIndexes[j]].field; - - columnText.add(refRows[i].cells[field]!.value.toString()); - } - - rowText.add(columnText.join('\t')); - } - - return rowText.join('\n'); - } - - String _selectingTextFromCurrentCell() { - return currentCell!.value.toString(); - } - - void _setFistCellAsCurrent() { - setCurrentCell(firstCell, 0, notify: false); - - if (isEditing == true) { - setEditing(false, notify: false); - } - } - - void _clearCurrentSelectingPosition({bool notify = true}) { - if (currentSelectingPosition == null) { - return; - } - - _state._currentSelectingPosition = null; - - if (notify) { - notifyListeners(); - } - } - - void _clearCurrentSelectingRows({bool notify = true}) { - if (currentSelectingRows.isEmpty) { - return; - } - - _state._currentSelectingRows = []; - - if (notify) { - notifyListeners(); - } - } -} +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +abstract class ISelectingState { + /// Multi-selection state. + bool get isSelecting; + + /// [selectingMode] + PlutoGridSelectingMode get selectingMode; + + /// Current position of multi-select cell. + /// Calculate the currently selected cell and its multi-selection range. + PlutoGridCellPosition? get currentSelectingPosition; + + /// Position list of currently selected. + /// Only valid in [PlutoGridSelectingMode.cell]. + /// + /// ```dart + /// stateManager.currentSelectingPositionList.forEach((element) { + /// final cellValue = stateManager.rows[element.rowIdx].cells[element.field].value; + /// }); + /// ``` + List get currentSelectingPositionList; + + bool get hasCurrentSelectingPosition; + + /// Rows of currently selected. + /// Only valid in [PlutoGridSelectingMode.row]. + List get currentSelectingRows; + + /// String of multi-selected cells. + /// Preserves the structure of the cells selected by the tabs and the enter key. + String get currentSelectingText; + + /// Change Multi-Select Status. + void setSelecting(bool flag, {bool notify = true}); + + /// Set the mode to select cells or rows. + /// + /// If [PlutoGrid.mode] is [PlutoGridMode.select] or [PlutoGridMode.selectWithOneTap] + /// Coerced to [PlutoGridSelectingMode.none] regardless of [selectingMode] value. + /// + /// When [PlutoGrid.mode] is [PlutoGridMode.multiSelect] + /// Coerced to [PlutoGridSelectingMode.row] regardless of [selectingMode] value. + void setSelectingMode( + PlutoGridSelectingMode selectingMode, { + bool notify = true, + }); + + void setAllCurrentSelecting(); + + /// Sets the position of a multi-selected cell. + void setCurrentSelectingPosition({ + PlutoGridCellPosition? cellPosition, + bool notify = true, + }); + + void setCurrentSelectingPositionByCellKey( + Key? cellKey, { + bool notify = true, + }); + + /// Sets the position of a multi-selected cell. + void setCurrentSelectingPositionWithOffset(Offset offset); + + /// Sets the currentSelectingRows by range. + /// [from] rowIdx of rows. + /// [to] rowIdx of rows. + void setCurrentSelectingRowsByRange(int from, int to, {bool notify = true}); + + /// Resets currently selected rows and cells. + void clearCurrentSelecting({bool notify = true}); + + /// Select or unselect a row. + void toggleSelectingRow(int rowIdx, {bool notify = true}); + + bool isSelectingInteraction(); + + bool isSelectedRow(Key rowKey); + + /// Whether the cell is the currently multi selected cell. + bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx); + + /// The action that is selected in the Select dialog + /// and processed after the dialog is closed. + void handleAfterSelectingRow(PlutoCell cell, dynamic value); +} + +class _State { + bool _isSelecting = false; + + PlutoGridSelectingMode _selectingMode = PlutoGridSelectingMode.cell; + + List _currentSelectingRows = []; + + PlutoGridCellPosition? _currentSelectingPosition; +} + +mixin SelectingState implements IPlutoGridState { + final _State _state = _State(); + + @override + bool get isSelecting => _state._isSelecting; + + @override + PlutoGridSelectingMode get selectingMode => _state._selectingMode; + + @override + PlutoGridCellPosition? get currentSelectingPosition => + _state._currentSelectingPosition; + + @override + List get currentSelectingPositionList { + if (currentCellPosition == null || currentSelectingPosition == null) { + return []; + } + + switch (selectingMode) { + case PlutoGridSelectingMode.cell: + return _selectingCells(); + case PlutoGridSelectingMode.horizontal: + return _selectingCellsHorizontally(); + case PlutoGridSelectingMode.row: + case PlutoGridSelectingMode.none: + return []; + } + } + + @override + bool get hasCurrentSelectingPosition => currentSelectingPosition != null; + + @override + List get currentSelectingRows => _state._currentSelectingRows; + + @override + String get currentSelectingText { + final bool fromSelectingRows = + selectingMode.isRow && currentSelectingRows.isNotEmpty; + + final bool fromSelectingPosition = + currentCellPosition != null && currentSelectingPosition != null; + + final bool fromCurrentCell = currentCellPosition != null; + + if (fromSelectingRows) { + return _selectingTextFromSelectingRows(); + } else if (fromSelectingPosition) { + return _selectingTextFromSelectingPosition(); + } else if (fromCurrentCell) { + return _selectingTextFromCurrentCell(); + } + + return ''; + } + + @override + void setSelecting(bool flag, {bool notify = true}) { + if (selectingMode.isNone) { + return; + } + + if (currentCell == null || isSelecting == flag) { + return; + } + + _state._isSelecting = flag; + + if (isEditing == true) { + setEditing(false, notify: false); + } + + // Invalidates the previously selected row. + if (isSelecting) { + clearCurrentSelecting(notify: false); + } + + notifyListeners(notify, setSelecting.hashCode); + } + + @override + void setSelectingMode( + PlutoGridSelectingMode selectingMode, { + bool notify = true, + }) { + if (mode.isSingleSelectMode) { + selectingMode = PlutoGridSelectingMode.none; + } else if (mode.isMultiSelectMode) { + selectingMode = PlutoGridSelectingMode.row; + } + + if (_state._selectingMode == selectingMode) { + return; + } + + _state._currentSelectingRows = []; + + _state._currentSelectingPosition = null; + + _state._selectingMode = selectingMode; + + notifyListeners(notify, setSelectingMode.hashCode); + } + + @override + void setAllCurrentSelecting() { + if (refRows.isEmpty) { + return; + } + + switch (selectingMode) { + case PlutoGridSelectingMode.cell: + case PlutoGridSelectingMode.horizontal: + _setFistCellAsCurrent(); + + setCurrentSelectingPosition( + cellPosition: PlutoGridCellPosition( + columnIdx: refColumns.length - 1, + rowIdx: refRows.length - 1, + ), + ); + break; + case PlutoGridSelectingMode.row: + if (currentCell == null) { + _setFistCellAsCurrent(); + } + + _state._currentSelectingPosition = PlutoGridCellPosition( + columnIdx: refColumns.length - 1, + rowIdx: refRows.length - 1, + ); + + setCurrentSelectingRowsByRange(0, refRows.length - 1); + break; + case PlutoGridSelectingMode.none: + break; + } + } + + @override + void setCurrentSelectingPosition({ + PlutoGridCellPosition? cellPosition, + bool notify = true, + }) { + if (selectingMode.isNone) { + return; + } + + if (currentSelectingPosition == cellPosition) { + return; + } + + _state._currentSelectingPosition = + isInvalidCellPosition(cellPosition) ? null : cellPosition; + + if (currentSelectingPosition != null && selectingMode.isRow) { + setCurrentSelectingRowsByRange( + currentRowIdx, + currentSelectingPosition!.rowIdx, + notify: false, + ); + } + + notifyListeners(notify, setCurrentSelectingPosition.hashCode); + } + + @override + void setCurrentSelectingPositionByCellKey( + Key? cellKey, { + bool notify = true, + }) { + if (cellKey == null) { + return; + } + + setCurrentSelectingPosition( + cellPosition: cellPositionByCellKey(cellKey), + notify: notify, + ); + } + + @override + void setCurrentSelectingPositionWithOffset(Offset? offset) { + if (currentCell == null) { + return; + } + + final double gridBodyOffsetDy = gridGlobalOffset!.dy + + gridBorderWidth + + headerHeight + + columnGroupHeight + + columnHeight + + columnFilterHeight; + + double currentCellOffsetDy = (currentRowIdx! * rowTotalHeight) + + gridBodyOffsetDy - + scroll.vertical!.offset; + + if (gridBodyOffsetDy > offset!.dy) { + return; + } + + int rowIdx = (((currentCellOffsetDy - offset.dy) / rowTotalHeight).ceil() - + currentRowIdx!) + .abs(); + + int? columnIdx; + + final directionalOffset = toDirectionalOffset(offset); + double currentWidth = isLTR ? gridGlobalOffset!.dx : 0.0; + + final columnIndexes = columnIndexesByShowFrozen; + + final savedRightBlankOffset = rightBlankOffset; + final savedHorizontalScrollOffset = scroll.horizontal!.offset; + + for (int i = 0; i < columnIndexes.length; i += 1) { + final column = refColumns[columnIndexes[i]]; + + currentWidth += column.width; + + final rightFrozenColumnOffset = + column.frozen.isEnd && showFrozenColumn ? savedRightBlankOffset : 0; + + if (currentWidth + rightFrozenColumnOffset > + directionalOffset.dx + savedHorizontalScrollOffset) { + columnIdx = i; + break; + } + } + + if (columnIdx == null) { + return; + } + + setCurrentSelectingPosition( + cellPosition: PlutoGridCellPosition( + columnIdx: columnIdx, + rowIdx: rowIdx, + ), + ); + } + + @override + void setCurrentSelectingRowsByRange(int? from, int? to, + {bool notify = true}) { + if (!selectingMode.isRow) { + return; + } + + final maxFrom = min(from!, to!); + + final maxTo = max(from, to) + 1; + + if (maxFrom < 0 || maxTo > refRows.length) { + return; + } + + _state._currentSelectingRows = refRows.getRange(maxFrom, maxTo).toList(); + + notifyListeners(notify, setCurrentSelectingRowsByRange.hashCode); + } + + @override + void clearCurrentSelecting({bool notify = true}) { + _clearCurrentSelectingPosition(notify: false); + + _clearCurrentSelectingRows(notify: false); + + notifyListeners(notify, clearCurrentSelecting.hashCode); + } + + @override + void toggleSelectingRow(int? rowIdx, {notify = true}) { + if (!selectingMode.isRow) { + return; + } + + if (rowIdx == null || rowIdx < 0 || rowIdx > refRows.length - 1) { + return; + } + + final PlutoRow row = refRows[rowIdx]; + + final keys = Set.from(currentSelectingRows.map((e) => e.key)); + + if (keys.contains(row.key)) { + currentSelectingRows.removeWhere((element) => element.key == row.key); + } else { + currentSelectingRows.add(row); + } + + notifyListeners(notify, toggleSelectingRow.hashCode); + } + + @override + bool isSelectingInteraction() { + return !selectingMode.isNone && + (keyPressed.shift || keyPressed.ctrl) && + currentCell != null; + } + + @override + bool isSelectedRow(Key? rowKey) { + if (rowKey == null || + !selectingMode.isRow || + currentSelectingRows.isEmpty) { + return false; + } + + return currentSelectingRows.firstWhereOrNull( + (element) => element.key == rowKey, + ) != + null; + } + + // todo : code cleanup + @override + bool isSelectedCell(PlutoCell cell, PlutoColumn column, int rowIdx) { + if (selectingMode.isNone) { + return false; + } + + if (currentCellPosition == null) { + return false; + } + + if (currentSelectingPosition == null) { + return false; + } + + if (selectingMode.isCell) { + final bool inRangeOfRows = min( + currentCellPosition!.rowIdx as num, + currentSelectingPosition!.rowIdx as num, + ) <= + rowIdx && + rowIdx <= + max( + currentCellPosition!.rowIdx!, + currentSelectingPosition!.rowIdx!, + ); + + if (inRangeOfRows == false) { + return false; + } + + final int? columnIdx = columnIndex(column); + + if (columnIdx == null) { + return false; + } + + final bool inRangeOfColumns = min( + currentCellPosition!.columnIdx as num, + currentSelectingPosition!.columnIdx as num, + ) <= + columnIdx && + columnIdx <= + max( + currentCellPosition!.columnIdx!, + currentSelectingPosition!.columnIdx!, + ); + + if (inRangeOfColumns == false) { + return false; + } + + return true; + } else if (selectingMode.isHorizontal) { + int startRowIdx = min( + currentCellPosition!.rowIdx!, + currentSelectingPosition!.rowIdx!, + ); + + int endRowIdx = max( + currentCellPosition!.rowIdx!, + currentSelectingPosition!.rowIdx!, + ); + + final int? columnIdx = columnIndex(column); + + if (columnIdx == null) { + return false; + } + + int? startColumnIdx; + + int? endColumnIdx; + + if (currentCellPosition!.rowIdx! < currentSelectingPosition!.rowIdx!) { + startColumnIdx = currentCellPosition!.columnIdx; + endColumnIdx = currentSelectingPosition!.columnIdx; + } else if (currentCellPosition!.rowIdx! > + currentSelectingPosition!.rowIdx!) { + startColumnIdx = currentSelectingPosition!.columnIdx; + endColumnIdx = currentCellPosition!.columnIdx; + } else { + startColumnIdx = min( + currentCellPosition!.columnIdx!, + currentSelectingPosition!.columnIdx!, + ); + endColumnIdx = max( + currentCellPosition!.columnIdx!, + currentSelectingPosition!.columnIdx!, + ); + } + + if (rowIdx == startRowIdx && startRowIdx == endRowIdx) { + return !(columnIdx < startColumnIdx! || columnIdx > endColumnIdx!); + } else if (rowIdx == startRowIdx && columnIdx >= startColumnIdx!) { + return true; + } else if (rowIdx == endRowIdx && columnIdx <= endColumnIdx!) { + return true; + } else if (rowIdx > startRowIdx && rowIdx < endRowIdx) { + return true; + } + + return false; + } else if (selectingMode.isRow) { + return false; + } else { + throw Exception('selectingMode is not handled'); + } + } + + @override + void handleAfterSelectingRow(PlutoCell cell, dynamic value) { + changeCellValue(cell, value, notify: false); + + if (configuration.enableMoveDownAfterSelecting) { + moveCurrentCell(PlutoMoveDirection.down, notify: false); + + setEditing(true, notify: false); + } + + setKeepFocus(true, notify: false); + + notifyListeners(true, handleAfterSelectingRow.hashCode); + } + + List _selectingCells() { + final List positions = []; + + final columnIndexes = columnIndexesByShowFrozen; + + int columnStartIdx = min( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int columnEndIdx = max( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int rowStartIdx = + min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + int rowEndIdx = + max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { + for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { + final String field = refColumns[columnIndexes[j]].field; + + positions.add(PlutoGridSelectingCellPosition( + rowIdx: i, + field: field, + )); + } + } + + return positions; + } + + List _selectingCellsHorizontally() { + final List positions = []; + + final columnIndexes = columnIndexesByShowFrozen; + + final bool firstCurrent = currentCellPosition!.rowIdx! < + currentSelectingPosition!.rowIdx! || + (currentCellPosition!.rowIdx! == currentSelectingPosition!.rowIdx! && + currentCellPosition!.columnIdx! <= + currentSelectingPosition!.columnIdx!); + + PlutoGridCellPosition startCell = + firstCurrent ? currentCellPosition! : currentSelectingPosition!; + + PlutoGridCellPosition endCell = + !firstCurrent ? currentCellPosition! : currentSelectingPosition!; + + int columnStartIdx = startCell.columnIdx!; + + int columnEndIdx = endCell.columnIdx!; + + int rowStartIdx = startCell.rowIdx!; + + int rowEndIdx = endCell.rowIdx!; + + final length = columnIndexes.length; + + for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { + for (int j = 0; j < length; j += 1) { + if (i == rowStartIdx && j < columnStartIdx) { + continue; + } + + final String field = refColumns[columnIndexes[j]].field; + + positions.add(PlutoGridSelectingCellPosition( + rowIdx: i, + field: field, + )); + + if (i == rowEndIdx && j == columnEndIdx) { + break; + } + } + } + + return positions; + } + + String _selectingTextFromSelectingRows() { + final columnIndexes = columnIndexesByShowFrozen; + + List rowText = []; + + for (final row in currentSelectingRows) { + List columnText = []; + + for (int i = 0; i < columnIndexes.length; i += 1) { + final String field = refColumns[columnIndexes[i]].field; + + columnText.add(row.cells[field]!.value.toString()); + } + + rowText.add(columnText.join('\t')); + } + + return rowText.join('\n'); + } + + String _selectingTextFromSelectingPosition() { + final columnIndexes = columnIndexesByShowFrozen; + + List rowText = []; + + int columnStartIdx = min( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int columnEndIdx = max( + currentCellPosition!.columnIdx!, currentSelectingPosition!.columnIdx!); + + int rowStartIdx = + min(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + int rowEndIdx = + max(currentCellPosition!.rowIdx!, currentSelectingPosition!.rowIdx!); + + for (int i = rowStartIdx; i <= rowEndIdx; i += 1) { + List columnText = []; + + for (int j = columnStartIdx; j <= columnEndIdx; j += 1) { + final String field = refColumns[columnIndexes[j]].field; + + columnText.add(refRows[i].cells[field]!.value.toString()); + } + + rowText.add(columnText.join('\t')); + } + + return rowText.join('\n'); + } + + String _selectingTextFromCurrentCell() { + return currentCell!.value.toString(); + } + + void _setFistCellAsCurrent() { + setCurrentCell(firstCell, 0, notify: false); + + if (isEditing == true) { + setEditing(false, notify: false); + } + } + + void _clearCurrentSelectingPosition({bool notify = true}) { + if (currentSelectingPosition == null) { + return; + } + + _state._currentSelectingPosition = null; + + if (notify) { + notifyListeners(); + } + } + + void _clearCurrentSelectingRows({bool notify = true}) { + if (currentSelectingRows.isEmpty) { + return; + } + + _state._currentSelectingRows = []; + + if (notify) { + notifyListeners(); + } + } +} diff --git a/lib/src/plugin/pluto_infinity_scroll_rows.dart b/lib/src/plugin/pluto_infinity_scroll_rows.dart index 756d0232..13f34058 100644 --- a/lib/src/plugin/pluto_infinity_scroll_rows.dart +++ b/lib/src/plugin/pluto_infinity_scroll_rows.dart @@ -203,7 +203,8 @@ class _PlutoInfinityScrollRowsState extends State { if (!_isLast) { WidgetsBinding.instance.addPostFrameCallback((_) { if (scroll.hasClients && scroll.position.maxScrollExtent == 0) { - var lastRow = stateManager.rows.isNotEmpty ? stateManager.rows.last : null; + var lastRow = + stateManager.rows.isNotEmpty ? stateManager.rows.last : null; _update(lastRow); } }); diff --git a/lib/src/plugin/pluto_lazy_pagination.dart b/lib/src/plugin/pluto_lazy_pagination.dart index 6126c8cc..4dd915d0 100644 --- a/lib/src/plugin/pluto_lazy_pagination.dart +++ b/lib/src/plugin/pluto_lazy_pagination.dart @@ -180,7 +180,7 @@ class _PlutoLazyPaginationState extends State { ), ) .then((data) { - if(!mounted)return; + if (!mounted) return; stateManager.scroll.bodyRowsVertical!.jumpTo(0); stateManager.refRows.clearFromOriginal(); diff --git a/lib/src/pluto_grid_configuration.dart b/lib/src/pluto_grid_configuration.dart index c3bed1f8..0c932002 100644 --- a/lib/src/pluto_grid_configuration.dart +++ b/lib/src/pluto_grid_configuration.dart @@ -642,7 +642,8 @@ class PlutoGridStyleConfig { defaultColumnFilterPadding ?? this.defaultColumnFilterPadding, defaultCellPadding: defaultCellPadding ?? this.defaultCellPadding, columnTextStyle: columnTextStyle ?? this.columnTextStyle, - columnUnselectedColor: columnUnselectedColor ?? this.columnUnselectedColor, + columnUnselectedColor: + columnUnselectedColor ?? this.columnUnselectedColor, columnActiveColor: columnActiveColor ?? this.columnActiveColor, cellUnselectedColor: cellUnselectedColor ?? this.cellUnselectedColor, cellActiveColor: cellActiveColor ?? this.cellActiveColor, @@ -1784,12 +1785,16 @@ class PlutoGridLocaleText { enum PlutoGridRowSelectionCheckBoxBehavior { /// Selecting a row does nothing to its checkbox none, + /// Automatically enables the checkbox of the selected rows checkRow, + /// Automatically toggles the checkbox of the selected rows toggleCheckRow, + /// Automatically enabels the checkbox of a selected row (if another row is checked via select, the previous one is unchecked) singleRowCheck, + /// Automatically toggles the checkbox of a selected row (if another row is checked via select, the previous one is unchecked) toggleSingleRowCheck, } diff --git a/lib/src/ui/cells/pluto_date_cell.dart b/lib/src/ui/cells/pluto_date_cell.dart index cf8d3d62..1d197096 100644 --- a/lib/src/ui/cells/pluto_date_cell.dart +++ b/lib/src/ui/cells/pluto_date_cell.dart @@ -52,7 +52,8 @@ class PlutoDateCellState extends State final date = await sm.selectDateCallback!(widget.cell, widget.column); isOpenedPopup = false; if (date != null) { - handleSelected(widget.column.type.date.dateFormat.format(date)); // Consider call onSelected + handleSelected(widget.column.type.date.dateFormat + .format(date)); // Consider call onSelected } } else { PlutoGridDatePicker( diff --git a/lib/src/widgets/pluto_scrollbar.dart b/lib/src/widgets/pluto_scrollbar.dart index dd72dbf2..d8a180c9 100644 --- a/lib/src/widgets/pluto_scrollbar.dart +++ b/lib/src/widgets/pluto_scrollbar.dart @@ -1,1440 +1,1440 @@ -/* - * This widget modifies [CupertinoScrollbar] a little, - * so that the horizontal and vertical scroll controllers work together. -*/ - -// All values eyeballed. -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; - -const double _kScrollbarMinLength = 36.0; -const double _kScrollbarMinOverscrollLength = 8.0; -const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); -const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); -const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); -const Duration _kScrollbarLongPressDuration = Duration(milliseconds: 100); - -// Extracted from iOS 13.1 beta using Debug View Hierarchy. -const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( - color: Color(0x59000000), - darkColor: Color(0x80FFFFFF), -); -const Color _kTrackColor = Color(0x00000000); -// This is the amount of space from the top of a vertical scrollbar to the -// top edge of the scrollable, measured when the vertical scrollbar overscrolls -// to the top. -// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 -const double _kScrollbarMainAxisMargin = 3.0; -const double _kScrollbarCrossAxisMargin = 3.0; - -class PlutoScrollbar extends StatefulWidget { - const PlutoScrollbar({ - super.key, - this.horizontalController, - this.verticalController, - this.isAlwaysShown = false, - this.onlyDraggingThumb = true, - this.enableHover = true, - this.enableScrollAfterDragEnd = true, - this.thickness = defaultThickness, - this.thicknessWhileDragging = defaultThicknessWhileDragging, - this.hoverWidth = defaultScrollbarHoverWidth, - double? mainAxisMargin, - double? crossAxisMargin, - Color? scrollBarColor, - Color? scrollBarTrackColor, - Duration? longPressDuration, - this.radius = defaultRadius, - this.radiusWhileDragging = defaultRadiusWhileDragging, - required this.child, - }) : assert(thickness < double.infinity), - assert(thicknessWhileDragging < double.infinity), - assert(!isAlwaysShown || - (horizontalController != null || verticalController != null)), - mainAxisMargin = mainAxisMargin ?? _kScrollbarMainAxisMargin, - crossAxisMargin = crossAxisMargin ?? _kScrollbarCrossAxisMargin, - scrollBarColor = scrollBarColor ?? _kScrollbarColor, - scrollBarTrackColor = scrollBarTrackColor ?? _kTrackColor, - longPressDuration = longPressDuration ?? _kScrollbarLongPressDuration; - final ScrollController? horizontalController; - - final ScrollController? verticalController; - - final bool isAlwaysShown; - - final bool onlyDraggingThumb; - - final bool enableHover; - - final bool enableScrollAfterDragEnd; - - final Duration longPressDuration; - - final double thickness; - - final double thicknessWhileDragging; - - final double hoverWidth; - - final double mainAxisMargin; - - final double crossAxisMargin; - - final Color scrollBarColor; - - final Color scrollBarTrackColor; - - final Radius radius; - - final Radius radiusWhileDragging; - - final Widget child; - - static const double defaultThickness = 3; - - static const double defaultThicknessWhileDragging = 8.0; - - static const double defaultScrollbarHoverWidth = 16.0; - - static const Radius defaultRadius = Radius.circular(1.5); - - static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); - - @override - PlutoGridCupertinoScrollbarState createState() => - PlutoGridCupertinoScrollbarState(); -} - -class PlutoGridCupertinoScrollbarState extends State - with TickerProviderStateMixin { - final GlobalKey _customPaintKey = GlobalKey(); - _ScrollbarPainter? _painter; - - late TextDirection _textDirection; - late AnimationController _fadeoutAnimationController; - late Animation _fadeoutOpacityAnimation; - late AnimationController _thicknessAnimationController; - Timer? _fadeoutTimer; - double? _dragScrollbarAxisPosition; - Drag? _drag; - - double get _thickness { - return widget.thickness + - _thicknessAnimationController.value * - (widget.thicknessWhileDragging - widget.thickness); - } - - Radius? get _radius { - return Radius.lerp(widget.radius, widget.radiusWhileDragging, - _thicknessAnimationController.value); - } - - ScrollController? _currentController; - - ScrollController? get _controller { - if (_currentAxis == null) { - return widget.verticalController ?? - widget.horizontalController ?? - PrimaryScrollController.of(context); - } - - return _currentAxis == Axis.vertical - ? widget.verticalController - : widget.horizontalController; - } - - Axis? _currentAxis; - - _HoverAxis _currentHoverAxis = _HoverAxis.none; - - @override - void initState() { - super.initState(); - _fadeoutAnimationController = AnimationController( - vsync: this, - duration: _kScrollbarFadeDuration, - ); - _fadeoutOpacityAnimation = CurvedAnimation( - parent: _fadeoutAnimationController, - curve: Curves.fastOutSlowIn, - ); - _thicknessAnimationController = AnimationController( - vsync: this, - duration: _kScrollbarResizeDuration, - ); - _thicknessAnimationController.addListener(() { - _painter!.updateThickness(_thickness, _radius!); - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _textDirection = Directionality.of(context); - if (_painter == null) { - _painter = _buildCupertinoScrollbarPainter(context); - } else { - _painter! - ..textDirection = _textDirection - ..color = CupertinoDynamicColor.resolve(widget.scrollBarColor, context) - ..padding = MediaQuery.of(context).padding; - } - _triggerScrollbar(); - } - - @override - void didUpdateWidget(PlutoScrollbar oldWidget) { - super.didUpdateWidget(oldWidget); - assert(_painter != null); - _painter!.updateThickness(_thickness, _radius!); - if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { - if (widget.isAlwaysShown == true) { - _triggerScrollbar(); - _fadeoutAnimationController.animateTo(1.0); - } else { - _fadeoutAnimationController.reverse(); - } - } - } - - /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar. - _ScrollbarPainter _buildCupertinoScrollbarPainter(BuildContext context) { - return _ScrollbarPainter( - trackColor: - CupertinoDynamicColor.resolve(widget.scrollBarTrackColor, context), - color: CupertinoDynamicColor.resolve(widget.scrollBarColor, context), - textDirection: Directionality.of(context), - thickness: _thickness, - fadeoutOpacityAnimation: _fadeoutOpacityAnimation, - mainAxisMargin: widget.mainAxisMargin, - crossAxisMargin: widget.crossAxisMargin, - radius: _radius, - padding: MediaQuery.of(context).padding, - minLength: _kScrollbarMinLength, - minOverscrollLength: _kScrollbarMinOverscrollLength, - ); - } - - // Wait one frame and cause an empty scroll event. This allows the thumb to - // show immediately when isAlwaysShown is true. A scroll event is required in - // order to paint the thumb. - void _triggerScrollbar() { - WidgetsBinding.instance.addPostFrameCallback((Duration duration) { - if (widget.isAlwaysShown) { - _fadeoutTimer?.cancel(); - if (widget.verticalController!.hasClients) { - widget.verticalController!.position.didUpdateScrollPositionBy(0); - } - } - }); - } - - // Handle a gesture that drags the scrollbar by the given amount. - void _dragScrollbar(double primaryDelta) { - assert(_currentController != null); - - // Convert primaryDelta, the amount that the scrollbar moved since the last - // time _dragScrollbar was called, into the coordinate space of the scroll - // position, and create/update the drag event with that position. - final double scrollOffsetLocal = _painter!.getTrackToScroll(primaryDelta); - final double scrollOffsetGlobal = - scrollOffsetLocal + _currentController!.position.pixels; - final Axis direction = _currentController!.position.axis; - - if (_drag == null) { - _drag = _currentController!.position.drag( - DragStartDetails( - globalPosition: direction == Axis.vertical - ? Offset(0.0, scrollOffsetGlobal) - : Offset(scrollOffsetGlobal, 0.0), - ), - () {}, - ); - } else { - _drag!.update(DragUpdateDetails( - globalPosition: direction == Axis.vertical - ? Offset(0.0, scrollOffsetGlobal) - : Offset(scrollOffsetGlobal, 0.0), - delta: direction == Axis.vertical - ? Offset(0.0, -scrollOffsetLocal) - : Offset(-scrollOffsetLocal, 0.0), - primaryDelta: -scrollOffsetLocal, - )); - } - } - - void _startFadeoutTimer() { - if (!widget.isAlwaysShown) { - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { - _fadeoutAnimationController.reverse(); - _fadeoutTimer = null; - }); - } - } - - Axis? _getDirection() { - try { - return _currentController!.position.axis; - } catch (_) { - // Ignore the gesture if we cannot determine the direction. - return null; - } - } - - double _pressStartAxisPosition = 0.0; - - // Long press event callbacks handle the gesture where the user long presses - // on the scrollbar thumb and then drags the scrollbar without releasing. - void _handleLongPressStart(LongPressStartDetails details) { - _currentController = _controller; - final Axis? direction = _getDirection(); - if (direction == null) { - return; - } - _fadeoutTimer?.cancel(); - _fadeoutAnimationController.forward(); - switch (direction) { - case Axis.vertical: - _pressStartAxisPosition = details.localPosition.dy; - _dragScrollbar(details.localPosition.dy); - _dragScrollbarAxisPosition = details.localPosition.dy; - break; - case Axis.horizontal: - _pressStartAxisPosition = details.localPosition.dx; - _dragScrollbar(details.localPosition.dx); - _dragScrollbarAxisPosition = details.localPosition.dx; - break; - } - } - - void _handleLongPress() { - if (_getDirection() == null) { - return; - } - _fadeoutTimer?.cancel(); - _thicknessAnimationController.forward().then( - (_) => HapticFeedback.mediumImpact(), - ); - } - - void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - final Axis? direction = _getDirection(); - if (direction == null) { - return; - } - switch (direction) { - case Axis.vertical: - _dragScrollbar(details.localPosition.dy - _dragScrollbarAxisPosition!); - _dragScrollbarAxisPosition = details.localPosition.dy; - break; - case Axis.horizontal: - _dragScrollbar(details.localPosition.dx - _dragScrollbarAxisPosition!); - _dragScrollbarAxisPosition = details.localPosition.dx; - break; - } - } - - void _handleLongPressEnd(LongPressEndDetails details) { - final Axis? direction = _getDirection(); - if (direction == null) { - return; - } - switch (direction) { - case Axis.vertical: - _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction); - if (details.velocity.pixelsPerSecond.dy.abs() < 10 && - (details.localPosition.dy - _pressStartAxisPosition).abs() > 0) { - HapticFeedback.mediumImpact(); - } - break; - case Axis.horizontal: - _handleDragScrollEnd(details.velocity.pixelsPerSecond.dx, direction); - if (details.velocity.pixelsPerSecond.dx.abs() < 10 && - (details.localPosition.dx - _pressStartAxisPosition).abs() > 0) { - HapticFeedback.mediumImpact(); - } - break; - } - _currentController = null; - } - - void _handleDragScrollEnd(double trackVelocity, Axis direction) { - _startFadeoutTimer(); - _thicknessAnimationController.reverse(); - _dragScrollbarAxisPosition = null; - final double scrollVelocity = widget.enableScrollAfterDragEnd - ? _painter!.getTrackToScroll(trackVelocity) - : 0; - _drag?.end(DragEndDetails( - primaryVelocity: -scrollVelocity, - velocity: Velocity( - pixelsPerSecond: direction == Axis.vertical - ? Offset(0.0, -scrollVelocity) - : Offset(-scrollVelocity, 0.0), - ), - )); - _drag = null; - } - - bool _handleScrollNotification(ScrollNotification notification) { - final ScrollMetrics metrics = notification.metrics; - if (metrics.maxScrollExtent <= metrics.minScrollExtent) { - return false; - } - - _currentAxis = axisDirectionToAxis(metrics.axisDirection); - - if (notification is ScrollUpdateNotification || - notification is OverscrollNotification || - notification is UserScrollNotification) { - // Any movements always makes the scrollbar start showing up. - if (_fadeoutAnimationController.status != AnimationStatus.forward) { - _fadeoutAnimationController.forward(); - } - - _fadeoutTimer?.cancel(); - _painter!.update(metrics, metrics.axisDirection); - - // Call ScrollController.jumpTo on keyboard move. - // An error where the Thumb does not disappear - // because UserScrollNotification is called - // after ScrollEndNotification when the horizontal axis is moved. - if ((notification is UserScrollNotification) && - notification.direction == ScrollDirection.idle) { - _callFadeoutTimer(); - } - } else if (notification is ScrollEndNotification) { - // On iOS, the scrollbar can only go away once the user lifted the finger. - _callFadeoutTimer(); - } - - return false; - } - - void _callFadeoutTimer() { - if (_dragScrollbarAxisPosition == null) { - _startFadeoutTimer(); - } - } - - // Get the GestureRecognizerFactories used to detect gestures on the scrollbar - // thumb. - Map get _gestures { - final Map gestures = - {}; - - gestures[_ThumbPressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( - () => _ThumbPressGestureRecognizer( - customPaintKey: _customPaintKey, - debugOwner: this, - duration: widget.longPressDuration, - onlyDraggingThumb: widget.onlyDraggingThumb, - ), - (_ThumbPressGestureRecognizer instance) { - instance - ..onLongPressStart = _handleLongPressStart - ..onLongPress = _handleLongPress - ..onLongPressMoveUpdate = _handleLongPressMoveUpdate - ..onLongPressEnd = _handleLongPressEnd; - }, - ); - - return gestures; - } - - @override - void dispose() { - _fadeoutAnimationController.dispose(); - _thicknessAnimationController.dispose(); - _fadeoutTimer?.cancel(); - _painter!.dispose(); - super.dispose(); - } - - bool _needUpdatePainterByHover(Axis axis) { - switch (_painter?._lastAxisDirection) { - case AxisDirection.up: - case AxisDirection.down: - return axis != Axis.vertical; - case AxisDirection.left: - case AxisDirection.right: - return axis != Axis.horizontal; - default: - return true; - } - } - - void _handleHoverExit(PointerExitEvent event) { - _callFadeoutTimer(); - } - - void _handleHover(PointerHoverEvent event) { - final hoverAxis = _getHoverAxis(event.position, event.kind, forHover: true); - if (hoverAxis == _currentHoverAxis) return; - _currentHoverAxis = hoverAxis; - - ScrollMetrics? metrics; - bool needUpdate = false; - - switch (hoverAxis) { - case _HoverAxis.vertical: - _currentAxis = Axis.vertical; - _currentController = widget.verticalController; - needUpdate = _needUpdatePainterByHover(Axis.vertical); - if (needUpdate) { - metrics = FixedScrollMetrics( - minScrollExtent: - widget.verticalController?.position.minScrollExtent, - maxScrollExtent: - widget.verticalController?.position.maxScrollExtent, - pixels: widget.verticalController?.position.pixels, - viewportDimension: - widget.verticalController?.position.viewportDimension, - axisDirection: widget.verticalController?.position.axisDirection ?? - AxisDirection.down, - devicePixelRatio: 1.0, - ); - } - break; - case _HoverAxis.horizontal: - _currentAxis = Axis.horizontal; - _currentController = widget.horizontalController; - needUpdate = _needUpdatePainterByHover(Axis.horizontal); - if (needUpdate) { - metrics = FixedScrollMetrics( - minScrollExtent: - widget.horizontalController?.position.minScrollExtent, - maxScrollExtent: - widget.horizontalController?.position.maxScrollExtent, - pixels: widget.horizontalController?.position.pixels, - viewportDimension: - widget.horizontalController?.position.viewportDimension, - axisDirection: - widget.horizontalController?.position.axisDirection ?? - AxisDirection.right, - devicePixelRatio: 1.0, - ); - } - break; - case _HoverAxis.none: - _callFadeoutTimer(); - return; - } - - if (_fadeoutAnimationController.status != AnimationStatus.forward) { - _fadeoutAnimationController.forward(); - } - - _fadeoutTimer?.cancel(); - - if (needUpdate) { - _painter!.update(metrics!, metrics.axisDirection); - } - } - - _HoverAxis _getHoverAxis( - Offset position, - PointerDeviceKind kind, { - bool forHover = false, - }) { - if (_customPaintKey.currentContext == null || _painter == null) { - return _HoverAxis.none; - } - - final RenderBox renderBox = - _customPaintKey.currentContext!.findRenderObject()! as RenderBox; - final localOffset = renderBox.globalToLocal(position); - final trackSize = renderBox.size; - final isRTL = _textDirection == TextDirection.rtl; - final hoverWidth = widget.hoverWidth; - - if (Rect.fromLTRB( - isRTL ? 0 : trackSize.width - hoverWidth, - 0, - isRTL ? hoverWidth : trackSize.width, - trackSize.height, - ).contains(localOffset)) { - return _HoverAxis.vertical; - } - - if (Rect.fromLTRB( - 0, - trackSize.height - hoverWidth, - trackSize.width, - trackSize.height, - ).contains(localOffset)) { - return _HoverAxis.horizontal; - } - - return _HoverAxis.none; - } - - @override - Widget build(BuildContext context) { - Widget child = CustomPaint( - key: _customPaintKey, - foregroundPainter: _painter, - child: RepaintBoundary(child: widget.child), - ); - - if (widget.enableHover) { - child = MouseRegion( - onExit: (PointerExitEvent event) { - switch (event.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.trackpad: - _handleHoverExit(event); - break; - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - case PointerDeviceKind.touch: - break; - } - }, - onHover: (PointerHoverEvent event) { - switch (event.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.trackpad: - _handleHover(event); - break; - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - case PointerDeviceKind.touch: - break; - } - }, - child: child, - ); - } - - return NotificationListener( - onNotification: _handleScrollNotification, - child: RepaintBoundary( - child: RawGestureDetector( - gestures: _gestures, - child: child, - ), - ), - ); - } -} - -const double _kMinInteractiveSize = 48.0; -const double _kScrollbarThickness = 6.0; -const double _kMinThumbExtent = 18.0; - -class _ScrollbarPainter extends ChangeNotifier implements CustomPainter { - /// Creates a scrollbar with customizations given by construction arguments. - _ScrollbarPainter({ - required Color color, - required this.fadeoutOpacityAnimation, - required Color trackColor, - Color trackBorderColor = const Color(0x00000000), - TextDirection? textDirection, - double thickness = _kScrollbarThickness, - EdgeInsets padding = EdgeInsets.zero, - double mainAxisMargin = 0.0, - double crossAxisMargin = 0.0, - Radius? radius, - Radius? trackRadius, - OutlinedBorder? shape, - double minLength = _kMinThumbExtent, - double? minOverscrollLength, - ScrollbarOrientation? scrollbarOrientation, - bool ignorePointer = false, - }) : assert(radius == null || shape == null), - assert(minLength >= 0), - assert(minOverscrollLength == null || minOverscrollLength <= minLength), - assert(minOverscrollLength == null || minOverscrollLength >= 0), - assert(padding.isNonNegative), - _color = color, - _textDirection = textDirection, - _thickness = thickness, - _radius = radius, - _shape = shape, - _padding = padding, - _mainAxisMargin = mainAxisMargin, - _crossAxisMargin = crossAxisMargin, - _minLength = minLength, - _trackColor = trackColor, - _trackBorderColor = trackBorderColor, - _trackRadius = trackRadius, - _scrollbarOrientation = scrollbarOrientation, - _minOverscrollLength = minOverscrollLength ?? minLength, - _ignorePointer = ignorePointer { - fadeoutOpacityAnimation.addListener(notifyListeners); - } - - /// [Color] of the thumb. Mustn't be null. - Color get color => _color; - Color _color; - set color(Color value) { - if (color == value) return; - - _color = value; - notifyListeners(); - } - - /// [Color] of the track. Mustn't be null. - Color get trackColor => _trackColor; - Color _trackColor; - set trackColor(Color value) { - if (trackColor == value) return; - - _trackColor = value; - notifyListeners(); - } - - /// [Color] of the track border. Mustn't be null. - Color get trackBorderColor => _trackBorderColor; - Color _trackBorderColor; - set trackBorderColor(Color value) { - if (trackBorderColor == value) return; - - _trackBorderColor = value; - notifyListeners(); - } - - /// [Radius] of corners of the Scrollbar's track. - /// - /// Scrollbar's track will be rectangular if [trackRadius] is null. - Radius? get trackRadius => _trackRadius; - Radius? _trackRadius; - set trackRadius(Radius? value) { - if (trackRadius == value) return; - - _trackRadius = value; - notifyListeners(); - } - - /// [TextDirection] of the [BuildContext] which dictates the side of the - /// screen the scrollbar appears in (the trailing side). Must be set prior to - /// calling paint. - TextDirection? get textDirection => _textDirection; - TextDirection? _textDirection; - set textDirection(TextDirection? value) { - assert(value != null); - if (textDirection == value) return; - - _textDirection = value; - notifyListeners(); - } - - /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. - double get thickness => _thickness; - double _thickness; - set thickness(double value) { - if (thickness == value) return; - - _thickness = value; - notifyListeners(); - } - - /// An opacity [Animation] that dictates the opacity of the thumb. - /// Changes in value of this [Listenable] will automatically trigger repaints. - /// Mustn't be null. - final Animation fadeoutOpacityAnimation; - - /// Distance from the scrollbar's start and end to the edge of the viewport - /// in logical pixels. It affects the amount of available paint area. - /// - /// Mustn't be null and defaults to 0. - double get mainAxisMargin => _mainAxisMargin; - double _mainAxisMargin; - set mainAxisMargin(double value) { - if (mainAxisMargin == value) return; - - _mainAxisMargin = value; - notifyListeners(); - } - - /// Distance from the scrollbar thumb to the nearest cross axis edge - /// in logical pixels. - /// - /// Must not be null and defaults to 0. - double get crossAxisMargin => _crossAxisMargin; - double _crossAxisMargin; - set crossAxisMargin(double value) { - if (crossAxisMargin == value) return; - - _crossAxisMargin = value; - notifyListeners(); - } - - /// [Radius] of corners if the scrollbar should have rounded corners. - /// - /// Scrollbar will be rectangular if [radius] is null. - Radius? get radius => _radius; - Radius? _radius; - set radius(Radius? value) { - assert(shape == null || value == null); - if (radius == value) return; - - _radius = value; - notifyListeners(); - } - - /// The [OutlinedBorder] of the scrollbar's thumb. - /// - /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, - /// it's simplest to just specify [radius]. By default, the scrollbar thumb's - /// shape is a simple rectangle. - /// - /// If [shape] is specified, the thumb will take the shape of the passed - /// [OutlinedBorder] and fill itself with [color] (or grey if it - /// is unspecified). - /// - OutlinedBorder? get shape => _shape; - OutlinedBorder? _shape; - set shape(OutlinedBorder? value) { - assert(radius == null || value == null); - if (shape == value) return; - - _shape = value; - notifyListeners(); - } - - /// The amount of space by which to inset the scrollbar's start and end, as - /// well as its side to the nearest edge, in logical pixels. - /// - /// This is typically set to the current [MediaQueryData.padding] to avoid - /// partial obstructions such as display notches. If you only want additional - /// margins around the scrollbar, see [mainAxisMargin]. - /// - /// Defaults to [EdgeInsets.zero]. Must not be null and offsets from all four - /// directions must be greater than or equal to zero. - EdgeInsets get padding => _padding; - EdgeInsets _padding; - set padding(EdgeInsets value) { - if (padding == value) return; - - _padding = value; - notifyListeners(); - } - - /// The preferred smallest size the scrollbar thumb can shrink to when the total - /// scrollable extent is large, the current visible viewport is small, and the - /// viewport is not overscrolled. - /// - /// The size of the scrollbar may shrink to a smaller size than [minLength] to - /// fit in the available paint area. E.g., when [minLength] is - /// `double.infinity`, it will not be respected if - /// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. - /// - /// Mustn't be null and the value has to be greater or equal to - /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. - double get minLength => _minLength; - double _minLength; - set minLength(double value) { - if (minLength == value) return; - - _minLength = value; - notifyListeners(); - } - - /// The preferred smallest size the scrollbar thumb can shrink to when viewport is - /// overscrolled. - /// - /// When overscrolling, the size of the scrollbar may shrink to a smaller size - /// than [minOverscrollLength] to fit in the available paint area. E.g., when - /// [minOverscrollLength] is `double.infinity`, it will not be respected if - /// the [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. - /// - /// The value is less than or equal to [minLength] and greater than or equal to 0. - /// When null, it will default to the value of [minLength]. - double get minOverscrollLength => _minOverscrollLength; - double _minOverscrollLength; - set minOverscrollLength(double value) { - if (minOverscrollLength == value) return; - - _minOverscrollLength = value; - notifyListeners(); - } - - /// {@template flutter.widgets.Scrollbar.scrollbarOrientation} - /// Dictates the orientation of the scrollbar. - /// - /// [ScrollbarOrientation.top] places the scrollbar on top of the screen. - /// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen. - /// [ScrollbarOrientation.left] places the scrollbar on the left of the screen. - /// [ScrollbarOrientation.right] places the scrollbar on the right of the screen. - /// - /// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be - /// used with a vertical scroll. - /// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be - /// used with a horizontal scroll. - /// - /// For a vertical scroll the orientation defaults to - /// [ScrollbarOrientation.right] for [TextDirection.ltr] and - /// [ScrollbarOrientation.left] for [TextDirection.rtl]. - /// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom]. - /// {@endtemplate} - ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation; - ScrollbarOrientation? _scrollbarOrientation; - set scrollbarOrientation(ScrollbarOrientation? value) { - if (scrollbarOrientation == value) return; - - _scrollbarOrientation = value; - notifyListeners(); - } - - /// Whether the painter will be ignored during hit testing. - bool get ignorePointer => _ignorePointer; - bool _ignorePointer; - set ignorePointer(bool value) { - if (ignorePointer == value) return; - - _ignorePointer = value; - notifyListeners(); - } - - void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { - assert( - (_isVertical && _isVerticalOrientation(orientation)) || - (!_isVertical && !_isVerticalOrientation(orientation)), - 'The given ScrollbarOrientation: $orientation is incompatible with the current AxisDirection: $_lastAxisDirection.'); - } - - /// Check whether given scrollbar orientation is vertical - bool _isVerticalOrientation(ScrollbarOrientation orientation) => - orientation == ScrollbarOrientation.left || - orientation == ScrollbarOrientation.right; - - ScrollMetrics? _lastMetrics; - AxisDirection? _lastAxisDirection; - - ScrollMetrics? _lastVerticalMetrics; - AxisDirection? _lastVerticalAxisDirection; - - ScrollMetrics? _lastHorizontalMetrics; - AxisDirection? _lastHorizontalAxisDirection; - - Rect? _thumbRect; - Rect? _trackRect; - late double _thumbOffset; - - /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will - /// show and redraw itself based on these new metrics. - /// - /// The scrollbar will remain on screen. - void update( - ScrollMetrics metrics, - AxisDirection axisDirection, - ) { - final bool vertical = axisDirection == AxisDirection.up || - axisDirection == AxisDirection.down; - - if (vertical) { - if (_lastVerticalMetrics != null && - _lastVerticalMetrics!.extentBefore == metrics.extentBefore && - _lastVerticalMetrics!.extentInside == metrics.extentInside && - _lastVerticalMetrics!.extentAfter == metrics.extentAfter && - _lastVerticalAxisDirection == axisDirection && - _lastAxisDirection == axisDirection) { - return; - } - - _lastVerticalMetrics = metrics; - _lastVerticalAxisDirection = axisDirection; - } else { - if (_lastHorizontalMetrics != null && - _lastHorizontalMetrics!.extentBefore == metrics.extentBefore && - _lastHorizontalMetrics!.extentInside == metrics.extentInside && - _lastHorizontalMetrics!.extentAfter == metrics.extentAfter && - _lastHorizontalAxisDirection == axisDirection && - _lastAxisDirection == axisDirection) { - return; - } - - _lastHorizontalMetrics = metrics; - _lastHorizontalAxisDirection = axisDirection; - } - - final ScrollMetrics? oldMetrics = - vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; - - _lastMetrics = vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; - _lastAxisDirection = - vertical ? _lastVerticalAxisDirection : _lastHorizontalAxisDirection; - - bool needPaint(ScrollMetrics? metrics) => - metrics != null && metrics.maxScrollExtent > metrics.minScrollExtent; - if (!needPaint(oldMetrics) && !needPaint(metrics)) return; - - notifyListeners(); - } - - /// Update and redraw with new scrollbar thickness and radius. - void updateThickness(double nextThickness, Radius nextRadius) { - thickness = nextThickness; - radius = nextRadius; - } - - Paint get _paintThumb { - return Paint() - ..color = - color.withValues(alpha: color.a * fadeoutOpacityAnimation.value); - } - - Paint _paintTrack({bool isBorder = false}) { - if (isBorder) { - return Paint() - ..color = trackBorderColor.withValues( - alpha: trackBorderColor.a * fadeoutOpacityAnimation.value) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.0; - } - return Paint() - ..color = trackColor.withValues( - alpha: trackColor.a * fadeoutOpacityAnimation.value); - } - - void _paintScrollbar( - Canvas canvas, Size size, double thumbExtent, AxisDirection direction) { - assert( - textDirection != null, - 'A TextDirection must be provided before a Scrollbar can be painted.', - ); - - final ScrollbarOrientation resolvedOrientation; - - if (scrollbarOrientation == null) { - if (_isVertical) { - resolvedOrientation = textDirection == TextDirection.ltr - ? ScrollbarOrientation.right - : ScrollbarOrientation.left; - } else { - resolvedOrientation = ScrollbarOrientation.bottom; - } - } else { - resolvedOrientation = scrollbarOrientation!; - } - - final double x, y; - final Size thumbSize, trackSize; - final Offset trackOffset, borderStart, borderEnd; - - _debugAssertIsValidOrientation(resolvedOrientation); - - switch (resolvedOrientation) { - case ScrollbarOrientation.left: - thumbSize = Size(thickness, thumbExtent); - trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); - x = crossAxisMargin + padding.left; - y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); - borderStart = trackOffset + Offset(trackSize.width, 0.0); - borderEnd = Offset( - trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); - break; - case ScrollbarOrientation.right: - thumbSize = Size(thickness, thumbExtent); - trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); - x = size.width - thickness - crossAxisMargin - padding.right; - y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); - borderStart = trackOffset; - borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); - break; - case ScrollbarOrientation.top: - thumbSize = Size(thumbExtent, thickness); - trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); - x = _thumbOffset; - y = crossAxisMargin + padding.top; - trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); - borderStart = trackOffset + Offset(0.0, trackSize.height); - borderEnd = Offset( - trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); - break; - case ScrollbarOrientation.bottom: - thumbSize = Size(thumbExtent, thickness); - trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); - x = _thumbOffset; - y = size.height - thickness - crossAxisMargin - padding.bottom; - trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); - borderStart = trackOffset; - borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); - break; - } - - // Whether we paint or not, calculating these rects allows us to hit test - // when the scrollbar is transparent. - _trackRect = trackOffset & trackSize; - _thumbRect = Offset(x, y) & thumbSize; - - // Paint if the opacity dictates visibility - if (fadeoutOpacityAnimation.value != 0.0) { - // Track - if (trackRadius == null) { - canvas.drawRect(_trackRect!, _paintTrack()); - } else { - canvas.drawRRect( - RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack()); - } - // Track Border - canvas.drawLine(borderStart, borderEnd, _paintTrack(isBorder: true)); - if (radius != null) { - // Rounded rect thumb - canvas.drawRRect( - RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); - return; - } - if (shape == null) { - // Square thumb - canvas.drawRect(_thumbRect!, _paintThumb); - return; - } - // Custom-shaped thumb - final Path outerPath = shape!.getOuterPath(_thumbRect!); - canvas.drawPath(outerPath, _paintThumb); - shape!.paint(canvas, _thumbRect!); - } - } - - double _thumbExtent() { - // Thumb extent reflects fraction of content visible, as long as this - // isn't less than the absolute minimum size. - // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 - final double fractionVisible = - ((_lastMetrics!.extentInside - _mainAxisPadding) / - (_totalContentExtent - _mainAxisPadding)) - .clamp(0.0, 1.0); - - final double thumbExtent = math.max( - math.min(_trackExtent, minOverscrollLength), - _trackExtent * fractionVisible, - ); - - final double fractionOverscrolled = - 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; - final double safeMinLength = math.min(minLength, _trackExtent); - final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) - // Thumb extent is no smaller than minLength if scrolling normally. - ? safeMinLength - // User is overscrolling. Thumb extent can be less than minLength - // but no smaller than minOverscrollLength. We can't use the - // fractionVisible to produce intermediate values between minLength and - // minOverscrollLength when the user is transitioning from regular - // scrolling to overscrolling, so we instead use the percentage of the - // content that is still in the viewport to determine the size of the - // thumb. iOS behavior appears to have the thumb reach its minimum size - // with ~20% of overscroll. We map the percentage of minLength from - // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce - // values for the thumb that range between minLength and the smallest - // possible value, minOverscrollLength. - : safeMinLength * (1.0 - fractionOverscrolled.clamp(0.0, 0.2) / 0.2); - - // The `thumbExtent` should be no greater than `trackSize`, otherwise - // the scrollbar may scroll towards the wrong direction. - return thumbExtent.clamp(newMinLength, _trackExtent); - } - - @override - void dispose() { - fadeoutOpacityAnimation.removeListener(notifyListeners); - super.dispose(); - } - - bool get _isVertical => - _lastAxisDirection == AxisDirection.down || - _lastAxisDirection == AxisDirection.up; - bool get _isReversed => - _lastAxisDirection == AxisDirection.up || - _lastAxisDirection == AxisDirection.left; - // The amount of scroll distance before and after the current position. - double get _beforeExtent => - _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; - double get _afterExtent => - _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; - // Padding of the thumb track. - double get _mainAxisPadding => - _isVertical ? padding.vertical : padding.horizontal; - // The size of the thumb track. - double get _trackExtent => - _lastMetrics!.viewportDimension - 2 * mainAxisMargin - _mainAxisPadding; - - // The total size of the scrollable content. - double get _totalContentExtent { - return _lastMetrics!.maxScrollExtent - - _lastMetrics!.minScrollExtent + - _lastMetrics!.viewportDimension; - } - - /// Convert between a thumb track position and the corresponding scroll - /// position. - /// - /// thumbOffsetLocal is a position in the thumb track. Cannot be null. - double getTrackToScroll(double thumbOffsetLocal) { - final double scrollableExtent = - _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; - final double thumbMovableExtent = _trackExtent - _thumbExtent(); - - return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; - } - - // Converts between a scroll position and the corresponding position in the - // thumb track. - double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { - final double scrollableExtent = - metrics.maxScrollExtent - metrics.minScrollExtent; - - final double fractionPast = (scrollableExtent > 0) - ? ((metrics.pixels - metrics.minScrollExtent) / scrollableExtent) - .clamp(0.0, 1.0) - : 0; - - return (_isReversed ? 1 - fractionPast : fractionPast) * - (_trackExtent - thumbExtent); - } - - @override - void paint(Canvas canvas, Size size) { - if (_lastAxisDirection == null || - _lastMetrics == null || - _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { - return; - } - - // Skip painting if there's not enough space. - if (_lastMetrics!.viewportDimension <= _mainAxisPadding || - _trackExtent <= 0) { - return; - } - - final double beforePadding = _isVertical ? padding.top : padding.left; - final double thumbExtent = _thumbExtent(); - final double thumbOffsetLocal = - _getScrollToTrack(_lastMetrics!, thumbExtent); - _thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding; - - // Do not paint a scrollbar if the scroll view is infinitely long. - // TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434 - if (_lastMetrics!.maxScrollExtent.isInfinite) return; - - return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!); - } - - bool get _lastMetricsAreScrollable => - _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; - - /// Same as hitTest, but includes some padding when the [PointerEvent] is - /// caused by [PointerDeviceKind.touch] to make sure that the region - /// isn't too small to be interacted with by the user. - /// - /// The hit test area for hovering with [PointerDeviceKind.mouse] over the - /// scrollbar also uses this extra padding. This is to make it easier to - /// interact with the scrollbar by presenting it to the mouse for interaction - /// based on proximity. When `forHover` is true, the larger hit test area will - /// be used. - bool hitTestInteractive(Offset position, PointerDeviceKind kind, - {bool forHover = false}) { - if (_trackRect == null) { - // We have not computed the scrollbar position yet. - return false; - } - if (ignorePointer) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - final Rect interactiveRect = _trackRect!; - final Rect paddedRect = interactiveRect.expandToInclude( - Rect.fromCircle( - center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), - ); - - // The scrollbar is not able to be hit when transparent - except when - // hovering with a mouse. This should bring the scrollbar into view so the - // mouse can interact with it. - if (fadeoutOpacityAnimation.value == 0.0) { - if (forHover && kind == PointerDeviceKind.mouse) { - return paddedRect.contains(position); - } - return false; - } - - switch (kind) { - case PointerDeviceKind.touch: - return paddedRect.contains(position); - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] - // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 - return interactiveRect.contains(position); - } - } - - /// Same as hitTestInteractive, but excludes the track portion of the scrollbar. - /// Used to evaluate interactions with only the scrollbar thumb. - bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) { - if (_thumbRect == null) { - return false; - } - if (ignorePointer) { - return false; - } - // The thumb is not able to be hit when transparent. - if (fadeoutOpacityAnimation.value == 0.0) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - switch (kind) { - case PointerDeviceKind.touch: - final Rect touchThumbRect = _thumbRect!.expandToInclude( - Rect.fromCircle( - center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), - ); - return touchThumbRect.contains(position); - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] - // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 - return _thumbRect!.contains(position); - } - } - - // Scrollbars are interactive. - @override - bool? hitTest(Offset? position) { - if (_thumbRect == null) { - return null; - } - if (ignorePointer) { - return false; - } - - // The thumb is not able to be hit when transparent. - if (fadeoutOpacityAnimation.value == 0.0) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - return _trackRect!.contains(position!); - } - - @override - bool shouldRepaint(_ScrollbarPainter oldDelegate) { - // Should repaint if any properties changed. - return color != oldDelegate.color || - trackColor != oldDelegate.trackColor || - trackBorderColor != oldDelegate.trackBorderColor || - textDirection != oldDelegate.textDirection || - thickness != oldDelegate.thickness || - fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation || - mainAxisMargin != oldDelegate.mainAxisMargin || - crossAxisMargin != oldDelegate.crossAxisMargin || - radius != oldDelegate.radius || - trackRadius != oldDelegate.trackRadius || - shape != oldDelegate.shape || - padding != oldDelegate.padding || - minLength != oldDelegate.minLength || - minOverscrollLength != oldDelegate.minOverscrollLength || - scrollbarOrientation != oldDelegate.scrollbarOrientation || - ignorePointer != oldDelegate.ignorePointer; - } - - @override - bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; - - @override - SemanticsBuilderCallback? get semanticsBuilder => null; - - @override - String toString() => describeIdentity(this); -} - -String describeIdentity(Object? object) => - '${objectRuntimeType(object, '')}#${shortHash(object)}'; - -String objectRuntimeType(Object? object, String optimizedValue) { - assert(() { - optimizedValue = object.runtimeType.toString(); - return true; - }()); - return optimizedValue; -} - -String shortHash(Object? object) { - return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0'); -} - -// A longpress gesture detector that only responds to events on the scrollbar's -// thumb and ignores everything else. -class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { - _ThumbPressGestureRecognizer({ - required GlobalKey customPaintKey, - required Object super.debugOwner, - required Duration super.duration, - this.onlyDraggingThumb = false, - }) : _customPaintKey = customPaintKey; - - final GlobalKey _customPaintKey; - final bool onlyDraggingThumb; - - @override - bool isPointerAllowed(PointerDownEvent event) { - if (!_hitTestInteractive( - _customPaintKey, event.position, event.kind, onlyDraggingThumb)) { - return false; - } - return super.isPointerAllowed(event); - } -} - -// foregroundPainter also hit tests its children by default, but the -// scrollbar should only respond to a gesture directly on its thumb, so -// manually check for a hit on the thumb here. -bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, - PointerDeviceKind kind, bool onlyDraggingThumb) { - if (customPaintKey.currentContext == null) { - return false; - } - final CustomPaint customPaint = - customPaintKey.currentContext!.widget as CustomPaint; - final _ScrollbarPainter painter = - customPaint.foregroundPainter! as _ScrollbarPainter; - final Offset localOffset = _getLocalOffset(customPaintKey, offset); - // We can only receive track taps that are on the thumb. - return onlyDraggingThumb - ? painter.hitTestOnlyThumbInteractive(localOffset, kind) - : painter.hitTestInteractive(localOffset, kind); -} - -Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { - final RenderBox renderBox = - scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; - return renderBox.globalToLocal(position); -} - -enum _HoverAxis { - vertical, - horizontal, - none; - - bool get isVertical => this == _HoverAxis.vertical; - bool get isHorizontal => this == _HoverAxis.horizontal; - bool get isNone => this == _HoverAxis.none; -} +/* + * This widget modifies [CupertinoScrollbar] a little, + * so that the horizontal and vertical scroll controllers work together. +*/ + +// All values eyeballed. +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +const double _kScrollbarMinLength = 36.0; +const double _kScrollbarMinOverscrollLength = 8.0; +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); +const Duration _kScrollbarLongPressDuration = Duration(milliseconds: 100); + +// Extracted from iOS 13.1 beta using Debug View Hierarchy. +const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: Color(0x59000000), + darkColor: Color(0x80FFFFFF), +); +const Color _kTrackColor = Color(0x00000000); +// This is the amount of space from the top of a vertical scrollbar to the +// top edge of the scrollable, measured when the vertical scrollbar overscrolls +// to the top. +// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 3.0; + +class PlutoScrollbar extends StatefulWidget { + const PlutoScrollbar({ + super.key, + this.horizontalController, + this.verticalController, + this.isAlwaysShown = false, + this.onlyDraggingThumb = true, + this.enableHover = true, + this.enableScrollAfterDragEnd = true, + this.thickness = defaultThickness, + this.thicknessWhileDragging = defaultThicknessWhileDragging, + this.hoverWidth = defaultScrollbarHoverWidth, + double? mainAxisMargin, + double? crossAxisMargin, + Color? scrollBarColor, + Color? scrollBarTrackColor, + Duration? longPressDuration, + this.radius = defaultRadius, + this.radiusWhileDragging = defaultRadiusWhileDragging, + required this.child, + }) : assert(thickness < double.infinity), + assert(thicknessWhileDragging < double.infinity), + assert(!isAlwaysShown || + (horizontalController != null || verticalController != null)), + mainAxisMargin = mainAxisMargin ?? _kScrollbarMainAxisMargin, + crossAxisMargin = crossAxisMargin ?? _kScrollbarCrossAxisMargin, + scrollBarColor = scrollBarColor ?? _kScrollbarColor, + scrollBarTrackColor = scrollBarTrackColor ?? _kTrackColor, + longPressDuration = longPressDuration ?? _kScrollbarLongPressDuration; + final ScrollController? horizontalController; + + final ScrollController? verticalController; + + final bool isAlwaysShown; + + final bool onlyDraggingThumb; + + final bool enableHover; + + final bool enableScrollAfterDragEnd; + + final Duration longPressDuration; + + final double thickness; + + final double thicknessWhileDragging; + + final double hoverWidth; + + final double mainAxisMargin; + + final double crossAxisMargin; + + final Color scrollBarColor; + + final Color scrollBarTrackColor; + + final Radius radius; + + final Radius radiusWhileDragging; + + final Widget child; + + static const double defaultThickness = 3; + + static const double defaultThicknessWhileDragging = 8.0; + + static const double defaultScrollbarHoverWidth = 16.0; + + static const Radius defaultRadius = Radius.circular(1.5); + + static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); + + @override + PlutoGridCupertinoScrollbarState createState() => + PlutoGridCupertinoScrollbarState(); +} + +class PlutoGridCupertinoScrollbarState extends State + with TickerProviderStateMixin { + final GlobalKey _customPaintKey = GlobalKey(); + _ScrollbarPainter? _painter; + + late TextDirection _textDirection; + late AnimationController _fadeoutAnimationController; + late Animation _fadeoutOpacityAnimation; + late AnimationController _thicknessAnimationController; + Timer? _fadeoutTimer; + double? _dragScrollbarAxisPosition; + Drag? _drag; + + double get _thickness { + return widget.thickness + + _thicknessAnimationController.value * + (widget.thicknessWhileDragging - widget.thickness); + } + + Radius? get _radius { + return Radius.lerp(widget.radius, widget.radiusWhileDragging, + _thicknessAnimationController.value); + } + + ScrollController? _currentController; + + ScrollController? get _controller { + if (_currentAxis == null) { + return widget.verticalController ?? + widget.horizontalController ?? + PrimaryScrollController.of(context); + } + + return _currentAxis == Axis.vertical + ? widget.verticalController + : widget.horizontalController; + } + + Axis? _currentAxis; + + _HoverAxis _currentHoverAxis = _HoverAxis.none; + + @override + void initState() { + super.initState(); + _fadeoutAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarFadeDuration, + ); + _fadeoutOpacityAnimation = CurvedAnimation( + parent: _fadeoutAnimationController, + curve: Curves.fastOutSlowIn, + ); + _thicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _thicknessAnimationController.addListener(() { + _painter!.updateThickness(_thickness, _radius!); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _textDirection = Directionality.of(context); + if (_painter == null) { + _painter = _buildCupertinoScrollbarPainter(context); + } else { + _painter! + ..textDirection = _textDirection + ..color = CupertinoDynamicColor.resolve(widget.scrollBarColor, context) + ..padding = MediaQuery.of(context).padding; + } + _triggerScrollbar(); + } + + @override + void didUpdateWidget(PlutoScrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + assert(_painter != null); + _painter!.updateThickness(_thickness, _radius!); + if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { + if (widget.isAlwaysShown == true) { + _triggerScrollbar(); + _fadeoutAnimationController.animateTo(1.0); + } else { + _fadeoutAnimationController.reverse(); + } + } + } + + /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar. + _ScrollbarPainter _buildCupertinoScrollbarPainter(BuildContext context) { + return _ScrollbarPainter( + trackColor: + CupertinoDynamicColor.resolve(widget.scrollBarTrackColor, context), + color: CupertinoDynamicColor.resolve(widget.scrollBarColor, context), + textDirection: Directionality.of(context), + thickness: _thickness, + fadeoutOpacityAnimation: _fadeoutOpacityAnimation, + mainAxisMargin: widget.mainAxisMargin, + crossAxisMargin: widget.crossAxisMargin, + radius: _radius, + padding: MediaQuery.of(context).padding, + minLength: _kScrollbarMinLength, + minOverscrollLength: _kScrollbarMinOverscrollLength, + ); + } + + // Wait one frame and cause an empty scroll event. This allows the thumb to + // show immediately when isAlwaysShown is true. A scroll event is required in + // order to paint the thumb. + void _triggerScrollbar() { + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + if (widget.isAlwaysShown) { + _fadeoutTimer?.cancel(); + if (widget.verticalController!.hasClients) { + widget.verticalController!.position.didUpdateScrollPositionBy(0); + } + } + }); + } + + // Handle a gesture that drags the scrollbar by the given amount. + void _dragScrollbar(double primaryDelta) { + assert(_currentController != null); + + // Convert primaryDelta, the amount that the scrollbar moved since the last + // time _dragScrollbar was called, into the coordinate space of the scroll + // position, and create/update the drag event with that position. + final double scrollOffsetLocal = _painter!.getTrackToScroll(primaryDelta); + final double scrollOffsetGlobal = + scrollOffsetLocal + _currentController!.position.pixels; + final Axis direction = _currentController!.position.axis; + + if (_drag == null) { + _drag = _currentController!.position.drag( + DragStartDetails( + globalPosition: direction == Axis.vertical + ? Offset(0.0, scrollOffsetGlobal) + : Offset(scrollOffsetGlobal, 0.0), + ), + () {}, + ); + } else { + _drag!.update(DragUpdateDetails( + globalPosition: direction == Axis.vertical + ? Offset(0.0, scrollOffsetGlobal) + : Offset(scrollOffsetGlobal, 0.0), + delta: direction == Axis.vertical + ? Offset(0.0, -scrollOffsetLocal) + : Offset(-scrollOffsetLocal, 0.0), + primaryDelta: -scrollOffsetLocal, + )); + } + } + + void _startFadeoutTimer() { + if (!widget.isAlwaysShown) { + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } + + Axis? _getDirection() { + try { + return _currentController!.position.axis; + } catch (_) { + // Ignore the gesture if we cannot determine the direction. + return null; + } + } + + double _pressStartAxisPosition = 0.0; + + // Long press event callbacks handle the gesture where the user long presses + // on the scrollbar thumb and then drags the scrollbar without releasing. + void _handleLongPressStart(LongPressStartDetails details) { + _currentController = _controller; + final Axis? direction = _getDirection(); + if (direction == null) { + return; + } + _fadeoutTimer?.cancel(); + _fadeoutAnimationController.forward(); + switch (direction) { + case Axis.vertical: + _pressStartAxisPosition = details.localPosition.dy; + _dragScrollbar(details.localPosition.dy); + _dragScrollbarAxisPosition = details.localPosition.dy; + break; + case Axis.horizontal: + _pressStartAxisPosition = details.localPosition.dx; + _dragScrollbar(details.localPosition.dx); + _dragScrollbarAxisPosition = details.localPosition.dx; + break; + } + } + + void _handleLongPress() { + if (_getDirection() == null) { + return; + } + _fadeoutTimer?.cancel(); + _thicknessAnimationController.forward().then( + (_) => HapticFeedback.mediumImpact(), + ); + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + final Axis? direction = _getDirection(); + if (direction == null) { + return; + } + switch (direction) { + case Axis.vertical: + _dragScrollbar(details.localPosition.dy - _dragScrollbarAxisPosition!); + _dragScrollbarAxisPosition = details.localPosition.dy; + break; + case Axis.horizontal: + _dragScrollbar(details.localPosition.dx - _dragScrollbarAxisPosition!); + _dragScrollbarAxisPosition = details.localPosition.dx; + break; + } + } + + void _handleLongPressEnd(LongPressEndDetails details) { + final Axis? direction = _getDirection(); + if (direction == null) { + return; + } + switch (direction) { + case Axis.vertical: + _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction); + if (details.velocity.pixelsPerSecond.dy.abs() < 10 && + (details.localPosition.dy - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + break; + case Axis.horizontal: + _handleDragScrollEnd(details.velocity.pixelsPerSecond.dx, direction); + if (details.velocity.pixelsPerSecond.dx.abs() < 10 && + (details.localPosition.dx - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + break; + } + _currentController = null; + } + + void _handleDragScrollEnd(double trackVelocity, Axis direction) { + _startFadeoutTimer(); + _thicknessAnimationController.reverse(); + _dragScrollbarAxisPosition = null; + final double scrollVelocity = widget.enableScrollAfterDragEnd + ? _painter!.getTrackToScroll(trackVelocity) + : 0; + _drag?.end(DragEndDetails( + primaryVelocity: -scrollVelocity, + velocity: Velocity( + pixelsPerSecond: direction == Axis.vertical + ? Offset(0.0, -scrollVelocity) + : Offset(-scrollVelocity, 0.0), + ), + )); + _drag = null; + } + + bool _handleScrollNotification(ScrollNotification notification) { + final ScrollMetrics metrics = notification.metrics; + if (metrics.maxScrollExtent <= metrics.minScrollExtent) { + return false; + } + + _currentAxis = axisDirectionToAxis(metrics.axisDirection); + + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification || + notification is UserScrollNotification) { + // Any movements always makes the scrollbar start showing up. + if (_fadeoutAnimationController.status != AnimationStatus.forward) { + _fadeoutAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + _painter!.update(metrics, metrics.axisDirection); + + // Call ScrollController.jumpTo on keyboard move. + // An error where the Thumb does not disappear + // because UserScrollNotification is called + // after ScrollEndNotification when the horizontal axis is moved. + if ((notification is UserScrollNotification) && + notification.direction == ScrollDirection.idle) { + _callFadeoutTimer(); + } + } else if (notification is ScrollEndNotification) { + // On iOS, the scrollbar can only go away once the user lifted the finger. + _callFadeoutTimer(); + } + + return false; + } + + void _callFadeoutTimer() { + if (_dragScrollbarAxisPosition == null) { + _startFadeoutTimer(); + } + } + + // Get the GestureRecognizerFactories used to detect gestures on the scrollbar + // thumb. + Map get _gestures { + final Map gestures = + {}; + + gestures[_ThumbPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( + () => _ThumbPressGestureRecognizer( + customPaintKey: _customPaintKey, + debugOwner: this, + duration: widget.longPressDuration, + onlyDraggingThumb: widget.onlyDraggingThumb, + ), + (_ThumbPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPress = _handleLongPress + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + + return gestures; + } + + @override + void dispose() { + _fadeoutAnimationController.dispose(); + _thicknessAnimationController.dispose(); + _fadeoutTimer?.cancel(); + _painter!.dispose(); + super.dispose(); + } + + bool _needUpdatePainterByHover(Axis axis) { + switch (_painter?._lastAxisDirection) { + case AxisDirection.up: + case AxisDirection.down: + return axis != Axis.vertical; + case AxisDirection.left: + case AxisDirection.right: + return axis != Axis.horizontal; + default: + return true; + } + } + + void _handleHoverExit(PointerExitEvent event) { + _callFadeoutTimer(); + } + + void _handleHover(PointerHoverEvent event) { + final hoverAxis = _getHoverAxis(event.position, event.kind, forHover: true); + if (hoverAxis == _currentHoverAxis) return; + _currentHoverAxis = hoverAxis; + + ScrollMetrics? metrics; + bool needUpdate = false; + + switch (hoverAxis) { + case _HoverAxis.vertical: + _currentAxis = Axis.vertical; + _currentController = widget.verticalController; + needUpdate = _needUpdatePainterByHover(Axis.vertical); + if (needUpdate) { + metrics = FixedScrollMetrics( + minScrollExtent: + widget.verticalController?.position.minScrollExtent, + maxScrollExtent: + widget.verticalController?.position.maxScrollExtent, + pixels: widget.verticalController?.position.pixels, + viewportDimension: + widget.verticalController?.position.viewportDimension, + axisDirection: widget.verticalController?.position.axisDirection ?? + AxisDirection.down, + devicePixelRatio: 1.0, + ); + } + break; + case _HoverAxis.horizontal: + _currentAxis = Axis.horizontal; + _currentController = widget.horizontalController; + needUpdate = _needUpdatePainterByHover(Axis.horizontal); + if (needUpdate) { + metrics = FixedScrollMetrics( + minScrollExtent: + widget.horizontalController?.position.minScrollExtent, + maxScrollExtent: + widget.horizontalController?.position.maxScrollExtent, + pixels: widget.horizontalController?.position.pixels, + viewportDimension: + widget.horizontalController?.position.viewportDimension, + axisDirection: + widget.horizontalController?.position.axisDirection ?? + AxisDirection.right, + devicePixelRatio: 1.0, + ); + } + break; + case _HoverAxis.none: + _callFadeoutTimer(); + return; + } + + if (_fadeoutAnimationController.status != AnimationStatus.forward) { + _fadeoutAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + + if (needUpdate) { + _painter!.update(metrics!, metrics.axisDirection); + } + } + + _HoverAxis _getHoverAxis( + Offset position, + PointerDeviceKind kind, { + bool forHover = false, + }) { + if (_customPaintKey.currentContext == null || _painter == null) { + return _HoverAxis.none; + } + + final RenderBox renderBox = + _customPaintKey.currentContext!.findRenderObject()! as RenderBox; + final localOffset = renderBox.globalToLocal(position); + final trackSize = renderBox.size; + final isRTL = _textDirection == TextDirection.rtl; + final hoverWidth = widget.hoverWidth; + + if (Rect.fromLTRB( + isRTL ? 0 : trackSize.width - hoverWidth, + 0, + isRTL ? hoverWidth : trackSize.width, + trackSize.height, + ).contains(localOffset)) { + return _HoverAxis.vertical; + } + + if (Rect.fromLTRB( + 0, + trackSize.height - hoverWidth, + trackSize.width, + trackSize.height, + ).contains(localOffset)) { + return _HoverAxis.horizontal; + } + + return _HoverAxis.none; + } + + @override + Widget build(BuildContext context) { + Widget child = CustomPaint( + key: _customPaintKey, + foregroundPainter: _painter, + child: RepaintBoundary(child: widget.child), + ); + + if (widget.enableHover) { + child = MouseRegion( + onExit: (PointerExitEvent event) { + switch (event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + _handleHoverExit(event); + break; + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + onHover: (PointerHoverEvent event) { + switch (event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + _handleHover(event); + break; + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + child: child, + ); + } + + return NotificationListener( + onNotification: _handleScrollNotification, + child: RepaintBoundary( + child: RawGestureDetector( + gestures: _gestures, + child: child, + ), + ), + ); + } +} + +const double _kMinInteractiveSize = 48.0; +const double _kScrollbarThickness = 6.0; +const double _kMinThumbExtent = 18.0; + +class _ScrollbarPainter extends ChangeNotifier implements CustomPainter { + /// Creates a scrollbar with customizations given by construction arguments. + _ScrollbarPainter({ + required Color color, + required this.fadeoutOpacityAnimation, + required Color trackColor, + Color trackBorderColor = const Color(0x00000000), + TextDirection? textDirection, + double thickness = _kScrollbarThickness, + EdgeInsets padding = EdgeInsets.zero, + double mainAxisMargin = 0.0, + double crossAxisMargin = 0.0, + Radius? radius, + Radius? trackRadius, + OutlinedBorder? shape, + double minLength = _kMinThumbExtent, + double? minOverscrollLength, + ScrollbarOrientation? scrollbarOrientation, + bool ignorePointer = false, + }) : assert(radius == null || shape == null), + assert(minLength >= 0), + assert(minOverscrollLength == null || minOverscrollLength <= minLength), + assert(minOverscrollLength == null || minOverscrollLength >= 0), + assert(padding.isNonNegative), + _color = color, + _textDirection = textDirection, + _thickness = thickness, + _radius = radius, + _shape = shape, + _padding = padding, + _mainAxisMargin = mainAxisMargin, + _crossAxisMargin = crossAxisMargin, + _minLength = minLength, + _trackColor = trackColor, + _trackBorderColor = trackBorderColor, + _trackRadius = trackRadius, + _scrollbarOrientation = scrollbarOrientation, + _minOverscrollLength = minOverscrollLength ?? minLength, + _ignorePointer = ignorePointer { + fadeoutOpacityAnimation.addListener(notifyListeners); + } + + /// [Color] of the thumb. Mustn't be null. + Color get color => _color; + Color _color; + set color(Color value) { + if (color == value) return; + + _color = value; + notifyListeners(); + } + + /// [Color] of the track. Mustn't be null. + Color get trackColor => _trackColor; + Color _trackColor; + set trackColor(Color value) { + if (trackColor == value) return; + + _trackColor = value; + notifyListeners(); + } + + /// [Color] of the track border. Mustn't be null. + Color get trackBorderColor => _trackBorderColor; + Color _trackBorderColor; + set trackBorderColor(Color value) { + if (trackBorderColor == value) return; + + _trackBorderColor = value; + notifyListeners(); + } + + /// [Radius] of corners of the Scrollbar's track. + /// + /// Scrollbar's track will be rectangular if [trackRadius] is null. + Radius? get trackRadius => _trackRadius; + Radius? _trackRadius; + set trackRadius(Radius? value) { + if (trackRadius == value) return; + + _trackRadius = value; + notifyListeners(); + } + + /// [TextDirection] of the [BuildContext] which dictates the side of the + /// screen the scrollbar appears in (the trailing side). Must be set prior to + /// calling paint. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + assert(value != null); + if (textDirection == value) return; + + _textDirection = value; + notifyListeners(); + } + + /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. + double get thickness => _thickness; + double _thickness; + set thickness(double value) { + if (thickness == value) return; + + _thickness = value; + notifyListeners(); + } + + /// An opacity [Animation] that dictates the opacity of the thumb. + /// Changes in value of this [Listenable] will automatically trigger repaints. + /// Mustn't be null. + final Animation fadeoutOpacityAnimation; + + /// Distance from the scrollbar's start and end to the edge of the viewport + /// in logical pixels. It affects the amount of available paint area. + /// + /// Mustn't be null and defaults to 0. + double get mainAxisMargin => _mainAxisMargin; + double _mainAxisMargin; + set mainAxisMargin(double value) { + if (mainAxisMargin == value) return; + + _mainAxisMargin = value; + notifyListeners(); + } + + /// Distance from the scrollbar thumb to the nearest cross axis edge + /// in logical pixels. + /// + /// Must not be null and defaults to 0. + double get crossAxisMargin => _crossAxisMargin; + double _crossAxisMargin; + set crossAxisMargin(double value) { + if (crossAxisMargin == value) return; + + _crossAxisMargin = value; + notifyListeners(); + } + + /// [Radius] of corners if the scrollbar should have rounded corners. + /// + /// Scrollbar will be rectangular if [radius] is null. + Radius? get radius => _radius; + Radius? _radius; + set radius(Radius? value) { + assert(shape == null || value == null); + if (radius == value) return; + + _radius = value; + notifyListeners(); + } + + /// The [OutlinedBorder] of the scrollbar's thumb. + /// + /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, + /// it's simplest to just specify [radius]. By default, the scrollbar thumb's + /// shape is a simple rectangle. + /// + /// If [shape] is specified, the thumb will take the shape of the passed + /// [OutlinedBorder] and fill itself with [color] (or grey if it + /// is unspecified). + /// + OutlinedBorder? get shape => _shape; + OutlinedBorder? _shape; + set shape(OutlinedBorder? value) { + assert(radius == null || value == null); + if (shape == value) return; + + _shape = value; + notifyListeners(); + } + + /// The amount of space by which to inset the scrollbar's start and end, as + /// well as its side to the nearest edge, in logical pixels. + /// + /// This is typically set to the current [MediaQueryData.padding] to avoid + /// partial obstructions such as display notches. If you only want additional + /// margins around the scrollbar, see [mainAxisMargin]. + /// + /// Defaults to [EdgeInsets.zero]. Must not be null and offsets from all four + /// directions must be greater than or equal to zero. + EdgeInsets get padding => _padding; + EdgeInsets _padding; + set padding(EdgeInsets value) { + if (padding == value) return; + + _padding = value; + notifyListeners(); + } + + /// The preferred smallest size the scrollbar thumb can shrink to when the total + /// scrollable extent is large, the current visible viewport is small, and the + /// viewport is not overscrolled. + /// + /// The size of the scrollbar may shrink to a smaller size than [minLength] to + /// fit in the available paint area. E.g., when [minLength] is + /// `double.infinity`, it will not be respected if + /// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. + /// + /// Mustn't be null and the value has to be greater or equal to + /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. + double get minLength => _minLength; + double _minLength; + set minLength(double value) { + if (minLength == value) return; + + _minLength = value; + notifyListeners(); + } + + /// The preferred smallest size the scrollbar thumb can shrink to when viewport is + /// overscrolled. + /// + /// When overscrolling, the size of the scrollbar may shrink to a smaller size + /// than [minOverscrollLength] to fit in the available paint area. E.g., when + /// [minOverscrollLength] is `double.infinity`, it will not be respected if + /// the [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. + /// + /// The value is less than or equal to [minLength] and greater than or equal to 0. + /// When null, it will default to the value of [minLength]. + double get minOverscrollLength => _minOverscrollLength; + double _minOverscrollLength; + set minOverscrollLength(double value) { + if (minOverscrollLength == value) return; + + _minOverscrollLength = value; + notifyListeners(); + } + + /// {@template flutter.widgets.Scrollbar.scrollbarOrientation} + /// Dictates the orientation of the scrollbar. + /// + /// [ScrollbarOrientation.top] places the scrollbar on top of the screen. + /// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen. + /// [ScrollbarOrientation.left] places the scrollbar on the left of the screen. + /// [ScrollbarOrientation.right] places the scrollbar on the right of the screen. + /// + /// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be + /// used with a vertical scroll. + /// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be + /// used with a horizontal scroll. + /// + /// For a vertical scroll the orientation defaults to + /// [ScrollbarOrientation.right] for [TextDirection.ltr] and + /// [ScrollbarOrientation.left] for [TextDirection.rtl]. + /// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom]. + /// {@endtemplate} + ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation; + ScrollbarOrientation? _scrollbarOrientation; + set scrollbarOrientation(ScrollbarOrientation? value) { + if (scrollbarOrientation == value) return; + + _scrollbarOrientation = value; + notifyListeners(); + } + + /// Whether the painter will be ignored during hit testing. + bool get ignorePointer => _ignorePointer; + bool _ignorePointer; + set ignorePointer(bool value) { + if (ignorePointer == value) return; + + _ignorePointer = value; + notifyListeners(); + } + + void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { + assert( + (_isVertical && _isVerticalOrientation(orientation)) || + (!_isVertical && !_isVerticalOrientation(orientation)), + 'The given ScrollbarOrientation: $orientation is incompatible with the current AxisDirection: $_lastAxisDirection.'); + } + + /// Check whether given scrollbar orientation is vertical + bool _isVerticalOrientation(ScrollbarOrientation orientation) => + orientation == ScrollbarOrientation.left || + orientation == ScrollbarOrientation.right; + + ScrollMetrics? _lastMetrics; + AxisDirection? _lastAxisDirection; + + ScrollMetrics? _lastVerticalMetrics; + AxisDirection? _lastVerticalAxisDirection; + + ScrollMetrics? _lastHorizontalMetrics; + AxisDirection? _lastHorizontalAxisDirection; + + Rect? _thumbRect; + Rect? _trackRect; + late double _thumbOffset; + + /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will + /// show and redraw itself based on these new metrics. + /// + /// The scrollbar will remain on screen. + void update( + ScrollMetrics metrics, + AxisDirection axisDirection, + ) { + final bool vertical = axisDirection == AxisDirection.up || + axisDirection == AxisDirection.down; + + if (vertical) { + if (_lastVerticalMetrics != null && + _lastVerticalMetrics!.extentBefore == metrics.extentBefore && + _lastVerticalMetrics!.extentInside == metrics.extentInside && + _lastVerticalMetrics!.extentAfter == metrics.extentAfter && + _lastVerticalAxisDirection == axisDirection && + _lastAxisDirection == axisDirection) { + return; + } + + _lastVerticalMetrics = metrics; + _lastVerticalAxisDirection = axisDirection; + } else { + if (_lastHorizontalMetrics != null && + _lastHorizontalMetrics!.extentBefore == metrics.extentBefore && + _lastHorizontalMetrics!.extentInside == metrics.extentInside && + _lastHorizontalMetrics!.extentAfter == metrics.extentAfter && + _lastHorizontalAxisDirection == axisDirection && + _lastAxisDirection == axisDirection) { + return; + } + + _lastHorizontalMetrics = metrics; + _lastHorizontalAxisDirection = axisDirection; + } + + final ScrollMetrics? oldMetrics = + vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; + + _lastMetrics = vertical ? _lastVerticalMetrics : _lastHorizontalMetrics; + _lastAxisDirection = + vertical ? _lastVerticalAxisDirection : _lastHorizontalAxisDirection; + + bool needPaint(ScrollMetrics? metrics) => + metrics != null && metrics.maxScrollExtent > metrics.minScrollExtent; + if (!needPaint(oldMetrics) && !needPaint(metrics)) return; + + notifyListeners(); + } + + /// Update and redraw with new scrollbar thickness and radius. + void updateThickness(double nextThickness, Radius nextRadius) { + thickness = nextThickness; + radius = nextRadius; + } + + Paint get _paintThumb { + return Paint() + ..color = + color.withValues(alpha: color.a * fadeoutOpacityAnimation.value); + } + + Paint _paintTrack({bool isBorder = false}) { + if (isBorder) { + return Paint() + ..color = trackBorderColor.withValues( + alpha: trackBorderColor.a * fadeoutOpacityAnimation.value) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + } + return Paint() + ..color = trackColor.withValues( + alpha: trackColor.a * fadeoutOpacityAnimation.value); + } + + void _paintScrollbar( + Canvas canvas, Size size, double thumbExtent, AxisDirection direction) { + assert( + textDirection != null, + 'A TextDirection must be provided before a Scrollbar can be painted.', + ); + + final ScrollbarOrientation resolvedOrientation; + + if (scrollbarOrientation == null) { + if (_isVertical) { + resolvedOrientation = textDirection == TextDirection.ltr + ? ScrollbarOrientation.right + : ScrollbarOrientation.left; + } else { + resolvedOrientation = ScrollbarOrientation.bottom; + } + } else { + resolvedOrientation = scrollbarOrientation!; + } + + final double x, y; + final Size thumbSize, trackSize; + final Offset trackOffset, borderStart, borderEnd; + + _debugAssertIsValidOrientation(resolvedOrientation); + + switch (resolvedOrientation) { + case ScrollbarOrientation.left: + thumbSize = Size(thickness, thumbExtent); + trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); + x = crossAxisMargin + padding.left; + y = _thumbOffset; + trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); + borderStart = trackOffset + Offset(trackSize.width, 0.0); + borderEnd = Offset( + trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); + break; + case ScrollbarOrientation.right: + thumbSize = Size(thickness, thumbExtent); + trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); + x = size.width - thickness - crossAxisMargin - padding.right; + y = _thumbOffset; + trackOffset = Offset(x - crossAxisMargin, mainAxisMargin); + borderStart = trackOffset; + borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); + break; + case ScrollbarOrientation.top: + thumbSize = Size(thumbExtent, thickness); + trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); + x = _thumbOffset; + y = crossAxisMargin + padding.top; + trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); + borderStart = trackOffset + Offset(0.0, trackSize.height); + borderEnd = Offset( + trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); + break; + case ScrollbarOrientation.bottom: + thumbSize = Size(thumbExtent, thickness); + trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); + x = _thumbOffset; + y = size.height - thickness - crossAxisMargin - padding.bottom; + trackOffset = Offset(mainAxisMargin, y - crossAxisMargin); + borderStart = trackOffset; + borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); + break; + } + + // Whether we paint or not, calculating these rects allows us to hit test + // when the scrollbar is transparent. + _trackRect = trackOffset & trackSize; + _thumbRect = Offset(x, y) & thumbSize; + + // Paint if the opacity dictates visibility + if (fadeoutOpacityAnimation.value != 0.0) { + // Track + if (trackRadius == null) { + canvas.drawRect(_trackRect!, _paintTrack()); + } else { + canvas.drawRRect( + RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack()); + } + // Track Border + canvas.drawLine(borderStart, borderEnd, _paintTrack(isBorder: true)); + if (radius != null) { + // Rounded rect thumb + canvas.drawRRect( + RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); + return; + } + if (shape == null) { + // Square thumb + canvas.drawRect(_thumbRect!, _paintThumb); + return; + } + // Custom-shaped thumb + final Path outerPath = shape!.getOuterPath(_thumbRect!); + canvas.drawPath(outerPath, _paintThumb); + shape!.paint(canvas, _thumbRect!); + } + } + + double _thumbExtent() { + // Thumb extent reflects fraction of content visible, as long as this + // isn't less than the absolute minimum size. + // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 + final double fractionVisible = + ((_lastMetrics!.extentInside - _mainAxisPadding) / + (_totalContentExtent - _mainAxisPadding)) + .clamp(0.0, 1.0); + + final double thumbExtent = math.max( + math.min(_trackExtent, minOverscrollLength), + _trackExtent * fractionVisible, + ); + + final double fractionOverscrolled = + 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; + final double safeMinLength = math.min(minLength, _trackExtent); + final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) + // Thumb extent is no smaller than minLength if scrolling normally. + ? safeMinLength + // User is overscrolling. Thumb extent can be less than minLength + // but no smaller than minOverscrollLength. We can't use the + // fractionVisible to produce intermediate values between minLength and + // minOverscrollLength when the user is transitioning from regular + // scrolling to overscrolling, so we instead use the percentage of the + // content that is still in the viewport to determine the size of the + // thumb. iOS behavior appears to have the thumb reach its minimum size + // with ~20% of overscroll. We map the percentage of minLength from + // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce + // values for the thumb that range between minLength and the smallest + // possible value, minOverscrollLength. + : safeMinLength * (1.0 - fractionOverscrolled.clamp(0.0, 0.2) / 0.2); + + // The `thumbExtent` should be no greater than `trackSize`, otherwise + // the scrollbar may scroll towards the wrong direction. + return thumbExtent.clamp(newMinLength, _trackExtent); + } + + @override + void dispose() { + fadeoutOpacityAnimation.removeListener(notifyListeners); + super.dispose(); + } + + bool get _isVertical => + _lastAxisDirection == AxisDirection.down || + _lastAxisDirection == AxisDirection.up; + bool get _isReversed => + _lastAxisDirection == AxisDirection.up || + _lastAxisDirection == AxisDirection.left; + // The amount of scroll distance before and after the current position. + double get _beforeExtent => + _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; + double get _afterExtent => + _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; + // Padding of the thumb track. + double get _mainAxisPadding => + _isVertical ? padding.vertical : padding.horizontal; + // The size of the thumb track. + double get _trackExtent => + _lastMetrics!.viewportDimension - 2 * mainAxisMargin - _mainAxisPadding; + + // The total size of the scrollable content. + double get _totalContentExtent { + return _lastMetrics!.maxScrollExtent - + _lastMetrics!.minScrollExtent + + _lastMetrics!.viewportDimension; + } + + /// Convert between a thumb track position and the corresponding scroll + /// position. + /// + /// thumbOffsetLocal is a position in the thumb track. Cannot be null. + double getTrackToScroll(double thumbOffsetLocal) { + final double scrollableExtent = + _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; + final double thumbMovableExtent = _trackExtent - _thumbExtent(); + + return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; + } + + // Converts between a scroll position and the corresponding position in the + // thumb track. + double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { + final double scrollableExtent = + metrics.maxScrollExtent - metrics.minScrollExtent; + + final double fractionPast = (scrollableExtent > 0) + ? ((metrics.pixels - metrics.minScrollExtent) / scrollableExtent) + .clamp(0.0, 1.0) + : 0; + + return (_isReversed ? 1 - fractionPast : fractionPast) * + (_trackExtent - thumbExtent); + } + + @override + void paint(Canvas canvas, Size size) { + if (_lastAxisDirection == null || + _lastMetrics == null || + _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { + return; + } + + // Skip painting if there's not enough space. + if (_lastMetrics!.viewportDimension <= _mainAxisPadding || + _trackExtent <= 0) { + return; + } + + final double beforePadding = _isVertical ? padding.top : padding.left; + final double thumbExtent = _thumbExtent(); + final double thumbOffsetLocal = + _getScrollToTrack(_lastMetrics!, thumbExtent); + _thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding; + + // Do not paint a scrollbar if the scroll view is infinitely long. + // TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434 + if (_lastMetrics!.maxScrollExtent.isInfinite) return; + + return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!); + } + + bool get _lastMetricsAreScrollable => + _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; + + /// Same as hitTest, but includes some padding when the [PointerEvent] is + /// caused by [PointerDeviceKind.touch] to make sure that the region + /// isn't too small to be interacted with by the user. + /// + /// The hit test area for hovering with [PointerDeviceKind.mouse] over the + /// scrollbar also uses this extra padding. This is to make it easier to + /// interact with the scrollbar by presenting it to the mouse for interaction + /// based on proximity. When `forHover` is true, the larger hit test area will + /// be used. + bool hitTestInteractive(Offset position, PointerDeviceKind kind, + {bool forHover = false}) { + if (_trackRect == null) { + // We have not computed the scrollbar position yet. + return false; + } + if (ignorePointer) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + final Rect interactiveRect = _trackRect!; + final Rect paddedRect = interactiveRect.expandToInclude( + Rect.fromCircle( + center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), + ); + + // The scrollbar is not able to be hit when transparent - except when + // hovering with a mouse. This should bring the scrollbar into view so the + // mouse can interact with it. + if (fadeoutOpacityAnimation.value == 0.0) { + if (forHover && kind == PointerDeviceKind.mouse) { + return paddedRect.contains(position); + } + return false; + } + + switch (kind) { + case PointerDeviceKind.touch: + return paddedRect.contains(position); + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] + // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 + return interactiveRect.contains(position); + } + } + + /// Same as hitTestInteractive, but excludes the track portion of the scrollbar. + /// Used to evaluate interactions with only the scrollbar thumb. + bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) { + if (_thumbRect == null) { + return false; + } + if (ignorePointer) { + return false; + } + // The thumb is not able to be hit when transparent. + if (fadeoutOpacityAnimation.value == 0.0) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + switch (kind) { + case PointerDeviceKind.touch: + final Rect touchThumbRect = _thumbRect!.expandToInclude( + Rect.fromCircle( + center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), + ); + return touchThumbRect.contains(position); + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind] + // TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604 + return _thumbRect!.contains(position); + } + } + + // Scrollbars are interactive. + @override + bool? hitTest(Offset? position) { + if (_thumbRect == null) { + return null; + } + if (ignorePointer) { + return false; + } + + // The thumb is not able to be hit when transparent. + if (fadeoutOpacityAnimation.value == 0.0) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + return _trackRect!.contains(position!); + } + + @override + bool shouldRepaint(_ScrollbarPainter oldDelegate) { + // Should repaint if any properties changed. + return color != oldDelegate.color || + trackColor != oldDelegate.trackColor || + trackBorderColor != oldDelegate.trackBorderColor || + textDirection != oldDelegate.textDirection || + thickness != oldDelegate.thickness || + fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation || + mainAxisMargin != oldDelegate.mainAxisMargin || + crossAxisMargin != oldDelegate.crossAxisMargin || + radius != oldDelegate.radius || + trackRadius != oldDelegate.trackRadius || + shape != oldDelegate.shape || + padding != oldDelegate.padding || + minLength != oldDelegate.minLength || + minOverscrollLength != oldDelegate.minOverscrollLength || + scrollbarOrientation != oldDelegate.scrollbarOrientation || + ignorePointer != oldDelegate.ignorePointer; + } + + @override + bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + String toString() => describeIdentity(this); +} + +String describeIdentity(Object? object) => + '${objectRuntimeType(object, '')}#${shortHash(object)}'; + +String objectRuntimeType(Object? object, String optimizedValue) { + assert(() { + optimizedValue = object.runtimeType.toString(); + return true; + }()); + return optimizedValue; +} + +String shortHash(Object? object) { + return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0'); +} + +// A longpress gesture detector that only responds to events on the scrollbar's +// thumb and ignores everything else. +class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { + _ThumbPressGestureRecognizer({ + required GlobalKey customPaintKey, + required Object super.debugOwner, + required Duration super.duration, + this.onlyDraggingThumb = false, + }) : _customPaintKey = customPaintKey; + + final GlobalKey _customPaintKey; + final bool onlyDraggingThumb; + + @override + bool isPointerAllowed(PointerDownEvent event) { + if (!_hitTestInteractive( + _customPaintKey, event.position, event.kind, onlyDraggingThumb)) { + return false; + } + return super.isPointerAllowed(event); + } +} + +// foregroundPainter also hit tests its children by default, but the +// scrollbar should only respond to a gesture directly on its thumb, so +// manually check for a hit on the thumb here. +bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, + PointerDeviceKind kind, bool onlyDraggingThumb) { + if (customPaintKey.currentContext == null) { + return false; + } + final CustomPaint customPaint = + customPaintKey.currentContext!.widget as CustomPaint; + final _ScrollbarPainter painter = + customPaint.foregroundPainter! as _ScrollbarPainter; + final Offset localOffset = _getLocalOffset(customPaintKey, offset); + // We can only receive track taps that are on the thumb. + return onlyDraggingThumb + ? painter.hitTestOnlyThumbInteractive(localOffset, kind) + : painter.hitTestInteractive(localOffset, kind); +} + +Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { + final RenderBox renderBox = + scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + return renderBox.globalToLocal(position); +} + +enum _HoverAxis { + vertical, + horizontal, + none; + + bool get isVertical => this == _HoverAxis.vertical; + bool get isHorizontal => this == _HoverAxis.horizontal; + bool get isNone => this == _HoverAxis.none; +} diff --git a/lib/src/widgets/pluto_shadow_container.dart b/lib/src/widgets/pluto_shadow_container.dart index 7f1e414a..fae54a6c 100644 --- a/lib/src/widgets/pluto_shadow_container.dart +++ b/lib/src/widgets/pluto_shadow_container.dart @@ -1,61 +1,61 @@ -import 'package:flutter/material.dart'; - -class PlutoShadowContainer extends StatelessWidget { - final double width; - - final double height; - - final EdgeInsetsGeometry padding; - - final Color backgroundColor; - - final Color borderColor; - - final AlignmentGeometry alignment; - - final Widget child; - - const PlutoShadowContainer({ - super.key, - required this.width, - required this.height, - required this.child, - this.padding = const EdgeInsets.symmetric( - horizontal: 10, - ), - this.backgroundColor = Colors.white, - this.borderColor = const Color(0xFFA1A5AE), - this.alignment = Alignment.centerLeft, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: width, - height: height, - child: DecoratedBox( - decoration: BoxDecoration( - color: backgroundColor, - border: Border.all( - color: borderColor, - ), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.5), - spreadRadius: 5, - blurRadius: 7, - offset: const Offset(0, 3), // changes position of shadow - ), - ], - ), - child: Padding( - padding: padding, - child: Align( - alignment: alignment, - child: child, - ), - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +class PlutoShadowContainer extends StatelessWidget { + final double width; + + final double height; + + final EdgeInsetsGeometry padding; + + final Color backgroundColor; + + final Color borderColor; + + final AlignmentGeometry alignment; + + final Widget child; + + const PlutoShadowContainer({ + super.key, + required this.width, + required this.height, + required this.child, + this.padding = const EdgeInsets.symmetric( + horizontal: 10, + ), + this.backgroundColor = Colors.white, + this.borderColor = const Color(0xFFA1A5AE), + this.alignment = Alignment.centerLeft, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + color: borderColor, + ), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.5), + spreadRadius: 5, + blurRadius: 7, + offset: const Offset(0, 3), // changes position of shadow + ), + ], + ), + child: Padding( + padding: padding, + child: Align( + alignment: alignment, + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/pluto_shadow_line.dart b/lib/src/widgets/pluto_shadow_line.dart index 5ab0b03a..2a0c3418 100644 --- a/lib/src/widgets/pluto_shadow_line.dart +++ b/lib/src/widgets/pluto_shadow_line.dart @@ -1,41 +1,41 @@ -import 'package:flutter/material.dart'; - -class PlutoShadowLine extends StatelessWidget { - final Axis? axis; - final bool? reverse; - final Color? color; - final bool? shadow; - - const PlutoShadowLine({ - this.axis, - this.reverse, - this.color, - this.shadow, - super.key, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: axis == Axis.vertical ? 1 : 0, - height: axis == Axis.horizontal ? 1 : 0, - child: DecoratedBox( - decoration: BoxDecoration( - color: color ?? Colors.black, - boxShadow: shadow == true - ? [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.15), - spreadRadius: 1, - blurRadius: 3, - offset: reverse == true - ? const Offset(-3, -3) - : const Offset(3, 3), // changes position of shadow - ), - ] - : [], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +class PlutoShadowLine extends StatelessWidget { + final Axis? axis; + final bool? reverse; + final Color? color; + final bool? shadow; + + const PlutoShadowLine({ + this.axis, + this.reverse, + this.color, + this.shadow, + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: axis == Axis.vertical ? 1 : 0, + height: axis == Axis.horizontal ? 1 : 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: color ?? Colors.black, + boxShadow: shadow == true + ? [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.15), + spreadRadius: 1, + blurRadius: 3, + offset: reverse == true + ? const Offset(-3, -3) + : const Offset(3, 3), // changes position of shadow + ), + ] + : [], + ), + ), + ); + } +} diff --git a/test/src/manager/state/hovering_row_state_test.dart b/test/src/manager/state/hovering_row_state_test.dart index da52c254..e5a7ee6a 100644 --- a/test/src/manager/state/hovering_row_state_test.dart +++ b/test/src/manager/state/hovering_row_state_test.dart @@ -38,7 +38,7 @@ void main() { group('setHoveredRowIdx', () { test( 'If the rowIdx passed as an argument is the same as' - 'hoveredRowIdx, then notifyListeners should not be called.', + 'hoveredRowIdx, then notifyListeners should not be called.', () { // given stateManager.setHoveredRowIdx(1); @@ -55,7 +55,7 @@ void main() { test( 'If the rowIdx passed as an argument is different from ' - 'hoveredRowIdx, notifyListeners should be called.', + 'hoveredRowIdx, notifyListeners should be called.', () { // given stateManager.setHoveredRowIdx(1); @@ -73,8 +73,8 @@ void main() { test( 'If the rowIdx passed as an argument is different from ' - 'hoveredRowIdx, but notify is false,' - 'notifyListeners should not be called.', + 'hoveredRowIdx, but notify is false,' + 'notifyListeners should not be called.', () { // given stateManager.setHoveredRowIdx(1); diff --git a/test/src/pluto_grid_test.dart b/test/src/pluto_grid_test.dart index 303831cb..161fdf24 100644 --- a/test/src/pluto_grid_test.dart +++ b/test/src/pluto_grid_test.dart @@ -1,1731 +1,1731 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:pluto_grid_plus/pluto_grid_plus.dart'; - -import '../helper/column_helper.dart'; -import '../helper/row_helper.dart'; -import '../helper/test_helper_util.dart'; -import '../matcher/pluto_object_matcher.dart'; -import '../mock/mock_methods.dart'; - -void main() { - const columnWidth = PlutoGridSettings.columnWidth; - - const ValueKey sortableGestureKey = ValueKey( - 'ColumnTitleSortableGesture', - ); - - testWidgets( - 'Directionality 가 rtl 인 경우 rtl 상태가 적용 되어야 한다.', - (WidgetTester tester) async { - // given - late final PlutoGridStateManager stateManager; - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Directionality( - textDirection: TextDirection.rtl, - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(stateManager.isLTR, false); - expect(stateManager.isRTL, true); - }, - ); - - testWidgets( - 'Directionality 가 rtl 인 경우 컬럼의 frozen 에 따라 방향에 맞게 위치해야 한다.', - (WidgetTester tester) async { - // given - await TestHelperUtil.changeWidth( - tester: tester, - width: 1400, - height: 600, - ); - final columns = ColumnHelper.textColumn('header', count: 6); - final rows = RowHelper.count(3, columns); - - columns[0].frozen = PlutoColumnFrozen.start; - columns[1].frozen = PlutoColumnFrozen.end; - columns[2].frozen = PlutoColumnFrozen.start; - columns[3].frozen = PlutoColumnFrozen.end; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Directionality( - textDirection: TextDirection.rtl, - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - final firstStartColumn = find.text('header0'); - final secondStartColumn = find.text('header2'); - final firstBodyColumn = find.text('header4'); - final secondBodyColumn = find.text('header5'); - final firstEndColumn = find.text('header1'); - final secondEndColumn = find.text('header3'); - - final firstStartColumnDx = tester.getTopRight(firstStartColumn).dx; - final secondStartColumnDx = tester.getTopRight(secondStartColumn).dx; - final firstBodyColumnDx = tester.getTopRight(firstBodyColumn).dx; - final secondBodyColumnDx = tester.getTopRight(secondBodyColumn).dx; - // frozen.end 컬럼은 전체 넓이로 인해 중앙 빈공간이 있어 좌측에서 위치 확인 - final firstEndColumnDx = tester.getTopLeft(firstEndColumn).dx; - final secondEndColumnDx = tester.getTopLeft(secondEndColumn).dx; - - double expectOffset = columnWidth; - expect(firstStartColumnDx - secondStartColumnDx, expectOffset); - - expectOffset = columnWidth + PlutoGridSettings.gridBorderWidth; - expect(secondStartColumnDx - firstBodyColumnDx, expectOffset); - - expectOffset = columnWidth; - expect(firstBodyColumnDx - secondBodyColumnDx, expectOffset); - - // end 컬럼은 중앙 컬럼보다 좌측에 위치해야 한다. - expect(firstEndColumnDx, lessThan(secondBodyColumnDx - columnWidth)); - - expectOffset = columnWidth; - expect(firstEndColumnDx - secondEndColumnDx, expectOffset); - }, - ); - - testWidgets('createFooter 를 설정 한 경우 footer 가 출력 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - createFooter: (stateManager) { - return const Text('Footer widget.'); - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final footer = find.text('Footer widget.'); - expect(footer, findsOneWidget); - }); - - testWidgets( - 'header 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - createHeader: (stateManager) { - return PlutoPagination(stateManager); - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final found = find.byType(PlutoPagination); - expect(found, findsOneWidget); - }); - - testWidgets( - 'footer 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - createFooter: (stateManager) { - return PlutoPagination(stateManager); - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final found = find.byType(PlutoPagination); - expect(found, findsOneWidget); - }); - - testWidgets('cell 값이 출력 되어야 한다.', (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - final cell1 = find.text('header0 value 0'); - expect(cell1, findsOneWidget); - - final cell2 = find.text('header0 value 1'); - expect(cell2, findsOneWidget); - - final cell3 = find.text('header0 value 2'); - expect(cell3, findsOneWidget); - }); - - testWidgets('header 탭 후 정렬 되어야 한다.', (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - Finder sortableGesture = find.descendant( - of: find.byKey(columns.first.key), - matching: find.byKey(sortableGestureKey), - ); - - // then - await tester.tap(sortableGesture); - // Ascending - expect(rows[0].cells['header0']!.value, 'header0 value 0'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - - await tester.tap(sortableGesture); - // Descending - expect(rows[0].cells['header0']!.value, 'header0 value 2'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 0'); - - await tester.tap(sortableGesture); - // Original - expect(rows[0].cells['header0']!.value, 'header0 value 0'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - }); - - testWidgets('셀 값 변경 후 헤더를 탭하면 변경 된 값에 맞게 정렬 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = ColumnHelper.textColumn('header'); - final rows = RowHelper.count(3, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - Finder firstCell = find.byKey(rows.first.cells['header0']!.key); - - // 셀 선택 - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - expect(stateManager!.isEditing, false); - - // 수정 상태로 변경 - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - // 수정 상태 확인 - expect(stateManager!.isEditing, true); - - // (1) - // await tester.pump(Duration(milliseconds:800)); - // - // await tester.enterText( - // find.descendant(of: firstCell, matching: find.byType(TextField)), - // 'cell value4'); - // (2) - stateManager! - .changeCellValue(stateManager!.currentCell!, 'header0 value 4'); - - // 다음 행으로 이동 - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - - expect(rows[0].cells['header0']!.value, 'header0 value 4'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - - Finder sortableGesture = find.descendant( - of: find.byKey(columns.first.key), - matching: find.byKey(sortableGestureKey), - ); - - await tester.tap(sortableGesture); - // Ascending - expect(rows[0].cells['header0']!.value, 'header0 value 1'); - expect(rows[1].cells['header0']!.value, 'header0 value 2'); - expect(rows[2].cells['header0']!.value, 'header0 value 4'); - - await tester.tap(sortableGesture); - // Descending - expect(rows[0].cells['header0']!.value, 'header0 value 4'); - expect(rows[1].cells['header0']!.value, 'header0 value 2'); - expect(rows[2].cells['header0']!.value, 'header0 value 1'); - - await tester.tap(sortableGesture); - // Original - expect(rows[0].cells['header0']!.value, 'header0 value 4'); - expect(rows[1].cells['header0']!.value, 'header0 value 1'); - expect(rows[2].cells['header0']!.value, 'header0 value 2'); - }); - - testWidgets( - 'WHEN selecting a specific cell without grid header' - 'THEN That cell should be selected.', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 10), - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - // first cell of first column - Finder firstCell = find.byKey(rows.first.cells['header0']!.key); - - // select first cell - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - Offset selectedCellOffset = - tester.getCenter(find.byKey(rows[7].cells['header3']!.key)); - - stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); - - // then - expect(stateManager!.currentSelectingPosition!.rowIdx, 7); - expect(stateManager!.currentSelectingPosition!.columnIdx, 3); - }); - - testWidgets( - 'WHEN selecting a specific cell with grid header' - 'THEN That cell should be selected.', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 10), - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - createHeader: (stateManager) => const Text('grid header'), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - // first cell of first column - Finder firstCell = find.byKey(rows.first.cells['header0']!.key); - - // select first cell - await tester.tap( - find.descendant(of: firstCell, matching: find.byType(GestureDetector))); - - Offset selectedCellOffset = - tester.getCenter(find.byKey(rows[5].cells['header3']!.key)); - - stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); - - // then - expect(stateManager!.currentSelectingPosition!.rowIdx, 5); - expect(stateManager!.currentSelectingPosition!.columnIdx, 3); - }); - - group('applyColumnRowOnInit', () { - testWidgets( - 'number column' - 'WHEN applyFormatOnInit value of Column is true(default value)' - 'THEN cell value of the column should be changed to format.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), - PlutoRow(cells: {'header': PlutoCell(value: 12)}), - PlutoRow(cells: {'header': PlutoCell(value: '12')}), - PlutoRow(cells: {'header': PlutoCell(value: -10)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), - PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 0); - expect(stateManager!.rows[1].cells['header']!.value, 12); - expect(stateManager!.rows[2].cells['header']!.value, 12); - expect(stateManager!.rows[3].cells['header']!.value, -10); - expect(stateManager!.rows[4].cells['header']!.value, 1234567); - expect(stateManager!.rows[5].cells['header']!.value, 12); - }); - - testWidgets( - 'number column' - 'WHEN applyFormatOnInit value of Column is false' - 'THEN cell value of the column should not be changed to format.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(applyFormatOnInit: false), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), - PlutoRow(cells: {'header': PlutoCell(value: 12)}), - PlutoRow(cells: {'header': PlutoCell(value: '12')}), - PlutoRow(cells: {'header': PlutoCell(value: -10)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), - PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 'not a number'); - expect(stateManager!.rows[1].cells['header']!.value, 12); - expect(stateManager!.rows[2].cells['header']!.value, '12'); - expect(stateManager!.rows[3].cells['header']!.value, -10); - expect(stateManager!.rows[4].cells['header']!.value, 1234567); - expect(stateManager!.rows[5].cells['header']!.value, 12.12345); - }); - - testWidgets( - 'number column' - 'WHEN format allows prime numbers' - 'THEN cell value should be displayed as a decimal number according to the number of digits in the format.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(format: '#,###.#####'), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567.1234)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567.12345)}), - PlutoRow(cells: {'header': PlutoCell(value: 1234567.123456)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 1234567); - expect(stateManager!.rows[1].cells['header']!.value, 1234567.1234); - expect(stateManager!.rows[2].cells['header']!.value, 1234567.12345); - expect(stateManager!.rows[3].cells['header']!.value, 1234567.12346); - }); - - testWidgets( - 'number column' - 'WHEN negative is false' - 'THEN negative numbers should not be displayed in the cell value.', - (WidgetTester tester) async { - // given - final columns = [ - PlutoColumn( - title: 'header', - field: 'header', - type: PlutoColumnType.number(negative: false), - ), - ]; - - final rows = [ - PlutoRow(cells: {'header': PlutoCell(value: 12345)}), - PlutoRow(cells: {'header': PlutoCell(value: -12345)}), - PlutoRow(cells: {'header': PlutoCell(value: 333.333)}), - PlutoRow(cells: {'header': PlutoCell(value: -333.333)}), - PlutoRow(cells: {'header': PlutoCell(value: 0)}), - PlutoRow(cells: {'header': PlutoCell(value: -0)}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].cells['header']!.value, 12345); - expect(stateManager!.rows[1].cells['header']!.value, 0); - expect(stateManager!.rows[2].cells['header']!.value, 333); - expect(stateManager!.rows[3].cells['header']!.value, 0); - expect(stateManager!.rows[4].cells['header']!.value, 0); - expect(stateManager!.rows[5].cells['header']!.value, 0); - }); - - testWidgets( - 'WHEN Row does not have sortIdx' - 'THEN sortIdx must be set in Row', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 1), - ]; - final rows = [ - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - createHeader: (stateManager) => const Text('grid header'), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].sortIdx, 0); - expect(stateManager!.rows[1].sortIdx, 1); - expect(stateManager!.rows[2].sortIdx, 2); - expect(stateManager!.rows[3].sortIdx, 3); - expect(stateManager!.rows[4].sortIdx, 4); - }); - - testWidgets( - 'WHEN Row has sortIdx' - 'THEN sortIdx is reset.', (WidgetTester tester) async { - // given - final columns = [ - ...ColumnHelper.textColumn('header', count: 1), - ]; - final rows = [ - PlutoRow(sortIdx: 5, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 6, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 7, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 8, cells: {'header0': PlutoCell(value: 'value')}), - PlutoRow(sortIdx: 9, cells: {'header0': PlutoCell(value: 'value')}), - ]; - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - createHeader: (stateManager) => const Text('grid header'), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // then - expect(stateManager!.rows[0].sortIdx, 0); - expect(stateManager!.rows[1].sortIdx, 1); - expect(stateManager!.rows[2].sortIdx, 2); - expect(stateManager!.rows[3].sortIdx, 3); - expect(stateManager!.rows[4].sortIdx, 4); - }); - }); - - group('moveColumn', () { - testWidgets( - '고정 컬럼이 없는 상태에서 ' - '0번 컬럼을 2번 컬럼으로 이동.', (WidgetTester tester) async { - // given - List columns = [ - ...ColumnHelper.textColumn('body', count: 10, width: 100), - ]; - - List rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - stateManager!.moveColumn(column: columns[0], targetColumn: columns[2]); - - // then - expect(columns[0].title, 'body1'); - expect(columns[1].title, 'body2'); - expect(columns[2].title, 'body0'); - }); - - testWidgets( - '고정 컬럼이 없는 상태에서 ' - '9번 컬럼을 0번 컬럼으로 이동.', (WidgetTester tester) async { - // given - List columns = [ - ...ColumnHelper.textColumn('body', count: 10, width: 100), - ]; - - List rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // when - stateManager!.moveColumn(column: columns[9], targetColumn: columns[0]); - - // then - expect(columns[0].title, 'body9'); - expect(columns[1].title, 'body0'); - expect(columns[2].title, 'body1'); - expect(columns[3].title, 'body2'); - expect(columns[4].title, 'body3'); - expect(columns[5].title, 'body4'); - expect(columns[6].title, 'body5'); - expect(columns[7].title, 'body6'); - expect(columns[8].title, 'body7'); - expect(columns[9].title, 'body8'); - }); - - testWidgets('넓이가 충분하지 않은 상태에서 고정 컬럼으로 설정하면 설정 되지 않아야 한다.', - (WidgetTester tester) async { - // given - List columns = [ - ...ColumnHelper.textColumn('body', count: 10, width: 100), - ]; - - List rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SizedBox( - width: 50, - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ), - ); - - stateManager! - .setLayout(const BoxConstraints(maxWidth: 50, maxHeight: 300)); - - // when - stateManager!.toggleFrozenColumn(columns[3], PlutoColumnFrozen.start); - - await tester.pumpAndSettle(const Duration(seconds: 1)); - - // then - expect(columns[0].title, 'body0'); - expect(columns[1].title, 'body1'); - expect(columns[2].title, 'body2'); - expect(columns[3].title, 'body3'); - expect(columns[3].frozen, PlutoColumnFrozen.none); - expect(columns[4].title, 'body4'); - expect(columns[5].title, 'body5'); - expect(columns[6].title, 'body6'); - expect(columns[7].title, 'body7'); - expect(columns[8].title, 'body8'); - expect(columns[9].title, 'body9'); - }); - }); - - testWidgets('editing 상태에서 shift + 우측 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태에서 shift + 좌측 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태에서 shift + 위쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태에서 shift + 아래쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - expect(stateManager!.isEditing, true); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. - expect(stateManager!.currentSelectingPosition, null); - // 이동도 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 우측 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - await tester.pumpAndSettle(); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 3); - expect(stateManager!.currentSelectingPosition!.rowIdx, 1); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 좌측 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 1); - expect(stateManager!.currentSelectingPosition!.rowIdx, 1); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 위쪽 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 2); - expect(stateManager!.currentSelectingPosition!.rowIdx, 0); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('editing 상태가 아니면, shift + 아래쪽 방향키 입력 시 셀이 선택 되어야 한다.', - (WidgetTester tester) async { - // given - final columns = [ - ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, - ...ColumnHelper.textColumn('headerB', count: 3), - ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, - ]; - final rows = RowHelper.count(10, columns); - - PlutoGridStateManager? stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // 1 번 컬럼의 1번 행의 셀 선택 - Finder currentCell = find.text('headerB1 value 1'); - - await tester.tap(currentCell); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - - // editing true - expect(stateManager!.isEditing, false); - - // 쉬프트 + 우측 키 입력 - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); - - expect(stateManager!.currentCell!.value, 'headerB1 value 1'); - // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. - expect(stateManager!.currentSelectingPosition!.columnIdx, 2); - expect(stateManager!.currentSelectingPosition!.rowIdx, 2); - // 현재 선택 셀은 이동 되지 않아야 한다. - expect(stateManager!.currentCellPosition!.columnIdx, 2); - expect(stateManager!.currentCellPosition!.rowIdx, 1); - }); - - testWidgets('showLoading 을 호출 하면 Loading 위젯이 나타나야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - stateManager.setShowLoading(true); - - await tester.pump(); - - expect(find.byType(PlutoLoading), findsOneWidget); - }); - - testWidgets( - 'showLoading 을 rows 레벨로 호출 하면 LinearProgressIndicator 위젯이 나타나야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - stateManager.setShowLoading(true, level: PlutoGridLoadingLevel.rows); - - await tester.pump(); - - expect(find.byType(LinearProgressIndicator), findsOneWidget); - }); - - testWidgets( - 'showLoading 을 rowsBottomCircular 레벨로 호출 하면 CircularProgressIndicator 위젯이 나타나야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (PlutoGridOnLoadedEvent event) { - stateManager = event.stateManager; - }, - ), - ), - ), - ); - - stateManager.setShowLoading( - true, - level: PlutoGridLoadingLevel.rowsBottomCircular, - ); - - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }); - - testWidgets('showLoading 을 호출 하지 않으면 Loading 위젯이 나타나지 않아야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - ), - ), - ), - ); - - await tester.pump(); - - expect(find.byType(PlutoLoading), findsNothing); - }); - - testWidgets('select 모드에서 첫번째 숨김 컬럼이 있는 경우 두번째 컬럼이 현재 컬럼으로 첫 셀이 선택 되어야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - - columns.first.hide = true; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - mode: PlutoGridMode.select, - onLoaded: (e) => stateManager = e.stateManager, - ), - ), - ), - ); - - await tester.pump(); - - expect(stateManager.currentColumn!.title, 'column1'); - expect(stateManager.currentCell!.value, 'column1 value 0'); - }); - - testWidgets('normal 모드에서 readOnly 모드로 변경 하면 셀이 편집 불가 상태가 되어야 한다.', - (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - const ValueKey buttonKey = ValueKey('setReadOnly'); - PlutoGridMode mode = PlutoGridMode.normal; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: StatefulBuilder( - builder: (context, setState) { - return PlutoGrid( - columns: columns, - rows: rows, - mode: mode, - onLoaded: (e) => stateManager = e.stateManager, - createHeader: (s) => TextButton( - key: buttonKey, - onPressed: () { - setState(() { - mode = PlutoGridMode.readOnly; - }); - }, - child: const Text('set readOnly'), - ), - ); - }, - ), - ), - ), - ); - - await tester.pump(); - - await tester.tap(find.text('column0 value 0')); - await tester.pump(); - await tester.tap(find.text('column0 value 0')); - await tester.pump(); - expect(stateManager.isEditing, true); - - await tester.tap(find.byKey(buttonKey)); - await tester.pumpAndSettle(); - expect(stateManager.mode, PlutoGridMode.readOnly); - expect(stateManager.isEditing, false); - }); - - testWidgets('셀 값을 변경하면 onChanged 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onChanged: mock.oneParamReturnVoid, - ), - ), - ), - ); - - await tester.pump(); - - final sampleCell = find.text('column1 value 2'); - - await tester.tap(sampleCell); - await tester.pump(); - await tester.tap(sampleCell); - await tester.pump(); - - await tester.enterText(sampleCell, 'text'); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.row == rows[2] && - e.column == columns[1] && - e.rowIdx == 2 && - e.columnIdx == 1 && - e.value == 'text' && - e.oldValue == 'column1 value 2'; - }))).called(1); - }); - - testWidgets('컬럼을 좌측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - onColumnsMoved: - mock.oneParamReturnVoid, - ), - ), - ), - ); - - stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.start); - await tester.pump(); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.idx == 1 && e.visualIdx == 0 && e.columns.length == 1; - }))).called(1); - }); - - testWidgets('컬럼을 우측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - onColumnsMoved: - mock.oneParamReturnVoid, - ), - ), - ), - ); - - stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.end); - await tester.pump(); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.idx == 1 && e.visualIdx == 9 && e.columns.length == 1; - }))).called(1); - }); - - testWidgets('컬럼을 드래그하여 이동하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { - final mock = MockMethods(); - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onColumnsMoved: - mock.oneParamReturnVoid, - ), - ), - ), - ); - - final sampleColumn = find.text('column1'); - - await tester.drag(sampleColumn, const Offset(400, 0)); - - await tester.pumpAndSettle(const Duration(milliseconds: 300)); - - verify(mock.oneParamReturnVoid( - PlutoObjectMatcher(rule: (e) { - return e.idx == 3 && e.visualIdx == 3 && e.columns.length == 1; - }))).called(1); - }); - - group('noRowsWidget', () { - testWidgets('행이 없는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = []; - const noRowsWidget = Center( - key: ValueKey('NoRowsWidget'), - child: Text('There are no rows.'), - ); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - noRowsWidget: noRowsWidget, - ), - ), - ), - ); - - expect(find.byKey(noRowsWidget.key!), findsOneWidget); - }); - - testWidgets('행을 삭제하는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = RowHelper.count(10, columns); - late final PlutoGridStateManager stateManager; - const noRowsWidget = Center( - key: ValueKey('NoRowsWidget'), - child: Text('There are no rows.'), - ); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - noRowsWidget: noRowsWidget, - ), - ), - ), - ); - - expect(find.byKey(noRowsWidget.key!), findsNothing); - - stateManager.removeAllRows(); - - await tester.pump(const Duration(milliseconds: 350)); - - expect(find.byKey(noRowsWidget.key!), findsOneWidget); - }); - - testWidgets('행을 추가하는 경우 noRowsWidget 이 렌더링 되지 않아야 한다.', (tester) async { - final columns = ColumnHelper.textColumn('column', count: 10); - final rows = []; - late final PlutoGridStateManager stateManager; - const noRowsWidget = Center( - key: ValueKey('NoRowsWidget'), - child: Text('There are no rows.'), - ); - - // when - await tester.pumpWidget( - MaterialApp( - home: Material( - child: PlutoGrid( - columns: columns, - rows: rows, - onLoaded: (e) => stateManager = e.stateManager, - noRowsWidget: noRowsWidget, - ), - ), - ), - ); - - expect(find.byKey(noRowsWidget.key!), findsOneWidget); - - stateManager.appendNewRows(); - - await tester.pumpAndSettle(const Duration(milliseconds: 350)); - - expect(find.byKey(noRowsWidget.key!), findsNothing); - }); - }); -} +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pluto_grid_plus/pluto_grid_plus.dart'; + +import '../helper/column_helper.dart'; +import '../helper/row_helper.dart'; +import '../helper/test_helper_util.dart'; +import '../matcher/pluto_object_matcher.dart'; +import '../mock/mock_methods.dart'; + +void main() { + const columnWidth = PlutoGridSettings.columnWidth; + + const ValueKey sortableGestureKey = ValueKey( + 'ColumnTitleSortableGesture', + ); + + testWidgets( + 'Directionality 가 rtl 인 경우 rtl 상태가 적용 되어야 한다.', + (WidgetTester tester) async { + // given + late final PlutoGridStateManager stateManager; + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(stateManager.isLTR, false); + expect(stateManager.isRTL, true); + }, + ); + + testWidgets( + 'Directionality 가 rtl 인 경우 컬럼의 frozen 에 따라 방향에 맞게 위치해야 한다.', + (WidgetTester tester) async { + // given + await TestHelperUtil.changeWidth( + tester: tester, + width: 1400, + height: 600, + ); + final columns = ColumnHelper.textColumn('header', count: 6); + final rows = RowHelper.count(3, columns); + + columns[0].frozen = PlutoColumnFrozen.start; + columns[1].frozen = PlutoColumnFrozen.end; + columns[2].frozen = PlutoColumnFrozen.start; + columns[3].frozen = PlutoColumnFrozen.end; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final firstStartColumn = find.text('header0'); + final secondStartColumn = find.text('header2'); + final firstBodyColumn = find.text('header4'); + final secondBodyColumn = find.text('header5'); + final firstEndColumn = find.text('header1'); + final secondEndColumn = find.text('header3'); + + final firstStartColumnDx = tester.getTopRight(firstStartColumn).dx; + final secondStartColumnDx = tester.getTopRight(secondStartColumn).dx; + final firstBodyColumnDx = tester.getTopRight(firstBodyColumn).dx; + final secondBodyColumnDx = tester.getTopRight(secondBodyColumn).dx; + // frozen.end 컬럼은 전체 넓이로 인해 중앙 빈공간이 있어 좌측에서 위치 확인 + final firstEndColumnDx = tester.getTopLeft(firstEndColumn).dx; + final secondEndColumnDx = tester.getTopLeft(secondEndColumn).dx; + + double expectOffset = columnWidth; + expect(firstStartColumnDx - secondStartColumnDx, expectOffset); + + expectOffset = columnWidth + PlutoGridSettings.gridBorderWidth; + expect(secondStartColumnDx - firstBodyColumnDx, expectOffset); + + expectOffset = columnWidth; + expect(firstBodyColumnDx - secondBodyColumnDx, expectOffset); + + // end 컬럼은 중앙 컬럼보다 좌측에 위치해야 한다. + expect(firstEndColumnDx, lessThan(secondBodyColumnDx - columnWidth)); + + expectOffset = columnWidth; + expect(firstEndColumnDx - secondEndColumnDx, expectOffset); + }, + ); + + testWidgets('createFooter 를 설정 한 경우 footer 가 출력 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + createFooter: (stateManager) { + return const Text('Footer widget.'); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final footer = find.text('Footer widget.'); + expect(footer, findsOneWidget); + }); + + testWidgets( + 'header 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + createHeader: (stateManager) { + return PlutoPagination(stateManager); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final found = find.byType(PlutoPagination); + expect(found, findsOneWidget); + }); + + testWidgets( + 'footer 에 PlutoPagination 을 설정 한 경우 PlutoPagination 가 렌더링 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + createFooter: (stateManager) { + return PlutoPagination(stateManager); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final found = find.byType(PlutoPagination); + expect(found, findsOneWidget); + }); + + testWidgets('cell 값이 출력 되어야 한다.', (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + final cell1 = find.text('header0 value 0'); + expect(cell1, findsOneWidget); + + final cell2 = find.text('header0 value 1'); + expect(cell2, findsOneWidget); + + final cell3 = find.text('header0 value 2'); + expect(cell3, findsOneWidget); + }); + + testWidgets('header 탭 후 정렬 되어야 한다.', (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + Finder sortableGesture = find.descendant( + of: find.byKey(columns.first.key), + matching: find.byKey(sortableGestureKey), + ); + + // then + await tester.tap(sortableGesture); + // Ascending + expect(rows[0].cells['header0']!.value, 'header0 value 0'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + + await tester.tap(sortableGesture); + // Descending + expect(rows[0].cells['header0']!.value, 'header0 value 2'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 0'); + + await tester.tap(sortableGesture); + // Original + expect(rows[0].cells['header0']!.value, 'header0 value 0'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + }); + + testWidgets('셀 값 변경 후 헤더를 탭하면 변경 된 값에 맞게 정렬 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = ColumnHelper.textColumn('header'); + final rows = RowHelper.count(3, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + Finder firstCell = find.byKey(rows.first.cells['header0']!.key); + + // 셀 선택 + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + expect(stateManager!.isEditing, false); + + // 수정 상태로 변경 + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + // 수정 상태 확인 + expect(stateManager!.isEditing, true); + + // (1) + // await tester.pump(Duration(milliseconds:800)); + // + // await tester.enterText( + // find.descendant(of: firstCell, matching: find.byType(TextField)), + // 'cell value4'); + // (2) + stateManager! + .changeCellValue(stateManager!.currentCell!, 'header0 value 4'); + + // 다음 행으로 이동 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(rows[0].cells['header0']!.value, 'header0 value 4'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + + Finder sortableGesture = find.descendant( + of: find.byKey(columns.first.key), + matching: find.byKey(sortableGestureKey), + ); + + await tester.tap(sortableGesture); + // Ascending + expect(rows[0].cells['header0']!.value, 'header0 value 1'); + expect(rows[1].cells['header0']!.value, 'header0 value 2'); + expect(rows[2].cells['header0']!.value, 'header0 value 4'); + + await tester.tap(sortableGesture); + // Descending + expect(rows[0].cells['header0']!.value, 'header0 value 4'); + expect(rows[1].cells['header0']!.value, 'header0 value 2'); + expect(rows[2].cells['header0']!.value, 'header0 value 1'); + + await tester.tap(sortableGesture); + // Original + expect(rows[0].cells['header0']!.value, 'header0 value 4'); + expect(rows[1].cells['header0']!.value, 'header0 value 1'); + expect(rows[2].cells['header0']!.value, 'header0 value 2'); + }); + + testWidgets( + 'WHEN selecting a specific cell without grid header' + 'THEN That cell should be selected.', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 10), + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + // first cell of first column + Finder firstCell = find.byKey(rows.first.cells['header0']!.key); + + // select first cell + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + Offset selectedCellOffset = + tester.getCenter(find.byKey(rows[7].cells['header3']!.key)); + + stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); + + // then + expect(stateManager!.currentSelectingPosition!.rowIdx, 7); + expect(stateManager!.currentSelectingPosition!.columnIdx, 3); + }); + + testWidgets( + 'WHEN selecting a specific cell with grid header' + 'THEN That cell should be selected.', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 10), + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + createHeader: (stateManager) => const Text('grid header'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + // first cell of first column + Finder firstCell = find.byKey(rows.first.cells['header0']!.key); + + // select first cell + await tester.tap( + find.descendant(of: firstCell, matching: find.byType(GestureDetector))); + + Offset selectedCellOffset = + tester.getCenter(find.byKey(rows[5].cells['header3']!.key)); + + stateManager!.setCurrentSelectingPositionWithOffset(selectedCellOffset); + + // then + expect(stateManager!.currentSelectingPosition!.rowIdx, 5); + expect(stateManager!.currentSelectingPosition!.columnIdx, 3); + }); + + group('applyColumnRowOnInit', () { + testWidgets( + 'number column' + 'WHEN applyFormatOnInit value of Column is true(default value)' + 'THEN cell value of the column should be changed to format.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), + PlutoRow(cells: {'header': PlutoCell(value: 12)}), + PlutoRow(cells: {'header': PlutoCell(value: '12')}), + PlutoRow(cells: {'header': PlutoCell(value: -10)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), + PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 0); + expect(stateManager!.rows[1].cells['header']!.value, 12); + expect(stateManager!.rows[2].cells['header']!.value, 12); + expect(stateManager!.rows[3].cells['header']!.value, -10); + expect(stateManager!.rows[4].cells['header']!.value, 1234567); + expect(stateManager!.rows[5].cells['header']!.value, 12); + }); + + testWidgets( + 'number column' + 'WHEN applyFormatOnInit value of Column is false' + 'THEN cell value of the column should not be changed to format.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(applyFormatOnInit: false), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 'not a number')}), + PlutoRow(cells: {'header': PlutoCell(value: 12)}), + PlutoRow(cells: {'header': PlutoCell(value: '12')}), + PlutoRow(cells: {'header': PlutoCell(value: -10)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), + PlutoRow(cells: {'header': PlutoCell(value: 12.12345)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 'not a number'); + expect(stateManager!.rows[1].cells['header']!.value, 12); + expect(stateManager!.rows[2].cells['header']!.value, '12'); + expect(stateManager!.rows[3].cells['header']!.value, -10); + expect(stateManager!.rows[4].cells['header']!.value, 1234567); + expect(stateManager!.rows[5].cells['header']!.value, 12.12345); + }); + + testWidgets( + 'number column' + 'WHEN format allows prime numbers' + 'THEN cell value should be displayed as a decimal number according to the number of digits in the format.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(format: '#,###.#####'), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 1234567)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567.1234)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567.12345)}), + PlutoRow(cells: {'header': PlutoCell(value: 1234567.123456)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 1234567); + expect(stateManager!.rows[1].cells['header']!.value, 1234567.1234); + expect(stateManager!.rows[2].cells['header']!.value, 1234567.12345); + expect(stateManager!.rows[3].cells['header']!.value, 1234567.12346); + }); + + testWidgets( + 'number column' + 'WHEN negative is false' + 'THEN negative numbers should not be displayed in the cell value.', + (WidgetTester tester) async { + // given + final columns = [ + PlutoColumn( + title: 'header', + field: 'header', + type: PlutoColumnType.number(negative: false), + ), + ]; + + final rows = [ + PlutoRow(cells: {'header': PlutoCell(value: 12345)}), + PlutoRow(cells: {'header': PlutoCell(value: -12345)}), + PlutoRow(cells: {'header': PlutoCell(value: 333.333)}), + PlutoRow(cells: {'header': PlutoCell(value: -333.333)}), + PlutoRow(cells: {'header': PlutoCell(value: 0)}), + PlutoRow(cells: {'header': PlutoCell(value: -0)}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].cells['header']!.value, 12345); + expect(stateManager!.rows[1].cells['header']!.value, 0); + expect(stateManager!.rows[2].cells['header']!.value, 333); + expect(stateManager!.rows[3].cells['header']!.value, 0); + expect(stateManager!.rows[4].cells['header']!.value, 0); + expect(stateManager!.rows[5].cells['header']!.value, 0); + }); + + testWidgets( + 'WHEN Row does not have sortIdx' + 'THEN sortIdx must be set in Row', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 1), + ]; + final rows = [ + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(cells: {'header0': PlutoCell(value: 'value')}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + createHeader: (stateManager) => const Text('grid header'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].sortIdx, 0); + expect(stateManager!.rows[1].sortIdx, 1); + expect(stateManager!.rows[2].sortIdx, 2); + expect(stateManager!.rows[3].sortIdx, 3); + expect(stateManager!.rows[4].sortIdx, 4); + }); + + testWidgets( + 'WHEN Row has sortIdx' + 'THEN sortIdx is reset.', (WidgetTester tester) async { + // given + final columns = [ + ...ColumnHelper.textColumn('header', count: 1), + ]; + final rows = [ + PlutoRow(sortIdx: 5, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 6, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 7, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 8, cells: {'header0': PlutoCell(value: 'value')}), + PlutoRow(sortIdx: 9, cells: {'header0': PlutoCell(value: 'value')}), + ]; + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + createHeader: (stateManager) => const Text('grid header'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // then + expect(stateManager!.rows[0].sortIdx, 0); + expect(stateManager!.rows[1].sortIdx, 1); + expect(stateManager!.rows[2].sortIdx, 2); + expect(stateManager!.rows[3].sortIdx, 3); + expect(stateManager!.rows[4].sortIdx, 4); + }); + }); + + group('moveColumn', () { + testWidgets( + '고정 컬럼이 없는 상태에서 ' + '0번 컬럼을 2번 컬럼으로 이동.', (WidgetTester tester) async { + // given + List columns = [ + ...ColumnHelper.textColumn('body', count: 10, width: 100), + ]; + + List rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + stateManager!.moveColumn(column: columns[0], targetColumn: columns[2]); + + // then + expect(columns[0].title, 'body1'); + expect(columns[1].title, 'body2'); + expect(columns[2].title, 'body0'); + }); + + testWidgets( + '고정 컬럼이 없는 상태에서 ' + '9번 컬럼을 0번 컬럼으로 이동.', (WidgetTester tester) async { + // given + List columns = [ + ...ColumnHelper.textColumn('body', count: 10, width: 100), + ]; + + List rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // when + stateManager!.moveColumn(column: columns[9], targetColumn: columns[0]); + + // then + expect(columns[0].title, 'body9'); + expect(columns[1].title, 'body0'); + expect(columns[2].title, 'body1'); + expect(columns[3].title, 'body2'); + expect(columns[4].title, 'body3'); + expect(columns[5].title, 'body4'); + expect(columns[6].title, 'body5'); + expect(columns[7].title, 'body6'); + expect(columns[8].title, 'body7'); + expect(columns[9].title, 'body8'); + }); + + testWidgets('넓이가 충분하지 않은 상태에서 고정 컬럼으로 설정하면 설정 되지 않아야 한다.', + (WidgetTester tester) async { + // given + List columns = [ + ...ColumnHelper.textColumn('body', count: 10, width: 100), + ]; + + List rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 50, + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ), + ); + + stateManager! + .setLayout(const BoxConstraints(maxWidth: 50, maxHeight: 300)); + + // when + stateManager!.toggleFrozenColumn(columns[3], PlutoColumnFrozen.start); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // then + expect(columns[0].title, 'body0'); + expect(columns[1].title, 'body1'); + expect(columns[2].title, 'body2'); + expect(columns[3].title, 'body3'); + expect(columns[3].frozen, PlutoColumnFrozen.none); + expect(columns[4].title, 'body4'); + expect(columns[5].title, 'body5'); + expect(columns[6].title, 'body6'); + expect(columns[7].title, 'body7'); + expect(columns[8].title, 'body8'); + expect(columns[9].title, 'body9'); + }); + }); + + testWidgets('editing 상태에서 shift + 우측 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태에서 shift + 좌측 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태에서 shift + 위쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태에서 shift + 아래쪽 방향키 입력 시 셀이 선택 되지 않아야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + expect(stateManager!.isEditing, true); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태에서 shift + 방향키 입력 시 셀이 선택 되지 않아야 한다. + expect(stateManager!.currentSelectingPosition, null); + // 이동도 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 우측 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + await tester.pumpAndSettle(); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 3); + expect(stateManager!.currentSelectingPosition!.rowIdx, 1); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 좌측 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 1); + expect(stateManager!.currentSelectingPosition!.rowIdx, 1); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 위쪽 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 2); + expect(stateManager!.currentSelectingPosition!.rowIdx, 0); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('editing 상태가 아니면, shift + 아래쪽 방향키 입력 시 셀이 선택 되어야 한다.', + (WidgetTester tester) async { + // given + final columns = [ + ColumnHelper.textColumn('headerL', frozen: PlutoColumnFrozen.start).first, + ...ColumnHelper.textColumn('headerB', count: 3), + ColumnHelper.textColumn('headerR', frozen: PlutoColumnFrozen.end).first, + ]; + final rows = RowHelper.count(10, columns); + + PlutoGridStateManager? stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // 1 번 컬럼의 1번 행의 셀 선택 + Finder currentCell = find.text('headerB1 value 1'); + + await tester.tap(currentCell); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + + // editing true + expect(stateManager!.isEditing, false); + + // 쉬프트 + 우측 키 입력 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(stateManager!.currentCell!.value, 'headerB1 value 1'); + // editing 상태가 아니면 shift + 방향키 입력 시 셀이 선택 되어야 한다. + expect(stateManager!.currentSelectingPosition!.columnIdx, 2); + expect(stateManager!.currentSelectingPosition!.rowIdx, 2); + // 현재 선택 셀은 이동 되지 않아야 한다. + expect(stateManager!.currentCellPosition!.columnIdx, 2); + expect(stateManager!.currentCellPosition!.rowIdx, 1); + }); + + testWidgets('showLoading 을 호출 하면 Loading 위젯이 나타나야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + stateManager.setShowLoading(true); + + await tester.pump(); + + expect(find.byType(PlutoLoading), findsOneWidget); + }); + + testWidgets( + 'showLoading 을 rows 레벨로 호출 하면 LinearProgressIndicator 위젯이 나타나야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + stateManager.setShowLoading(true, level: PlutoGridLoadingLevel.rows); + + await tester.pump(); + + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + + testWidgets( + 'showLoading 을 rowsBottomCircular 레벨로 호출 하면 CircularProgressIndicator 위젯이 나타나야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (PlutoGridOnLoadedEvent event) { + stateManager = event.stateManager; + }, + ), + ), + ), + ); + + stateManager.setShowLoading( + true, + level: PlutoGridLoadingLevel.rowsBottomCircular, + ); + + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('showLoading 을 호출 하지 않으면 Loading 위젯이 나타나지 않아야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + + await tester.pump(); + + expect(find.byType(PlutoLoading), findsNothing); + }); + + testWidgets('select 모드에서 첫번째 숨김 컬럼이 있는 경우 두번째 컬럼이 현재 컬럼으로 첫 셀이 선택 되어야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + + columns.first.hide = true; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + mode: PlutoGridMode.select, + onLoaded: (e) => stateManager = e.stateManager, + ), + ), + ), + ); + + await tester.pump(); + + expect(stateManager.currentColumn!.title, 'column1'); + expect(stateManager.currentCell!.value, 'column1 value 0'); + }); + + testWidgets('normal 모드에서 readOnly 모드로 변경 하면 셀이 편집 불가 상태가 되어야 한다.', + (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + const ValueKey buttonKey = ValueKey('setReadOnly'); + PlutoGridMode mode = PlutoGridMode.normal; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (context, setState) { + return PlutoGrid( + columns: columns, + rows: rows, + mode: mode, + onLoaded: (e) => stateManager = e.stateManager, + createHeader: (s) => TextButton( + key: buttonKey, + onPressed: () { + setState(() { + mode = PlutoGridMode.readOnly; + }); + }, + child: const Text('set readOnly'), + ), + ); + }, + ), + ), + ), + ); + + await tester.pump(); + + await tester.tap(find.text('column0 value 0')); + await tester.pump(); + await tester.tap(find.text('column0 value 0')); + await tester.pump(); + expect(stateManager.isEditing, true); + + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + expect(stateManager.mode, PlutoGridMode.readOnly); + expect(stateManager.isEditing, false); + }); + + testWidgets('셀 값을 변경하면 onChanged 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onChanged: mock.oneParamReturnVoid, + ), + ), + ), + ); + + await tester.pump(); + + final sampleCell = find.text('column1 value 2'); + + await tester.tap(sampleCell); + await tester.pump(); + await tester.tap(sampleCell); + await tester.pump(); + + await tester.enterText(sampleCell, 'text'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.row == rows[2] && + e.column == columns[1] && + e.rowIdx == 2 && + e.columnIdx == 1 && + e.value == 'text' && + e.oldValue == 'column1 value 2'; + }))).called(1); + }); + + testWidgets('컬럼을 좌측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + onColumnsMoved: + mock.oneParamReturnVoid, + ), + ), + ), + ); + + stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.start); + await tester.pump(); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.idx == 1 && e.visualIdx == 0 && e.columns.length == 1; + }))).called(1); + }); + + testWidgets('컬럼을 우측 고정 하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + onColumnsMoved: + mock.oneParamReturnVoid, + ), + ), + ), + ); + + stateManager.toggleFrozenColumn(columns[1], PlutoColumnFrozen.end); + await tester.pump(); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.idx == 1 && e.visualIdx == 9 && e.columns.length == 1; + }))).called(1); + }); + + testWidgets('컬럼을 드래그하여 이동하면 onColumnsMoved 콜백이 호출 되어야 한다.', (tester) async { + final mock = MockMethods(); + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onColumnsMoved: + mock.oneParamReturnVoid, + ), + ), + ), + ); + + final sampleColumn = find.text('column1'); + + await tester.drag(sampleColumn, const Offset(400, 0)); + + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + + verify(mock.oneParamReturnVoid( + PlutoObjectMatcher(rule: (e) { + return e.idx == 3 && e.visualIdx == 3 && e.columns.length == 1; + }))).called(1); + }); + + group('noRowsWidget', () { + testWidgets('행이 없는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = []; + const noRowsWidget = Center( + key: ValueKey('NoRowsWidget'), + child: Text('There are no rows.'), + ); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + noRowsWidget: noRowsWidget, + ), + ), + ), + ); + + expect(find.byKey(noRowsWidget.key!), findsOneWidget); + }); + + testWidgets('행을 삭제하는 경우 noRowsWidget 이 렌더링 되어야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = RowHelper.count(10, columns); + late final PlutoGridStateManager stateManager; + const noRowsWidget = Center( + key: ValueKey('NoRowsWidget'), + child: Text('There are no rows.'), + ); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + noRowsWidget: noRowsWidget, + ), + ), + ), + ); + + expect(find.byKey(noRowsWidget.key!), findsNothing); + + stateManager.removeAllRows(); + + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byKey(noRowsWidget.key!), findsOneWidget); + }); + + testWidgets('행을 추가하는 경우 noRowsWidget 이 렌더링 되지 않아야 한다.', (tester) async { + final columns = ColumnHelper.textColumn('column', count: 10); + final rows = []; + late final PlutoGridStateManager stateManager; + const noRowsWidget = Center( + key: ValueKey('NoRowsWidget'), + child: Text('There are no rows.'), + ); + + // when + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlutoGrid( + columns: columns, + rows: rows, + onLoaded: (e) => stateManager = e.stateManager, + noRowsWidget: noRowsWidget, + ), + ), + ), + ); + + expect(find.byKey(noRowsWidget.key!), findsOneWidget); + + stateManager.appendNewRows(); + + await tester.pumpAndSettle(const Duration(milliseconds: 350)); + + expect(find.byKey(noRowsWidget.key!), findsNothing); + }); + }); +}