diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index e834391bc7a0a..5839f51f216a5 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -21,6 +21,7 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import 'package:provider/provider.dart'; @@ -37,6 +38,7 @@ import 'widgets/footer/grid_footer.dart'; import 'widgets/header/grid_header.dart'; import 'widgets/row/row.dart'; import 'widgets/shortcuts.dart'; +import 'widgets/selection_controller.dart'; class ToggleExtensionNotifier extends ChangeNotifier { bool _isToggled = false; @@ -131,9 +133,14 @@ class _GridPageState extends State { shrinkWrapped: widget.shrinkWrap, )..add(const GridEvent.initial()); + late final GridSelectionController selectionController = GridSelectionController( + getRowInfos: () => gridBloc.state.rowInfos, + ); + @override void dispose() { gridBloc.close(); + selectionController.dispose(); super.dispose(); } @@ -149,35 +156,38 @@ class _GridPageState extends State { ..add(PageAccessLevelEvent.initial()), ), ], - child: BlocListener( - listener: (context, state) { - final action = state.action; - if (action?.type == ActionType.openRow && - action?.objectId == widget.view.id) { - final rowId = action!.arguments?[ActionArgumentKeys.rowId]; - if (rowId != null) { - // If Reminder in existing database is pressed - // then open the row - _openRow(context, rowId); + child: ChangeNotifierProvider.value( + value: selectionController, + child: BlocListener( + listener: (context, state) { + final action = state.action; + if (action?.type == ActionType.openRow && + action?.objectId == widget.view.id) { + final rowId = action!.arguments?[ActionArgumentKeys.rowId]; + if (rowId != null) { + // If Reminder in existing database is pressed + // then open the row + _openRow(context, rowId); + } } - } - }, - child: BlocConsumer( - listener: listener, - builder: (context, state) => state.loadingState.map( - idle: (_) => const SizedBox.shrink(), - loading: (_) => const Center( - child: CircularProgressIndicator.adaptive(), - ), - finish: (result) => result.successOrFail.fold( - (_) => GridShortcuts( - child: GridPageContent( - key: ValueKey(widget.view.id), - view: widget.view, - shrinkWrap: widget.shrinkWrap, + }, + child: BlocConsumer( + listener: listener, + builder: (context, state) => state.loadingState.map( + idle: (_) => const SizedBox.shrink(), + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + finish: (result) => result.successOrFail.fold( + (_) => GridShortcuts( + child: GridPageContent( + key: ValueKey(widget.view.id), + view: widget.view, + shrinkWrap: widget.shrinkWrap, + ), ), + (err) => Center(child: AppFlowyErrorPage(error: err)), ), - (err) => Center(child: AppFlowyErrorPage(error: err)), ), ), ), @@ -363,6 +373,7 @@ class _GridRows extends StatefulWidget { class _GridRowsState extends State<_GridRows> { bool showFloatingCalculations = false; bool isAtBottom = false; + int? _dragStartRowIndex; @override void initState() { @@ -455,6 +466,57 @@ class _GridRowsState extends State<_GridRows> { ); } + final horizontalPadding = context.read().horizontalPadding; + final compactMode = context.read().databaseController.compactModeNotifier.value; + final rowHeight = compactMode ? GridSize.compactRowHeight : GridSize.rowHeight; + + child = Listener( + onPointerDown: (PointerDownEvent event) { + final localX = event.localPosition.dx; + final localY = event.localPosition.dy; + if (localX >= paddingLeft && localX <= paddingLeft + horizontalPadding) { + final scrollOffset = widget.scrollController.verticalController.offset; + final absoluteY = localY + scrollOffset; + final clickedRowIndex = (absoluteY ~/ rowHeight); + + final selection = context.read(); + final rowInfos = selection.getRowInfos(); + if (clickedRowIndex >= 0 && clickedRowIndex < rowInfos.length) { + final isShift = HardwareKeyboard.instance.isShiftPressed; + final isMeta = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed; + + _dragStartRowIndex = clickedRowIndex; + selection.selectRow( + rowInfos[clickedRowIndex].rowId, + isMultiSelect: isMeta, + isRangeSelect: isShift, + ); + } + } + }, + onPointerMove: (PointerMoveEvent event) { + if (_dragStartRowIndex != null) { + final localY = event.localPosition.dy; + final scrollOffset = widget.scrollController.verticalController.offset; + final absoluteY = localY + scrollOffset; + final currentRowIndex = (absoluteY ~/ rowHeight); + + final selection = context.read(); + final rowInfos = selection.getRowInfos(); + if (rowInfos.isEmpty) return; + final clampedIndex = currentRowIndex.clamp(0, rowInfos.length - 1).toInt(); + selection.selectRange(_dragStartRowIndex!, clampedIndex); + } + }, + onPointerUp: (PointerUpEvent event) { + _dragStartRowIndex = null; + }, + onPointerCancel: (PointerCancelEvent event) { + _dragStartRowIndex = null; + }, + child: child, + ); + if (widget.shrinkWrap) { return child; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart index 78a8c97daecb5..3356bcae3c239 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart @@ -12,6 +12,10 @@ class GridSize { static double get footerHeight => 36 * scale; + static double get rowHeight => 36 * scale; + + static double get compactRowHeight => 32 * scale; + static double get horizontalHeaderPadding => UniversalPlatform.isDesktop ? 40 * scale : 16 * scale; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart index d212c507464fc..434bb7bd156f6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart @@ -10,6 +10,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import '../selection_controller.dart'; + class RowActionMenu extends StatelessWidget { const RowActionMenu({ @@ -132,11 +135,21 @@ enum RowAction { RowBackendService.duplicateRow(viewId, rowId); break; case delete: + final selection = context.read(); + final rowsToDelete = selection.isSelected(rowId) + ? selection.selectedRowIds.toList() + : [rowId]; + showConfirmDeletionDialog( context: context, name: LocaleKeys.grid_row_label.tr(), description: LocaleKeys.grid_row_deleteRowPrompt.tr(), - onConfirm: () => RowBackendService.deleteRows(viewId, [rowId]), + onConfirm: () { + RowBackendService.deleteRows(viewId, rowsToDelete); + if (selection.isSelected(rowId)) { + selection.clearSelection(); + } + }, ); break; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart index 2306767f46b0c..88ef42ccfdb0d 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart @@ -21,6 +21,8 @@ import '../../../../widgets/row/accessory/cell_accessory.dart'; import '../../../../widgets/row/cells/cell_container.dart'; import '../../layout/sizes.dart'; import 'action.dart'; +import '../selection_controller.dart'; + class GridRow extends StatelessWidget { const GridRow({ @@ -81,7 +83,18 @@ class GridRow extends StatelessWidget { ); } - return rowContent; + return Consumer( + builder: (context, selection, child) { + final isSelected = selection.isSelected(rowId); + return Container( + color: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + child: child, + ); + }, + child: rowContent, + ); } } @@ -111,8 +124,13 @@ class _RowLeadingState extends State<_RowLeading> { margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), popupBuilder: (_) { final bloc = context.read(); - return BlocProvider.value( - value: context.read(), + return MultiProvider( + providers: [ + BlocProvider.value(value: context.read()), + ChangeNotifierProvider.value( + value: context.read(), + ), + ], child: RowActionMenu( viewId: bloc.viewId, rowId: bloc.rowId, @@ -313,7 +331,7 @@ class RowContent extends StatelessWidget { builder: (context, compactMode, _) { return Container( width: GridSize.newPropertyButtonWidth, - constraints: BoxConstraints(minHeight: compactMode ? 32 : 36), + constraints: BoxConstraints(minHeight: compactMode ? GridSize.compactRowHeight : GridSize.rowHeight), decoration: BoxDecoration( border: Border( bottom: diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/selection_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/selection_controller.dart new file mode 100644 index 0000000000000..fa8bcbf489ec4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/selection_controller.dart @@ -0,0 +1,94 @@ +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; + +class GridSelectionController extends ChangeNotifier { + GridSelectionController({required this.getRowInfos}); + + final List Function() getRowInfos; + final Set _selectedRowIds = {}; + String? _lastSelectedRowId; + + UnmodifiableSetView get selectedRowIds => + UnmodifiableSetView(_selectedRowIds); + bool get hasSelection => _selectedRowIds.isNotEmpty; + + bool isSelected(String rowId) => _selectedRowIds.contains(rowId); + + void selectRow( + String rowId, { + bool isMultiSelect = false, + bool isRangeSelect = false, + }) { + if (isRangeSelect && _lastSelectedRowId != null) { + final rowInfos = getRowInfos(); + final startIndex = + rowInfos.indexWhere((r) => r.rowId == _lastSelectedRowId); + final endIndex = rowInfos.indexWhere((r) => r.rowId == rowId); + if (startIndex != -1 && endIndex != -1) { + _applyRange(startIndex, endIndex, clearFirst: !isMultiSelect); + } + // Don't update _lastSelectedRowId on range select so subsequent + // shift-clicks extend from the original anchor. + } else if (isMultiSelect) { + if (_selectedRowIds.contains(rowId)) { + _selectedRowIds.remove(rowId); + } else { + _selectedRowIds.add(rowId); + } + _lastSelectedRowId = rowId; + } else { + _selectedRowIds.clear(); + _selectedRowIds.add(rowId); + _lastSelectedRowId = rowId; + } + notifyListeners(); + } + + void selectRange(int startIndex, int endIndex) { + _applyRange(startIndex, endIndex, clearFirst: true); + // Update _lastSelectedRowId to the end of the range so subsequent + // shift-clicks anchor correctly after a drag. + final rowInfos = getRowInfos(); + final clampedEnd = endIndex.clamp(0, rowInfos.length - 1); + if (rowInfos.isNotEmpty) { + _lastSelectedRowId = rowInfos[clampedEnd].rowId; + } + notifyListeners(); + } + + void clearSelection() { + if (_selectedRowIds.isNotEmpty) { + _selectedRowIds.clear(); + _lastSelectedRowId = null; + notifyListeners(); + } + } + + void selectAll() { + final rowInfos = getRowInfos(); + _selectedRowIds.clear(); + _selectedRowIds.addAll(rowInfos.map((r) => r.rowId)); + if (rowInfos.isNotEmpty) { + _lastSelectedRowId = rowInfos.first.rowId; + } + notifyListeners(); + } + + /// Shared helper that selects all rows between [startIndex] and [endIndex]. + void _applyRange(int startIndex, int endIndex, {required bool clearFirst}) { + final rowInfos = getRowInfos(); + final start = startIndex < endIndex ? startIndex : endIndex; + final end = startIndex < endIndex ? endIndex : startIndex; + + if (clearFirst) { + _selectedRowIds.clear(); + } + for (int i = start; i <= end; i++) { + if (i >= 0 && i < rowInfos.length) { + _selectedRowIds.add(rowInfos[i].rowId); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart index 7d743702fc8d3..4a8bbc9222675 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart @@ -1,5 +1,13 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/selection_controller.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; class GridShortcuts extends StatelessWidget { const GridShortcuts({required this.child, super.key}); @@ -8,37 +16,54 @@ class GridShortcuts extends StatelessWidget { @override Widget build(BuildContext context) { - return Shortcuts( - shortcuts: bindKeys([]), - child: Actions( - dispatcher: LoggingActionDispatcher(), - actions: const {}, - child: child, - ), + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.delete): () => _deleteSelectedRows(context), + const SingleActivator(LogicalKeyboardKey.backspace): () => _deleteSelectedRows(context), + const SingleActivator(LogicalKeyboardKey.escape): () => _clearSelection(context), + const SingleActivator(LogicalKeyboardKey.keyA, control: true): () => _selectAllRows(context), + const SingleActivator(LogicalKeyboardKey.keyA, meta: true): () => _selectAllRows(context), + }, + child: child, ); } -} -Map bindKeys(List keys) { - return {for (final key in keys) LogicalKeySet(key): KeyboardKeyIdent(key)}; -} + void _deleteSelectedRows(BuildContext context) { + final primaryFocus = FocusManager.instance.primaryFocus; + if (primaryFocus != null && primaryFocus.context != null) { + bool isTextInputFocused = false; + primaryFocus.context!.visitAncestorElements((element) { + if (element.widget is EditableText || element.widget is TextField) { + isTextInputFocused = true; + return false; + } + return true; + }); + if (isTextInputFocused) return; + } -class KeyboardKeyIdent extends Intent { - const KeyboardKeyIdent(this.key); + final selection = context.read(); + if (selection.hasSelection) { + final selectedIds = selection.selectedRowIds.toList(); + final viewId = context.read().viewId; - final KeyboardKey key; -} + showConfirmDeletionDialog( + context: context, + name: LocaleKeys.grid_row_label.tr(), + description: LocaleKeys.grid_row_deleteRowPrompt.tr(), + onConfirm: () { + RowBackendService.deleteRows(viewId, selectedIds); + selection.clearSelection(); + }, + ); + } + } -class LoggingActionDispatcher extends ActionDispatcher { - @override - Object? invokeAction( - covariant Action action, - covariant Intent intent, [ - BuildContext? context, - ]) { - // print('Action invoked: $action($intent) from $context'); - super.invokeAction(action, intent, context); - - return null; + void _clearSelection(BuildContext context) { + context.read().clearSelection(); + } + + void _selectAllRows(BuildContext context) { + context.read().selectAll(); } }