diff --git a/.gitignore b/.gitignore index 035c9b2..d41caa2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ ios/.generated/ .project .settings .vscode -pubspec.lock \ No newline at end of file +pubspec.lock +.metadata diff --git a/packages/swayze/CHANGELOG.md b/packages/swayze/CHANGELOG.md index 9356f23..da9023e 100644 --- a/packages/swayze/CHANGELOG.md +++ b/packages/swayze/CHANGELOG.md @@ -1,10 +1,18 @@ +# 1.2.0 + +- Adds drag and fill support. When enabled on the `SwayzeConfig`, applications can then react to + `FillIntoTargetIntent` and `FillIntoUnknownIntent`. +- Deprecates `id` property on `UserSelectionModel`. +- Allow resizing of columns or rows. +- Add drag and drop for columns and rows. + # 1.1.0 - Bump `swayze_math` from 1.0.0 to 1.1.0 # 1.0.3 -- Fix `SwayzeSliverTable` using `Navigator`'s `overlay` property instead of fetching the closest +- Fix `SwayzeSliverTable` using `Navigator`'s `overlay` property instead of fetching the closest `Overlay` ancestor. # 1.0.2 diff --git a/packages/swayze/README.md b/packages/swayze/README.md index 7f9c394..fd213da 100644 --- a/packages/swayze/README.md +++ b/packages/swayze/README.md @@ -70,3 +70,9 @@ final myStyle = SwayzeStyle.defaultSwayzeStyle.copyWith( selectionStyle: SelectionStyle(color: Colors.pink), ); ``` + + +### Example + +Take a look at [example/lib/main.dart](example/lib/main.dart) for a working example. + diff --git a/packages/swayze/example/.gitignore b/packages/swayze/example/.gitignore new file mode 100644 index 0000000..445fab3 --- /dev/null +++ b/packages/swayze/example/.gitignore @@ -0,0 +1,54 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +/macos +/android +/ios +/windows +/linux +/web +/test diff --git a/packages/swayze/example/README.md b/packages/swayze/example/README.md new file mode 100644 index 0000000..4472e4e --- /dev/null +++ b/packages/swayze/example/README.md @@ -0,0 +1,3 @@ +# Swayze Example + +A testbed for swayze. \ No newline at end of file diff --git a/packages/swayze/example/analysis_options.yaml b/packages/swayze/example/analysis_options.yaml new file mode 100644 index 0000000..f0f2301 --- /dev/null +++ b/packages/swayze/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:rows_lint/analysis_options.yaml diff --git a/packages/swayze/example/lib/backend/fake_cells_backend.dart b/packages/swayze/example/lib/backend/fake_cells_backend.dart new file mode 100644 index 0000000..27c83fc --- /dev/null +++ b/packages/swayze/example/lib/backend/fake_cells_backend.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; + +final map = { + 0: ''' +[ + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-0-0", + "position":{ "x":0, "y":0 }, + "value":"Bold", + "type":"TEXT", + "style":{ + "isBold":true + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-0-1", + "position":{ "x":0, "y":1 }, + "value":"Italic", + "type":"TEXT", + "style":{ + "isItalic":true + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-0-2", + "position":{ "x":0, "y":2 }, + "value":"Underline", + "type":"TEXT", + "style":{ + "isUnderline":true + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-0-3", + "position":{ "x":0, "y":3 }, + "value":"Background color", + "type":"TEXT", + "style":{ + "fontColor":"#2c2c2c", + "backgroundColor":"#ab5cb3" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-1-0", + "position":{ "x":1, "y":0 }, + "value":"Align left", + "type":"TEXT", + "style":{ + "alignment":"LEFT" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-1-1", + "position":{ "x":1, "y":1 }, + "value":"Align right", + "type":"TEXT", + "style":{ + "alignment":"RIGHT" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-1-2", + "position":{ "x":1, "y":2 }, + "value":"Align center", + "type":"TEXT", + "style":{ + "alignment":"CENTER" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-1-3", + "position":{ "x":1, "y":3 }, + "value":"Align left Align left Align left Align left Align left", + "type":"TEXT", + "style":{ + "alignment":"LEFT" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-1-4", + "position":{ "x":1, "y":4 }, + "value":"Align right Align right Align right Align right", + "type":"TEXT", + "style":{ + "alignment":"RIGHT" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-1-5", + "position":{ "x":1, "y":5 }, + "value":"Align center Align center Align center Align center Align center Align center ", + "type":"TEXT", + "style":{ + "alignment":"CENTER" + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-2-0", + "position":{ "x":2, "y":0 }, + "value":"✅ ", + "type":"TEXT", + "style":{ + + } + }, + { + "id":"b49a32df-3605-4397-9984-d7ab62afd143-2-1", + "position":{ "x":2, "y":1 }, + "value":"❌", + "type":"TEXT", + "style":{ + "alignment":"RIGHT" + } + } +]''', + 1: ''' +[ + { + "id":"2b45b403-caf9-4fb5-83b7-0dae2b7f6228-0-0", + "position":{ "x":0, "y":0 }, + "value":"Red", + "type":"TEXT", + "style":{ + "backgroundColor":"#D12229", + "fontColor": "#FFFFFF" + } + }, + { + "id":"2b45b403-caf9-4fb5-83b7-0dae2b7f6228-0-1", + "position":{ "x":0, "y":1 }, + "value":"Orange", + "type":"TEXT", + "style":{ + "backgroundColor":"#F68A1E", + "fontColor": "#FFFFFF" + } + }, + { + "id":"2b45b403-caf9-4fb5-83b7-0dae2b7f6228-0-2", + "position":{ "x":0, "y":2 }, + "value":"Yellow", + "type":"TEXT", + "style":{ + "isBold":false, + "backgroundColor":"#FDE01A", + "fontColor": "#FFFFFF" + } + }, + { + "id":"2b45b403-caf9-4fb5-83b7-0dae2b7f6228-0-3", + "position":{ "x":0, "y":3 }, + "value":"Green", + "type":"TEXT", + "style":{ + "isBold":false, + "backgroundColor":"#007940", + "fontColor": "#FFFFFF" + } + }, + { + "id":"2b45b403-caf9-4fb5-83b7-0dae2b7f6228-0-3", + "position":{ "x":0, "y":4 }, + "value":"Indigo", + "type":"TEXT", + "style":{ + "isBold":false, + "backgroundColor":"#24408E", + "fontColor": "#FFFFFF" + } + }, + { + "id":"2b45b403-caf9-4fb5-83b7-0dae2b7f6228-0-3", + "position":{ "x":0, "y":5 }, + "value":"Violet", + "type":"TEXT", + "style":{ + "isBold":false, + "backgroundColor":"#732982", + "fontColor": "#FFFFFF" + } + } +] + ''', +}; + +const decoder = JsonDecoder(); + +/// Don't take this function seriously +List getCellsData(int index) { + final data = map[index]!; + final dynamic dec = decoder.convert(data); + return dec as List; +} diff --git a/packages/swayze/example/lib/backend/fake_table_backend.dart b/packages/swayze/example/lib/backend/fake_table_backend.dart new file mode 100644 index 0000000..cd1873b --- /dev/null +++ b/packages/swayze/example/lib/backend/fake_table_backend.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +final map = { + 0: ''' +{ + "id":"274d2509-a651-49d8-88b0-e72e0aaa63b3", + "name":"Styles", + "columns":6, + "rows":6, + "columnStyles":[{"position":1,"size":162,"__typename":"DimensionStyle"},{"position":4,"size":283,"__typename":"DimensionStyle"}], + "rowStyles":[] +}''', + 1: ''' +{ + "id":"26163cb0-7c0e-11eb-b54b-d161845548b6", + "name":"Longer table", + "index":1, + "columns":36, + "rows":1060, + "columnStyles":[{"position":0,"size":167,"__typename":"DimensionStyle"}], + "rowStyles":[{"position":190,"size":54,"hidden":false,"__typename":"DimensionStyle"}] +}''', +}; + +const decoder = JsonDecoder(); + +/// Don't take this function seriously +Map getTableData(int index) { + final data = map[index]!; + final dynamic dec = decoder.convert(data); + return dec as Map; +} diff --git a/packages/swayze/example/lib/cell_editor.dart b/packages/swayze/example/lib/cell_editor.dart new file mode 100644 index 0000000..eb7a6be --- /dev/null +++ b/packages/swayze/example/lib/cell_editor.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import 'data/my_cells_controller.dart'; + +class CellEditor extends StatefulWidget { + final MyCellsController cellsController; + final IntVector2 cellCoordinate; + final VoidCallback close; + final BuildContext originContext; + + const CellEditor({ + Key? key, + required this.cellCoordinate, + required this.close, + required this.cellsController, + required this.originContext, + }) : super(key: key); + + @override + State createState() => _CellEditorState(); +} + +class _CellEditorState extends State { + late final cell = + widget.cellsController.cellMatrixReadOnly[widget.cellCoordinate]; + + late final TextEditingController textController = TextEditingController( + text: cell?.value ?? '', + ); + late final FocusNode focusNode = FocusNode( + debugLabel: 'CellEditor', + )..requestFocus(); + + @override + void initState() { + super.initState(); + focusNode.addListener(checkFocus); + } + + @override + void dispose() { + focusNode.removeListener(checkFocus); + super.dispose(); + } + + void checkFocus() { + if (!focusNode.hasFocus) { + widget.close(); + } + } + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: EditableText( + onSubmitted: (value) {}, + controller: textController, + focusNode: focusNode, + cursorColor: Colors.black, + backgroundCursorColor: Colors.grey.shade200, + style: DefaultTextStyle.of(context).style, + ), + ); + } +} diff --git a/packages/swayze/example/lib/cells/delegate.dart b/packages/swayze/example/lib/cells/delegate.dart new file mode 100644 index 0000000..83dd1c6 --- /dev/null +++ b/packages/swayze/example/lib/cells/delegate.dart @@ -0,0 +1,68 @@ +import 'package:flutter/widgets.dart'; +import 'package:swayze/delegates.dart'; + +import '../data/cell_data.dart'; +import 'painters/cell_text.dart'; +import 'policies/painting_policies.dart'; + +class MyCellDelegate + extends CellDelegate with Overlays { + @override + final Iterable>? overlayPolicies; + + MyCellDelegate({this.overlayPolicies}); + + @override + CellLayout getCellLayout(CellDataType data) { + return _MyCellLayout( + cellData: data, + cellOverlays: getOverlaysOfACell(data), + ); + } +} + +class _MyCellLayout extends CellLayout { + final CellDataType cellData; + final CellOverlays cellOverlays; + + @override + bool get isActiveCellAware => cellOverlays.hasAnyOverlay; + + @override + bool get isHoverAware => cellOverlays.hasAnyOverlay; + + _MyCellLayout({ + required this.cellData, + required this.cellOverlays, + }); + + @override + Iterable buildOverlayWidgets( + BuildContext context, { + bool isHover = false, + bool isActive = false, + }) { + final cellOverlayWidgets = []; + if (cellOverlays.hasAnyOverlay && isHover) { + cellOverlayWidgets.addAll( + cellOverlays.overlayPolicies!.map( + (cellOverlay) => cellOverlay.builder(context, cellData), + ), + ); + } + return cellOverlayWidgets; + } + + @override + Widget buildCell( + BuildContext context, { + bool isHover = false, + bool isActive = false, + }) { + return CellTextOnly( + data: cellData, + position: cellData.position, + textDirection: TextDirection.ltr, + ); + } +} diff --git a/packages/swayze/example/lib/cells/painters/cell_background.dart b/packages/swayze/example/lib/cells/painters/cell_background.dart new file mode 100644 index 0000000..f240298 --- /dev/null +++ b/packages/swayze/example/lib/cells/painters/cell_background.dart @@ -0,0 +1,138 @@ +import 'package:cached_value/cached_value.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A [SingleChildRenderObjectWidget] that render a cell in the canvas, using +/// the given [backgroundColor] and [alignment]. +class CellBackgroundPainter extends SingleChildRenderObjectWidget { + /// The cell model that mandates the paint of this cell + final Color? backgroundColor; + + final Alignment? alignment; + + const CellBackgroundPainter({ + Key? key, + required this.backgroundColor, + this.alignment, + Widget? child, + }) : super(key: key, child: child); + + @override + _RenderCellBackgroundPainter createRenderObject(BuildContext context) => + _RenderCellBackgroundPainter( + alignment: alignment, + backgroundColor: backgroundColor, + ); + + @override + void updateRenderObject( + BuildContext context, + _RenderCellBackgroundPainter renderObject, + ) { + renderObject + ..alignment = alignment + ..backgroundColor = backgroundColor; + } +} + +class _RenderCellBackgroundPainter extends RenderShiftedBox { + late Color? _backgroundColor; + + Color? get backgroundColor => _backgroundColor; + + set backgroundColor(Color? value) { + if (_backgroundColor == value) { + return; + } + + _backgroundColor = value; + markNeedsPaint(); + } + + late Alignment? _alignment; + + Alignment? get alignment => _alignment; + + set alignment(Alignment? value) { + if (_alignment == value) { + return; + } + + _alignment = value; + markNeedsLayout(); + } + + late final backgroundPaintCache = CachedValue( + () { + if (backgroundColor == null) { + return null; + } + + return Paint() + ..color = backgroundColor! + ..style = PaintingStyle.fill; + }, + ).withDependency(() => backgroundColor); + + _RenderCellBackgroundPainter({ + required Alignment? alignment, + required Color? backgroundColor, + RenderBox? child, + }) : _alignment = alignment, + _backgroundColor = backgroundColor, + super(child); + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.biggest; + } + + @override + void performLayout() { + if (child != null) { + final childConstraints = constraints.loosen(); + child!.layout(childConstraints, parentUsesSize: true); + + if (alignment != null) { + final childParentData = child!.parentData! as BoxParentData; + childParentData.offset = alignment!.alongOffset( + size - child!.size as Offset, + ); + } + } + } + + @override + void paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + if (backgroundPaintCache.value != null) { + canvas.drawRect(Offset.zero & size, backgroundPaintCache.value!); + } + + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + context.paintChild(child!, childParentData.offset); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + final isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset? transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed!); + }, + ); + return isHit; + } + return false; + } +} diff --git a/packages/swayze/example/lib/cells/painters/cell_text.dart b/packages/swayze/example/lib/cells/painters/cell_text.dart new file mode 100644 index 0000000..140b5f9 --- /dev/null +++ b/packages/swayze/example/lib/cells/painters/cell_text.dart @@ -0,0 +1,266 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:swayze/controller.dart'; +import 'package:swayze/widgets.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import '../../data/cell_data.dart'; +import 'cell_background.dart'; + +const _kCellPadding = EdgeInsets.symmetric( + vertical: 6.0, + horizontal: 8.0, +); + +const _kDefaultCellTextStyle = TextStyle( + fontSize: 14, + color: Colors.black, + height: 1.428, + letterSpacing: -0.1, +); + +class CellTextOnly extends StatelessWidget { + final MyCellData data; + final IntVector2 position; + final TextDirection textDirection; + + const CellTextOnly({ + Key? key, + required this.data, + required this.position, + required this.textDirection, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return CellBackgroundPainter( + backgroundColor: data.style?.backgroundColor, + child: CellTextPainter( + data: data, + textDirection: textDirection, + ), + ); + } +} + +/// A [SingleChildRenderObjectWidget] that render a the text of a cell +/// in the canvas. +class CellTextPainter extends SingleChildRenderObjectWidget { + /// The cell model that mandates the paint of this cell + final MyCellData data; + + final TextDirection textDirection; + + final double clipPadding; + + const CellTextPainter({ + Key? key, + required this.data, + required this.textDirection, + this.clipPadding = 0.0, + Widget? child, + }) : super(key: key, child: child); + + @override + _RenderCellTextPainter createRenderObject(BuildContext context) { + return _RenderCellTextPainter( + textDirection, + clipPadding, + )..update(data); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderCellTextPainter renderObject, + ) { + renderObject + ..update(data) + ..clipPadding = clipPadding; + } +} + +class _RenderCellTextPainter extends RenderBox + with RenderObjectWithChildMixin { + final TextDirection textDirection; + MyCellData? previousData; + TextStyle? previousStyle; + TextAlign? previousTextAlign; + + late final TextPainter textPainter = TextPainter( + textDirection: textDirection, + ); + + _RenderCellTextPainter( + this.textDirection, + this._clipPadding, + ); + + /// Updates text on [textPainter] with the given [SwayzeCellData] and + /// [SwayzeStyle]. + void update(MyCellData data) { + var changedAnything = false; + + final style = data.toTextStyle(_kDefaultCellTextStyle); + + if (style != previousStyle || data.value != previousData?.value) { + textPainter.text = TextSpan( + text: data.value, + style: style, + ); + previousData = data; + previousStyle = style; + changedAnything = true; + } + + final textAlign = + data.style?.horizontalAlignment?.toTextAlign() ?? TextAlign.left; + + if (textAlign != previousTextAlign) { + textPainter.textAlign = textAlign; + previousTextAlign = textAlign; + changedAnything = true; + } + + if (!changedAnything) { + return; + } + + markNeedsLayout(); + } + + double get clipPadding => _clipPadding; + double _clipPadding; + + set clipPadding(double value) { + if (value == _clipPadding) { + return; + } + + _clipPadding = value; + markNeedsPaint(); + } + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.biggest; + } + + @override + void performLayout() { + if (child != null) { + final actualChild = child!; + final childConstraints = constraints.loosen(); + actualChild.layout(childConstraints, parentUsesSize: true); + + final childParentData = actualChild.parentData! as BoxParentData; + + final xPos = math.max(size.width - actualChild.size.width, 0.0); + childParentData.offset = Offset(xPos, 0); + } + + final contentWidth = size.width - _kCellPadding.horizontal; + textPainter.layout(minWidth: contentWidth); + + final hasOverflowedHorizontally = textPainter.width > contentWidth; + + // If a numeric cell overflows the grid width, align it to the left + // unless its alignment is explicitly set. + if (hasOverflowedHorizontally) { + textPainter.textAlign = TextAlign.left; + textPainter.layout(minWidth: contentWidth); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + paintText(context, offset); + + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + context.paintChild(child!, offset + childParentData.offset); + } + } + + void paintText(PaintingContext context, Offset offset) { + final canvas = context.canvas; + + canvas.save(); + + final textVerticalCenter = textPainter.height > size.height + ? _kCellPadding.top + offset.dy + : (size.height / 2 - textPainter.height / 2) + offset.dy; + + final isToLeft = textPainter.textAlign == TextAlign.left || + (textPainter.textDirection == TextDirection.ltr + ? textPainter.textAlign == TextAlign.start + : textPainter.textAlign == TextAlign.end); + + final isToRight = textPainter.textAlign == TextAlign.right || + (textPainter.textDirection == TextDirection.ltr + ? textPainter.textAlign == TextAlign.end + : textPainter.textAlign == TextAlign.start); + + final isCentered = textPainter.textAlign == TextAlign.center; + + late final double textHorizontalPos; + if (isToLeft) { + textHorizontalPos = _kCellPadding.left; + } else if (isToRight) { + textHorizontalPos = _computeRightAlignedTextHorizontalPosition(); + } else if (isCentered) { + textHorizontalPos = (size.width - textPainter.size.width) / 2; + } + + final clippedSize = size + Offset(-clipPadding * 2, -clipPadding * 2); + canvas.clipRect(Offset(clipPadding, clipPadding) & clippedSize); + + canvas.translate(textHorizontalPos, textVerticalCenter); + + textPainter.paint(canvas, Offset.zero); + + canvas.restore(); + } + + /// Calculate the horizontal position of the text when it's right aligned. + /// + /// It takes into consideration the child's width in order to proper display + /// the text when, for example, a badge is shown on the cell. + double _computeRightAlignedTextHorizontalPosition() { + if (child != null) { + return size.width - textPainter.size.width - child!.size.width; + } + + return size.width - _kCellPadding.right - textPainter.size.width; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + final isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset? transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed!); + }, + ); + return isHit; + } + return false; + } + + /// Describe the text semantics. + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + config + ..textDirection = TextDirection.ltr + ..label = textPainter.text?.toPlainText() ?? ''; + } +} diff --git a/packages/swayze/example/lib/cells/policies/painting_policies.dart b/packages/swayze/example/lib/cells/policies/painting_policies.dart new file mode 100644 index 0000000..1689cda --- /dev/null +++ b/packages/swayze/example/lib/cells/policies/painting_policies.dart @@ -0,0 +1,104 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; +import 'package:swayze/delegates.dart'; + +import '../../data/cell_data.dart'; + +/// Build the widgets to be shown inside eligible cells on a +/// [CellPaintingPolicy]. +/// +/// See also: +/// - [CellPaintingPolicy.builder] +/// - [CellBadgePolicy] +/// - [CellOverlayPolicy] +typedef CellPolicyBuilder = Widget Function( + BuildContext context, + CellDataType cellData, { + bool? isHover, + bool? isActive, +}); + +/// From a [cellData] define if a cell should comply to a specific +/// [CellPaintingPolicy] +/// +/// ⚠️ It should only depend on the cell model and only that. +/// Since it is called before each cell build, it is very performance critical. +/// Make it as declarative as possible. +/// +/// See also: +/// - [CellPaintingPolicy.checkEligibility] +typedef CellPolicyDecider = bool Function( + CellDataType cellData, +); + +/// Semantic class to encapsulate the eligibility for special rendering widgets +/// on cells ion certain conditions. +/// +/// Should not be subclassed directly. Instead, instantiate or subclass +/// [CellOverlayPolicy] or [CellBadgePolicy]. +/// +/// See also: +/// - [CellOverlayPolicy] that defines rules to show any widget over a cell on +/// mouse hover. +@immutable +abstract class CellPaintingPolicy + extends Equatable { + /// For a particular cell, defines if the [builder] will + final CellPolicyDecider checkEligibility; + + /// Build the widgets to be shown inside eligible cells. + final CellPolicyBuilder builder; + + const CellPaintingPolicy(this.checkEligibility, this.builder); + + @override + List get props => [checkEligibility, builder]; +} + +/// Describes the if a cell should include widgets to be built over it when +/// the mouse is hover. +/// +/// The widget returned by [builder] will be rendered with +/// [BoxConstraints.loose] to the size of the cell in which it is covering with. +/// +/// It will render the widget over cells that are declared eligible via +/// [checkEligibility]. +/// +/// See also: +/// - [CellPaintingPolicy] the superclass for all painting policies. +class CellOverlayPolicy + extends CellPaintingPolicy { + const CellOverlayPolicy({ + required CellPolicyDecider checkEligibility, + required CellPolicyBuilder builder, + }) : super(checkEligibility, builder); +} + +/// To be mixed on a [CellDelegate], it contains the logics to define which +/// [CellPaintingPolicy]s are eligible for a cell. +mixin Overlays on CellDelegate { + /// The [CellOverlayPolicy]s to be evaluated by the [CellDelegate] to be + /// applied to its cells. + Iterable>? get overlayPolicies; + + /// Define which overlays are applicable to a specific cell with [cellData]. + CellOverlays getOverlaysOfACell(CellDataType cellData) { + final overlays = overlayPolicies?.where((badge) { + return badge.checkEligibility(cellData); + }); + return CellOverlays(overlays); + } +} + +@immutable +class CellOverlays extends Equatable { + final Iterable>? overlayPolicies; + + const CellOverlays(this.overlayPolicies); + + bool get hasAnyOverlay => + overlayPolicies != null && overlayPolicies!.isNotEmpty; + + @override + List get props => [overlayPolicies]; +} diff --git a/packages/swayze/example/lib/cells/policies/tooltip_policy.dart b/packages/swayze/example/lib/cells/policies/tooltip_policy.dart new file mode 100644 index 0000000..c1b4a01 --- /dev/null +++ b/packages/swayze/example/lib/cells/policies/tooltip_policy.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../../../data/cell_data.dart'; +import 'painting_policies.dart'; + +final kTooltipCellHover = CellOverlayPolicy( + checkEligibility: (cellData) => true, + builder: ( + context, + data, { + bool? isHover, + bool? isActive, + }) { + return LayoutBuilder( + builder: (context, constraints) { + return Tooltip( + message: ''' +There is content on cell ${data.position.dx}:${data.position.dy}''', + preferBelow: true, + verticalOffset: constraints.minHeight / 2, + child: const SizedBox.shrink(), + ); + }, + ); + }, +); diff --git a/packages/swayze/example/lib/data/cell_data.dart b/packages/swayze/example/lib/data/cell_data.dart new file mode 100644 index 0000000..f06d403 --- /dev/null +++ b/packages/swayze/example/lib/data/cell_data.dart @@ -0,0 +1,73 @@ +import 'package:flutter/painting.dart'; +import 'package:swayze/controller.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import 'cell_style.dart'; + +class MyCellData extends SwayzeCellData { + final String? value; + + final MyCellStyle? style; + + const MyCellData({ + required String id, + required IntVector2 position, + required this.style, + required this.value, + }) : super( + id: id, + position: position, + ); + + @override + Alignment get contentAlignment => Alignment.center; + + @override + bool get hasVisibleContent => true; + + factory MyCellData.fromJson(Map json) { + final id = json['id'] as String; + + final rawPosition = json['position'] as Map; + final position = IntVector2( + rawPosition['x'] as int, + rawPosition['y'] as int, + ); + final value = json['value'] as String?; + + final styleMap = json['style'] as Map?; + final style = styleMap != null ? MyCellStyle.fromJson(styleMap) : null; + + return MyCellData( + id: id, + position: position, + value: value, + style: style, + ); + } + + TextStyle toTextStyle( + TextStyle defaultTextStyle, + ) { + var textStyle = defaultTextStyle; + + if (style?.fontColor != null) { + final color = style!.fontColor!; + textStyle = textStyle.copyWith(color: color); + } + + if (style?.isBold == true) { + textStyle = textStyle.copyWith(fontWeight: FontWeight.bold); + } + + if (style?.isItalic == true) { + textStyle = textStyle.apply(fontStyle: FontStyle.italic); + } + + if (style?.isUnderline == true) { + textStyle = textStyle.copyWith(decoration: TextDecoration.underline); + } + + return textStyle; + } +} diff --git a/packages/swayze/example/lib/data/cell_style.dart b/packages/swayze/example/lib/data/cell_style.dart new file mode 100644 index 0000000..22a723f --- /dev/null +++ b/packages/swayze/example/lib/data/cell_style.dart @@ -0,0 +1,95 @@ +import 'package:flutter/painting.dart'; +import 'package:swayze/helpers.dart'; + +class MyCellStyle { + final String? fontFamily; + final Color? fontColor; + final bool? isBold; + final bool? isItalic; + final bool? isUnderline; + final int? numberDecimalPlaces; + final Color? backgroundColor; + final CellHorizontalAlignment? horizontalAlignment; + + const MyCellStyle({ + required this.fontFamily, + required this.fontColor, + required this.isBold, + required this.isItalic, + required this.isUnderline, + required this.numberDecimalPlaces, + required this.backgroundColor, + required this.horizontalAlignment, + }); + + factory MyCellStyle.fromJson(Map json) { + final fontFamily = json['fontFamily'] as String?; + final fontColor = json['fontColor'] as String?; + + final isBold = json['isBold'] as bool?; + final isItalic = json['isItalic'] as bool?; + final isUnderline = json['isUnderline'] as bool?; + + final numberDecimalPlaces = json['numberDecimalPlaces'] as int?; + final backgroundColor = json['backgroundColor'] as String?; + + final horizontalAlignmentString = json['alignment'] as String?; + final horizontalAlignment = horizontalAlignmentString != null + ? CellHorizontalAlignment.from(horizontalAlignmentString) + : null; + + return MyCellStyle( + fontFamily: fontFamily, + fontColor: fontColor != null ? createColorFromHEX(fontColor) : null, + isBold: isBold, + isItalic: isItalic, + isUnderline: isUnderline, + numberDecimalPlaces: numberDecimalPlaces, + backgroundColor: + backgroundColor != null ? createColorFromHEX(backgroundColor) : null, + horizontalAlignment: horizontalAlignment, + ); + } +} + +enum CellHorizontalAlignment { + left('LEFT'), + center('CENTER'), + right('RIGHT'); + + final String value; + const CellHorizontalAlignment(this.value); + + factory CellHorizontalAlignment.from(String value) { + return CellHorizontalAlignment.values.firstWhere( + (element) => element.value == value, + ); + } + + /// Convert to a flutter's [TextAlign] + TextAlign toTextAlign() { + if (this == CellHorizontalAlignment.center) { + return TextAlign.center; + } else if (this == CellHorizontalAlignment.left) { + return TextAlign.left; + } else if (this == CellHorizontalAlignment.right) { + return TextAlign.right; + } + + return TextAlign.start; + } + + /// Convert to Flutter's [Alignment]. + Alignment toAlignment() { + switch (this) { + case CellHorizontalAlignment.left: + return Alignment.centerLeft; + + case CellHorizontalAlignment.center: + return Alignment.center; + + case CellHorizontalAlignment.right: + return Alignment.centerRight; + } + } +} diff --git a/packages/swayze/example/lib/data/my_cells_controller.dart b/packages/swayze/example/lib/data/my_cells_controller.dart new file mode 100644 index 0000000..2ac5c2d --- /dev/null +++ b/packages/swayze/example/lib/data/my_cells_controller.dart @@ -0,0 +1,18 @@ +import 'package:swayze/controller.dart'; + +import 'cell_data.dart'; + +MyCellData cellParser(dynamic json) => MyCellData.fromJson( + json as Map, + ); + +class MyCellsController extends SwayzeCellsController { + MyCellsController({ + required Iterable initialCells, + required SwayzeController parent, + }) : super( + parent: parent, + initialRawCells: initialCells, + cellParser: cellParser, + ); +} diff --git a/packages/swayze/example/lib/data/my_swayze_controller.dart b/packages/swayze/example/lib/data/my_swayze_controller.dart new file mode 100644 index 0000000..7c2b66f --- /dev/null +++ b/packages/swayze/example/lib/data/my_swayze_controller.dart @@ -0,0 +1,27 @@ +import 'package:swayze/controller.dart'; + +import '../backend/fake_cells_backend.dart' as cells_backend; +import '../backend/fake_table_backend.dart' as table_backend; +import 'my_cells_controller.dart'; +import 'my_table_controller.dart'; + +class MySwayzeController extends SwayzeController { + @override + late final MyTableController tableDataController; + + @override + late final MyCellsController cellsController; + + MySwayzeController({ + required int tableIndex, + }) { + tableDataController = MyTableController.fromJson( + table_backend.getTableData(tableIndex), + parent: this, + ); + cellsController = MyCellsController( + initialCells: cells_backend.getCellsData(tableIndex), + parent: this, + ); + } +} diff --git a/packages/swayze/example/lib/data/my_table_controller.dart b/packages/swayze/example/lib/data/my_table_controller.dart new file mode 100644 index 0000000..ed1f7bd --- /dev/null +++ b/packages/swayze/example/lib/data/my_table_controller.dart @@ -0,0 +1,85 @@ +import 'package:swayze/controller.dart'; + +class MyTableController extends SwayzeTableDataController { + final String name; + + MyTableController._({ + required this.name, + required SwayzeController parent, + required String id, + required int columnCount, + required int rowCount, + required Iterable columns, + required Iterable rows, + }) : super( + parent: parent, + id: id, + columnCount: columnCount, + rowCount: rowCount, + columns: columns, + rows: rows, + frozenColumns: 0, + frozenRows: 0, + ); + + factory MyTableController.fromJson( + Map json, { + required SwayzeController parent, + }) { + final id = json['id'] as String; + final name = json['name'] as String; + + final columnCount = json['columns'] as int; + final rowCount = json['rows'] as int; + + final columns = + (json['columnStyles'] as List).map((dynamic headerJson) { + return MyHeaderData.fromJson(headerJson as Map); + }); + + final rows = (json['rowStyles'] as List).map((dynamic headerJson) { + return MyHeaderData.fromJson(headerJson as Map); + }); + + return MyTableController._( + parent: parent, + id: id, + name: name, + columnCount: columnCount, + rowCount: rowCount, + columns: columns, + rows: rows, + ); + } + + // example of custom operation + void insertRows(int howMany) { + rows.updateState( + (previousState) => + previousState.copyWith(count: previousState.count + howMany), + ); + } +} + +class MyHeaderData extends SwayzeHeaderData { + const MyHeaderData({ + required int index, + required double? extent, + required bool hidden, + }) : super( + index: index, + extent: extent, + hidden: hidden, + ); + + factory MyHeaderData.fromJson(Map json) { + final index = json['position'] as int; + final extent = json['size'] as int?; + final hidden = (json['hidden'] as bool?) ?? false; + return MyHeaderData( + index: index, + extent: extent?.toDouble(), + hidden: hidden, + ); + } +} diff --git a/packages/swayze/example/lib/main.dart b/packages/swayze/example/lib/main.dart new file mode 100644 index 0000000..3bc6579 --- /dev/null +++ b/packages/swayze/example/lib/main.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'table_wrapper.dart'; + +class DummyIntent extends Intent { + const DummyIntent(); +} + +void main() { + runApp(EditorTableTestBedApp()); +} + +class EditorTableTestBedApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: MultiViewPage(), + ), + ); + } +} + +class MultiViewPage extends StatefulWidget { + @override + _MultiViewPageState createState() => _MultiViewPageState(); +} + +class _MultiViewPageState extends State { + late final verticalScrollController = ScrollController(); + + @override + void dispose() { + verticalScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CustomScrollView( + controller: verticalScrollController, + slivers: [ + SliverTableWrapper( + tableIndex: 0, + verticalScrollController: verticalScrollController, + ), + SliverTableWrapper( + tableIndex: 1, + verticalScrollController: verticalScrollController, + ), + ], + ); + } +} diff --git a/packages/swayze/example/lib/table_wrapper.dart b/packages/swayze/example/lib/table_wrapper.dart new file mode 100644 index 0000000..8284299 --- /dev/null +++ b/packages/swayze/example/lib/table_wrapper.dart @@ -0,0 +1,134 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:swayze/widgets.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import 'cell_editor.dart'; +import 'cells/delegate.dart'; +import 'cells/policies/tooltip_policy.dart'; +import 'data/cell_data.dart'; +import 'data/my_swayze_controller.dart'; + +class SliverTableWrapper extends StatefulWidget { + final int tableIndex; + final ScrollController verticalScrollController; + + const SliverTableWrapper({ + Key? key, + required this.tableIndex, + required this.verticalScrollController, + }) : super(key: key); + + @override + _SliverTableWrapperState createState() => _SliverTableWrapperState(); +} + +class _SliverTableWrapperState extends State { + late final FocusNode myFocusNode = FocusNode( + debugLabel: 'SliverTableWrapper', + ); + late final swayzeController = MySwayzeController( + tableIndex: widget.tableIndex, + ); + + @override + void dispose() { + swayzeController.dispose(); + myFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SliverSwayzeTable( + key: ValueKey(swayzeController.tableDataController.id), + cellDelegate: MyCellDelegate( + overlayPolicies: [kTooltipCellHover], + ), + focusNode: myFocusNode, + autofocus: widget.tableIndex == 0, + controller: swayzeController, + wrapTableBody: (context, viewportContext, child) { + return TableBodyWrapper( + viewportContext: viewportContext, + child: child, + ); + }, + inlineEditorBuilder: ( + BuildContext context, + IntVector2 coordinate, + VoidCallback close, { + required bool overlapCell, + required bool overlapTable, + String? initialText, + }) { + return CellEditor( + cellsController: swayzeController.cellsController, + cellCoordinate: coordinate, + close: close, + originContext: context, + ); + }, + verticalScrollController: widget.verticalScrollController, + stickyHeaderSize: 60, + stickyHeader: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _TableTitle( + title: swayzeController.tableDataController.name, + ), + ), + ); + } +} + +class _TableTitle extends StatelessWidget { + final String title; + + const _TableTitle({ + Key? key, + required this.title, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + title, + style: const TextStyle(fontSize: 24), + ), + ); + } +} + +class TableBodyWrapper extends StatelessWidget { + final ViewportContext viewportContext; + final Widget child; + + const TableBodyWrapper({ + Key? key, + required this.viewportContext, + required this.child, + }) : super(key: key); + + void onHover(PointerHoverEvent event) { + final x = viewportContext.pixelToPosition( + event.localPosition.dx, + Axis.horizontal, + ); + + final y = viewportContext.pixelToPosition( + event.localPosition.dy, + Axis.vertical, + ); + print('Hovering cell: ${IntVector2(x.position, y.position)}'); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onHover: onHover, + child: child, + ); + } +} diff --git a/packages/swayze/example/pubspec.yaml b/packages/swayze/example/pubspec.yaml new file mode 100644 index 0000000..f59630c --- /dev/null +++ b/packages/swayze/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: swayze_example +description: An example for Swayze +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + flutter_sticky_header: ^0.6.0 + swayze: + path: ../ + equatable: ^2.0.0 + flutter: + sdk: flutter + +dev_dependencies: + rows_lint: 0.1.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/swayze/lib/helpers.dart b/packages/swayze/lib/helpers.dart index 2a32b16..0b44532 100644 --- a/packages/swayze/lib/helpers.dart +++ b/packages/swayze/lib/helpers.dart @@ -2,3 +2,4 @@ library helpers; export 'src/helpers/color.dart'; export 'src/helpers/label_generator.dart'; +export 'src/helpers/wrapped.dart'; diff --git a/packages/swayze/lib/src/config.dart b/packages/swayze/lib/src/config.dart index 48740b9..94de770 100644 --- a/packages/swayze/lib/src/config.dart +++ b/packages/swayze/lib/src/config.dart @@ -24,5 +24,7 @@ double headerWidthForRange(Range range) { const kDefaultCellWidth = 120.0; const kDefaultCellHeight = 33.0; +const kMinCellWidth = 30.0; + const kDefaultScrollAnimationDuration = Duration(milliseconds: 50); const kDefaultScrollAnimationCurve = Curves.easeOut; diff --git a/packages/swayze/lib/src/core/config/config.dart b/packages/swayze/lib/src/core/config/config.dart new file mode 100644 index 0000000..1375765 --- /dev/null +++ b/packages/swayze/lib/src/core/config/config.dart @@ -0,0 +1,48 @@ +import 'package:meta/meta.dart'; + +/// A set of configurations to enable and disable certain interactions with +/// Swayze widgets. +@immutable +class SwayzeConfig { + /// Enables the drag of the bottom right corner of the cell to + /// allow for drag and fill values. + final bool isDragFillEnabled; + + /// Enables drag and drop of selected headers (rows and columns). + final bool isHeaderDragAndDropEnabled; + + final bool isResizingHeadersEnabled; + + const SwayzeConfig({ + this.isDragFillEnabled = false, + this.isHeaderDragAndDropEnabled = false, + this.isResizingHeadersEnabled = false, + }); + + SwayzeConfig copyWith({ + bool? isDragFillEnabled, + bool? isHeaderDragAndDropEnabled, + bool? isResizingHeadersEnabled, + }) => + SwayzeConfig( + isDragFillEnabled: isDragFillEnabled ?? this.isDragFillEnabled, + isHeaderDragAndDropEnabled: + isHeaderDragAndDropEnabled ?? this.isHeaderDragAndDropEnabled, + isResizingHeadersEnabled: + isResizingHeadersEnabled ?? this.isResizingHeadersEnabled, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SwayzeConfig && + isDragFillEnabled == other.isDragFillEnabled && + isHeaderDragAndDropEnabled == other.isHeaderDragAndDropEnabled && + isResizingHeadersEnabled == other.isResizingHeadersEnabled; + + @override + int get hashCode => + isDragFillEnabled.hashCode ^ + isHeaderDragAndDropEnabled.hashCode ^ + isResizingHeadersEnabled.hashCode; +} diff --git a/packages/swayze/lib/src/core/controller/cells/cells_controller.dart b/packages/swayze/lib/src/core/controller/cells/cells_controller.dart index ca64ceb..5ed6bd3 100644 --- a/packages/swayze/lib/src/core/controller/cells/cells_controller.dart +++ b/packages/swayze/lib/src/core/controller/cells/cells_controller.dart @@ -146,38 +146,53 @@ class SwayzeCellsController /// Access the table of cells in a read only interface. MatrixMapReadOnly get cellMatrixReadOnly => _cellMatrix; - /// Given a [IntVector2] and a [AxisDirection] return the - /// next coordinate in the current block of cells. + /// Given an [IntVector2] and an [AxisDirection], return the next coordinate + /// in the current block of cells. /// - /// If the first neighbour cell has value, it traverses the cells - /// until it finds an empty cell and returns the previous coordinate (the - /// last with value). + /// If the base cell has a value, it traverses the cells until it finds an + /// empty cell, and returns the previous coordinate (the last with value). /// - /// If the first neighbour cell does not have value, it traverses the - /// cells until it fiends a cell with value and return the previous coordinate - /// (the last empty). + /// If the base cell does not have value, it traverses the cells until it + /// finds a cell with value, and returns the previous coordinate (the last + /// empty). + /// + /// The base cell is the given [originalCoordinate] if + /// [useNeighboringCellAsBase] is `false`, or the neighboring cell to the + /// [originalCoordinate] in the given [AxisDirection] if + /// [useNeighboringCellAsBase] is `true` (the default). + /// + /// An optional [limit] may be passed to limit how many coordinates can be + /// checked. IntVector2 getNextCoordinateInCellsBlock({ required IntVector2 originalCoordinate, required AxisDirection direction, + bool useNeighboringCellAsBase = true, + int? limit, }) { final axis = axisDirectionToAxis(direction); - var previousCoordinate = originalCoordinate; - var currentCoordinate = originalCoordinate + _moveVectors[direction]!; + var previousCoordinate = useNeighboringCellAsBase + ? originalCoordinate + : originalCoordinate - _moveVectors[direction]!; + var currentCoordinate = useNeighboringCellAsBase + ? originalCoordinate + _moveVectors[direction]! + : originalCoordinate; + final headerController = parent.tableDataController.getHeaderControllerFor(axis: axis); - final limit = headerController.value.totalCount - 1; - final isFirstNeighbourCellFilled = + final effectiveLimit = limit ?? headerController.value.totalCount - 1; + + final isBaseCellFilled = _cellMatrix[currentCoordinate]?.hasVisibleContent == true; /// Conditions to stop iterating and finding the next cell. /// /// - if the previous coordinate and new coordinate are the same /// (it probably means we reached a edge of the grid). - /// - If the first neighbour cell was filled, then we'll keep iterating - /// while we find filled cells. - /// - If the first neighbour cell was not filled, then we'll keep iterating - /// while we find empty cells. + /// - If the base cell was filled, then we'll keep iterating while we find + /// filled cells. + /// - If the base cell was not filled, then we'll keep iterating while we + /// find empty cells. bool shouldContinueLookup(IntVector2 prev, IntVector2 curr) { if (prev == curr) { return false; @@ -191,7 +206,7 @@ class SwayzeCellsController } final hasValue = _cellMatrix[curr]?.hasVisibleContent == true; - return isFirstNeighbourCellFilled ? hasValue : !hasValue; + return isBaseCellFilled == hasValue; } // Iteratively move the current coordinate until [shouldContinueLookup] @@ -201,10 +216,10 @@ class SwayzeCellsController currentCoordinate += _moveVectors[direction]!; currentCoordinate = IntVector2( axis == Axis.horizontal - ? max(0, min(currentCoordinate.dx, limit)) + ? max(0, min(currentCoordinate.dx, effectiveLimit)) : currentCoordinate.dx, axis == Axis.vertical - ? max(0, min(currentCoordinate.dy, limit)) + ? max(0, min(currentCoordinate.dy, effectiveLimit)) : currentCoordinate.dy, ); } while (shouldContinueLookup(previousCoordinate, currentCoordinate)); @@ -212,7 +227,7 @@ class SwayzeCellsController final currentCellIsEmpty = _cellMatrix[currentCoordinate]?.hasNoVisibleContent == true; - return (isFirstNeighbourCellFilled || currentCellIsEmpty) + return (isBaseCellFilled != currentCellIsEmpty) ? previousCoordinate : currentCoordinate; } @@ -238,9 +253,24 @@ class SwayzeCellsController // finds the a suitable coordinate. do { newCoordinate += _moveVectors[direction]!; + + final elasticCount = headerController.value.maxElasticCount; + final count = headerController.value.count; + + final position = + axis == Axis.horizontal ? newCoordinate.dx : newCoordinate.dy; + + final maxPosition = elasticCount != null + // in case the user has set a max elastic count, we should + // limit the grid expansion to that count, however, if that limit + // is lower than the table size, we should prioritize the table size + // over it. + ? min(position, max(elasticCount - 1, count - 1)) + : position; + newCoordinate = IntVector2( - axis == Axis.horizontal ? max(0, newCoordinate.dx) : newCoordinate.dx, - axis == Axis.vertical ? max(0, newCoordinate.dy) : newCoordinate.dy, + axis == Axis.horizontal ? max(0, maxPosition) : newCoordinate.dx, + axis == Axis.vertical ? max(0, maxPosition) : newCoordinate.dy, ); } while (shouldContinueLookup(newCoordinate)); diff --git a/packages/swayze/lib/src/core/controller/selection/selection_controller.dart b/packages/swayze/lib/src/core/controller/selection/selection_controller.dart index 401d851..18776b1 100644 --- a/packages/swayze/lib/src/core/controller/selection/selection_controller.dart +++ b/packages/swayze/lib/src/core/controller/selection/selection_controller.dart @@ -1,11 +1,16 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import '../controller.dart'; +import 'user_selections/fill_selection_state.dart'; + export 'model/selection.dart'; export 'model/selection_style.dart'; export 'user_selections/model.dart'; export 'user_selections/user_selection_state.dart'; +typedef SwayzeSelectionChangerCallback = T Function(T previousState); + /// A [ControllerBase] that keeps track of the active selections in the table. /// /// Selections are divided in two main groups that are defined by its @@ -35,8 +40,14 @@ class SwayzeSelectionController extends Listenable implements ControllerBase { @protected final userSelections = _UserSelectionsValueNotifier(); + /// Internal [ValueNotifier] that controls the [FillSelectionState]. + @protected + final fillSelection = _FillSelectionsValueNotifier(); + Listenable get userSelectionsListenable => userSelections; + Listenable get fillSelectionListenable => fillSelection; + SwayzeSelectionController(); /// The [ValueListenable] that is supposed to maintain a iterable of @@ -57,12 +68,16 @@ class SwayzeSelectionController extends Listenable implements ControllerBase { // selections and data selections. late final _mergeListenable = Listenable.merge([ userSelections, + fillSelection, dataSelectionsValueListenable, ]); /// Recover the current [UserSelectionState] UserSelectionState get userSelectionState => userSelections.value; + /// Recover the current [FillSelectionState] + FillSelectionState get fillSelectionState => fillSelection.value; + /// Recover the current list of data selections. Iterable get dataSelections => dataSelectionsValueListenable.value; @@ -71,11 +86,29 @@ class SwayzeSelectionController extends Listenable implements ControllerBase { /// version. /// If the returned state is the same as before, no update is triggered. void updateUserSelections( - UserSelectionState Function(UserSelectionState previousState) stateUpdate, + SwayzeSelectionChangerCallback stateUpdate, ) { userSelections.value = stateUpdate(userSelections.value); } + /// Update the current [fillSelectionState]. The callback [stateUpdate] + /// receives the actual version of the state and should return the updated + /// version. + /// If the returned state is the same as before, no update is triggered. + void updateFillSelections( + SwayzeSelectionChangerCallback stateUpdate, + ) => + fillSelection.value = stateUpdate(fillSelection.value); + + /// Check if the index [headerIndex] is covered by any header selection. + bool isHeaderSelected(int headerIndex, Axis axis) => + userSelectionState.selections.any( + (selection) => + selection is HeaderUserSelectionModel && + selection.axis == axis && + selection.contains(headerIndex), + ); + @override void addListener(VoidCallback listener) { _mergeListenable.addListener(listener); @@ -88,6 +121,7 @@ class SwayzeSelectionController extends Listenable implements ControllerBase { @override void dispose() { + fillSelection.dispose(); userSelections.dispose(); } } @@ -108,7 +142,26 @@ class _UserSelectionsValueNotifier extends ValueNotifier { UserSelectionState get value => super.value; } -/// Dummy [ValueListenable] thata ct as default to +/// The internal [ValueNotifier] that keeps track of changes on +/// [FillSelectionState]. +class _FillSelectionsValueNotifier extends ValueNotifier { + _FillSelectionsValueNotifier() + : super( + const FillSelectionState.empty(), + ); + + @override + @protected + set value(FillSelectionState newValue) { + super.value = newValue; + } + + @override + @protected + FillSelectionState get value => super.value; +} + +/// Dummy [ValueListenable] that act as default to /// [SwayzeSelectionController.dataSelectionsValueListenable] class _DummyDataSelectionsValueNotifier implements ValueListenable> { diff --git a/packages/swayze/lib/src/core/controller/selection/user_selections/fill_selection_state.dart b/packages/swayze/lib/src/core/controller/selection/user_selections/fill_selection_state.dart new file mode 100644 index 0000000..f9ee638 --- /dev/null +++ b/packages/swayze/lib/src/core/controller/selection/user_selections/fill_selection_state.dart @@ -0,0 +1,61 @@ +import 'package:meta/meta.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import 'model.dart'; + +/// A immutable description of the disposition of the [FillSelectionModel], used +/// for the drag and fill operations. +/// +/// It allows at most one selection at a time, if any. +/// +/// This is the portion of selections on [SwayzeSelectionController] that are +/// created and changed by user gestures. +/// +/// See also: +/// - [SwayzeSelectionController] which keeps this state in a [ValueListenable]. +@immutable +class FillSelectionState { + final FillSelectionModel? selection; + + const FillSelectionState._({ + required this.selection, + }); + + const FillSelectionState.empty() : this._(selection: null); + + /// Clears the current selection.. + FillSelectionState clear() => const FillSelectionState.empty(); + + /// Adds a new selection, if none exists. + FillSelectionState addIfNoneExists( + FillSelectionModel newSelection, + ) { + if (selection != null) { + return this; + } + + return FillSelectionState._( + selection: newSelection, + ); + } + + /// Updates the selection, if one exists. + FillSelectionState update({ + required IntVector2 anchor, + required IntVector2 focus, + }) { + final currentSelection = selection; + + if (currentSelection == null) { + return this; + } + + return FillSelectionState._( + selection: FillSelectionModel.fromSelectionModel( + currentSelection, + anchor: anchor, + focus: focus, + ), + ); + } +} diff --git a/packages/swayze/lib/src/core/controller/selection/user_selections/model.dart b/packages/swayze/lib/src/core/controller/selection/user_selections/model.dart index 24e58dc..8b06533 100644 --- a/packages/swayze/lib/src/core/controller/selection/user_selections/model.dart +++ b/packages/swayze/lib/src/core/controller/selection/user_selections/model.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:math'; import 'package:flutter/rendering.dart'; @@ -13,6 +15,7 @@ const _uuid = Uuid(); /// Defines a [Selection] that is controllable by a [UserSelectionState]. abstract class UserSelectionModel extends Selection { /// Unique identifier of a selection in a [UserSelectionState] + @Deprecated('') String get id; @override @@ -28,16 +31,16 @@ abstract class UserSelectionModel extends Selection { /// It selects the entire table. class TableUserSelectionModel implements UserSelectionModel { @override + @Deprecated('') final String id; @override final SelectionStyle? style = null; TableUserSelectionModel._({ - String? id, + @Deprecated('') String? id, required this.anchorCoordinate, - }) : id = id ?? _uuid.v4(), - super(); + }) : id = id ?? _uuid.v4(); factory TableUserSelectionModel.fromSelectionModel( UserSelectionModel original, @@ -92,6 +95,7 @@ class HeaderUserSelectionModel extends AxisBoundedSelection implements UserSelectionModel { /// See [UserSelectionModel.id] @override + @Deprecated('') final String id; /// See [UserSelectionModel.style] @@ -99,7 +103,7 @@ class HeaderUserSelectionModel extends AxisBoundedSelection final SelectionStyle? style; HeaderUserSelectionModel._({ - String? id, + @Deprecated('') String? id, required Axis boundedAxis, required RangeEdge anchorEdge, required int start, @@ -121,7 +125,7 @@ class HeaderUserSelectionModel extends AxisBoundedSelection /// Since this selection is simply a [Range], we convert [anchor] and [focus] /// into range's [start] and [end] values. factory HeaderUserSelectionModel.fromAnchorFocus({ - String? id, + @Deprecated('') String? id, required int anchor, required int focus, required Axis axis, @@ -183,11 +187,10 @@ class HeaderUserSelectionModel extends AxisBoundedSelection @override bool operator ==(Object other) => identical(this, other) || - super == other && + (super == other && other is HeaderUserSelectionModel && runtimeType == other.runtimeType && - id == other.id && - style == other.style; + style == other.style); @override int get hashCode => super.hashCode ^ id.hashCode ^ style.hashCode; @@ -195,19 +198,20 @@ class HeaderUserSelectionModel extends AxisBoundedSelection /// A [UserSelectionModel] that represents a [Range2D] of cells. /// -/// Unlike [HeaderUserSelectionModel], the edges of this king of selection are +/// Unlike [HeaderUserSelectionModel], the edges of this kind of selection are /// defined by the coordinates in both axis. The selection only covers the /// cells in the axis. class CellUserSelectionModel extends BoundedSelection implements UserSelectionModel { @override + @Deprecated('') final String id; @override final SelectionStyle? style; CellUserSelectionModel._({ - String? id, + @Deprecated('') String? id, required IntVector2 leftTop, required IntVector2 rightBottom, required Corner anchorCorner, @@ -227,38 +231,26 @@ class CellUserSelectionModel extends BoundedSelection /// Since this selection is a [Range2D], we convert [anchor] and [focus] /// into range's [leftTop] and [rightBottom] values. factory CellUserSelectionModel.fromAnchorFocus({ - String? id, + @Deprecated('') String? id, required IntVector2 anchor, required IntVector2 focus, SelectionStyle? style, }) { - final leftTop = IntVector2( - min(anchor.dx, focus.dx), - min(anchor.dy, focus.dy), - ); - - // Focus and anchor are inclusive, rightBottom on Range2D is not. - final rightBottom = IntVector2( - max(anchor.dx, focus.dx) + 1, - max(anchor.dy, focus.dy) + 1, + final range = _calculateRange( + anchor: anchor, + focus: focus, ); // Define where the anchor is from their positions - late Corner anchorCorner; - if (anchor.dx <= focus.dx && anchor.dy <= focus.dy) { - anchorCorner = Corner.leftTop; - } else if (anchor.dx >= focus.dx && anchor.dy >= focus.dy) { - anchorCorner = Corner.rightBottom; - } else if (anchor.dx < focus.dx) { - anchorCorner = Corner.leftBottom; - } else { - anchorCorner = Corner.rightTop; - } + final anchorCorner = _calculateCorner( + anchor: anchor, + focus: focus, + ); return CellUserSelectionModel._( id: id, - leftTop: leftTop, - rightBottom: rightBottom, + leftTop: range.leftTop, + rightBottom: range.rightBottom, anchorCorner: anchorCorner, style: style, ); @@ -310,3 +302,142 @@ class CellUserSelectionModel extends BoundedSelection @override int get hashCode => super.hashCode ^ id.hashCode ^ style.hashCode; } + +/// A [UserSelectionModel] that represents a [Range2D] used on drag and fill +/// cells operations. +/// +/// Unlike [HeaderUserSelectionModel], the edges of this kind of selection are +/// defined by the coordinates in both axis. The selection only covers the +/// cells in the axis. +class FillSelectionModel extends BoundedSelection + implements UserSelectionModel { + @override + @Deprecated('') + final String id; + + @override + final SelectionStyle? style; + + FillSelectionModel._({ + @Deprecated('') String? id, + required IntVector2 leftTop, + required IntVector2 rightBottom, + required Corner anchorCorner, + required this.style, + }) : id = id ?? _uuid.v4(), + super( + leftTop: leftTop, + rightBottom: rightBottom, + anchorCorner: anchorCorner, + ); + + /// Create a [FillSelectionModel] given its opposite corners + /// ([anchor] and [focus]). + /// + /// If [id] is omitted, an uuid is generated. + /// + /// Since this selection is a [Range2D], we convert [anchor] and [focus] + /// into range's [leftTop] and [rightBottom] values. + factory FillSelectionModel.fromAnchorFocus({ + @Deprecated('') String? id, + required IntVector2 anchor, + required IntVector2 focus, + SelectionStyle? style, + }) { + final range = _calculateRange( + anchor: anchor, + focus: focus, + ); + + return FillSelectionModel._( + id: id, + leftTop: range.leftTop, + rightBottom: range.rightBottom, + anchorCorner: _calculateCorner( + anchor: anchor, + focus: focus, + ), + style: style, + ); + } + + /// Creates a [FillSelectionModel] from any [UserSelectionModel] given an + /// [anchor] and [focus]. + factory FillSelectionModel.fromSelectionModel( + UserSelectionModel original, { + required IntVector2 anchor, + required IntVector2 focus, + }) => + FillSelectionModel.fromAnchorFocus( + id: original.id, + anchor: anchor, + focus: focus, + style: original.style, + ); + + /// Creates a copy of the selection with the specified properties replaced. + /// + /// Calling this method on a selection will return a new transformed selection + /// based on the provided properties. + FillSelectionModel copyWith({ + IntVector2? anchor, + IntVector2? focus, + SelectionStyle? style, + }) => + FillSelectionModel.fromAnchorFocus( + id: id, + anchor: anchor ?? this.anchor, + focus: focus ?? this.focus, + style: style ?? this.style, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is CellUserSelectionModel && + runtimeType == other.runtimeType && + id == other.id && + style == other.style; + + @override + int get hashCode => super.hashCode ^ id.hashCode ^ style.hashCode; +} + +/// Returns an inclusive range with points based on [anchor] and [focus]. +Range2D _calculateRange({ + required IntVector2 anchor, + required IntVector2 focus, +}) { + return Range2D.fromPoints( + IntVector2( + min(anchor.dx, focus.dx), + min(anchor.dy, focus.dy), + ), + // Focus and anchor are inclusive, rightBottom on Range2D is not. + IntVector2( + max(anchor.dx, focus.dx) + 1, + max(anchor.dy, focus.dy) + 1, + ), + ); +} + +/// Returns the corner based on the given [anchor] and [focus]. +Corner _calculateCorner({ + required IntVector2 anchor, + required IntVector2 focus, +}) { + if (anchor.dx <= focus.dx && anchor.dy <= focus.dy) { + return Corner.leftTop; + } + + if (anchor.dx >= focus.dx && anchor.dy >= focus.dy) { + return Corner.rightBottom; + } + + if (anchor.dx < focus.dx) { + return Corner.leftBottom; + } + + return Corner.rightTop; +} diff --git a/packages/swayze/lib/src/core/controller/selection/user_selections/user_selection_state.dart b/packages/swayze/lib/src/core/controller/selection/user_selections/user_selection_state.dart index 2329099..68ed1a4 100644 --- a/packages/swayze/lib/src/core/controller/selection/user_selections/user_selection_state.dart +++ b/packages/swayze/lib/src/core/controller/selection/user_selections/user_selection_state.dart @@ -77,6 +77,10 @@ class UserSelectionState { UserSelectionState addSelection( UserSelectionModel newSelection, ) { + if (selections.any((selection) => selection == newSelection)) { + return this; + } + final newSelections = selections.rebuild((builder) => builder.add(newSelection)); diff --git a/packages/swayze/lib/src/core/controller/table/header_state.dart b/packages/swayze/lib/src/core/controller/table/header_state.dart index 6fb41be..86a5137 100644 --- a/packages/swayze/lib/src/core/controller/table/header_state.dart +++ b/packages/swayze/lib/src/core/controller/table/header_state.dart @@ -1,9 +1,12 @@ import 'dart:collection'; import 'dart:math'; +import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:swayze_math/swayze_math.dart'; +import '../../../helpers/wrapped.dart'; import '../controller.dart'; const _kMapEquality = MapEquality(); @@ -29,6 +32,10 @@ class SwayzeHeaderState { /// elastic expansion. final int elasticCount; + /// The maximum amount allowed of headers in this axis that exist only due to + /// table's elastic expansion. + final int? maxElasticCount; + /// The amount of headers in this axis. final int count; @@ -79,6 +86,11 @@ class SwayzeHeaderState { return result; })(); + /// Current state of a drag and drop action. + /// + /// null if no drag is being performed. + final SwayzeHeaderDragState? dragState; + /// Creates a header state from an unsorted list of [SwayzeHeaderData]. /// /// This is axis agnostic. @@ -88,6 +100,8 @@ class SwayzeHeaderState { required Iterable headerData, required int frozenCount, int? elasticCount, + this.maxElasticCount, + this.dragState, }) : _frozenCount = frozenCount, elasticCount = elasticCount ?? 0, _customSizedHeaders = headerData.fold( @@ -105,6 +119,8 @@ class SwayzeHeaderState { required this.count, required SplayTreeMap sortedHeaderData, required int frozenCount, + this.dragState, + this.maxElasticCount, }) : _frozenCount = frozenCount, _customSizedHeaders = sortedHeaderData; @@ -115,25 +131,31 @@ class SwayzeHeaderState { SwayzeHeaderState copyWith({ int? count, int? elasticCount, + Wrapped? maxElasticCount, Iterable? headerData, int? frozenCount, + Wrapped? dragState, }) { if (headerData != null) { return SwayzeHeaderState( elasticCount: elasticCount ?? this.elasticCount, + maxElasticCount: maxElasticCount?.value ?? this.maxElasticCount, defaultHeaderExtent: defaultHeaderExtent, count: count ?? this.count, headerData: headerData, frozenCount: frozenCount ?? this.frozenCount, + dragState: dragState != null ? dragState.value : this.dragState, ); } return SwayzeHeaderState._fromSortedHeaderData( elasticCount: elasticCount ?? this.elasticCount, + maxElasticCount: maxElasticCount?.value ?? this.maxElasticCount, defaultHeaderExtent: defaultHeaderExtent, count: count ?? this.count, sortedHeaderData: _customSizedHeaders, frozenCount: frozenCount ?? this.frozenCount, + dragState: dragState != null ? dragState.value : this.dragState, ); } @@ -159,6 +181,7 @@ class SwayzeHeaderState { return SwayzeHeaderState._fromSortedHeaderData( elasticCount: elasticCount, + maxElasticCount: maxElasticCount, defaultHeaderExtent: defaultHeaderExtent, count: count, sortedHeaderData: _newCustomSizedHeaders, @@ -180,6 +203,8 @@ class SwayzeHeaderState { defaultHeaderExtent == other.defaultHeaderExtent && count == other.count && elasticCount == other.elasticCount && + maxElasticCount == other.maxElasticCount && + dragState == other.dragState && _kMapEquality.equals(customSizedHeaders, other.customSizedHeaders); @override @@ -188,7 +213,9 @@ class SwayzeHeaderState { defaultHeaderExtent.hashCode ^ count.hashCode ^ elasticCount.hashCode ^ - customSizedHeaders.hashCode; + maxElasticCount.hashCode ^ + customSizedHeaders.hashCode ^ + dragState.hashCode; @override String toString() { @@ -197,11 +224,13 @@ class SwayzeHeaderState { defaultHeaderExtent: $defaultHeaderExtent, count: $count, elasticCount: $elasticCount, + maxElasticCount: $maxElasticCount, orderedCustomSizedIndices: $orderedCustomSizedIndices, hasCustomSizes: $hasCustomSizes, customSizedHeaders: $customSizedHeaders, extent: $extent, frozenCount: $frozenCount, + dragState: $dragState, ) '''; } @@ -252,3 +281,57 @@ class SwayzeHeaderData { @override int get hashCode => index.hashCode ^ extent.hashCode ^ hidden.hashCode; } + +/// Holds the state of a header drag and drop action. +@immutable +class SwayzeHeaderDragState { + /// Headers being dragged. + final Range headers; + + /// Current dropping position. + final int dropAtIndex; + + /// Current drag global position. + final Offset position; + + const SwayzeHeaderDragState({ + required this.headers, + required this.dropAtIndex, + required this.position, + }); + + /// Checks if the current [headers] can be dropped at the [dropAtIndex] + /// position. + /// + /// The headers can only be dropped outside its own range. + bool get isDropAllowed => !headers.contains(dropAtIndex); + + SwayzeHeaderDragState copyWith({ + Range? headers, + int? dropAtIndex, + Offset? position, + }) { + return SwayzeHeaderDragState( + headers: headers ?? this.headers, + dropAtIndex: dropAtIndex ?? this.dropAtIndex, + position: position ?? this.position, + ); + } + + @override + String toString() => + 'SwayzeHeaderDragState($headers, $dropAtIndex, $position)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SwayzeHeaderDragState && + runtimeType == other.runtimeType && + headers == other.headers && + dropAtIndex == other.dropAtIndex && + position == other.position; + + @override + int get hashCode => + headers.hashCode ^ dropAtIndex.hashCode ^ position.hashCode; +} diff --git a/packages/swayze/lib/src/core/controller/table/table_controller.dart b/packages/swayze/lib/src/core/controller/table/table_controller.dart index 2b76239..911746c 100644 --- a/packages/swayze/lib/src/core/controller/table/table_controller.dart +++ b/packages/swayze/lib/src/core/controller/table/table_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart' show Axis; import 'package:swayze_math/swayze_math.dart'; @@ -38,6 +39,12 @@ class SwayzeTableDataController /// A [SwayzeHeaderController] for the vertical axis final SwayzeHeaderController rows; + /// The maximum amount of columns allowed in elastic expansion. + final int? _maxElasticColumns; + + /// The maximum amount of rows allowed in elastic expansion. + final int? _maxElasticRows; + /// Merged [Listenable] to listen for changes on [columns] and [rows]. late final _columnsAndRowsListenable = Listenable.merge([columns, rows]); @@ -50,12 +57,15 @@ class SwayzeTableDataController required Iterable rows, required int frozenColumns, required int frozenRows, + int? maxElasticColumns, + int? maxElasticRows, }) : columns = SwayzeHeaderController._( initialState: SwayzeHeaderState( defaultHeaderExtent: config.kDefaultCellWidth, count: columnCount, headerData: columns, frozenCount: frozenColumns, + maxElasticCount: maxElasticColumns, ), ), rows = SwayzeHeaderController._( @@ -64,8 +74,11 @@ class SwayzeTableDataController count: rowCount, headerData: rows, frozenCount: frozenRows, + maxElasticCount: maxElasticRows, ), ), + _maxElasticColumns = maxElasticColumns, + _maxElasticRows = maxElasticRows, super() { parent.selection.addListener(handleSelectionChange); } @@ -108,8 +121,9 @@ class SwayzeTableDataController void handleSelectionChange() { final selections = [ ...parent.selection.userSelectionState.selections, + parent.selection.fillSelectionState.selection, ...parent.selection.dataSelections, - ]; + ].whereNotNull(); final currentElasticEdge = IntVector2( columns.value.elasticCount, @@ -134,8 +148,12 @@ class SwayzeTableDataController } scheduleMicrotask(() { - columns.updateElasticCount(elasticEdge.dx); - rows.updateElasticCount(elasticEdge.dy); + columns.updateElasticCount( + min(_maxElasticColumns ?? elasticEdge.dx, elasticEdge.dx), + ); + rows.updateElasticCount( + min(_maxElasticRows ?? elasticEdge.dy, elasticEdge.dy), + ); }); } diff --git a/packages/swayze/lib/src/core/intents/drag_n_drop_intents.dart b/packages/swayze/lib/src/core/intents/drag_n_drop_intents.dart new file mode 100644 index 0000000..1ad1345 --- /dev/null +++ b/packages/swayze/lib/src/core/intents/drag_n_drop_intents.dart @@ -0,0 +1,68 @@ +import 'package:flutter/widgets.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import 'swayze_intent.dart'; + +/// A [SwayzeIntent] to start a header drag by creating a new +/// [SwayzeHeaderDragState]. +class HeaderDragStartIntent extends SwayzeIntent { + /// Headers being dragged. + final Range headers; + + /// Headers axis. + final Axis axis; + + /// Current drag offset. + final Offset draggingPosition; + + const HeaderDragStartIntent({ + required this.headers, + required this.axis, + required this.draggingPosition, + }); +} + +/// A [SwayzeIntent] to update the current drag state by setting a new given +/// [draggingPosition] and a new reference [header]. +class HeaderDragUpdateIntent extends SwayzeIntent { + /// The current header index where the [draggingPosition] is on top of. + /// + /// This would be the index to move the current dragged headers if a + /// [HeaderDragEndIntent] is invoked. + final int header; + + /// Header axis. + final Axis axis; + + /// Current drag offset. + final Offset draggingPosition; + + const HeaderDragUpdateIntent({ + required this.header, + required this.axis, + required this.draggingPosition, + }); +} + +/// A [SwayzeIntent] that completes a drag event (a drop action), it should +/// end the [SwayzeHeaderDragState]. +class HeaderDragEndIntent extends SwayzeIntent { + /// The current header index where the headers should be moved to. + final int header; + + /// Header axis. + final Axis axis; + + const HeaderDragEndIntent({ + required this.header, + required this.axis, + }); +} + +/// A [SwayzeIntent] to cancel a drag action resetting [SwayzeHeaderDragState]. +class HeaderDragCancelIntent extends SwayzeIntent { + /// Header axis. + final Axis axis; + + const HeaderDragCancelIntent(this.axis); +} diff --git a/packages/swayze/lib/src/core/intents/intents.dart b/packages/swayze/lib/src/core/intents/intents.dart index 3ada078..5086728 100644 --- a/packages/swayze/lib/src/core/intents/intents.dart +++ b/packages/swayze/lib/src/core/intents/intents.dart @@ -1,3 +1,4 @@ +export 'drag_n_drop_intents.dart'; export 'inline_editor_intents.dart'; export 'selection_intents.dart'; export 'swayze_intent.dart'; diff --git a/packages/swayze/lib/src/core/intents/selection_intents.dart b/packages/swayze/lib/src/core/intents/selection_intents.dart index 9fcd7e5..10d115b 100644 --- a/packages/swayze/lib/src/core/intents/selection_intents.dart +++ b/packages/swayze/lib/src/core/intents/selection_intents.dart @@ -41,6 +41,38 @@ class ExpandSelectionByBlockIntent extends SwayzeIntent { const ExpandSelectionByBlockIntent(this.direction); } +/// A [SwayzeIntent] to fill a range based on cells from the source range. +/// +/// This differs from [FillIntoUnknownIntent] as we know the target range. +/// +/// See also: +/// - [TableBodyGestureDetector] that triggers this intent +class FillIntoTargetIntent extends SwayzeIntent { + final Range2D source; + + final Range2D target; + + const FillIntoTargetIntent({ + required this.source, + required this.target, + }); +} + +/// A [SwayzeIntent] to fill unknown cells from a given range. +/// +/// This differs from [FillIntoTargetIntent] as we know don't know the target +/// range. +/// +/// See also: +/// - [TableBodyGestureDetector] that triggers this intent +class FillIntoUnknownIntent extends SwayzeIntent { + final Range2D source; + + const FillIntoUnknownIntent({ + required this.source, + }); +} + /// A [SwayzeIntent] to start a selection in the table body. /// /// See also: @@ -48,7 +80,13 @@ class ExpandSelectionByBlockIntent extends SwayzeIntent { class TableBodySelectionStartIntent extends SwayzeIntent { final IntVector2 cellCoordinate; - const TableBodySelectionStartIntent(this.cellCoordinate); + /// `true` if this is a selection to be used to fill the cells. + final bool fill; + + const TableBodySelectionStartIntent( + this.cellCoordinate, { + this.fill = false, + }); } /// A [SwayzeIntent] to start a selection in the table headers. @@ -75,6 +113,22 @@ class TableBodySelectionUpdateIntent extends SwayzeIntent { const TableBodySelectionUpdateIntent(this.cellCoordinate); } +/// A [SwayzeIntent] to end a selection in the table body. +/// +/// See also: +/// - [TableBodyGestureDetector] that triggers this intent +class TableBodySelectionEndIntent extends SwayzeIntent { + const TableBodySelectionEndIntent(); +} + +/// A [SwayzeIntent] to cancel a selection in the table body. +/// +/// See also: +/// - [TableBodyGestureDetector] that triggers this intent +class TableBodySelectionCancelIntent extends SwayzeIntent { + const TableBodySelectionCancelIntent(); +} + /// A [SwayzeIntent] to update a selection in the headers. /// /// See also: diff --git a/packages/swayze/lib/src/core/style/resize_header_style.dart b/packages/swayze/lib/src/core/style/resize_header_style.dart new file mode 100644 index 0000000..d51a296 --- /dev/null +++ b/packages/swayze/lib/src/core/style/resize_header_style.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart' show Color, immutable; + +/// Defines the width of the resize header line as well as its colors. +@immutable +class ResizeHeaderStyle { + /// The color of the resize line circle fill. + final Color fillColor; + + /// The color of the resize line. + final Color lineColor; + + const ResizeHeaderStyle({ + required this.fillColor, + required this.lineColor, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is ResizeHeaderStyle && + other.fillColor == fillColor && + other.lineColor == lineColor; + } + + @override + int get hashCode => fillColor.hashCode ^ lineColor.hashCode; +} diff --git a/packages/swayze/lib/src/core/style/style.dart b/packages/swayze/lib/src/core/style/style.dart index 366d97f..253943e 100644 --- a/packages/swayze/lib/src/core/style/style.dart +++ b/packages/swayze/lib/src/core/style/style.dart @@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart'; import '../../../widgets.dart'; import '../controller/selection/model/selection_style.dart'; +export 'resize_header_style.dart'; +export 'table_select_style.dart'; /// Describes a collection of colors for headers in a determinate state. @immutable @@ -53,6 +55,11 @@ class SwayzeStyle { headerTextStyle: const TextStyle( fontSize: 12, ), + tableSelectStyle: const TableSelectStyle( + foregroundColor: Colors.transparent, + selectedForegroundColor: Colors.transparent, + backgroundFillColor: Colors.transparent, + ), cellSeparatorColor: const Color(0xFFE1E1E1), cellSeparatorStrokeWidth: 1.0, defaultCellBackground: const Color(0xFFFFFFFF), @@ -67,6 +74,21 @@ class SwayzeStyle { offset: Offset(2, 2), ), ], + dragAndDropStyle: const SwayzeHeaderDragAndDropStyle( + previewHeadersColor: Colors.black26, + previewLineColor: Colors.amberAccent, + previewLineWidth: 2.0, + ), + dragAndFillStyle: const SwayzeDragAndFillStyle( + color: Color(0xFF6F6F6F), + handle: SwayzeDragAndFillHandleStyle( + color: Color(0xFFFFC800), + ), + ), + resizeHeaderStyle: const ResizeHeaderStyle( + fillColor: Color(0xFFFFF6D4), + lineColor: Color(0xFFFFC800), + ), ); // Headers @@ -86,6 +108,9 @@ class SwayzeStyle { /// [SwayzeHeaderPalette.foreground]. final TextStyle headerTextStyle; + /// The style of the table select area + final TableSelectStyle tableSelectStyle; + /// The color of the lines that separates cells. final Color cellSeparatorColor; @@ -105,18 +130,29 @@ class SwayzeStyle { final List inlineEditorShadow; + final SwayzeHeaderDragAndDropStyle dragAndDropStyle; + + final SwayzeDragAndFillStyle dragAndFillStyle; + + /// The style of the resize header line widget. + final ResizeHeaderStyle resizeHeaderStyle; + const SwayzeStyle({ required this.defaultHeaderPalette, required this.selectedHeaderPalette, required this.highlightedHeaderPalette, required this.headerSeparatorColor, required this.headerTextStyle, + required this.tableSelectStyle, required this.defaultCellBackground, required this.cellSeparatorColor, required this.cellSeparatorStrokeWidth, required this.userSelectionStyle, required this.selectionAnimationDuration, required this.inlineEditorShadow, + required this.dragAndDropStyle, + required this.dragAndFillStyle, + required this.resizeHeaderStyle, }); /// Copy an instance of [SwayzeStyle] with certain modifications. @@ -128,12 +164,16 @@ class SwayzeStyle { SwayzeHeaderPalette? highlightedHeaderPalette, Color? headerSeparatorColor, TextStyle? headerTextStyle, + TableSelectStyle? tableSelectStyle, Color? defaultCellBackground, Color? cellSeparatorColor, double? cellSeparatorStrokeWidth, SelectionStyle? userSelectionStyle, Duration? selectionAnimationDuration, List? inlineEditorShadow, + SwayzeHeaderDragAndDropStyle? dragAndDropStyle, + SwayzeDragAndFillStyle? dragAndFillStyle, + ResizeHeaderStyle? resizeHeaderStyle, }) { return SwayzeStyle( defaultHeaderPalette: defaultHeaderPalette ?? this.defaultHeaderPalette, @@ -143,6 +183,7 @@ class SwayzeStyle { highlightedHeaderPalette ?? this.highlightedHeaderPalette, headerSeparatorColor: headerSeparatorColor ?? this.headerSeparatorColor, headerTextStyle: headerTextStyle ?? this.headerTextStyle, + tableSelectStyle: tableSelectStyle ?? this.tableSelectStyle, defaultCellBackground: defaultCellBackground ?? this.defaultCellBackground, cellSeparatorColor: cellSeparatorColor ?? this.cellSeparatorColor, @@ -152,6 +193,9 @@ class SwayzeStyle { selectionAnimationDuration: selectionAnimationDuration ?? this.selectionAnimationDuration, inlineEditorShadow: inlineEditorShadow ?? this.inlineEditorShadow, + dragAndDropStyle: dragAndDropStyle ?? this.dragAndDropStyle, + dragAndFillStyle: dragAndFillStyle ?? this.dragAndFillStyle, + resizeHeaderStyle: resizeHeaderStyle ?? this.resizeHeaderStyle, ); } @@ -165,11 +209,15 @@ class SwayzeStyle { highlightedHeaderPalette == other.highlightedHeaderPalette && headerSeparatorColor == other.headerSeparatorColor && headerTextStyle == other.headerTextStyle && + tableSelectStyle == other.tableSelectStyle && cellSeparatorColor == other.cellSeparatorColor && defaultCellBackground == other.defaultCellBackground && userSelectionStyle == other.userSelectionStyle && selectionAnimationDuration == other.selectionAnimationDuration && - inlineEditorShadow == other.inlineEditorShadow; + inlineEditorShadow == other.inlineEditorShadow && + dragAndDropStyle == other.dragAndDropStyle && + dragAndFillStyle == other.dragAndFillStyle && + resizeHeaderStyle == other.resizeHeaderStyle; @override int get hashCode => @@ -178,8 +226,113 @@ class SwayzeStyle { highlightedHeaderPalette.hashCode ^ headerSeparatorColor.hashCode ^ headerTextStyle.hashCode ^ + tableSelectStyle.hashCode ^ cellSeparatorColor.hashCode ^ defaultCellBackground.hashCode ^ userSelectionStyle.hashCode ^ - inlineEditorShadow.hashCode; + inlineEditorShadow.hashCode ^ + dragAndDropStyle.hashCode ^ + dragAndFillStyle.hashCode ^ + resizeHeaderStyle.hashCode; +} + +/// Style for header drag and drop preview widgets. +@immutable +class SwayzeHeaderDragAndDropStyle { + /// The color of the line that previews where dragged headers will be dropped. + final Color previewLineColor; + + /// Width of the line that previews where dragged headers will be dropped. + final double previewLineWidth; + + /// The color of the preview headers that are being dragged. + final Color previewHeadersColor; + + const SwayzeHeaderDragAndDropStyle({ + required this.previewLineColor, + required this.previewLineWidth, + required this.previewHeadersColor, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SwayzeHeaderDragAndDropStyle && + runtimeType == other.runtimeType && + previewLineColor == other.previewLineColor && + previewLineWidth == other.previewLineWidth && + previewHeadersColor == other.previewHeadersColor; + + @override + int get hashCode => + previewLineColor.hashCode ^ + previewLineWidth.hashCode ^ + previewHeadersColor.hashCode; +} + +/// Style for the drag and fill selection. +@immutable +class SwayzeDragAndFillStyle { + /// The color of the selection. + final Color color; + + /// The width of the selection border. + /// + /// Defaults to `1.0`. + final double borderWidth; + + final SwayzeDragAndFillHandleStyle handle; + + const SwayzeDragAndFillStyle({ + required this.color, + this.borderWidth = 1.0, + required this.handle, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SwayzeDragAndFillStyle && + runtimeType == other.runtimeType && + color == other.color && + borderWidth == other.borderWidth && + handle == other.handle; + + @override + int get hashCode => color.hashCode ^ borderWidth.hashCode ^ handle.hashCode; +} + +/// Style for the drag and fill handle. +@immutable +class SwayzeDragAndFillHandleStyle { + /// The color of the handle. + final Color color; + + /// The size of the handle rectangle. + /// + /// Defaults to `Size(5.0, 5.0)`. + final Size size; + + /// The width of the border. + /// + /// Defaults to `1.0`. + final double borderWidth; + + const SwayzeDragAndFillHandleStyle({ + required this.color, + this.size = const Size(5.0, 5.0), + this.borderWidth = 1.0, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SwayzeDragAndFillHandleStyle && + runtimeType == other.runtimeType && + color == other.color && + size == other.size && + borderWidth == other.borderWidth; + + @override + int get hashCode => color.hashCode ^ size.hashCode ^ borderWidth.hashCode; } diff --git a/packages/swayze/lib/src/core/style/table_select_style.dart b/packages/swayze/lib/src/core/style/table_select_style.dart new file mode 100644 index 0000000..9ff05ef --- /dev/null +++ b/packages/swayze/lib/src/core/style/table_select_style.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart' show Color, immutable; + +/// Styles the square intersection of headers in the top left corner +/// Typically a triangle pointer +@immutable +class TableSelectStyle { + /// The color of the resize line. + final Color foregroundColor; + + /// The color of the resize line. + final Color selectedForegroundColor; + + /// The color of the resize line circle fill. + final Color backgroundFillColor; + + const TableSelectStyle({ + required this.foregroundColor, + required this.selectedForegroundColor, + required this.backgroundFillColor, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is TableSelectStyle && + other.foregroundColor == foregroundColor && + other.selectedForegroundColor == selectedForegroundColor && + other.backgroundFillColor == backgroundFillColor; + } + + @override + int get hashCode => + foregroundColor.hashCode ^ + selectedForegroundColor.hashCode ^ + backgroundFillColor.hashCode; +} diff --git a/packages/swayze/lib/src/core/viewport_context/viewport_context.dart b/packages/swayze/lib/src/core/viewport_context/viewport_context.dart index b1cbe79..f06de48 100644 --- a/packages/swayze/lib/src/core/viewport_context/viewport_context.dart +++ b/packages/swayze/lib/src/core/viewport_context/viewport_context.dart @@ -3,11 +3,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:swayze_math/swayze_math.dart'; +import '../../widgets/headers/gestures/resize_header/header_edge_info.dart'; import '../virtualization/virtualization_calculator.dart'; import 'viewport_context_provider.dart'; const _kDoubleListEquality = ListEquality(); const _kIntIterableEquality = IterableEquality(); +const _kDoubleHeaderEdgeInfoMapEquality = MapEquality(); /// Interface that provides information about the visible rows and columns: /// Their sizes, which space in the viewport each one occupies and their @@ -54,6 +56,12 @@ abstract class ViewportContext extends Listenable { /// Given a cell's coordinates it returns it's [CellPositionResult] which /// contains info about it's [Offset] in pixels and it's [Size]. CellPositionResult getCellPosition(IntVector2 globalPosition); + + /// Checks if the point in the table belongs to a place that should react + /// differently, like a drag and fill start position. + /// + /// The [pixelOffset] is the offset from the leading edge of the table. + EvaluateHoverResult evaluateHover(Offset pixelOffset); } /// A [ChangeNotifier] that manages has [ViewportAxisContextState] @@ -86,6 +94,7 @@ class ViewportAxisContext extends ChangeNotifier frozenRange: Range.zero, visibleIndices: [], visibleFrozenIndices: [], + headersEdgesOffsets: {}, ); ViewportAxisContext(this.axis, this.virtualizationState); @@ -162,6 +171,16 @@ class ViewportAxisContextState { /// Just like [visibleIndices] but for the [frozenRange] final Iterable visibleFrozenIndices; + /// Holds the current header drag state if there is an ongoing drag and drop + /// action. + final ViewportHeaderDragContextState? headerDragState; + + /// A map that contains the headers edges offsets. + /// + /// Useful to show the correct cursor when hovering the edge of an header + /// for resizing purposes. + final Map headersEdgesOffsets; + const ViewportAxisContextState({ required this.scrollableRange, required this.frozenRange, @@ -173,8 +192,13 @@ class ViewportAxisContextState { required this.frozenSizes, required this.visibleIndices, required this.visibleFrozenIndices, + required this.headersEdgesOffsets, + this.headerDragState, }); + /// True if there is an ongoing drag and drop action. + bool get isDragging => headerDragState != null; + @override bool operator ==(Object other) => identical(this, other) || @@ -184,6 +208,7 @@ class ViewportAxisContextState { frozenRange == other.frozenRange && extent == other.extent && frozenExtent == other.frozenExtent && + headerDragState == other.headerDragState && _kDoubleListEquality.equals(offsets, other.offsets) && _kDoubleListEquality.equals(frozenOffsets, other.frozenOffsets) && _kDoubleListEquality.equals(sizes, other.sizes) && @@ -192,6 +217,10 @@ class ViewportAxisContextState { _kIntIterableEquality.equals( visibleFrozenIndices, other.visibleFrozenIndices, + ) && + _kDoubleHeaderEdgeInfoMapEquality.equals( + headersEdgesOffsets, + other.headersEdgesOffsets, ); @override @@ -205,7 +234,50 @@ class ViewportAxisContextState { sizes.hashCode ^ frozenSizes.hashCode ^ visibleIndices.hashCode ^ - visibleFrozenIndices.hashCode; + visibleFrozenIndices.hashCode ^ + headerDragState.hashCode ^ + headersEdgesOffsets.hashCode; +} + +/// Holds the state of an ongoing header drag and drop action. +@immutable +class ViewportHeaderDragContextState { + /// Headers that are being dragged. + final Range headers; + + /// Current dragging reference, eg, the current header that [position] + /// is hovering. + final int dropAtIndex; + + /// Current dragging position. + final Offset position; + + /// Extent of all headers being dragged. + final double headersExtent; + + const ViewportHeaderDragContextState({ + required this.headers, + required this.dropAtIndex, + required this.position, + required this.headersExtent, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ViewportHeaderDragContextState && + runtimeType == other.runtimeType && + headers == other.headers && + dropAtIndex == other.dropAtIndex && + position == other.position && + headersExtent == other.headersExtent; + + @override + int get hashCode => + headers.hashCode ^ + position.hashCode ^ + headersExtent.hashCode ^ + dropAtIndex.hashCode; } /// A result of a conversion of a pixel offset into column/row index. @@ -342,3 +414,49 @@ extension OverflowViewportMethods on OffscreenDetails { /// Defines if a [OffscreenDetails] describes a overflow situation. bool get isOffscreen => this != OffscreenDetails.noOverflow; } + +/// The result of the evaluation of a position on the table. +/// +/// See also: +/// - [ViewportContext.evaluateHover] that generates this result. +@immutable +class EvaluateHoverResult { + /// The coordinate of the cell on the given position. + final IntVector2 cell; + + /// The horizontal axis overflow details of [cell]. + final OffscreenDetails overflowX; + + /// The vertical axis overflow details of [cell]. + final OffscreenDetails overflowY; + + /// If the position allows a drag and fill operation, this holds the + /// source range for the operation. + final Range2D? fillRange; + + const EvaluateHoverResult({ + required this.cell, + required this.overflowX, + required this.overflowY, + required this.fillRange, + }); + + bool get canFillCell => fillRange != null; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EvaluateHoverResult && + runtimeType == other.runtimeType && + cell == other.cell && + overflowX == other.overflowX && + overflowY == other.overflowY && + fillRange == other.fillRange; + + @override + int get hashCode => + cell.hashCode ^ + overflowX.hashCode ^ + overflowY.hashCode ^ + fillRange.hashCode; +} diff --git a/packages/swayze/lib/src/core/viewport_context/viewport_context_provider.dart b/packages/swayze/lib/src/core/viewport_context/viewport_context_provider.dart index e857cd0..e0f3c52 100644 --- a/packages/swayze/lib/src/core/viewport_context/viewport_context_provider.dart +++ b/packages/swayze/lib/src/core/viewport_context/viewport_context_provider.dart @@ -2,11 +2,16 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:swayze_math/swayze_math.dart'; +import '../../widgets/headers/gestures/resize_header/header_edge_info.dart'; import '../../widgets/internal_scope.dart'; +import '../controller/controller.dart'; import '../virtualization/virtualization_calculator.dart' show VirtualizationCalculator, VirtualizationState; import 'viewport_context.dart'; +const _kMinEdgeOffsetAdder = -2; +const kMaxEdgeOffsetAdder = 2; + /// A [StatefulWidget] that detects changes on the two axis /// [VirtualizationState.rangeNotifier] to create a [ViewportContext] and /// add it to the tree context via [_ViewportContextProviderScope]. @@ -140,6 +145,8 @@ class _ViewportContextProviderState extends State final headerController = tableController.getHeaderControllerFor(axis: axis); final scrollableRange = rangeNotifier.value; + final headersEdgesOffsets = {}; + // Frozen final frozenSizes = []; final frozenOffsets = []; @@ -153,6 +160,14 @@ class _ViewportContextProviderState extends State final size = headerController.value.getHeaderExtentFor(index: index); frozenSizes.add(size); frozenExtentAcc += size; + + _addHeaderEdge( + headersEdgesOffsets, + offset: frozenExtentAcc, + index: index, + size: size, + ); + if (size > 0) { visibleFrozenHeaders.add(index); } @@ -169,11 +184,36 @@ class _ViewportContextProviderState extends State final size = headerController.value.getHeaderExtentFor(index: index); sizes.add(size); extentAcc += size; + + _addHeaderEdge( + headersEdgesOffsets, + offset: extentAcc, + index: index, + size: size, + ); + if (size > 0) { visibleHeaders.add(index); } } + final dragState = headerController.value.dragState; + ViewportHeaderDragContextState? dragContextState; + if (dragState != null) { + var draggingHeaderExtent = 0.0; + for (final index in dragState.headers.iterable) { + draggingHeaderExtent += + headerController.value.getHeaderExtentFor(index: index); + } + + dragContextState = ViewportHeaderDragContextState( + headers: dragState.headers, + dropAtIndex: dragState.dropAtIndex, + position: dragState.position, + headersExtent: draggingHeaderExtent, + ); + } + viewportAxisContext._unprotectedSetState( ViewportAxisContextState( scrollableRange: scrollableRange, @@ -186,10 +226,33 @@ class _ViewportContextProviderState extends State frozenSizes: frozenSizes, visibleIndices: visibleHeaders, visibleFrozenIndices: visibleFrozenHeaders, + headerDragState: dragContextState, + headersEdgesOffsets: headersEdgesOffsets, ), ); } + /// Maps the headers edges to the corresponding index. + /// + /// Since we also want to show the resize cursor when the user hovers a bit + /// to the left or right of the edge, we save a range of positions and map + /// them to the right header index. + void _addHeaderEdge( + Map headersEdgesOffsets, { + required double offset, + required int index, + required double size, + }) { + for (var i = _kMinEdgeOffsetAdder; i <= kMaxEdgeOffsetAdder; i++) { + headersEdgesOffsets[offset.floorToDouble() + i] = HeaderEdgeInfo( + index: index, + width: size, + displacement: -i, + offset: offset, + ); + } + } + @override PositionResult pixelToPosition(double pixelOffset, Axis axis) { const leadingEdge = 0.0; @@ -356,6 +419,59 @@ class _ViewportContextProviderState extends State ); } + @override + EvaluateHoverResult evaluateHover(Offset pixelOffset) { + final internalScope = InternalScope.of(context); + + final selectionController = internalScope.controller.selection; + + final selectionState = selectionController.userSelectionState; + + final primarySelection = + selectionState.primarySelection is CellUserSelectionModel + ? selectionState.primarySelection as CellUserSelectionModel + : null; + + final style = internalScope.config.isDragFillEnabled + ? internalScope.style.dragAndFillStyle.handle + : null; + + final positionX = pixelToPosition(pixelOffset.dx, Axis.horizontal); + final positionY = pixelToPosition(pixelOffset.dy, Axis.vertical); + + // Tries to set the range using the current fill selection, if there's one. + Range2D? fillRange = selectionController.fillSelectionState.selection; + + // If the primary selection allows fill, check if we're over the handle. + if (fillRange == null && primarySelection != null && style != null) { + final range = Range2D.fromPoints( + primarySelection.anchor, + primarySelection.focus, + ); + + final cellPosition = getCellPosition(range.rightBottom); + final cellRect = cellPosition.leftTop & cellPosition.cellSize; + + final canFillCell = Rect.fromLTRB( + cellRect.right - style.size.width, + cellRect.bottom - style.size.height, + cellRect.right + style.size.width, + cellRect.bottom + style.size.height, + ).inflate(style.borderWidth).contains(pixelOffset); + + if (canFillCell) { + fillRange = range; + } + } + + return EvaluateHoverResult( + cell: IntVector2(positionX.position, positionY.position), + overflowX: positionX.overflow, + overflowY: positionY.overflow, + fillRange: fillRange, + ); + } + @override Widget build(BuildContext context) => _ViewportContextProviderScope( // this state is provided as ViewportContext diff --git a/packages/swayze/lib/src/helpers/wrapped.dart b/packages/swayze/lib/src/helpers/wrapped.dart new file mode 100644 index 0000000..63f2955 --- /dev/null +++ b/packages/swayze/lib/src/helpers/wrapped.dart @@ -0,0 +1,6 @@ +/// Wrapper to allow the use of nullable properties +/// on copyWith style methods. +class Wrapped { + final T value; + const Wrapped.value(this.value); +} diff --git a/packages/swayze/lib/src/widgets/default_actions/default_table_actions.dart b/packages/swayze/lib/src/widgets/default_actions/default_table_actions.dart index b13bfdd..a6cf919 100644 --- a/packages/swayze/lib/src/widgets/default_actions/default_table_actions.dart +++ b/packages/swayze/lib/src/widgets/default_actions/default_table_actions.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../../core/intents/intents.dart'; import '../../core/viewport_context/viewport_context_provider.dart'; import '../internal_scope.dart'; +import 'drag_n_drop_actions.dart'; import 'inline_editor_actions.dart'; import 'selection_actions.dart'; @@ -75,10 +76,34 @@ class _DefaultActionsState extends State { internalScope, viewportContext, ).overridable(context), + TableBodySelectionEndIntent: CellSelectionEndAction( + internalScope, + viewportContext, + ), + TableBodySelectionCancelIntent: CellSelectionCancelAction( + internalScope, + viewportContext, + ), HeaderSelectionUpdateIntent: HeaderSelectionUpdateAction( internalScope, viewportContext, ).overridable(context), + HeaderDragStartIntent: HeaderDragStartAction( + internalScope, + viewportContext, + ).overridable(context), + HeaderDragEndIntent: HeaderDragEndAction( + internalScope, + viewportContext, + ).overridable(context), + HeaderDragUpdateIntent: HeaderDragUpdateAction( + internalScope, + viewportContext, + ).overridable(context), + HeaderDragCancelIntent: HeaderDragCancelAction( + internalScope, + viewportContext, + ).overridable(context), }, child: widget.child, ); diff --git a/packages/swayze/lib/src/widgets/default_actions/drag_n_drop_actions.dart b/packages/swayze/lib/src/widgets/default_actions/drag_n_drop_actions.dart new file mode 100644 index 0000000..3c259ac --- /dev/null +++ b/packages/swayze/lib/src/widgets/default_actions/drag_n_drop_actions.dart @@ -0,0 +1,151 @@ +import 'package:flutter/widgets.dart'; + +import '../../../controller.dart'; +import '../../core/intents/drag_n_drop_intents.dart'; +import '../../core/viewport_context/viewport_context.dart'; +import '../../helpers/wrapped.dart'; +import '../internal_scope.dart'; +import 'default_swayze_action.dart'; + +/// Default [Action] for [HeaderDragStartIntent]. +/// +/// See also: +/// * [HeaderGestureDetector] that triggers the intent. +/// * [SwayzeHeaderDragState] that holds the drag state. +/// * [DefaultActions] for the widget that binds this action into the +/// widget tree. +class HeaderDragStartAction extends DefaultSwayzeAction { + HeaderDragStartAction( + InternalScope internalScope, + ViewportContext viewportContext, + ) : super(internalScope, viewportContext); + + @override + void invokeAction( + HeaderDragStartIntent intent, + BuildContext context, + ) { + final controller = internalScope.controller.tableDataController + .getHeaderControllerFor(axis: intent.axis); + controller.updateState( + (state) => state.copyWith( + dragState: Wrapped.value( + SwayzeHeaderDragState( + headers: intent.headers, + dropAtIndex: intent.headers.start, + position: intent.draggingPosition, + ), + ), + ), + ); + } +} + +/// Default [Action] for [HeaderDragUpdateIntent]. +/// +/// See also: +/// * [HeaderGestureDetector] that triggers the intent. +/// * [SwayzeHeaderDragState] that holds the drag state. +/// * [DefaultActions] for the widget that binds this action into the +/// widget tree. +class HeaderDragUpdateAction + extends DefaultSwayzeAction { + HeaderDragUpdateAction( + InternalScope internalScope, + ViewportContext viewportContext, + ) : super(internalScope, viewportContext); + + @override + void invokeAction( + HeaderDragUpdateIntent intent, + BuildContext context, + ) { + final controller = internalScope.controller.tableDataController + .getHeaderControllerFor(axis: intent.axis); + + controller.updateState( + (state) => state.copyWith( + dragState: Wrapped.value( + state.dragState?.copyWith( + dropAtIndex: intent.header, + position: intent.draggingPosition, + ), + ), + ), + ); + } +} + +/// Default [Action] for [HeaderDragEndIntent]. +/// +/// See also: +/// * [HeaderGestureDetector] that triggers the intent. +/// * [SwayzeHeaderDragState] that holds the drag state. +/// * [DefaultActions] for the widget that binds this action into the +/// widget tree. +class HeaderDragEndAction extends DefaultSwayzeAction { + HeaderDragEndAction( + InternalScope internalScope, + ViewportContext viewportContext, + ) : super(internalScope, viewportContext); + + @override + void invokeAction( + HeaderDragEndIntent intent, + BuildContext context, + ) { + final controller = + internalScope.controller.tableDataController.getHeaderControllerFor( + axis: intent.axis, + ); + + final dragState = controller.value.dragState; + if (dragState == null) { + return; + } + + final insertAfter = dragState.dropAtIndex >= dragState.headers.start; + + final size = dragState.headers.end - dragState.headers.start - 1; + + controller.updateState( + (state) => state.copyWith(dragState: const Wrapped.value(null)), + ); + internalScope.controller.selection.updateUserSelections((state) { + return state.resetSelectionsToHeaderSelection( + anchor: intent.header, + focus: insertAfter ? intent.header - size : intent.header + size, + axis: intent.axis, + ); + }); + } +} + +/// Default [Action] for [HeaderDragCancelIntent]. +/// +/// See also: +/// * [HeaderGestureDetector] that triggers the intent. +/// * [SwayzeHeaderDragState] that holds the drag state. +/// * [DefaultActions] for the widget that binds this action into the +/// widget tree. +class HeaderDragCancelAction + extends DefaultSwayzeAction { + HeaderDragCancelAction( + InternalScope internalScope, + ViewportContext viewportContext, + ) : super(internalScope, viewportContext); + + @override + void invokeAction( + HeaderDragCancelIntent intent, + BuildContext context, + ) { + final controller = + internalScope.controller.tableDataController.getHeaderControllerFor( + axis: intent.axis, + ); + controller.updateState( + (state) => state.copyWith(dragState: const Wrapped.value(null)), + ); + } +} diff --git a/packages/swayze/lib/src/widgets/default_actions/selection_actions.dart b/packages/swayze/lib/src/widgets/default_actions/selection_actions.dart index 1f2a163..dae6a2b 100644 --- a/packages/swayze/lib/src/widgets/default_actions/selection_actions.dart +++ b/packages/swayze/lib/src/widgets/default_actions/selection_actions.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -349,38 +350,60 @@ class CellSelectionStartAction TableBodySelectionStartIntent intent, BuildContext context, ) { - final tableFocus = TableFocus.of(context); final selectionController = internalScope.controller.selection; + TableFocus.of(context).requestFocus(); + + // Drag and fill creates a new selection of the fill type. + if (intent.fill) { + final primary = internalScope + .controller.selection.userSelectionState.primarySelection; + + selectionController.updateFillSelections( + (state) => state.addIfNoneExists( + FillSelectionModel.fromAnchorFocus( + anchor: primary.anchorCoordinate, + focus: primary.focusCoordinate, + ), + ), + ); + + return; + } + final keysPressed = LogicalKeyboardKey.collapseSynonyms( RawKeyboard.instance.keysPressed, ); if (keysPressed.contains(LogicalKeyboardKey.shift)) { - tableFocus.requestFocus(); selectionController.updateUserSelections( (state) => state.updateLastSelectionToCellSelection( focus: intent.cellCoordinate, ), ); - } else if (keysPressed.containsModifier) { + + return; + } + + if (keysPressed.containsModifier) { final selection = CellUserSelectionModel.fromAnchorFocus( anchor: intent.cellCoordinate, focus: intent.cellCoordinate, ); - tableFocus.requestFocus(); + selectionController.updateUserSelections( (state) => state.addSelection(selection), ); - } else { - tableFocus.requestFocus(); - selectionController.updateUserSelections( - (state) => state.resetSelectionsToACellSelection( - anchor: intent.cellCoordinate, - focus: intent.cellCoordinate, - ), - ); + + return; } + + selectionController.updateUserSelections( + (state) => state.resetSelectionsToACellSelection( + anchor: intent.cellCoordinate, + focus: intent.cellCoordinate, + ), + ); } } @@ -441,6 +464,9 @@ class HeaderSelectionStartAction /// Default [Action] for [TableBodySelectionUpdateIntent] /// +/// This will restrict the axis of the selection if the selection is a drag +/// and fill type. +/// /// See also: /// * [TableBodyGestureDetector] that triggers the intent /// * [UserSelectionState] for the implementation of the expand selection. @@ -459,12 +485,151 @@ class CellSelectionUpdateAction BuildContext context, ) { final selectionController = internalScope.controller.selection; + final fillSelection = selectionController.fillSelectionState.selection; + + if (fillSelection != null) { + _updateFillSelection(intent.cellCoordinate); + + return; + } + selectionController.updateUserSelections( (state) => state.updateLastSelectionToCellSelection( focus: intent.cellCoordinate, ), ); } + + void _updateFillSelection(IntVector2 coordinate) { + final selectionController = internalScope.controller.selection; + + final primary = selectionController.userSelectionState.primarySelection; + + final anchor = primary.anchorCoordinate; + final focus = primary.focusCoordinate; + + final currentRange = Range2D.fromPoints( + IntVector2( + min(anchor.dx, focus.dx), + min(anchor.dy, focus.dy), + ), + IntVector2( + max(anchor.dx, focus.dx), + max(anchor.dy, focus.dy), + ), + ); + + final newRange = Range2D.fromPoints( + IntVector2( + min(coordinate.dx, currentRange.leftTop.dx), + min(coordinate.dy, currentRange.leftTop.dy), + ), + IntVector2( + max(coordinate.dx, currentRange.rightBottom.dx), + max(coordinate.dy, currentRange.rightBottom.dy), + ), + ); + + // We can only grow the selection vertically or horizontally, and vertical + // selections have the preference. + final restrictVertical = newRange.size.dy - currentRange.size.dy >= + newRange.size.dx - currentRange.size.dx; + + selectionController.updateFillSelections( + (state) => state.update( + anchor: currentRange.leftTop.copyWith( + x: restrictVertical ? null : newRange.leftTop.dx, + y: restrictVertical ? newRange.leftTop.dy : null, + ), + focus: currentRange.rightBottom.copyWith( + x: restrictVertical ? null : newRange.rightBottom.dx, + y: restrictVertical ? newRange.rightBottom.dy : null, + ), + ), + ); + } +} + +/// Default [Action] for [TableBodySelectionEndIntent] +/// +/// See also: +/// * [TableBodyGestureDetector] that triggers the intent +/// * [UserSelectionState] for the implementation of the expand selection. +/// * [DefaultActions] for the widget that binds this action into the +/// widget tree. +class CellSelectionEndAction + extends DefaultSwayzeAction { + CellSelectionEndAction( + InternalScope internalScope, + ViewportContext viewportContext, + ) : super(internalScope, viewportContext); + + @override + void invokeAction( + TableBodySelectionEndIntent intent, + BuildContext context, + ) { + final selectionController = internalScope.controller.selection; + + final primary = selectionController.userSelectionState.primarySelection; + final fill = selectionController.fillSelectionState.selection; + + if (primary is! CellUserSelectionModel || fill == null) { + return; + } + + // Transform the fill selection into a regular selection + selectionController.updateUserSelections( + (state) => state.resetSelectionsToACellSelection( + anchor: fill.anchor, + focus: fill.focus, + ), + ); + + // Clear the fill selection + selectionController.updateFillSelections( + (state) => state.clear(), + ); + + Actions.invoke( + context, + FillIntoTargetIntent( + source: primary, + target: fill, + ), + ); + } +} + +/// Default [Action] for [TableBodySelectionCancelIntent] +/// +/// See also: +/// * [TableBodyGestureDetector] that triggers the intent +/// * [UserSelectionState] for the implementation of the expand selection. +/// * [DefaultActions] for the widget that binds this action into the +/// widget tree. +class CellSelectionCancelAction + extends DefaultSwayzeAction { + CellSelectionCancelAction( + InternalScope internalScope, + ViewportContext viewportContext, + ) : super(internalScope, viewportContext); + + @override + void invokeAction( + TableBodySelectionCancelIntent intent, + BuildContext context, + ) { + final selectionController = internalScope.controller.selection; + + final fill = selectionController.fillSelectionState.selection; + + if (fill != null) { + selectionController.updateFillSelections( + (state) => state.clear(), + ); + } + } } /// Default [Action] for [HeaderSelectionUpdateIntent] diff --git a/packages/swayze/lib/src/widgets/headers/gestures/header_gesture_detector.dart b/packages/swayze/lib/src/widgets/headers/gestures/header_gesture_detector.dart index 29bced8..6098f45 100644 --- a/packages/swayze/lib/src/widgets/headers/gestures/header_gesture_detector.dart +++ b/packages/swayze/lib/src/widgets/headers/gestures/header_gesture_detector.dart @@ -1,3 +1,6 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -9,6 +12,7 @@ import '../../../core/viewport_context/viewport_context.dart'; import '../../../core/viewport_context/viewport_context_provider.dart'; import '../../../helpers/scroll/auto_scroll.dart'; import '../../internal_scope.dart'; +import 'resize_header/resize_header_details_notifier.dart'; /// A transport class for auxiliary data about a header gesture and it's /// position. @@ -24,8 +28,8 @@ class _HeaderGestureDetails { } /// Return the [Range] edge to expand according to the given [ScrollDirection]. -int _getRangeEdgeOnAutoScroll(Range range, ScrollDirection scrolDirection) { - if (scrolDirection == ScrollDirection.forward) { +int _getRangeEdgeOnAutoScroll(Range range, ScrollDirection scrollDirection) { + if (scrollDirection == ScrollDirection.forward) { return range.start; } @@ -45,7 +49,18 @@ _HeaderGestureDetails _getHeaderGestureDetails({ }) { final box = context.findRenderObject()! as RenderBox; final localPosition = box.globalToLocal(globalPosition); + return _getHeaderLocalPositionGestureDetails( + context: context, + axis: axis, + localPosition: localPosition, + ); +} +_HeaderGestureDetails _getHeaderLocalPositionGestureDetails({ + required BuildContext context, + required Axis axis, + required Offset localPosition, +}) { final viewportContext = ViewportContextProvider.of(context); final tableDataController = InternalScope.of(context).controller.tableDataController; @@ -88,11 +103,19 @@ class HeaderGestureDetector extends StatefulWidget { class _HeaderGestureDetectorState extends State { late final internalScope = InternalScope.of(context); late final viewportContext = ViewportContextProvider.of(context); + late final resizeNotifier = + ResizeHeaderDetailsNotifierProvider.maybeOf(context); /// Cache to make the position of the start of a drag gesture acessible in /// the drag updates. Offset? dragOriginOffsetCache; + /// Current mouse cursor. + /// + /// A grab cursor is displayed when a header is selected and it can be + /// dragged. + MouseCursor cursor = MouseCursor.defer; + @override void initState() { super.initState(); @@ -114,6 +137,10 @@ class _HeaderGestureDetectorState extends State { /// Listen for [ViewportContext] range changes to update selections in case /// a [AutoScrollActivity] is in progress. void onRangesChanged() { + if (isDraggingHeader()) { + return; + } + final scrollController = internalScope.controller.scroll; final selectionController = internalScope.controller.selection; @@ -250,67 +277,273 @@ class _HeaderGestureDetectorState extends State { ); } + /// Handles drag starts that should start dragging a header around. + void handleStartDraggingHeader( + DragStartDetails details, + Range selectionRange, + ) { + // Sets the dragging cursor to be basic. + // Instead of using [SystemMouseCursors.grabbing], we set the basic cursors + // because currently there is no mechanism to globally change the cursor on + // desktop, this means that the cursor would be a closed hand only when + // hovering the header, which would cause a weird change of cursors during + // a drag action, making the user think that something went wrong with the + // action. + setCursorState(SystemMouseCursors.basic); + Actions.invoke( + context, + HeaderDragStartIntent( + draggingPosition: details.localPosition, + headers: selectionRange, + axis: widget.axis, + ), + ); + } + + /// Handles header dragging updates. + void handleUpdateDraggingHeader( + DragUpdateDetails gestureDetails, + _HeaderGestureDetails details, + ) { + Actions.invoke( + context, + HeaderDragUpdateIntent( + draggingPosition: gestureDetails.localPosition, + header: details.headerPosition, + axis: widget.axis, + ), + ); + } + + void handleDragEnd(SwayzeHeaderDragState state) { + if (state.isDropAllowed) { + Actions.invoke( + context, + HeaderDragEndIntent( + header: state.dropAtIndex, + axis: widget.axis, + ), + ); + } else { + handleDragCancel(); + } + } + + void handleDragCancel() { + Actions.invoke( + context, + HeaderDragCancelIntent(widget.axis), + ); + } + + /// Sets a new cursor state. + void setCursorState(MouseCursor newCursor) { + if (newCursor != cursor) { + setState(() => cursor = newCursor); + } + } + + /// Checks if a header is selected. + bool isHeaderSelected(int position, Axis axis) => + internalScope.controller.selection.isHeaderSelected(position, axis); + + /// Finds the selection that is under position. + /// + /// Returns null if position header is not selected. + HeaderUserSelectionModel? hoverSelection(int position, Axis axis) { + final selectionController = internalScope.controller.selection; + final selections = selectionController.userSelectionState.selections + .whereType(); + + for (final selection in selections) { + if (selection.axis == axis) { + final range = Range(selection.start, selection.end); + if (range.contains(position)) { + return selection; + } + } + } + return null; + } + + /// Finds the range of the current reference selection and all adjacent + /// selections. + /// + /// Returns a range containing all adjacent selections from the reference + /// selection, so it can be dragged as a single group. + Range headerSelectionRange(HeaderUserSelectionModel referenceSelection) { + final selectionController = internalScope.controller.selection; + final selections = selectionController.userSelectionState.selections + .whereType(); + + var selectionRange = + Range(referenceSelection.start, referenceSelection.end); + + final sortedSelections = selections + .where((selection) => selection.axis == referenceSelection.axis) + .sorted((lhs, rhs) => lhs.start.compareTo(rhs.start)); + + final selectionIndex = sortedSelections.indexOf(referenceSelection); + + void updateAdjacentSelection(HeaderUserSelectionModel selection) { + if (selection.end == selectionRange.start || + selection.start == selectionRange.end || + (selection & selectionRange).isNotNil) { + selectionRange = Range( + min(selectionRange.start, selection.start), + max(selectionRange.end, selection.end), + ); + } + } + + for (var i = selectionIndex; i >= 0; i--) { + updateAdjacentSelection(sortedSelections.elementAt(i)); + } + for (var i = selectionIndex; i < sortedSelections.length; i++) { + updateAdjacentSelection(sortedSelections.elementAt(i)); + } + return selectionRange; + } + + /// Checks if a header is being dragged. + bool isDraggingHeader() { + final tableDataController = internalScope.controller.tableDataController; + final header = + tableDataController.getHeaderControllerFor(axis: widget.axis); + return header.value.dragState != null; + } + @override Widget build(BuildContext context) { - return RawGestureDetector( - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(debugOwner: this), - (PanGestureRecognizer instance) { - instance.onStart = (DragStartDetails details) { - final headerGestureDetails = _getHeaderGestureDetails( - axis: widget.axis, - context: context, - globalPosition: details.globalPosition, - ); - - handleStartSelection(headerGestureDetails); - - dragOriginOffsetCache = headerGestureDetails.localPosition; - }; - instance.onUpdate = (DragUpdateDetails details) { - final headerGestureDetails = _getHeaderGestureDetails( - axis: widget.axis, - context: context, - globalPosition: details.globalPosition, - ); - - updateDragScroll( - localOffset: headerGestureDetails.localPosition, - globalOffset: details.globalPosition, - originOffset: dragOriginOffsetCache!, - ); - - handleUpdateSelection(headerGestureDetails); - }; - instance.onEnd = (DragEndDetails details) { - dragOriginOffsetCache = null; - internalScope.controller.scroll.stopAutoScroll(widget.axis); - }; - instance.onCancel = () { - dragOriginOffsetCache = null; - internalScope.controller.scroll.stopAutoScroll(widget.axis); - }; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (TapGestureRecognizer instance) { - instance.onTapDown = (TapDownDetails details) { - final headerGestureDetails = _getHeaderGestureDetails( - axis: widget.axis, - context: context, - globalPosition: details.globalPosition, - ); - - handleStartSelection(headerGestureDetails); - }; - }, - ), + return MouseRegion( + cursor: cursor, + onHover: (event) { + if (!internalScope.config.isHeaderDragAndDropEnabled) { + return; + } + + final headerGestureDetails = _getHeaderLocalPositionGestureDetails( + axis: widget.axis, + context: context, + localPosition: event.localPosition, + ); + final isSelected = isHeaderSelected( + headerGestureDetails.headerPosition, + widget.axis, + ); + setCursorState( + isSelected ? SystemMouseCursors.grab : MouseCursor.defer, + ); }, - behavior: HitTestBehavior.translucent, + child: RawGestureDetector( + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(debugOwner: this), + (PanGestureRecognizer instance) { + instance.onStart = (DragStartDetails details) { + if (resizeNotifier?.isHoveringHeaderEdge ?? false) { + return; + } + + final headerGestureDetails = _getHeaderGestureDetails( + axis: widget.axis, + context: context, + globalPosition: details.globalPosition, + ); + + final selection = hoverSelection( + headerGestureDetails.headerPosition, + widget.axis, + ); + + if (selection != null && + internalScope.config.isHeaderDragAndDropEnabled) { + final range = headerSelectionRange(selection); + handleStartDraggingHeader(details, range); + } else { + handleStartSelection(headerGestureDetails); + } + + dragOriginOffsetCache = headerGestureDetails.localPosition; + }; + instance.onUpdate = (DragUpdateDetails details) { + if (resizeNotifier?.isResizingHeader ?? false) { + return; + } + + final headerGestureDetails = _getHeaderGestureDetails( + axis: widget.axis, + context: context, + globalPosition: details.globalPosition, + ); + + updateDragScroll( + localOffset: headerGestureDetails.localPosition, + globalOffset: details.globalPosition, + originOffset: dragOriginOffsetCache!, + ); + + if (isDraggingHeader()) { + handleUpdateDraggingHeader(details, headerGestureDetails); + return; + } + handleUpdateSelection(headerGestureDetails); + }; + instance.onEnd = (DragEndDetails details) { + if (resizeNotifier?.isResizingHeader ?? false) { + return; + } + + dragOriginOffsetCache = null; + internalScope.controller.scroll.stopAutoScroll(widget.axis); + + final tableDataController = + internalScope.controller.tableDataController; + final header = tableDataController + .getHeaderControllerFor( + axis: widget.axis, + ) + .value; + if (header.dragState != null) { + handleDragEnd(header.dragState!); + } + }; + instance.onCancel = () { + if (resizeNotifier?.isResizingHeader ?? false) { + return; + } + + dragOriginOffsetCache = null; + internalScope.controller.scroll.stopAutoScroll(widget.axis); + + if (isDraggingHeader()) { + handleDragCancel(); + } + }; + }, + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance.onTapUp = (TapUpDetails details) { + if (resizeNotifier?.isHoveringHeaderEdge ?? false) { + return; + } + + final headerGestureDetails = _getHeaderGestureDetails( + axis: widget.axis, + context: context, + globalPosition: details.globalPosition, + ); + handleStartSelection(headerGestureDetails); + }; + }, + ), + }, + behavior: HitTestBehavior.translucent, + ), ); } } diff --git a/packages/swayze/lib/src/widgets/headers/gestures/resize_header/header_edge_info.dart b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/header_edge_info.dart new file mode 100644 index 0000000..07f450c --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/header_edge_info.dart @@ -0,0 +1,47 @@ +import 'package:meta/meta.dart'; + +@immutable +class HeaderEdgeInfo { + /// The header index of the hovered edge. + final int index; + + /// The header width of the hovered edge. + final double width; + + /// This value is the offset from the header separator to the mouse cursor. + /// + /// Useful to display the resize line at the header separator and not a bit + /// to the left or right of it (since when hovering the edge, the resize + /// cursor still shows when the user hovers a bit to the left or right + /// of the separator). + final int displacement; + + /// The position of the header separator (which together with the + /// [displacement] can be used to know if the user is hovering an header's + /// edge or not. + final double offset; + + const HeaderEdgeInfo({ + required this.index, + required this.width, + required this.displacement, + required this.offset, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is HeaderEdgeInfo && + other.index == index && + other.width == width && + other.displacement == displacement && + other.offset == offset; + } + + @override + int get hashCode => + index.hashCode ^ width.hashCode ^ displacement.hashCode ^ offset.hashCode; +} diff --git a/packages/swayze/lib/src/widgets/headers/gestures/resize_header/header_edge_mouse_listener.dart b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/header_edge_mouse_listener.dart new file mode 100644 index 0000000..4da346d --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/header_edge_mouse_listener.dart @@ -0,0 +1,282 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../../helpers.dart'; +import '../../../../../widgets.dart'; +import '../../../../core/viewport_context/viewport_context_provider.dart'; +import '../../../internal_scope.dart'; +import 'resize_header_details_notifier.dart'; +import 'resize_line_overlay_manager.dart'; + +/// A class that returns a mouse region that changes the mouse cursor to +/// [SystemMouseCursors.resizeColumn] or [SystemMouseCursors.resizeRow], +/// depending on the axis, when the user is hovering an header edge. +/// +/// It also has a listener that takes care of showing the resize line when +/// the user taps the mouse button. Updating the resize line when the user +/// moves the mouse cursor. Updating the header extent when the user lets go +/// the mouse button. +class HeaderEdgeMouseListener extends StatefulWidget { + final OnHeaderExtentChanged? onHeaderExtentChanged; + final Widget child; + + const HeaderEdgeMouseListener({ + Key? key, + required this.onHeaderExtentChanged, + required this.child, + }) : super(key: key); + + @override + State createState() => + _HeaderEdgeMouseListenerState(); +} + +class _HeaderEdgeMouseListenerState extends State { + late final resizeNotifier = ResizeHeaderDetailsNotifier(null); + late final internalScope = InternalScope.of(context); + late final viewportContext = ViewportContextProvider.of(context); + + late final resizeLineOverlayManager = ResizeLineOverlayManager( + internalScope: internalScope, + resizeNotifier: resizeNotifier, + ); + + bool _showResizeCursor = false; + + @override + void initState() { + super.initState(); + + resizeNotifier.addListener(_didHoverHeaderEdge); + _didHoverHeaderEdge(); + } + + @override + void dispose() { + resizeNotifier.removeListener(_didHoverHeaderEdge); + + super.dispose(); + } + + /// Update the resize cursor if the user is hovering an header edge. + void _didHoverHeaderEdge() { + final showResizeCursor = resizeNotifier.value != null; + + if (showResizeCursor == _showResizeCursor) { + return; + } + + setState(() { + _showResizeCursor = showResizeCursor; + }); + } + + MouseCursor _getMouseCursor() { + if (!_showResizeCursor) { + return MouseCursor.defer; + } + + return resizeNotifier.value!.axis == Axis.horizontal + ? SystemMouseCursors.resizeColumn + : SystemMouseCursors.resizeRow; + } + + /// Gets the pixel offset for the given [offset] and [axis]. + double _getOffsetPositionForAxis(Offset offset, Axis axis) { + return axis == Axis.horizontal ? offset.dx : offset.dy; + } + + /// Checks if the mouse coordinates are at an header edge. + void _handleOnHover(PointerHoverEvent event) { + Axis? axis; + + // vertical header is being hovered + if (event.localPosition.dy > kColumnHeaderHeight && + event.localPosition.dx < kRowHeaderWidth) { + axis = Axis.vertical; + } + + // horizontal header is being hovered + if (event.localPosition.dy < kColumnHeaderHeight) { + axis = Axis.horizontal; + } + + if (axis != null) { + final result = _updateHeaderEdgeDetails( + localPosition: event.localPosition, + axis: axis, + ); + + if (result) { + return; + } + } + + resizeNotifier.value = null; + } + + /// Updates [resizeNotifier.value] if the user is hovering an headers edge. + /// + /// Returns `true` if [resizeNotifier.value] has been updated and `false` + /// otherwise. + bool _updateHeaderEdgeDetails({ + required Offset localPosition, + required Axis axis, + }) { + var localPixelOffset = _getOffsetPositionForAxis(localPosition, axis); + + final axisContext = viewportContext.getAxisContextFor(axis: axis); + + // since this widget will be placed at a table, we need to take into account + // that from `0` to `kRowHeaderWidth` or `kColumnHeaderHeight` (depending on + // the axis), there's an empty space. Since the `localPosition` will still + // count that into its offset, we subtract that extent. + if (axis == Axis.horizontal) { + localPixelOffset -= kRowHeaderWidth; + } else { + localPixelOffset -= kColumnHeaderHeight; + } + + final frozenExtent = axisContext.value.frozenExtent; + final hasFrozenHeaders = frozenExtent > 0; + final displacement = axisContext.virtualizationState.displacement; + + final ignoreDisplacement = hasFrozenHeaders && + displacement < 0 && + // since hovering the last frozen header separator plus + // `kMaxEdgeOffsetAdder` also counts as hovering the frozen header, + // we take into account the frozen headers extent and + // `kMaxEdgeOffsetAdder`. + localPixelOffset < frozenExtent + kMaxEdgeOffsetAdder; + + // we need to ignore the `displacement` in frozen headers since they are + // fixed when we scroll. + if (!ignoreDisplacement) { + localPixelOffset += displacement.abs(); + } + + localPixelOffset = localPixelOffset.floorToDouble(); + + final offsets = axisContext.value.headersEdgesOffsets; + + if (offsets.containsKey(localPixelOffset)) { + final headerEdgeInfo = offsets[localPixelOffset]!; + + resizeNotifier.value = ResizeHeaderDetails( + edgeInfo: headerEdgeInfo, + axis: axis, + ); + + return true; + } + + return false; + } + + double minExtent(Axis axis) => + axis == Axis.horizontal ? kMinCellWidth : kDefaultCellHeight; + + /// Inserts an [OverlayEntry] to the current [OverlayState] with resize line + /// on it at the header edge that is being hovered. + void _handleOnPointerDown(PointerDownEvent event) { + if (!resizeNotifier.isHoveringHeaderEdge) { + return; + } + + final details = resizeNotifier.value!; + final axis = details.axis; + + final initialOffset = _getOffsetPositionForAxis(event.localPosition, axis) + + details.edgeInfo.displacement; + + final minOffset = + initialOffset - (details.edgeInfo.width - minExtent(axis)); + + resizeNotifier.value = details.copyWith( + offset: Wrapped.value(initialOffset), + initialOffset: Wrapped.value(initialOffset), + minOffset: Wrapped.value(minOffset), + ); + + resizeLineOverlayManager.insertResizeLine(context); + } + + /// Updates the resize line position by adding [event.delta] to + /// its current position. + void _handleOnPointerMove(PointerMoveEvent event) { + if (!resizeNotifier.isResizingHeader) { + return; + } + + final details = resizeNotifier.value!; + final axis = details.axis; + final offset = details.offset!; + + final newOffset = offset + _getOffsetPositionForAxis(event.delta, axis); + + resizeNotifier.value = details.copyWith(offset: Wrapped.value(newOffset)); + } + + /// Sets the header extent of the header that has been resized and removes the + /// [OverlayEntry] that contains the resize line. + void _handleOnPointerUp(PointerUpEvent event) { + if (!resizeNotifier.isResizingHeader) { + return; + } + + final value = resizeNotifier.value!; + final axis = value.axis; + + final headerController = internalScope.controller.tableDataController + .getHeaderControllerFor(axis: axis); + + final index = value.edgeInfo.index; + final initialOffset = value.initialOffset!; + final offset = value.offset!; + final extent = value.edgeInfo.width; + + double newExtent; + + if (offset < initialOffset) { + newExtent = max(minExtent(axis), extent - (initialOffset - offset)); + } else { + newExtent = extent + (offset - initialOffset); + } + + newExtent = newExtent.floorToDouble(); + + headerController.updateState( + (previousState) => headerController.value.setHeaderExtent( + index, + newExtent, + ), + ); + + if (extent != newExtent) { + widget.onHeaderExtentChanged?.call(index, axis, extent, newExtent); + } + + resizeNotifier.value = null; + + resizeLineOverlayManager.removeResizeLine(context); + } + + @override + Widget build(BuildContext context) { + return ResizeHeaderDetailsNotifierProvider( + notifier: resizeNotifier, + child: Listener( + onPointerDown: _handleOnPointerDown, + onPointerMove: _handleOnPointerMove, + onPointerUp: _handleOnPointerUp, + child: MouseRegion( + cursor: _getMouseCursor(), + onHover: _handleOnHover, + child: widget.child, + ), + ), + ); + } +} diff --git a/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_header_details_notifier.dart b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_header_details_notifier.dart new file mode 100644 index 0000000..9bae503 --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_header_details_notifier.dart @@ -0,0 +1,97 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../helpers.dart'; +import '../../../internal_scope.dart'; +import 'header_edge_info.dart'; + +class ResizeHeaderDetailsNotifier extends ValueNotifier { + ResizeHeaderDetailsNotifier(ResizeHeaderDetails? value) : super(value); + + bool get isHoveringHeaderEdge => value?.edgeInfo.index != null; + + bool get isResizingHeader => value?.offset != null; +} + +class ResizeHeaderDetailsNotifierProvider + extends InheritedNotifier { + const ResizeHeaderDetailsNotifierProvider({ + Key? key, + required ResizeHeaderDetailsNotifier? notifier, + required Widget child, + }) : super(key: key, notifier: notifier, child: child); + + static ResizeHeaderDetailsNotifier? maybeOf(BuildContext context) { + final internalScope = InternalScope.of(context); + + if (!internalScope.config.isResizingHeadersEnabled) { + return null; + } + + return context + .dependOnInheritedWidgetOfExactType< + ResizeHeaderDetailsNotifierProvider>() + ?.notifier; + } +} + +@immutable +class ResizeHeaderDetails { + final HeaderEdgeInfo edgeInfo; + final Axis axis; + + /// The offset when the user has started resizing the header. + final double? initialOffset; + + /// The minimum offset that the resize line can go to. + final double? minOffset; + + /// The current offset of the resize line. + final double? offset; + + const ResizeHeaderDetails({ + required this.edgeInfo, + required this.axis, + this.initialOffset, + this.minOffset, + this.offset, + }); + + ResizeHeaderDetails copyWith({ + HeaderEdgeInfo? edgeInfo, + Wrapped? initialOffset, + Wrapped? minOffset, + Wrapped? offset, + }) { + return ResizeHeaderDetails( + edgeInfo: edgeInfo ?? this.edgeInfo, + axis: axis, + initialOffset: + initialOffset != null ? initialOffset.value : this.initialOffset, + minOffset: minOffset != null ? minOffset.value : this.minOffset, + offset: offset != null ? offset.value : this.offset, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is ResizeHeaderDetails && + other.edgeInfo == edgeInfo && + other.axis == axis && + other.initialOffset == initialOffset && + other.minOffset == minOffset && + other.offset == offset; + } + + @override + int get hashCode { + return edgeInfo.hashCode ^ + axis.hashCode ^ + initialOffset.hashCode ^ + minOffset.hashCode ^ + offset.hashCode; + } +} diff --git a/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_header_line.dart b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_header_line.dart new file mode 100644 index 0000000..4d223b9 --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_header_line.dart @@ -0,0 +1,188 @@ +import 'package:cached_value/cached_value.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../core/style/style.dart'; + +/// Renders the resize header line. +/// +/// See also: +/// - [_ResizeHeaderLine]. +class ResizeHeaderLine extends StatelessWidget { + final SwayzeStyle style; + final Axis axis; + + const ResizeHeaderLine({ + Key? key, + required this.style, + required this.axis, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return _ResizeHeaderLine( + axis: axis, + fillColor: style.resizeHeaderStyle.fillColor, + lineColor: style.resizeHeaderStyle.lineColor, + thickness: style.cellSeparatorStrokeWidth, + ); + } +} + +/// A leaf render object that returns a [RenderBox] that paints an horizontal +/// or vertical line (depending on the axis) with the given properties. +/// +/// See also: +/// - [_RenderResizeHeaderLine]. +class _ResizeHeaderLine extends LeafRenderObjectWidget { + final Axis axis; + final Color lineColor; + final Color fillColor; + final double thickness; + + const _ResizeHeaderLine({ + required this.axis, + required this.lineColor, + required this.fillColor, + required this.thickness, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderResizeHeaderLine(axis, lineColor, fillColor, thickness); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderResizeHeaderLine renderObject, + ) { + renderObject + ..axis = axis + ..lineColor = lineColor + ..fillColor = fillColor + ..thickness = thickness; + } +} + +/// Paints the header resize line at the canvas. +class _RenderResizeHeaderLine extends RenderBox { + Axis _axis; + + Axis get axis => _axis; + + set axis(Axis value) { + if (_axis == value) { + return; + } + + _axis = value; + markNeedsLayout(); + } + + Color _lineColor; + + Color get lineColor => _lineColor; + + set lineColor(Color value) { + if (_lineColor == value) { + return; + } + + _lineColor = value; + markNeedsPaint(); + } + + Color _fillColor; + + Color get fillColor => _fillColor; + + set fillColor(Color value) { + if (_fillColor == value) { + return; + } + + _fillColor = value; + markNeedsPaint(); + } + + double _thickness; + + double get thickness => _thickness; + + set thickness(double value) { + if (_thickness == value) { + return; + } + + _thickness = value; + markNeedsPaint(); + } + + _RenderResizeHeaderLine( + this._axis, + this._lineColor, + this._fillColor, + this._thickness, + ); + + @override + bool get isRepaintBoundary => true; + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.biggest; + } + + late final lineStrokePaintCache = CachedValue( + () { + return Paint() + ..color = lineColor + ..strokeWidth = thickness + ..style = PaintingStyle.stroke; + }, + ) + .withDependency(() => lineColor) + .withDependency(() => thickness); + + late final lineFillPaintCache = CachedValue( + () { + return Paint()..color = fillColor; + }, + ).withDependency(() => fillColor); + + @override + void paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + + canvas.save(); + + const radius = 5.0; + + // draw the circle first. + canvas.drawCircle(Offset.zero, radius, lineFillPaintCache.value); + + // only then draw the border of the circle. + canvas.drawCircle(Offset.zero, radius, lineStrokePaintCache.value); + + if (axis == Axis.horizontal) { + canvas.drawLine( + // draw the line after the circle + const Offset(0, radius), + Offset(0, size.height), + lineStrokePaintCache.value, + ); + } else { + canvas.drawLine( + // draw the line after the circle + const Offset(radius, 0), + Offset(size.width, 0), + lineStrokePaintCache.value, + ); + } + + canvas.restore(); + } +} diff --git a/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_line_overlay_manager.dart b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_line_overlay_manager.dart new file mode 100644 index 0000000..1290b0a --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/gestures/resize_header/resize_line_overlay_manager.dart @@ -0,0 +1,101 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; + +import '../../../internal_scope.dart'; +import 'resize_header_details_notifier.dart'; +import 'resize_header_line.dart'; + +/// An overlay manager that creates a backdrop overlay entry to disable scroll +/// when resizing an header and another overlay entry for the resize line. +class ResizeLineOverlayManager { + final InternalScope internalScope; + final ValueNotifier resizeNotifier; + + OverlayEntry? line; + + ResizeLineOverlayManager({ + required this.internalScope, + required this.resizeNotifier, + }); + + void insertResizeLine(BuildContext context) { + final overlayState = Overlay.of(context); + final box = context.findRenderObject()! as RenderBox; + final target = box.localToGlobal( + Offset.zero, + ancestor: overlayState.context.findRenderObject(), + ); + + line ??= OverlayEntry( + builder: (context) { + return ValueListenableBuilder( + valueListenable: resizeNotifier, + builder: (context, resizeDetails, child) { + if (resizeDetails == null) { + return const SizedBox.shrink(); + } + + final axis = resizeDetails.axis; + + double left; + double top; + + final offset = resizeDetails.offset!; + final minOffset = resizeDetails.minOffset!; + + if (axis == Axis.horizontal) { + left = max(offset, minOffset) + target.dx; + top = target.dy; + } else { + left = target.dx; + top = max(offset, minOffset) + target.dy; + } + + return Positioned( + left: left, + top: top, + width: box.size.width, + height: box.size.height, + child: child!, + ); + }, + child: ResizeHeaderLine( + style: internalScope.style, + axis: resizeNotifier.value!.axis, + ), + ); + }, + ); + + _insertBackdrop(context); + + overlayState.insert(line!); + } + + /// Pushes a new route to disable keyboard interaction when resizing. + void _insertBackdrop(BuildContext context) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (_, __, ___) => const MouseRegion( + cursor: SystemMouseCursors.grab, + child: ColoredBox( + color: Color(0x00000000), + ), + ), + ), + ); + } + + void _removeBackdrop(BuildContext context) { + Navigator.of(context).pop(); + } + + void removeResizeLine(BuildContext context) { + _removeBackdrop(context); + + line?.remove(); + line = null; + } +} diff --git a/packages/swayze/lib/src/widgets/headers/header_drag_and_drop_preview.dart b/packages/swayze/lib/src/widgets/headers/header_drag_and_drop_preview.dart new file mode 100644 index 0000000..818ef65 --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/header_drag_and_drop_preview.dart @@ -0,0 +1,325 @@ +import 'package:cached_value/cached_value.dart'; +import 'package:flutter/material.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import '../../core/style/style.dart'; +import '../../core/viewport_context/viewport_context_provider.dart'; + +/// Renders the preview line and block of a header drag and drop action. +class HeaderDragAndDropPreview extends StatelessWidget { + final Axis axis; + final SwayzeStyle swayzeStyle; + + const HeaderDragAndDropPreview({ + Key? key, + required this.axis, + required this.swayzeStyle, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final lineColor = swayzeStyle.dragAndDropStyle.previewLineColor; + final lineWidth = swayzeStyle.dragAndDropStyle.previewLineWidth; + + final viewportContext = ViewportContextProvider.of(context); + final header = viewportContext.getAxisContextFor(axis: axis); + final dragState = header.value.headerDragState; + if (dragState == null || lineWidth == 0.0 || lineColor.alpha == 0) { + return const SizedBox.shrink(); + } + + final currentHeaderIndex = dragState.dropAtIndex < dragState.headers.start + ? dragState.dropAtIndex + : dragState.dropAtIndex + 1; + + final dropHeaderAtPosition = viewportContext + .positionToPixel( + currentHeaderIndex, + axis, + isForFrozenPanes: currentHeaderIndex < header.value.frozenRange.end, + ) + .pixel; + + final headerExtent = dragState.headersExtent; + final headerPosition = viewportContext + .positionToPixel( + dragState.headers.start, + axis, + isForFrozenPanes: false, + ) + .pixel; + + final blockedRange = Range( + dragState.headers.start, + dragState.headers.end + 1, + ); + + return Stack( + children: [ + _PreviewRect( + axis: axis, + pointerPosition: dragState.position, + headerPosition: headerPosition, + headerExtent: headerExtent, + color: swayzeStyle.dragAndDropStyle.previewHeadersColor, + ), + if (!blockedRange.contains(currentHeaderIndex)) + _PreviewLine( + axis: axis, + lineColor: lineColor, + lineWidth: lineWidth, + dropHeaderAtPosition: dropHeaderAtPosition, + ), + ], + ); + } +} + +class _PreviewLine extends LeafRenderObjectWidget { + final Color lineColor; + + final double lineWidth; + + final double dropHeaderAtPosition; + + final Axis axis; + + const _PreviewLine({ + Key? key, + required this.lineColor, + required this.lineWidth, + required this.dropHeaderAtPosition, + required this.axis, + }) : super(key: key); + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderPreviewLine( + axis, + lineColor, + lineWidth, + dropHeaderAtPosition, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderPreviewLine renderObject, + ) { + renderObject + ..axis = axis + ..lineWidth = lineWidth + ..lineColor = lineColor + ..dropHeaderAtPosition = dropHeaderAtPosition; + } +} + +class _RenderPreviewLine extends RenderBox { + Color _lineColor; + + Color get lineColor => _lineColor; + + set lineColor(Color value) { + _lineColor = value; + markNeedsPaint(); + } + + double _lineWidth; + + double get lineWidth => _lineWidth; + + set lineWidth(double value) { + _lineWidth = value; + markNeedsLayout(); + } + + double _dropHeaderAtPosition; + double get dropHeaderAtPosition => _dropHeaderAtPosition; + set dropHeaderAtPosition(double value) { + _dropHeaderAtPosition = value; + markNeedsLayout(); + } + + Axis _axis; + Axis get axis => _axis; + set axis(Axis value) { + _axis = value; + markNeedsLayout(); + } + + _RenderPreviewLine( + this._axis, + this._lineColor, + this._lineWidth, + this._dropHeaderAtPosition, + ); + + @override + bool get alwaysNeedsCompositing => true; + + @override + bool get isRepaintBoundary => true; + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.biggest; + } + + late final linePaintCache = CachedValue( + () { + return Paint() + ..color = lineColor + ..strokeWidth = lineWidth; + }, + ).withDependency(() => lineColor).withDependency(() => lineWidth); + + @override + void paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + canvas.translate(-0.5, -0.5); + canvas.save(); + + if (axis == Axis.horizontal) { + canvas.translate(dropHeaderAtPosition, 0); + canvas.drawLine( + Offset.zero, + Offset(0, size.height), + linePaintCache.value, + ); + } else { + canvas.translate(0, dropHeaderAtPosition); + canvas.drawLine( + Offset.zero, + Offset(size.width, 0), + linePaintCache.value, + ); + } + canvas.restore(); + } +} + +/// Renders a preview rect that represents the headers being dragged. +/// The preview follows the position of [pointerPosition]. +class _PreviewRect extends LeafRenderObjectWidget { + final Axis axis; + final Offset pointerPosition; + final double headerPosition; + final double headerExtent; + final Color color; + + const _PreviewRect({ + required this.headerPosition, + required this.headerExtent, + Key? key, + required this.axis, + required this.pointerPosition, + required this.color, + }) : super(key: key); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderPreviewRect( + axis, + pointerPosition, + headerPosition, + headerExtent, + color, + ); + + @override + void updateRenderObject( + BuildContext context, + _RenderPreviewRect renderObject, + ) { + renderObject + ..axis = axis + ..pointerPosition = pointerPosition + ..headerPosition = headerPosition + ..headerExtent = headerExtent + ..color = color; + } +} + +class _RenderPreviewRect extends RenderBox { + _RenderPreviewRect( + this._axis, + this._pointerPosition, + this._headerPosition, + this._headerExtent, + this._color, + ); + + Offset _pointerPosition; + Offset get pointerPosition => _pointerPosition; + set pointerPosition(Offset value) { + _pointerPosition = value; + markNeedsPaint(); + } + + Axis _axis; + Axis get axis => _axis; + set axis(Axis value) { + _axis = value; + markNeedsPaint(); + } + + double _headerPosition; + double get headerPosition => _headerPosition; + set headerPosition(double value) { + _headerPosition = value; + markNeedsPaint(); + } + + double _headerExtent; + double get headerExtent => _headerExtent; + set headerExtent(double value) { + _headerExtent = value; + markNeedsPaint(); + } + + Color _color; + Color get color => _color; + set color(Color value) { + _color = value; + markNeedsPaint(); + } + + late final backgroundPaint = CachedValue( + () => Paint()..color = color, + ); + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.biggest; + } + + @override + void paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + canvas.save(); + if (axis == Axis.horizontal) { + final previewRect = Rect.fromLTWH( + pointerPosition.dx - headerExtent / 2, + 0, + headerExtent, + size.height, + ); + canvas.drawRect(previewRect, backgroundPaint.value); + } else { + final previewRect = Rect.fromLTWH( + 0, + pointerPosition.dy - headerExtent / 2, + size.width, + headerExtent, + ); + canvas.drawRect(previewRect, backgroundPaint.value); + } + canvas.restore(); + } +} diff --git a/packages/swayze/lib/src/widgets/headers/header_table_select.dart b/packages/swayze/lib/src/widgets/headers/header_table_select.dart new file mode 100644 index 0000000..c915176 --- /dev/null +++ b/packages/swayze/lib/src/widgets/headers/header_table_select.dart @@ -0,0 +1,113 @@ +import 'package:cached_value/cached_value.dart'; +import 'package:flutter/material.dart'; + +import '../../../controller.dart'; +import '../internal_scope.dart'; + +class HeaderTableSelect extends StatefulWidget { + const HeaderTableSelect({ + super.key, + }); + + @override + State createState() => _HeaderTableSelectState(); +} + +class _HeaderTableSelectState extends State { + late final internalScope = InternalScope.of(context); + late final style = internalScope.style; + late final controller = internalScope.controller; + late final selectionController = controller.selection; + + bool _isTableSelected = false; + bool _isHover = false; + + @override + void initState() { + super.initState(); + selectionController.userSelectionsListenable.addListener( + onSelectionsChange, + ); + } + + @override + void dispose() { + selectionController.userSelectionsListenable.removeListener( + onSelectionsChange, + ); + super.dispose(); + } + + void onSelectionsChange() { + final selections = selectionController.userSelectionState.selections; + final isTableSelected = selections.first is TableUserSelectionModel; + if (isTableSelected != _isTableSelected) { + setState(() { + _isTableSelected = isTableSelected; + }); + } + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onExit: (_) => setState(() => _isHover = false), + onEnter: (_) => setState(() => _isHover = true), + child: GestureDetector( + onTap: () { + Focus.of(context).requestFocus(); + selectionController.updateUserSelections( + (state) => state.resetSelectionsToTableSelection(), + ); + }, + child: ColoredBox( + color: style.tableSelectStyle.backgroundFillColor, + child: CustomPaint( + painter: _TrianglePainter( + color: (_isTableSelected || _isHover) + ? style.tableSelectStyle.selectedForegroundColor + : style.tableSelectStyle.foregroundColor, + ), + ), + ), + ), + ); + } +} + +class _TrianglePainter extends CustomPainter { + final Color color; + + CachedValue paintCache(Color color) => CachedValue( + () { + return Paint() + ..color = color + ..strokeWidth = 0 + ..style = PaintingStyle.fill; + }, + ).withDependency(() => color); + + _TrianglePainter({ + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = paintCache(color); + final right = size.width - 1; + final bottom = size.height - 1; + final triangle = Path() + ..moveTo(right, 3) + ..lineTo(right, bottom) + ..lineTo(2, bottom) + ..lineTo(right, 2); + + canvas.save(); + canvas.drawPath(triangle, paint.value); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _TrianglePainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/packages/swayze/lib/src/widgets/inline_editor/inline_editor.dart b/packages/swayze/lib/src/widgets/inline_editor/inline_editor.dart index 6576ecd..5b0c68e 100644 --- a/packages/swayze/lib/src/widgets/inline_editor/inline_editor.dart +++ b/packages/swayze/lib/src/widgets/inline_editor/inline_editor.dart @@ -137,7 +137,7 @@ class _InlineEditorPlacerState extends State { final initialText = inlineEditorController.initialText; if (coordinate != null) { updateRectPositions(); - Overlay.of(context)!.insert( + Overlay.of(context).insert( overlayEntryCache = generateOverlayEntryForInlineEditor( cellCoordinate: coordinate, initialText: initialText, @@ -168,7 +168,7 @@ Rect? _getTableRect(BuildContext context) { return null; } - final overlay = Overlay.of(context)!.context.findRenderObject()! as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; final topLeft = MatrixUtils.transformPoint( table.getTransformTo(overlay), @@ -192,9 +192,8 @@ Rect? _getCellRect({ return null; } - final overlay = - (Overlay.of(context)!.context.findRenderObject()! as RenderBox) - .paintBounds; + final overlay = (Overlay.of(context).context.findRenderObject()! as RenderBox) + .paintBounds; final displacedRect = cellPositionResult.leftTop & cellPositionResult.cellSize; diff --git a/packages/swayze/lib/src/widgets/inline_editor/overlay.dart b/packages/swayze/lib/src/widgets/inline_editor/overlay.dart index dc25332..b0a6d37 100644 --- a/packages/swayze/lib/src/widgets/inline_editor/overlay.dart +++ b/packages/swayze/lib/src/widgets/inline_editor/overlay.dart @@ -249,7 +249,7 @@ class _TableOverlapCalculatorState extends State<_TableOverlapCalculator> { return; } - final overlay = Overlay.of(context)!.context.findRenderObject()!; + final overlay = Overlay.of(context).context.findRenderObject()!; final translation = contextRenderObjectRect.getTransformTo(overlay).getTranslation(); diff --git a/packages/swayze/lib/src/widgets/internal_scope.dart b/packages/swayze/lib/src/widgets/internal_scope.dart index 984b2d2..49bce3e 100644 --- a/packages/swayze/lib/src/widgets/internal_scope.dart +++ b/packages/swayze/lib/src/widgets/internal_scope.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import '../core/config/config.dart'; import '../core/controller/controller.dart'; import '../core/delegates/cell_delegate.dart'; import '../core/style/style.dart'; @@ -20,6 +21,9 @@ abstract class InternalScope { CellDelegate get cellDelegate; + /// Current swayze configuration. + SwayzeConfig get config; + /// Access the scope from a [context] subtree. /// Should be called by descendants of [InternalScopeProvider]. /// @@ -45,12 +49,16 @@ class InternalScopeProvider @override final CellDelegate cellDelegate; + @override + final SwayzeConfig config; + const InternalScopeProvider({ Key? key, required Widget child, required this.controller, required this.style, required this.cellDelegate, + required this.config, }) : super(key: key, child: child); @override diff --git a/packages/swayze/lib/src/widgets/shared/expand_all.dart b/packages/swayze/lib/src/widgets/shared/expand_all.dart index c8e3183..8e94fa6 100644 --- a/packages/swayze/lib/src/widgets/shared/expand_all.dart +++ b/packages/swayze/lib/src/widgets/shared/expand_all.dart @@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart'; /// A widget that forces all children to render at maximum size possible given /// the current [BoxConstraints] class ExpandAll extends MultiChildRenderObjectWidget { - ExpandAll({ + const ExpandAll({ Key? key, List children = const [], }) : super(key: key, children: children); diff --git a/packages/swayze/lib/src/widgets/shortcuts/shortcuts.dart b/packages/swayze/lib/src/widgets/shortcuts/shortcuts.dart index d4d73b7..1ab7d0d 100644 --- a/packages/swayze/lib/src/widgets/shortcuts/shortcuts.dart +++ b/packages/swayze/lib/src/widgets/shortcuts/shortcuts.dart @@ -27,13 +27,14 @@ class TableShortcuts extends StatefulWidget { class _TableShortcutsState extends State { late final internalScope = InternalScope.of(context); - late final manager = _CustomShortcutManager({ + late final manager = + _CustomShortcutManager({ const AnyCharacterActivator(): (event) { return OpenInlineEditorIntent( initialText: event.character, ); - } - }); + }, + }, shortcuts: _staticShortcuts); @override void dispose() { @@ -43,9 +44,8 @@ class _TableShortcutsState extends State { @override Widget build(BuildContext context) { - return Shortcuts( + return Shortcuts.manager( debugLabel: '', - shortcuts: _staticShortcuts, manager: manager, child: widget.child, ); @@ -193,7 +193,7 @@ const _kMacShortcuts = { class _CustomShortcutManager extends ShortcutManager { final Map customShortcuts; - _CustomShortcutManager(this.customShortcuts); + _CustomShortcutManager(this.customShortcuts, {required super.shortcuts}); @override KeyEventResult handleKeypress(BuildContext context, RawKeyEvent event) { diff --git a/packages/swayze/lib/src/widgets/table.dart b/packages/swayze/lib/src/widgets/table.dart index 9a81262..019745c 100644 --- a/packages/swayze/lib/src/widgets/table.dart +++ b/packages/swayze/lib/src/widgets/table.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import '../config.dart'; +import '../core/config/config.dart'; import '../core/controller/controller.dart'; import '../core/delegates/cell_delegate.dart'; import '../core/internal_state/table_focus/table_focus_provider.dart'; @@ -20,6 +21,16 @@ import 'wrappers.dart'; export '../config.dart'; export 'inline_editor/inline_editor.dart' show InlineEditorBuilder; +/// A callback invoked when the extent of an header has changed. +/// +/// It gives back the header's index, axis, the old and new extent. +typedef OnHeaderExtentChanged = Function( + int index, + Axis axis, + double oldExtent, + double newExtent, +); + /// Padding to add to the right side of the sticky header when the sticky header /// is occupying the full available width. const _kStickyHeaderRightPadding = 24; @@ -46,6 +57,9 @@ class SliverSwayzeTable /// The style of the table, defaults to [SwayzeStyle.defaultSwayzeStyle]. final SwayzeStyle style; + /// Configuration for swayze interactions. + final SwayzeConfig config; + /// The [ScrollController] that manages the external vertical scroll view. final ScrollController verticalScrollController; @@ -97,6 +111,12 @@ class SliverSwayzeTable final CellDelegate cellDelegate; + /// Callback invoked every time an header is resized. + /// + /// See also: + /// - [OnHeaderExtentChanged]. + final OnHeaderExtentChanged? onHeaderExtentChanged; + SliverSwayzeTable({ Key? key, required this.controller, @@ -111,8 +131,11 @@ class SliverSwayzeTable this.wrapBox, this.wrapTableBody, this.wrapHeader, + SwayzeConfig? config, + this.onHeaderExtentChanged, }) : autofocus = autofocus ?? false, style = style ?? SwayzeStyle.defaultSwayzeStyle, + config = config ?? const SwayzeConfig(), assert( stickyHeader == null || stickyHeaderSize != null, 'if stickyHeader is not null, stickyHeaderSize must be also not null', @@ -152,6 +175,7 @@ class SliverSwayzeTableState extends State { horizontalDisplacement: horizontalDisplacement, wrapTableBody: widget.wrapTableBody, wrapHeader: widget.wrapHeader, + onHeaderExtentChanged: widget.onHeaderExtentChanged, ); return TableShortcuts( @@ -189,6 +213,7 @@ class SliverSwayzeTableState extends State { cellDelegate: widget.cellDelegate, controller: widget.controller, style: widget.style, + config: widget.config, child: child, ); } diff --git a/packages/swayze/lib/src/widgets/table_body/cells/cells.dart b/packages/swayze/lib/src/widgets/table_body/cells/cells.dart index d494392..dde29a6 100644 --- a/packages/swayze/lib/src/widgets/table_body/cells/cells.dart +++ b/packages/swayze/lib/src/widgets/table_body/cells/cells.dart @@ -516,7 +516,7 @@ class _CellsElement } @override - void rebuild() { + void rebuild({bool force = false}) { super.rebuild(); // Sync with cells store if necessary cellControllerSync(); diff --git a/packages/swayze/lib/src/widgets/table_body/gestures/table_body_gesture_detector.dart b/packages/swayze/lib/src/widgets/table_body/gestures/table_body_gesture_detector.dart index ac99ecd..78abce1 100644 --- a/packages/swayze/lib/src/widgets/table_body/gestures/table_body_gesture_detector.dart +++ b/packages/swayze/lib/src/widgets/table_body/gestures/table_body_gesture_detector.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:swayze_math/swayze_math.dart'; import '../../../../intents.dart'; +import '../../../core/controller/selection/selection_controller.dart'; import '../../../core/viewport_context/viewport_context.dart'; import '../../../core/viewport_context/viewport_context_provider.dart'; import '../../../helpers/scroll/auto_scroll.dart'; @@ -16,10 +17,12 @@ import '../../internal_scope.dart'; class _TableGestureDetails { final Offset localPosition; final IntVector2 cellCoordinate; + final bool dragAndFill; const _TableGestureDetails({ required this.localPosition, required this.cellCoordinate, + required this.dragAndFill, }); @override @@ -28,10 +31,19 @@ class _TableGestureDetails { other is _TableGestureDetails && runtimeType == other.runtimeType && localPosition == other.localPosition && - cellCoordinate == other.cellCoordinate; + cellCoordinate == other.cellCoordinate && + dragAndFill == other.dragAndFill; @override - int get hashCode => localPosition.hashCode ^ cellCoordinate.hashCode; + int get hashCode => + localPosition.hashCode ^ cellCoordinate.hashCode ^ dragAndFill.hashCode; + + @override + String toString() => '_TableGestureDetails{' + 'localPosition: $localPosition' + ', cellCoordinate: $cellCoordinate' + ', dragAndFill: $dragAndFill' + '}'; } /// Given a [globalPosition] it creates a [_TableGestureDetails] with the @@ -44,21 +56,25 @@ _TableGestureDetails _getTableGestureDetails( BuildContext context, Offset globalPosition, ) { - final tableDataController = - InternalScope.of(context).controller.tableDataController; final viewportContext = ViewportContextProvider.of(context); + final internalScope = InternalScope.of(context); - /// Local function to get cordinates with aditional ofscreen offset in a given - /// [axis]. - int _getCoordinateWithAditionalOffset({ + final tableDataController = internalScope.controller.tableDataController; + + /// Local function to get coordinates with additional offscreen offset in a + /// given [axis]. + int _getCoordinateWithAdditionalOffset({ required Axis axis, - required PositionResult positionResult, + required int position, + required OffscreenDetails overflow, required double localPosition, }) { - var result = positionResult.position; - if (positionResult.overflow == OffscreenDetails.trailing) { + var result = position; + + if (overflow == OffscreenDetails.trailing) { final diff = localPosition - viewportContext.getAxisContextFor(axis: axis).value.extent; + final defaultExtent = tableDataController .getHeaderControllerFor(axis: axis) .value @@ -73,27 +89,23 @@ _TableGestureDetails _getTableGestureDetails( final box = context.findRenderObject()! as RenderBox; final localPosition = box.globalToLocal(globalPosition); - final positionResultX = viewportContext.pixelToPosition( - localPosition.dx, - Axis.horizontal, - ); - final positionResultY = viewportContext.pixelToPosition( - localPosition.dy, - Axis.vertical, - ); + final hoverResult = viewportContext.evaluateHover(localPosition); return _TableGestureDetails( localPosition: localPosition, + dragAndFill: hoverResult.canFillCell, cellCoordinate: IntVector2( - _getCoordinateWithAditionalOffset( + _getCoordinateWithAdditionalOffset( axis: Axis.horizontal, - positionResult: positionResultX, + position: hoverResult.cell.dx, + overflow: hoverResult.overflowX, localPosition: localPosition.dx, ), - _getCoordinateWithAditionalOffset( + _getCoordinateWithAdditionalOffset( axis: Axis.vertical, - positionResult: positionResultY, + position: hoverResult.cell.dy, + overflow: hoverResult.overflowY, localPosition: localPosition.dy, ), ), @@ -139,6 +151,11 @@ class _TableBodyGestureDetectorState extends State { /// valued during a drag gesture and null otherwise. IntVector2? cachedDragCellCoordinate; + /// Caches the drag gesture, as the tap down may be on a fill handle and + /// we should know the correct cell to use as anchor to a fill and drag + /// operation. + _TableGestureDetails? _cachedDragGestureDetails; + /// Tracks cell to be tapped IntVector2? tapDownCoordinateCache; @@ -289,7 +306,10 @@ class _TableBodyGestureDetectorState extends State { void handleStartSelection(_TableGestureDetails details) { Actions.invoke( context, - TableBodySelectionStartIntent(details.cellCoordinate), + TableBodySelectionStartIntent( + details.cellCoordinate, + fill: details.dragAndFill, + ), ); } @@ -299,7 +319,29 @@ class _TableBodyGestureDetectorState extends State { if (cellCoordinate == cachedDragCellCoordinate) { return; } - Actions.invoke(context, TableBodySelectionUpdateIntent(cellCoordinate)); + + Actions.invoke( + context, + TableBodySelectionUpdateIntent(cellCoordinate), + ); + } + + /// Handles the end to a ongoing drag operation. + void handleDragEnd() { + Actions.invoke( + context, + const TableBodySelectionEndIntent(), + ); + } + + /// Handles the cancelling of an ongoing drag operation. + void handleDragCancel() { + if (mounted) { + Actions.invoke( + context, + const TableBodySelectionCancelIntent(), + ); + } } @override @@ -330,6 +372,9 @@ class _TableBodyGestureDetectorState extends State { tapDownCoordinateCache = tableGestureDetails.cellCoordinate; + // Cache the gesture for a possible drag. + _cachedDragGestureDetails = tableGestureDetails; + handleStartSelection(tableGestureDetails); }, child: RawGestureDetector( @@ -340,13 +385,23 @@ class _TableBodyGestureDetectorState extends State { (PanGestureRecognizer instance) { instance ..onStart = (DragStartDetails details) { - final tableGestureDetails = _getTableGestureDetails( - context, - details.globalPosition, - ); + // Uses the cached drag if one exists. + final tableGestureDetails = _cachedDragGestureDetails ?? + _getTableGestureDetails( + context, + details.globalPosition, + ); + cachedDragCellCoordinate = tableGestureDetails.cellCoordinate; - handleStartSelection(tableGestureDetails); + + _cachedDragGestureDetails = tableGestureDetails; + + // Does not start one if we have the cache drag already + // which means we already started on the onPointerDown. + if (_cachedDragGestureDetails == null) { + handleStartSelection(tableGestureDetails); + } dragOriginOffsetCache = tableGestureDetails.localPosition; } @@ -360,6 +415,7 @@ class _TableBodyGestureDetectorState extends State { cachedDragCellCoordinate = tableGestureDetails.cellCoordinate; + updateDragScroll( localOffset: tableGestureDetails.localPosition, globalOffset: details.globalPosition, @@ -368,19 +424,9 @@ class _TableBodyGestureDetectorState extends State { ); } ..onEnd = (DragEndDetails details) { - final scrollController = internalScope.controller.scroll; - scrollController.stopAutoScroll(Axis.vertical); - scrollController.stopAutoScroll(Axis.horizontal); - cachedDragCellCoordinate = null; - dragOriginOffsetCache = null; + _endDrag(cancelled: false); } - ..onCancel = () { - final scrollController = internalScope.controller.scroll; - scrollController.stopAutoScroll(Axis.vertical); - scrollController.stopAutoScroll(Axis.horizontal); - cachedDragCellCoordinate = null; - dragOriginOffsetCache = null; - }; + ..onCancel = () => _endDrag(cancelled: true); }, ), DoubleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< @@ -389,12 +435,15 @@ class _TableBodyGestureDetectorState extends State { (DoubleTapGestureRecognizer instance) { instance ..onDoubleTapDown = (TapDownDetails details) { - // Get the coordinate in which the double tap gesture is - // effective to. Equals to the position of the first tap - final gestureCoordinate = _getTableGestureDetails( + final tableGestureDetails = _getTableGestureDetails( context, details.globalPosition, - ).cellCoordinate; + ); + + // Get the coordinate in which the double tap gesture is + // effective to. Equals to the position of the first tap + final gestureCoordinate = + tableGestureDetails.cellCoordinate; // If the fist and second tap were made over different // cells, do nothing. @@ -402,6 +451,22 @@ class _TableBodyGestureDetectorState extends State { return; } + if (tableGestureDetails.dragAndFill) { + final primary = internalScope.controller.selection + .userSelectionState.primarySelection; + + if (primary is! CellUserSelectionModel) { + return; + } + + Actions.invoke( + context, + FillIntoUnknownIntent(source: primary), + ); + + return; + } + Actions.invoke( context, OpenInlineEditorIntent(cellPosition: gestureCoordinate), @@ -416,4 +481,18 @@ class _TableBodyGestureDetectorState extends State { }, ); } + + void _endDrag({ + required bool cancelled, + }) { + internalScope.controller.scroll + ..stopAutoScroll(Axis.vertical) + ..stopAutoScroll(Axis.horizontal); + + cachedDragCellCoordinate = null; + dragOriginOffsetCache = null; + _cachedDragGestureDetails = null; + + cancelled ? handleDragCancel() : handleDragEnd(); + } } diff --git a/packages/swayze/lib/src/widgets/table_body/mouse_hover/mouse_hover.dart b/packages/swayze/lib/src/widgets/table_body/mouse_hover/mouse_hover.dart index 16e7772..9ae9af5 100644 --- a/packages/swayze/lib/src/widgets/table_body/mouse_hover/mouse_hover.dart +++ b/packages/swayze/lib/src/widgets/table_body/mouse_hover/mouse_hover.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:swayze_math/swayze_math.dart'; +import '../../../core/internal_state/table_focus/table_focus_provider.dart'; import '../../../core/viewport_context/viewport_context_provider.dart'; import '../../../helpers/keyed_notifier/keyed_notifier.dart'; @@ -33,12 +34,22 @@ class _MouseHoverTableBodyState extends State { Timer? _debounce; + MouseCursor _cursor = MouseCursor.defer; + MouseCursor get cursor => _cursor; + set cursor(MouseCursor value) { + if (mounted && _cursor != value) { + setState(() => _cursor = value); + } + } + @override void dispose() { _debounce?.cancel(); super.dispose(); } + void _resetCursor() => cursor = MouseCursor.defer; + void onPointerHover(PointerHoverEvent event) { final localPosition = event.localPosition; @@ -52,19 +63,22 @@ class _MouseHoverTableBodyState extends State { } void updateMouseHoverNotifier(Offset localPosition) { - final column = - viewportContext.pixelToPosition(localPosition.dx, Axis.horizontal); - final row = - viewportContext.pixelToPosition(localPosition.dy, Axis.vertical); + final hoverResult = viewportContext.evaluateHover(localPosition); + + mouseHoverNotifier.setKey(hoverResult.cell); - mouseHoverNotifier.setKey(IntVector2(column.position, row.position)); + hoverResult.canFillCell && TableFocus.of(context).value.isActive + ? cursor = SystemMouseCursors.precise + : _resetCursor(); } void onPointerExit(PointerExitEvent event) { if (_debounce?.isActive ?? false) { _debounce!.cancel(); } + mouseHoverNotifier.setKey(null); + _resetCursor(); } @override @@ -72,6 +86,7 @@ class _MouseHoverTableBodyState extends State { onHover: onPointerHover, onExit: onPointerExit, opaque: false, + cursor: _cursor, child: _TableBodyMouseHoverProvider( hoverNotifier: mouseHoverNotifier, child: widget.child, diff --git a/packages/swayze/lib/src/widgets/table_body/selections/fill_selections/fill_selection.dart b/packages/swayze/lib/src/widgets/table_body/selections/fill_selections/fill_selection.dart new file mode 100644 index 0000000..da84593 --- /dev/null +++ b/packages/swayze/lib/src/widgets/table_body/selections/fill_selections/fill_selection.dart @@ -0,0 +1,253 @@ +import 'package:flutter/widgets.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import '../../../../../controller.dart'; +import '../../../../core/viewport_context/viewport_context_provider.dart'; +import '../../../internal_scope.dart'; +import '../selection_rendering_helpers.dart'; + +/// A [StatefulWidget] to render the a fill selection. +/// +/// It is implicitly animated, it means that changes on the size and position +/// of the selection should reflect in an animation. +class FillSelection extends StatefulWidget { + /// The [FillSelectionModel] to be rendered. + final FillSelectionModel selectionModel; + + final Range xRange; + final Range yRange; + + final bool isOnFrozenColumns; + + final bool isOnFrozenRows; + + const FillSelection({ + Key? key, + required this.selectionModel, + required this.xRange, + required this.yRange, + required this.isOnFrozenColumns, + required this.isOnFrozenRows, + }) : super(key: key); + + @override + State createState() => _FillSelectionState(); +} + +class _FillSelectionState extends State + with SelectionRenderingHelpers { + late final styleContext = InternalScope.of(context).style; + + @override + Range get xRange => widget.xRange; + + @override + Range get yRange => widget.yRange; + + @override + bool get isOnFrozenColumns => widget.isOnFrozenColumns; + + @override + bool get isOnFrozenRows => widget.isOnFrozenRows; + + /// Holds the handle style, if the configuration says we should use one. + late final handleStyle = InternalScope.of(context).config.isDragFillEnabled + ? InternalScope.of(context).style.dragAndFillStyle.handle + : null; + + @override + late final viewportContext = ViewportContextProvider.of(context); + + late final tableDataController = + InternalScope.of(context).controller.tableDataController; + + @override + Widget build(BuildContext context) { + final selectionModel = widget.selectionModel; + final range = selectionModel.bound(to: tableDataController.tableRange); + final leftTopPixelOffset = getLeftTopOffset(range.leftTop); + + final rightBottomPixelOffset = getRightBottomOffset(range.rightBottom); + final sizeOffset = rightBottomPixelOffset - leftTopPixelOffset; + + final effectiveStyle = widget.selectionModel.toSelectionStyle(context); + + return _AnimatedFillSelection( + size: Size(sizeOffset.dx, sizeOffset.dy), + offset: leftTopPixelOffset, + border: getVisibleBorder( + range, + effectiveStyle.borderSide, + ), + duration: styleContext.selectionAnimationDuration, + ); + } +} + +/// An [ImplicitlyAnimatedWidget] that swiftly animates when [offset] and [size] +/// changes. +class _AnimatedFillSelection extends ImplicitlyAnimatedWidget { + final SelectionBorder border; + final Size size; + final Offset offset; + + const _AnimatedFillSelection({ + Key? key, + required this.border, + required this.size, + required this.offset, + required Duration duration, + }) : super(key: key, duration: duration); + + @override + _AnimatedSelectionState createState() => _AnimatedSelectionState(); +} + +class _AnimatedSelectionState + extends AnimatedWidgetBaseState<_AnimatedFillSelection> { + late final viewportContext = ViewportContextProvider.of(context); + + Tween? _left; + Tween? _top; + SizeTween? _size; + + @override + void forEachTween(TweenVisitor visitor) { + _left = visitor( + _left, + widget.offset.dx, + (dynamic value) => Tween(begin: value as double), + ) as Tween?; + + _top = visitor( + _top, + widget.offset.dy, + (dynamic value) => Tween(begin: value as double), + ) as Tween?; + + _size = visitor( + _size, + widget.size, + (dynamic value) => SizeTween(begin: value as Size), + ) as SizeTween?; + } + + @override + Widget build(BuildContext context) { + final animation = this.animation; + final left = _left?.evaluate(animation) ?? 0; + final top = _top?.evaluate(animation) ?? 0; + final size = _size?.evaluate(animation) ?? Size.zero; + + return FillSelectionPainter( + border: widget.border, + offset: Offset(left, top), + size: size, + ); + } +} + +/// A [LeafRenderObjectWidget] that render a fill selection. +@visibleForTesting +class FillSelectionPainter extends LeafRenderObjectWidget { + final SelectionBorder border; + final Offset offset; + final Size size; + + const FillSelectionPainter({ + required this.border, + required this.offset, + required this.size, + }); + + @override + _RenderFillSelectionPainter createRenderObject(BuildContext context) { + return _RenderFillSelectionPainter( + border, + offset, + size, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderFillSelectionPainter renderObject, + ) { + renderObject + ..border = border + ..offset = offset + ..definedSize = size; + } +} + +class _RenderFillSelectionPainter extends RenderBox { + _RenderFillSelectionPainter( + this._border, + this._offset, + this._definedSize, + ); + + Offset _offset; + Offset get offset => _offset; + set offset(Offset value) { + if (_offset != value) { + _offset = value; + markNeedsPaint(); + } + } + + Size _definedSize; + Size get definedSize => _definedSize; + set definedSize(Size value) { + if (_definedSize != value) { + _definedSize = value; + markNeedsLayout(); + } + } + + SelectionBorder _border; + SelectionBorder get border => _border; + set border(SelectionBorder value) { + if (_border != value) { + _border = value; + markNeedsPaint(); + } + } + + @override + void performLayout() { + size = constraints.constrain(_definedSize); + } + + @override + void paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + + canvas.save(); + canvas.translate(offset.dx, offset.dy); + + paintSelectionBorder( + canvas, + _offset & size, + border, + ); + + canvas.restore(); + } +} + +extension on FillSelectionModel { + SelectionStyle toSelectionStyle(BuildContext context) { + if (style != null) { + return style!; + } + + final effectiveStyle = InternalScope.of(context).style; + + return SelectionStyle.dashedBorderOnly( + color: effectiveStyle.dragAndFillStyle.color, + borderWidth: effectiveStyle.dragAndFillStyle.borderWidth, + ); + } +} diff --git a/packages/swayze/lib/src/widgets/table_body/selections/primary_selection/primary_selection.dart b/packages/swayze/lib/src/widgets/table_body/selections/primary_selection/primary_selection.dart index 8beef74..c350ead 100644 --- a/packages/swayze/lib/src/widgets/table_body/selections/primary_selection/primary_selection.dart +++ b/packages/swayze/lib/src/widgets/table_body/selections/primary_selection/primary_selection.dart @@ -1,8 +1,10 @@ -import 'package:cached_value/cached_value.dart'; +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'package:swayze_math/swayze_math.dart'; import '../../../../../controller.dart'; +import '../../../../core/style/style.dart'; import '../../../../core/viewport_context/viewport_context_provider.dart'; import '../../../internal_scope.dart'; import '../selection_rendering_helpers.dart'; @@ -63,6 +65,11 @@ class _PrimarySelectionState extends State late final selectionStyle = widget.selectionModel.style ?? InternalScope.of(context).style.userSelectionStyle; + /// Holds the handle style, if the configuration says we should use one. + late final handleStyle = InternalScope.of(context).config.isDragFillEnabled + ? InternalScope.of(context).style.dragAndFillStyle.handle + : null; + @override late final viewportContext = ViewportContextProvider.of(context); @@ -84,6 +91,16 @@ class _PrimarySelectionState extends State final borderSide = selectionStyle.borderSide; + final visibleRangeForHandle = Range2D.fromLTRB( + IntVector2(xRange.start, yRange.start), + IntVector2(xRange.end + 1, yRange.end + 1), + ); + + // Avoid drawing handle in frozen cells if not adjacent + final handleStyle = visibleRangeForHandle.containsVector(range.rightBottom) + ? this.handleStyle + : null; + return _AnimatedPrimarySelection( size: size, offset: leftTopPixelOffset, @@ -91,6 +108,7 @@ class _PrimarySelectionState extends State color: selectionStyle.backgroundColor, border: getVisibleBorder(range, borderSide).toFlutterBorder(), ), + handleStyle: handleStyle, duration: styleContext.selectionAnimationDuration, activeCellRect: widget.activeCellRect, isSingleCell: isSingleCell, @@ -104,6 +122,7 @@ class _AnimatedPrimarySelection extends ImplicitlyAnimatedWidget { final Offset offset; final Size size; final BoxDecoration decoration; + final SwayzeDragAndFillHandleStyle? handleStyle; final Rect activeCellRect; final bool isSingleCell; @@ -112,6 +131,7 @@ class _AnimatedPrimarySelection extends ImplicitlyAnimatedWidget { required this.offset, required this.size, required this.decoration, + required this.handleStyle, required Duration duration, required this.activeCellRect, required this.isSingleCell, @@ -129,6 +149,11 @@ class _AnimatedSelectionState Tween? _top; SizeTween? _size; + /// Holds the animation value for the handle. + /// `0.0` - No handle. + /// `1.0` - Paint handle. + Tween? _handleValue; + @override void forEachTween(TweenVisitor visitor) { _left = visitor( @@ -136,16 +161,24 @@ class _AnimatedSelectionState widget.offset.dx, (dynamic value) => Tween(begin: value as double), ) as Tween?; + _top = visitor( _top, widget.offset.dy, (dynamic value) => Tween(begin: value as double), ) as Tween?; + _size = visitor( _size, widget.size, (dynamic value) => SizeTween(begin: value as Size), ) as SizeTween?; + + _handleValue = visitor( + _handleValue, + widget.handleStyle != null ? 1.0 : 0.0, + (dynamic value) => Tween(begin: value as double), + ) as Tween?; } @override @@ -160,9 +193,11 @@ class _AnimatedSelectionState return PrimarySelectionPainter( isSingleCell: widget.isSingleCell, decoration: widget.decoration, + handleStyle: widget.handleStyle, offset: offset, size: size, activeCellRect: widget.activeCellRect, + handleValue: _handleValue?.evaluate(animation) ?? 0, ); } } @@ -178,12 +213,20 @@ class PrimarySelectionPainter extends LeafRenderObjectWidget { final Rect activeCellRect; final bool isSingleCell; + /// Paints the handle depending on the value. + /// `0.0` - No handle. + /// `1.0` - Paint handle. + final double handleValue; + final SwayzeDragAndFillHandleStyle? handleStyle; + const PrimarySelectionPainter({ required this.isSingleCell, required this.size, required this.offset, required this.decoration, required this.activeCellRect, + required this.handleValue, + this.handleStyle, }); @override @@ -194,6 +237,8 @@ class PrimarySelectionPainter extends LeafRenderObjectWidget { size, activeCellRect, isSingleCell: isSingleCell, + handleValue: handleValue, + handleStyle: handleStyle, ); } @@ -207,7 +252,9 @@ class PrimarySelectionPainter extends LeafRenderObjectWidget { ..decoration = decoration ..offset = offset ..definedSize = size - ..activeCellRect = activeCellRect; + ..activeCellRect = activeCellRect + ..handleValue = handleValue + ..handleStyle = handleStyle; } } @@ -218,61 +265,73 @@ class _RenderPrimarySelectionPainter extends RenderBox { this._definedSize, this._activeCellRect, { required bool isSingleCell, - }) : _isSingleCell = isSingleCell; + required double handleValue, + SwayzeDragAndFillHandleStyle? handleStyle, + }) : _isSingleCell = isSingleCell, + _handleValue = handleValue, + _handleStyle = handleStyle; bool _isSingleCell; - - bool get isSingleCell { - return _isSingleCell; - } - + bool get isSingleCell => _isSingleCell; set isSingleCell(bool value) { - _isSingleCell = value; - markNeedsPaint(); + if (_isSingleCell != value) { + _isSingleCell = value; + markNeedsPaint(); + } } Offset _offset; - - Offset get offset { - return _offset; - } - + Offset get offset => _offset; set offset(Offset value) { - _offset = value; - markNeedsPaint(); + if (_offset != value) { + _offset = value; + markNeedsPaint(); + } } Size _definedSize; - - Size get definedSize { - return _definedSize; - } - + Size get definedSize => _definedSize; set definedSize(Size value) { - _definedSize = value; - markNeedsLayout(); + if (_definedSize != value) { + _definedSize = value; + markNeedsLayout(); + } } BoxDecoration _decoration; - - BoxDecoration get decoration { - return _decoration; - } - + BoxDecoration get decoration => _decoration; set decoration(BoxDecoration value) { - _decoration = value; - markNeedsPaint(); + if (_decoration != value) { + _decoration = value; + markNeedsPaint(); + } } Rect _activeCellRect; + Rect get activeCellRect => _activeCellRect; + set activeCellRect(Rect value) { + if (_activeCellRect != value) { + _activeCellRect = value; + markNeedsPaint(); + } + } - Rect get activeCellRect { - return _activeCellRect; + double _handleValue; + double get handleValue => _handleValue; + set handleValue(double value) { + if (_handleValue != value) { + _handleValue = value; + markNeedsPaint(); + } } - set activeCellRect(Rect value) { - _activeCellRect = value; - markNeedsPaint(); + SwayzeDragAndFillHandleStyle? _handleStyle; + SwayzeDragAndFillHandleStyle? get handleStyle => _handleStyle; + set handleStyle(SwayzeDragAndFillHandleStyle? value) { + if (_handleStyle != value) { + _handleStyle = value; + markNeedsPaint(); + } } @override @@ -280,25 +339,62 @@ class _RenderPrimarySelectionPainter extends RenderBox { size = constraints.constrain(_definedSize); } - late final backgroundPaint = CachedValue( - () => Paint()..color = _decoration.color!, - ).withDependency(() => _decoration); - @override void paint(PaintingContext context, Offset offset) { final canvas = context.canvas; canvas.save(); - canvas.translate(offset.dx, offset.dy); final selectionRect = _offset & size; final selectionPath = Path()..addRect(selectionRect); - // paint border + // Prepares the handle rect, if one is to be shown. + final handleRect = _handleValue > 0.0 && _handleStyle != null + ? Rect.fromLTWH( + selectionRect.right - + (_handleStyle!.size.width / 2.0).ceilToDouble(), + selectionRect.bottom - + (_handleStyle!.size.height / 2.0).ceilToDouble(), + _handleStyle!.size.width, + _handleStyle!.size.height, + ) + : null; + final emptyHandleRect = handleRect != null + ? Rect.fromCenter( + center: handleRect.center, + width: 0.0, + height: 0.0, + ) + : null; + + // If a handle is to be shown, we need to first clip the border so that + // is doesn't paint where the handle will be painted. + if (handleRect != null) { + canvas.save(); + + final clipRect = Rect.lerp( + emptyHandleRect!, + handleRect.inflate(_handleStyle!.borderWidth), + _handleValue, + ); + + if (clipRect != null) { + canvas.clipRect( + clipRect, + clipOp: ClipOp.difference, + ); + } + } + _decoration.border!.paint(canvas, selectionRect); - if (!_isSingleCell) { + // Restores the saved stack when adding the border clipping. + if (handleRect != null) { + canvas.restore(); + } + + if (!_isSingleCell && _decoration.color != null) { final activeCellPath = Path()..addRect(_activeCellRect); // crop active cell @@ -308,7 +404,18 @@ class _RenderPrimarySelectionPainter extends RenderBox { activeCellPath, ); - canvas.drawPath(overallPath, backgroundPaint.value); + canvas.drawPath( + overallPath, + Paint()..color = _decoration.color!, + ); + } + + // Paints the handle, if one is needed. + if (handleRect != null) { + canvas.drawRect( + Rect.lerp(emptyHandleRect!, handleRect, _handleValue) ?? Rect.zero, + Paint()..color = handleStyle!.color, + ); } canvas.restore(); diff --git a/packages/swayze/lib/src/widgets/table_body/selections/selections.dart b/packages/swayze/lib/src/widgets/table_body/selections/selections.dart index 5a6c861..57fc10b 100644 --- a/packages/swayze/lib/src/widgets/table_body/selections/selections.dart +++ b/packages/swayze/lib/src/widgets/table_body/selections/selections.dart @@ -8,6 +8,7 @@ import '../../../core/viewport_context/viewport_context_provider.dart'; import '../../../helpers/range_pair_key.dart'; import '../../internal_scope.dart'; import 'data_selections/data_selections.dart'; +import 'fill_selections/fill_selection.dart'; import 'primary_selection/primary_selection.dart'; import 'secondary_selections/secondary_selections.dart'; @@ -126,6 +127,7 @@ class _TableBodySelectionsState extends State<_TableBodySelections> { final dataSelections = widget.selectionController.dataSelections; final primary = userSelectionState.primarySelection; + final fill = widget.selectionController.fillSelectionState.selection; final positionActiveCell = viewportContext.getCellPosition( userSelectionState.activeCellCoordinate, @@ -162,9 +164,23 @@ class _TableBodySelectionsState extends State<_TableBodySelections> { ), ); } + + if (fill != null) { + children.add( + FillSelection( + key: ValueKey(fill), + selectionModel: fill, + xRange: xRange, + yRange: yRange, + isOnFrozenColumns: widget.isOnAFrozenColumnsArea, + isOnFrozenRows: widget.isOnAFrozenRowsArea, + ), + ); + } + children.add( PrimarySelection( - key: ValueKey(primary.id), + key: ValueKey(primary), selectionModel: primary, activeCellRect: activeCellRect, xRange: xRange, diff --git a/packages/swayze/lib/src/widgets/table_body/table_body.dart b/packages/swayze/lib/src/widgets/table_body/table_body.dart index 225a2b7..db67189 100644 --- a/packages/swayze/lib/src/widgets/table_body/table_body.dart +++ b/packages/swayze/lib/src/widgets/table_body/table_body.dart @@ -3,10 +3,10 @@ import 'package:flutter/widgets.dart'; import '../../core/style/style.dart'; import '../../core/viewport_context/viewport_context.dart'; import '../../core/viewport_context/viewport_context_provider.dart'; +import '../headers/header_drag_and_drop_preview.dart'; import '../internal_scope.dart'; import '../shared/expand_all.dart'; import '../wrappers.dart'; - import 'cells/cells_wrapper.dart'; import 'gestures/table_body_gesture_detector.dart'; import 'mouse_hover/mouse_hover.dart'; @@ -54,7 +54,7 @@ class TableBody extends StatelessWidget { Widget tableBody = MouseHoverTableBody( child: ExpandAll( children: [ - // There is 4 possible areas of content in the table body. + // There are 5 possible areas of content in the table body. // There is the always present scrollable area _TableBodyScrollableArea( @@ -99,6 +99,20 @@ class TableBody extends StatelessWidget { isOnAFrozenRowsArea: true, ), + // If columns or rows are being dragged, add the preview on top + // of other table layers. + if (viewportContext.columns.value.isDragging || + viewportContext.rows.value.isDragging) + RepaintBoundary( + key: const ValueKey('RepaintBoundaryHeaderDragAndDropPreview'), + child: HeaderDragAndDropPreview( + axis: viewportContext.columns.value.isDragging + ? Axis.horizontal + : Axis.vertical, + swayzeStyle: style, + ), + ), + // All areas respond to only one gesture detector TableBodyGestureDetector( horizontalDisplacement: horizontalDisplacement, diff --git a/packages/swayze/lib/src/widgets/table_scaffold.dart b/packages/swayze/lib/src/widgets/table_scaffold.dart index 76963b1..3b38714 100644 --- a/packages/swayze/lib/src/widgets/table_scaffold.dart +++ b/packages/swayze/lib/src/widgets/table_scaffold.dart @@ -4,7 +4,10 @@ import '../config.dart' as config; import '../core/scrolling/sliver_scrolling_data_builder.dart'; import '../core/viewport_context/viewport_context_provider.dart'; import '../core/virtualization/virtualization_calculator.dart'; +import 'headers/gestures/resize_header/header_edge_mouse_listener.dart'; import 'headers/header.dart'; +import 'headers/header_table_select.dart'; +import 'internal_scope.dart'; import 'table.dart'; import 'table_body/table_body.dart'; import 'wrappers.dart'; @@ -43,24 +46,29 @@ class TableScaffold extends StatefulWidget { /// See [SliverSwayzeTable.wrapHeader] final WrapHeaderBuilder? wrapHeader; + /// See [SliverSwayzeTable.onHeaderExtentChanged]. + final OnHeaderExtentChanged? onHeaderExtentChanged; + const TableScaffold({ Key? key, required this.horizontalDisplacement, required this.verticalDisplacement, this.wrapTableBody, this.wrapHeader, + this.onHeaderExtentChanged, }) : super(key: key); @override _TableScaffoldState createState() => _TableScaffoldState(); } -enum _TableScaffoldSlot { columnHeaders, rowsHeaders, tableBody } +enum _TableScaffoldSlot { columnHeaders, rowsHeaders, tableBody, tableSelect } class _TableScaffoldState extends State { late final viewportContext = ViewportContextProvider.of(context); late final verticalRangeNotifier = viewportContext.rows.virtualizationState.rangeNotifier; + late final internalScope = InternalScope.of(context); // The state for sizes of headers final double columnHeaderHeight = config.kColumnHeaderHeight; @@ -100,9 +108,13 @@ class _TableScaffoldState extends State { @override Widget build(BuildContext context) { - return CustomMultiChildLayout( + final child = CustomMultiChildLayout( delegate: _TableScaffoldDelegate(rowHeaderWidth, columnHeaderHeight), children: [ + LayoutId( + id: _TableScaffoldSlot.tableSelect, + child: const HeaderTableSelect(), + ), LayoutId( id: _TableScaffoldSlot.columnHeaders, child: Header( @@ -129,6 +141,15 @@ class _TableScaffoldState extends State { ), ], ); + + if (internalScope.config.isResizingHeadersEnabled) { + return HeaderEdgeMouseListener( + onHeaderExtentChanged: widget.onHeaderExtentChanged, + child: child, + ); + } + + return child; } } @@ -143,6 +164,14 @@ class _TableScaffoldDelegate extends MultiChildLayoutDelegate { @override void performLayout(Size size) { + if (hasChild(_TableScaffoldSlot.tableSelect)) { + layoutChild( + _TableScaffoldSlot.tableSelect, + BoxConstraints.tight(Size(headerWidth, headerHeight)), + ); + positionChild(_TableScaffoldSlot.tableSelect, Offset.zero); + } + // The dimensions of the table area excluding the space covered by headers final remainingHeight = (size.height - headerHeight).clamp(0.0, size.height); diff --git a/packages/swayze/lib/widgets.dart b/packages/swayze/lib/widgets.dart index 530550c..48fa3da 100644 --- a/packages/swayze/lib/widgets.dart +++ b/packages/swayze/lib/widgets.dart @@ -1,6 +1,7 @@ /// A library to include the visual layer of the swayze. library widgets; +export 'src/core/config/config.dart'; export 'src/core/style/style.dart'; export 'src/core/viewport_context/viewport_context.dart'; export 'src/widgets/shared/expand_all.dart'; diff --git a/packages/swayze/pubspec.yaml b/packages/swayze/pubspec.yaml index 2994dce..8244355 100644 --- a/packages/swayze/pubspec.yaml +++ b/packages/swayze/pubspec.yaml @@ -1,34 +1,34 @@ name: swayze description: A set of widgets and controllers to display very large tables on flutter apps. -version: 1.1.0 +version: 1.2.0 repository: https://github.com/rows/swayze issue_tracker: https://github.com/rows/swayze/issues homepage: https://github.com/rows/swayze environment: - sdk: ">=2.12.0 <3.0.0" + sdk: '>=3.2.3 <4.0.0' flutter: ">=1.17.0" dependencies: flutter: sdk: flutter - flutter_sticky_header: ^0.6.0 + flutter_sticky_header: ^0.6.5 memoize: ^3.0.0 - built_collection: ^5.0.0 - uuid: ^3.0.3 + built_collection: ^5.1.1 + uuid: ^4.2.2 cached_value: ^0.2.0 - path_drawing: ^1.0.0 - swayze_math: ^1.1.0 - meta: ^1.7.0 - collection: ^1.15.0 + path_drawing: ^1.0.1 + swayze_math: + path: ../swayze_math + collection: ^1.18.0 dev_dependencies: flutter_test: sdk: flutter rows_lint: 0.1.1 - test: ^1.16.8 - mocktail: ^0.2.0 - build_runner: ^2.0.1 - dartdoc: ^2.0.0 - + test: ^1.24.9 + mocktail: ^1.0.2 + build_runner: ^2.4.7 + dartdoc: ^6.3.0 + flutter: diff --git a/packages/swayze/test/core/controller/cells/cells_controller_test.dart b/packages/swayze/test/core/controller/cells/cells_controller_test.dart index 391f151..bc0d4f9 100644 --- a/packages/swayze/test/core/controller/cells/cells_controller_test.dart +++ b/packages/swayze/test/core/controller/cells/cells_controller_test.dart @@ -277,6 +277,49 @@ void main() { IntVector2(0, 6), ], ); + + testCellsController( + 'should respect given limit', + (cellsController) { + addHeaderControllerMock(cellsController, count: 11); + + expect( + cellsController.getNextCoordinateInCellsBlock( + originalCoordinate: const IntVector2(0, 0), + direction: AxisDirection.down, + limit: 1, + ), + const IntVector2(0, 1), + ); + }, + initialRawCells: const [ + IntVector2(0, 0), + IntVector2(0, 1), + IntVector2(0, 2), + IntVector2(0, 3), + ], + ); + + testCellsController( + 'should be able to use the given cell as base, instead of neighbor', + (cellsController) { + addHeaderControllerMock(cellsController, count: 11); + + expect( + cellsController.getNextCoordinateInCellsBlock( + originalCoordinate: const IntVector2(0, 0), + direction: AxisDirection.down, + useNeighboringCellAsBase: false, + ), + const IntVector2(0, 1), + ); + }, + initialRawCells: const [ + IntVector2(0, 1), + IntVector2(0, 2), + IntVector2(0, 3), + ], + ); }); group('getNextCoordinate', () { diff --git a/packages/swayze/test/core/controller/scroll/goldens/cell-leading-offscreen.png b/packages/swayze/test/core/controller/scroll/goldens/cell-leading-offscreen.png index 56ea66e..c92009a 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/cell-leading-offscreen.png and b/packages/swayze/test/core/controller/scroll/goldens/cell-leading-offscreen.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible-before.png b/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible-before.png index ef244dc..719a982 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible-before.png and b/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible-before.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible.png b/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible.png index 56ea66e..c92009a 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible.png and b/packages/swayze/test/core/controller/scroll/goldens/cell-leading-visible.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-offscreen.png b/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-offscreen.png index 4dee374..0d1e085 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-offscreen.png and b/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-offscreen.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-visible.png b/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-visible.png index 7b2922e..48fd742 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-visible.png and b/packages/swayze/test/core/controller/scroll/goldens/cell-trailing-visible.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-horizontal.png b/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-horizontal.png index 6c273dc..8cd2eb5 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-horizontal.png and b/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-horizontal.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-vertical.png b/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-vertical.png index 56ea66e..c92009a 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-vertical.png and b/packages/swayze/test/core/controller/scroll/goldens/header-leading-offscreen-vertical.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-horizontal.png b/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-horizontal.png index 6c273dc..8cd2eb5 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-horizontal.png and b/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-horizontal.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-vertical.png b/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-vertical.png index 56ea66e..c92009a 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-vertical.png and b/packages/swayze/test/core/controller/scroll/goldens/header-leading-visible-vertical.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-horizontal.png b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-horizontal.png index ee8c96f..d672fd8 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-horizontal.png and b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-horizontal.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-vertical.png b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-vertical.png index 137ccb9..a674626 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-vertical.png and b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-offscreen-vertical.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-horizontal.png b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-horizontal.png index 9d8dec2..4616be6 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-horizontal.png and b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-horizontal.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-vertical.png b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-vertical.png index d445a42..1a5e2b3 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-vertical.png and b/packages/swayze/test/core/controller/scroll/goldens/header-trailing-visible-vertical.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell-before.png b/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell-before.png index c17cbf0..585db6c 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell-before.png and b/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell-before.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell.png b/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell.png index 97ca249..8199332 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell.png and b/packages/swayze/test/core/controller/scroll/goldens/on-screen-cell.png differ diff --git a/packages/swayze/test/core/controller/scroll/goldens/on-screen-header.png b/packages/swayze/test/core/controller/scroll/goldens/on-screen-header.png index f7f9039..0b315d7 100644 Binary files a/packages/swayze/test/core/controller/scroll/goldens/on-screen-header.png and b/packages/swayze/test/core/controller/scroll/goldens/on-screen-header.png differ diff --git a/packages/swayze/test/core/controller/table/swayze_header_drag_state_test.dart b/packages/swayze/test/core/controller/table/swayze_header_drag_state_test.dart new file mode 100644 index 0000000..63bdb9c --- /dev/null +++ b/packages/swayze/test/core/controller/table/swayze_header_drag_state_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:swayze/src/core/controller/table/header_state.dart'; +import 'package:swayze_math/swayze_math.dart'; +import 'package:test/test.dart'; + +void main() { + group('Swayze header drag state', () { + test('is allowed to drop', () { + expect( + const SwayzeHeaderDragState( + headers: Range(0, 5), + position: Offset(10, 0), + dropAtIndex: 10, + ).isDropAllowed, + isTrue, + ); + expect( + const SwayzeHeaderDragState( + headers: Range(0, 5), + position: Offset(10, 0), + dropAtIndex: 6, + ).isDropAllowed, + isTrue, + ); + expect( + const SwayzeHeaderDragState( + headers: Range(10, 11), + position: Offset(10, 0), + dropAtIndex: 9, + ).isDropAllowed, + isTrue, + ); + }); + + test('is not allowed to drop', () { + expect( + const SwayzeHeaderDragState( + headers: Range(0, 5), + position: Offset(10, 0), + dropAtIndex: 2, + ).isDropAllowed, + isFalse, + ); + expect( + const SwayzeHeaderDragState( + headers: Range(10, 11), + position: Offset(10, 0), + dropAtIndex: 10, + ).isDropAllowed, + isFalse, + ); + expect( + const SwayzeHeaderDragState( + headers: Range(10, 15), + position: Offset(10, 0), + dropAtIndex: 14, + ).isDropAllowed, + isFalse, + ); + }); + + test('equality', () { + expect( + const SwayzeHeaderDragState( + headers: Range(0, 5), + position: Offset(10, 0), + dropAtIndex: 10, + ), + const SwayzeHeaderDragState( + headers: Range(0, 5), + position: Offset(10, 0), + dropAtIndex: 10, + ), + ); + }); + }); +} diff --git a/packages/swayze/test/core/controller/table/table_controller_test.dart b/packages/swayze/test/core/controller/table/table_controller_test.dart index 884f2c1..92fe22a 100644 --- a/packages/swayze/test/core/controller/table/table_controller_test.dart +++ b/packages/swayze/test/core/controller/table/table_controller_test.dart @@ -260,5 +260,77 @@ void main() { expect(tableDataController.tableRange, _kTestDefaultTableRange); }, ); + + group('elastic grid', () { + test('should not expand table beyond elastic limits', () async { + final parent = createSwayzeController(); + final tableDataController = SwayzeTableDataController( + parent: parent, + id: 'id', + columnCount: 5, + rowCount: 5, + frozenColumns: 1, + frozenRows: 2, + columns: [], + rows: [], + maxElasticColumns: 10, + maxElasticRows: 10, + ); + + expect(tableDataController.tableRange, _kTestDefaultTableRange); + + await addUserSelection( + parent.selection, + CellUserSelectionModel.fromAnchorFocus( + anchor: const IntVector2(15, 16), + focus: const IntVector2(17, 18), + ), + ); + + expect( + tableDataController.tableRange, + Range2D.fromLTWH( + const IntVector2.symmetric(0), + const IntVector2(10, 10), + ), + ); + }); + + test( + 'should be able to select any cell of the table when elastic limits ' + 'are lower than table size', () async { + final parent = createSwayzeController(); + final tableDataController = SwayzeTableDataController( + parent: parent, + id: 'id', + columnCount: 5, + rowCount: 5, + frozenColumns: 1, + frozenRows: 2, + columns: [], + rows: [], + maxElasticColumns: 3, + maxElasticRows: 3, + ); + + expect(tableDataController.tableRange, _kTestDefaultTableRange); + + await addUserSelection( + parent.selection, + CellUserSelectionModel.fromAnchorFocus( + anchor: const IntVector2(15, 16), + focus: const IntVector2(17, 18), + ), + ); + + expect( + tableDataController.tableRange, + Range2D.fromLTWH( + const IntVector2.symmetric(0), + const IntVector2(5, 5), + ), + ); + }); + }); }); } diff --git a/packages/swayze/test/core/style/style_test.dart b/packages/swayze/test/core/style/style_test.dart index fe49ca7..2f22a9c 100644 --- a/packages/swayze/test/core/style/style_test.dart +++ b/packages/swayze/test/core/style/style_test.dart @@ -9,6 +9,8 @@ void main() { final everythingIsBlue = Colors.blue.shade100; const everythingIsBold = TextStyle(fontWeight: FontWeight.bold); const everythingIsFast = Duration(milliseconds: 510); + const everythingIsTwo = 2.0; + final emptyShadowList = List.empty(); final headerPalette = SwayzeHeaderPalette( background: everythingIsBlue, @@ -19,34 +21,141 @@ void main() { color: everythingIsBlue, ); + final tableSelectStyle = TableSelectStyle( + foregroundColor: everythingIsBlue, + selectedForegroundColor: everythingIsBlue, + backgroundFillColor: everythingIsBlue, + ); + + final dragAndDropStyle = SwayzeHeaderDragAndDropStyle( + previewLineColor: everythingIsBlue, + previewLineWidth: everythingIsTwo, + previewHeadersColor: everythingIsBlue, + ); + + final dragAndFillHandleStyle = + SwayzeDragAndFillHandleStyle(color: everythingIsBlue); + + final dragAndFillStyle = SwayzeDragAndFillStyle( + color: everythingIsBlue, + handle: dragAndFillHandleStyle, + ); + + final resizeHeaderStyle = ResizeHeaderStyle( + fillColor: everythingIsBlue, + lineColor: everythingIsBlue, + ); + final style = SwayzeStyle.defaultSwayzeStyle.copyWith( defaultHeaderPalette: headerPalette, selectedHeaderPalette: headerPalette, + highlightedHeaderPalette: headerPalette, headerSeparatorColor: everythingIsBlue, headerTextStyle: everythingIsBold, + tableSelectStyle: tableSelectStyle, + defaultCellBackground: everythingIsBlue, cellSeparatorColor: everythingIsBlue, + cellSeparatorStrokeWidth: everythingIsTwo, userSelectionStyle: selectionStyle, selectionAnimationDuration: everythingIsFast, + inlineEditorShadow: emptyShadowList, + dragAndDropStyle: dragAndDropStyle, + dragAndFillStyle: dragAndFillStyle, + resizeHeaderStyle: resizeHeaderStyle, ); - // Overridden with copyWith + // Overridden with copyWith identical expect(style.defaultHeaderPalette, equals(headerPalette)); expect(style.selectedHeaderPalette, equals(headerPalette)); + expect(style.highlightedHeaderPalette, equals(headerPalette)); expect(style.headerSeparatorColor, equals(everythingIsBlue)); expect(style.headerTextStyle, equals(everythingIsBold)); + expect(style.tableSelectStyle, equals(tableSelectStyle)); + expect(style.defaultCellBackground, equals(everythingIsBlue)); expect(style.cellSeparatorColor, equals(everythingIsBlue)); + expect(style.cellSeparatorStrokeWidth, equals(everythingIsTwo)); expect(style.userSelectionStyle, equals(selectionStyle)); expect(style.selectionAnimationDuration, equals(everythingIsFast)); + expect(style.inlineEditorShadow, equals(emptyShadowList)); + expect(style.dragAndDropStyle, equals(dragAndDropStyle)); + expect(style.dragAndFillStyle, equals(dragAndFillStyle)); + expect(style.resizeHeaderStyle, equals(resizeHeaderStyle)); + + // Same but not identical checks, ensuring key fields still match + expect( + style.defaultHeaderPalette, + equals( + SwayzeHeaderPalette( + background: headerPalette.background, + foreground: headerPalette.foreground, + ), + ), + ); + expect( + style.defaultHeaderPalette.hashCode, + equals( + headerPalette.hashCode, + ), + ); + expect( + style.tableSelectStyle, + equals( + TableSelectStyle( + foregroundColor: everythingIsBlue, + selectedForegroundColor: everythingIsBlue, + backgroundFillColor: everythingIsBlue, + ), + ), + ); + expect( + style.defaultHeaderPalette.hashCode, + equals( + headerPalette.hashCode, + ), + ); + + expect( + style.dragAndDropStyle, + equals( + SwayzeHeaderDragAndDropStyle( + previewLineColor: everythingIsBlue, + previewLineWidth: everythingIsTwo, + previewHeadersColor: everythingIsBlue, + ), + ), + ); + expect( + style.dragAndFillStyle, + equals( + SwayzeDragAndFillStyle( + color: everythingIsBlue, + handle: dragAndFillHandleStyle, + ), + ), + ); + expect( + dragAndFillHandleStyle, + equals( + SwayzeDragAndFillHandleStyle(color: everythingIsBlue), + ), + ); + expect( + resizeHeaderStyle, + equals( + ResizeHeaderStyle( + fillColor: everythingIsBlue, + lineColor: everythingIsBlue, + ), + ), + ); }); test('should be able to use all the defaults', () { final style = SwayzeStyle.defaultSwayzeStyle.copyWith(); // Keep the default - expect( - style.headerSeparatorColor, - equals(SwayzeStyle.defaultSwayzeStyle.headerSeparatorColor), - ); + expect(style, equals(SwayzeStyle.defaultSwayzeStyle)); + expect(style.hashCode, equals(SwayzeStyle.defaultSwayzeStyle.hashCode)); expect( style.defaultHeaderPalette, equals(SwayzeStyle.defaultSwayzeStyle.defaultHeaderPalette), @@ -55,15 +164,34 @@ void main() { style.selectedHeaderPalette, equals(SwayzeStyle.defaultSwayzeStyle.selectedHeaderPalette), ); + expect( + style.highlightedHeaderPalette, + equals(SwayzeStyle.defaultSwayzeStyle.highlightedHeaderPalette), + ); + expect( + style.headerSeparatorColor, + equals(SwayzeStyle.defaultSwayzeStyle.headerSeparatorColor), + ); expect( style.headerTextStyle, equals(SwayzeStyle.defaultSwayzeStyle.headerTextStyle), ); - + expect( + style.tableSelectStyle, + equals(SwayzeStyle.defaultSwayzeStyle.tableSelectStyle), + ); + expect( + style.defaultCellBackground, + equals(SwayzeStyle.defaultSwayzeStyle.defaultCellBackground), + ); expect( style.cellSeparatorColor, equals(SwayzeStyle.defaultSwayzeStyle.cellSeparatorColor), ); + expect( + style.cellSeparatorStrokeWidth, + equals(SwayzeStyle.defaultSwayzeStyle.cellSeparatorStrokeWidth), + ); expect( style.userSelectionStyle, equals(SwayzeStyle.defaultSwayzeStyle.userSelectionStyle), @@ -72,6 +200,22 @@ void main() { style.selectionAnimationDuration, equals(SwayzeStyle.defaultSwayzeStyle.selectionAnimationDuration), ); + expect( + style.inlineEditorShadow, + equals(SwayzeStyle.defaultSwayzeStyle.inlineEditorShadow), + ); + expect( + style.dragAndDropStyle, + equals(SwayzeStyle.defaultSwayzeStyle.dragAndDropStyle), + ); + expect( + style.dragAndFillStyle, + equals(SwayzeStyle.defaultSwayzeStyle.dragAndFillStyle), + ); + expect( + style.resizeHeaderStyle, + equals(SwayzeStyle.defaultSwayzeStyle.resizeHeaderStyle), + ); }); }); } diff --git a/packages/swayze/test/fill_selections_test.dart b/packages/swayze/test/fill_selections_test.dart new file mode 100644 index 0000000..adb5087 --- /dev/null +++ b/packages/swayze/test/fill_selections_test.dart @@ -0,0 +1,438 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:swayze/src/core/config/config.dart'; +import 'package:swayze/src/core/intents/intents.dart'; +import 'package:swayze/src/widgets/table_body/selections/fill_selections/fill_selection.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import 'test_utils/create_cells_controller.dart'; +import 'test_utils/create_swayze_controller.dart'; +import 'test_utils/create_table_data.dart'; +import 'test_utils/create_test_victim.dart'; +import 'test_utils/fonts.dart'; +import 'test_utils/get_cell_offset.dart'; +import 'test_utils/widget_tester_extension.dart'; + +void main() async { + await loadFonts(); + + _testFillUnknown(); + + group('Fill Handle works across frozen edge', () { + Future _setupTable(WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1024, 1024); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + // resets the screen to its original size after the test end + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget( + TestSwayzeVictim( + tables: [ + TestTableWrapper( + config: const SwayzeConfig( + isDragFillEnabled: true, + ), + autofocus: true, + swayzeController: createSwayzeController( + tableDataController: createTableController( + tableColumnCount: 5, + tableRowCount: 5, + frozenColumns: 1, + frozenRows: 1, + ), + ), + ), + ], + ), + ); + } + + testWidgets('checking handle on frozen row edge', + (WidgetTester tester) async { + await _setupTable(tester); + + await tester.tapAt(getCellOffset(tester, column: 0, row: 1)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/fill_selection_single_cell_frozen_col.png'), + ); + }); + + testWidgets('checking handle on frozen col edge', + (WidgetTester tester) async { + await _setupTable(tester); + + await tester.tapAt(getCellOffset(tester, column: 1, row: 0)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/fill_selection_single_cell_frozen_row.png'), + ); + }); + + testWidgets('checking handle on frozen col + row edge', + (WidgetTester tester) async { + await _setupTable(tester); + + await tester.tapAt(getCellOffset(tester, column: 0, row: 0)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile( + 'goldens/fill_selection_single_cell_frozen_colrow.png'), + ); + }); + }); + group( + 'Fill Target Selection', + () { + _testFillTarget( + 'Vertical Positive', + startCell: const IntVector2(1, 1), + endCell: const IntVector2(4, 4), + targetRange: Range2D.fromPoints( + const IntVector2(1, 1), + const IntVector2(2, 5), + ), + goldenNameStart: 'fill_selection_single_cell_handle', + goldenNameOngoing: 'fill_selection_vertical_positive_ongoing', + goldenNameEnd: 'fill_selection_vertical_positive_end', + ); + + _testFillTarget( + 'Vertical Negative', + startCell: const IntVector2(1, 2), + endCell: const IntVector2(3, 0), + targetRange: Range2D.fromPoints( + const IntVector2(1, 0), + const IntVector2(2, 3), + ), + ); + + _testFillTarget( + 'Horizontal Positive', + startCell: const IntVector2(1, 1), + endCell: const IntVector2(4, 3), + targetRange: Range2D.fromPoints( + const IntVector2(1, 1), + const IntVector2(5, 2), + ), + ); + + _testFillTarget( + 'Horizontal Negative', + startCell: const IntVector2(2, 2), + endCell: const IntVector2(0, 3), + targetRange: Range2D.fromPoints( + const IntVector2(0, 2), + const IntVector2(3, 3), + ), + ); + }, + ); +} + +Future _createTable( + WidgetTester tester, { + Map> actions = const {}, +}) { + tester.binding.window.physicalSizeTestValue = const Size(1024, 1024); + tester.binding.window.devicePixelRatioTestValue = 1.0; + + // resets the screen to its original size after the test end + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + final verticalScrollController = ScrollController(); + + return tester.pumpWidget( + Actions( + actions: actions, + child: TestSwayzeVictim( + verticalScrollController: verticalScrollController, + tables: [ + TestTableWrapper( + config: const SwayzeConfig( + isDragFillEnabled: true, + ), + autofocus: true, + verticalScrollController: verticalScrollController, + swayzeController: createSwayzeController( + tableDataController: createTableController( + tableColumnCount: 5, + tableRowCount: 5, + ), + cellsController: createCellsController( + cells: List.generate( + 16, + (index) { + final column = (index / 4).floor(); + final row = (index % 4).floor(); + + return TestCellData( + position: IntVector2(column, row), + value: 'Table1 Cell $column,$row', + ); + }, + ), + ), + ), + ), + ], + ), + ), + ); +} + +void _testFillTarget( + String description, { + required IntVector2 startCell, + required IntVector2 endCell, + required Range2D targetRange, + String? goldenNameStart, + String? goldenNameOngoing, + String? goldenNameEnd, +}) { + testWidgets( + description, + (WidgetTester tester) async { + Range2D? fillSource; + Range2D? fillTarget; + + await _createTable( + tester, + actions: { + FillIntoTargetIntent: CallbackAction( + onInvoke: (intent) { + fillSource = Range2D.fromPoints( + intent.source.leftTop, + intent.source.rightBottom, + ); + fillTarget = Range2D.fromPoints( + intent.target.leftTop, + intent.target.rightBottom, + ); + + return null; + }, + ), + }, + ); + + final startRange = Range2D.fromLTWH(startCell, const IntVector2(1, 1)); + final endRange = Range2D.fromLTWH(endCell, const IntVector2(1, 1)); + + final startOffset = startRange.startOffset(tester); + final handlerOffset = startOffset.toHandlerOffset( + tester, + endOffset: startRange.endOffset(tester), + ); + + await tester.tapAt(startOffset); + + // Pump and wait a bit so that the double tap isn't triggered. + await tester.pumpAndSettle(const Duration(seconds: 2)); + + expect( + find.byType(FillSelection), + findsNothing, + reason: 'Should not show a fill selection before drag', + ); + + if (goldenNameStart != null) { + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/$goldenNameStart.png'), + ); + } + + final gesture = await tester.startGesture( + handlerOffset, + kind: PointerDeviceKind.mouse, + ); + + // Move far away and then move back so that the drag registers on the test + await gesture.moveTo( + const Offset(1000.0, 1000.0), + timeStamp: const Duration(seconds: 2), + ); + + await gesture.moveTo( + endRange.startOffset(tester), + timeStamp: const Duration(seconds: 4), + ); + + await tester.pumpAndSettle( + const Duration(seconds: 6), + ); + + expect( + find.byType(FillSelection), + findsOneWidget, + reason: 'Should show a fill selection during drag', + ); + + if (goldenNameOngoing != null) { + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/$goldenNameOngoing.png'), + ); + } + + await gesture.up( + timeStamp: const Duration(seconds: 8), + ); + await gesture.removePointer(); + + await tester.pumpAndSettle( + const Duration(seconds: 10), + ); + + if (goldenNameEnd != null) { + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/$goldenNameEnd.png'), + ); + } + + expect( + fillSource, + startRange, + reason: 'Should call FillIntoTargetIntent with correct source range.', + ); + + expect( + fillTarget, + targetRange, + reason: 'Should call FillIntoTargetIntent with correct target range.', + ); + }, + ); +} + +void _testFillUnknown() { + testWidgets( + 'Fill Unknown Selection', + (WidgetTester tester) async { + Range2D? fillSource; + + await _createTable( + tester, + actions: { + FillIntoUnknownIntent: CallbackAction( + onInvoke: (intent) { + fillSource = Range2D.fromPoints( + intent.source.leftTop, + intent.source.rightBottom, + ); + + return null; + }, + ), + }, + ); + + const rangeStart = IntVector2(1, 1); + const rangeEnd = IntVector2(1, 3); + + final startOffset = getCellOffset( + tester, + column: rangeStart.dx, + row: rangeStart.dy, + ); + final endOffset = getCellOffset( + tester, + column: rangeEnd.dx, + row: rangeEnd.dy, + ); + + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture( + startOffset, + kind: PointerDeviceKind.mouse, + ); + + // Move far away and then move back so that the drag registers on the test + await gesture.moveTo( + const Offset(1000.0, 1000.0), + timeStamp: const Duration(seconds: 2), + ); + + await gesture.moveTo( + endOffset, + timeStamp: const Duration(seconds: 4), + ); + + await tester.pumpAndSettle( + const Duration(seconds: 6), + ); + + await gesture.up( + timeStamp: const Duration(seconds: 8), + ); + + await gesture.removePointer(); + + await tester.pumpAndSettle( + const Duration(seconds: 10), + ); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/fill_selection_multiple_cells_handle.png'), + ); + + final handlerOffset = endOffset.toHandlerOffset( + tester, + endOffset: getCellOffset( + tester, + column: rangeEnd.dx + 1, + row: rangeEnd.dy + 1, + ), + ); + + await tester.doubleTapAt(handlerOffset); + + expect( + fillSource, + Range2D.fromPoints( + rangeStart, + rangeEnd + const IntVector2.symmetric(1), + ), + reason: 'Should call FillIntoUnknownIntent with correct source range.', + ); + }, + ); +} + +extension on Range2D { + Offset startOffset(WidgetTester tester) => getCellOffset( + tester, + column: leftTop.dx, + row: leftTop.dy, + ); + + Offset endOffset(WidgetTester tester) => getCellOffset( + tester, + column: rightBottom.dx, + row: rightBottom.dy, + ); +} + +extension on Offset { + Offset toHandlerOffset( + WidgetTester tester, { + required Offset endOffset, + }) => + translate( + (endOffset.dx - dx) / 2 - 1.0, + (endOffset.dy - dy) / 2 - 1.0, + ); +} diff --git a/packages/swayze/test/goldens/cells-data-selection-2.png b/packages/swayze/test/goldens/cells-data-selection-2.png index 9387931..810aa21 100644 Binary files a/packages/swayze/test/goldens/cells-data-selection-2.png and b/packages/swayze/test/goldens/cells-data-selection-2.png differ diff --git a/packages/swayze/test/goldens/cells-data-selection.png b/packages/swayze/test/goldens/cells-data-selection.png index a0ea379..449bf70 100644 Binary files a/packages/swayze/test/goldens/cells-data-selection.png and b/packages/swayze/test/goldens/cells-data-selection.png differ diff --git a/packages/swayze/test/goldens/cells-selection.png b/packages/swayze/test/goldens/cells-selection.png index 68aac02..f434acc 100644 Binary files a/packages/swayze/test/goldens/cells-selection.png and b/packages/swayze/test/goldens/cells-selection.png differ diff --git a/packages/swayze/test/goldens/column-header-resize-frozen-panes.png b/packages/swayze/test/goldens/column-header-resize-frozen-panes.png new file mode 100644 index 0000000..03b7cb6 Binary files /dev/null and b/packages/swayze/test/goldens/column-header-resize-frozen-panes.png differ diff --git a/packages/swayze/test/goldens/column-header-resize.png b/packages/swayze/test/goldens/column-header-resize.png new file mode 100644 index 0000000..03b7cb6 Binary files /dev/null and b/packages/swayze/test/goldens/column-header-resize.png differ diff --git a/packages/swayze/test/goldens/column-header-resizing-frozen-panes.png b/packages/swayze/test/goldens/column-header-resizing-frozen-panes.png new file mode 100644 index 0000000..7f9d721 Binary files /dev/null and b/packages/swayze/test/goldens/column-header-resizing-frozen-panes.png differ diff --git a/packages/swayze/test/goldens/column-header-resizing.png b/packages/swayze/test/goldens/column-header-resizing.png new file mode 100644 index 0000000..eb0bb6a Binary files /dev/null and b/packages/swayze/test/goldens/column-header-resizing.png differ diff --git a/packages/swayze/test/goldens/drag-selection-header-frozen.png b/packages/swayze/test/goldens/drag-selection-header-frozen.png index 24041e8..62c7119 100644 Binary files a/packages/swayze/test/goldens/drag-selection-header-frozen.png and b/packages/swayze/test/goldens/drag-selection-header-frozen.png differ diff --git a/packages/swayze/test/goldens/drag-selection-header.png b/packages/swayze/test/goldens/drag-selection-header.png index 0961c8e..65bc1c2 100644 Binary files a/packages/swayze/test/goldens/drag-selection-header.png and b/packages/swayze/test/goldens/drag-selection-header.png differ diff --git a/packages/swayze/test/goldens/external-headers-1.png b/packages/swayze/test/goldens/external-headers-1.png index 800e1ee..80b03d7 100644 Binary files a/packages/swayze/test/goldens/external-headers-1.png and b/packages/swayze/test/goldens/external-headers-1.png differ diff --git a/packages/swayze/test/goldens/fill_selection_multiple_cells_handle.png b/packages/swayze/test/goldens/fill_selection_multiple_cells_handle.png new file mode 100644 index 0000000..9e7cfd5 Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_multiple_cells_handle.png differ diff --git a/packages/swayze/test/goldens/fill_selection_single_cell_frozen_col.png b/packages/swayze/test/goldens/fill_selection_single_cell_frozen_col.png new file mode 100644 index 0000000..55c4b6f Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_single_cell_frozen_col.png differ diff --git a/packages/swayze/test/goldens/fill_selection_single_cell_frozen_colrow.png b/packages/swayze/test/goldens/fill_selection_single_cell_frozen_colrow.png new file mode 100644 index 0000000..355e1a6 Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_single_cell_frozen_colrow.png differ diff --git a/packages/swayze/test/goldens/fill_selection_single_cell_frozen_row.png b/packages/swayze/test/goldens/fill_selection_single_cell_frozen_row.png new file mode 100644 index 0000000..87c285c Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_single_cell_frozen_row.png differ diff --git a/packages/swayze/test/goldens/fill_selection_single_cell_handle.png b/packages/swayze/test/goldens/fill_selection_single_cell_handle.png new file mode 100644 index 0000000..5591fba Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_single_cell_handle.png differ diff --git a/packages/swayze/test/goldens/fill_selection_vertical_positive_end.png b/packages/swayze/test/goldens/fill_selection_vertical_positive_end.png new file mode 100644 index 0000000..fce7330 Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_vertical_positive_end.png differ diff --git a/packages/swayze/test/goldens/fill_selection_vertical_positive_ongoing.png b/packages/swayze/test/goldens/fill_selection_vertical_positive_ongoing.png new file mode 100644 index 0000000..6a3d51e Binary files /dev/null and b/packages/swayze/test/goldens/fill_selection_vertical_positive_ongoing.png differ diff --git a/packages/swayze/test/goldens/inline-editor-center-aligned.png b/packages/swayze/test/goldens/inline-editor-center-aligned.png index 86021ec..afaa15a 100644 Binary files a/packages/swayze/test/goldens/inline-editor-center-aligned.png and b/packages/swayze/test/goldens/inline-editor-center-aligned.png differ diff --git a/packages/swayze/test/goldens/inline-editor-left-aligned.png b/packages/swayze/test/goldens/inline-editor-left-aligned.png index 1d44060..a57c542 100644 Binary files a/packages/swayze/test/goldens/inline-editor-left-aligned.png and b/packages/swayze/test/goldens/inline-editor-left-aligned.png differ diff --git a/packages/swayze/test/goldens/inline-editor-min-size.png b/packages/swayze/test/goldens/inline-editor-min-size.png index 6613497..e324e0d 100644 Binary files a/packages/swayze/test/goldens/inline-editor-min-size.png and b/packages/swayze/test/goldens/inline-editor-min-size.png differ diff --git a/packages/swayze/test/goldens/inline-editor-overlap-cell.png b/packages/swayze/test/goldens/inline-editor-overlap-cell.png index 8240ba2..c73c6a9 100644 Binary files a/packages/swayze/test/goldens/inline-editor-overlap-cell.png and b/packages/swayze/test/goldens/inline-editor-overlap-cell.png differ diff --git a/packages/swayze/test/goldens/inline-editor-overlap-table.png b/packages/swayze/test/goldens/inline-editor-overlap-table.png index d44992a..7ff8706 100644 Binary files a/packages/swayze/test/goldens/inline-editor-overlap-table.png and b/packages/swayze/test/goldens/inline-editor-overlap-table.png differ diff --git a/packages/swayze/test/goldens/inline-editor-right-aligned.png b/packages/swayze/test/goldens/inline-editor-right-aligned.png index 9761727..717803e 100644 Binary files a/packages/swayze/test/goldens/inline-editor-right-aligned.png and b/packages/swayze/test/goldens/inline-editor-right-aligned.png differ diff --git a/packages/swayze/test/goldens/multiple-tables-1.png b/packages/swayze/test/goldens/multiple-tables-1.png index 5eb6a9e..b04cd67 100644 Binary files a/packages/swayze/test/goldens/multiple-tables-1.png and b/packages/swayze/test/goldens/multiple-tables-1.png differ diff --git a/packages/swayze/test/goldens/multiple-tables-2.png b/packages/swayze/test/goldens/multiple-tables-2.png index 49dab9c..8159846 100644 Binary files a/packages/swayze/test/goldens/multiple-tables-2.png and b/packages/swayze/test/goldens/multiple-tables-2.png differ diff --git a/packages/swayze/test/goldens/row-header-resize-frozen-panes.png b/packages/swayze/test/goldens/row-header-resize-frozen-panes.png new file mode 100644 index 0000000..b0da914 Binary files /dev/null and b/packages/swayze/test/goldens/row-header-resize-frozen-panes.png differ diff --git a/packages/swayze/test/goldens/row-header-resize.png b/packages/swayze/test/goldens/row-header-resize.png new file mode 100644 index 0000000..400b88c Binary files /dev/null and b/packages/swayze/test/goldens/row-header-resize.png differ diff --git a/packages/swayze/test/goldens/row-header-resizing-frozen-panes.png b/packages/swayze/test/goldens/row-header-resizing-frozen-panes.png new file mode 100644 index 0000000..d8c017f Binary files /dev/null and b/packages/swayze/test/goldens/row-header-resizing-frozen-panes.png differ diff --git a/packages/swayze/test/goldens/row-header-resizing.png b/packages/swayze/test/goldens/row-header-resizing.png new file mode 100644 index 0000000..869d2e1 Binary files /dev/null and b/packages/swayze/test/goldens/row-header-resizing.png differ diff --git a/packages/swayze/test/goldens/scroll-long-tables-horizontal.png b/packages/swayze/test/goldens/scroll-long-tables-horizontal.png index eec3141..4221f3a 100644 Binary files a/packages/swayze/test/goldens/scroll-long-tables-horizontal.png and b/packages/swayze/test/goldens/scroll-long-tables-horizontal.png differ diff --git a/packages/swayze/test/goldens/scroll-long-tables-vertical.png b/packages/swayze/test/goldens/scroll-long-tables-vertical.png index 9834ccf..c0b68a0 100644 Binary files a/packages/swayze/test/goldens/scroll-long-tables-vertical.png and b/packages/swayze/test/goldens/scroll-long-tables-vertical.png differ diff --git a/packages/swayze/test/goldens/selection-header.png b/packages/swayze/test/goldens/selection-header.png index c2de56a..890f193 100644 Binary files a/packages/swayze/test/goldens/selection-header.png and b/packages/swayze/test/goldens/selection-header.png differ diff --git a/packages/swayze/test/goldens/table-select-background.png b/packages/swayze/test/goldens/table-select-background.png new file mode 100644 index 0000000..dca2147 Binary files /dev/null and b/packages/swayze/test/goldens/table-select-background.png differ diff --git a/packages/swayze/test/goldens/table-select-foreground.png b/packages/swayze/test/goldens/table-select-foreground.png new file mode 100644 index 0000000..4d64e38 Binary files /dev/null and b/packages/swayze/test/goldens/table-select-foreground.png differ diff --git a/packages/swayze/test/goldens/table-select-hover.png b/packages/swayze/test/goldens/table-select-hover.png new file mode 100644 index 0000000..7465ac4 Binary files /dev/null and b/packages/swayze/test/goldens/table-select-hover.png differ diff --git a/packages/swayze/test/goldens/table-select-tap.png b/packages/swayze/test/goldens/table-select-tap.png new file mode 100644 index 0000000..7465ac4 Binary files /dev/null and b/packages/swayze/test/goldens/table-select-tap.png differ diff --git a/packages/swayze/test/goldens/table-selection.png b/packages/swayze/test/goldens/table-selection.png index ab1c543..0f2e4c0 100644 Binary files a/packages/swayze/test/goldens/table-selection.png and b/packages/swayze/test/goldens/table-selection.png differ diff --git a/packages/swayze/test/headers_test.dart b/packages/swayze/test/headers_test.dart index 6b22ea5..33f8258 100644 --- a/packages/swayze/test/headers_test.dart +++ b/packages/swayze/test/headers_test.dart @@ -1,11 +1,14 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:swayze/controller.dart'; import 'package:swayze/src/widgets/headers/header.dart'; import 'package:swayze/src/widgets/headers/header_item.dart'; +import 'package:swayze/src/widgets/headers/header_table_select.dart'; import 'package:swayze/src/widgets/table_body/selections/primary_selection/primary_selection.dart'; import 'package:swayze/src/widgets/table_body/selections/secondary_selections/secondary_selections.dart'; +import 'package:swayze/widgets.dart'; import 'package:swayze_math/swayze_math.dart'; import 'test_utils/create_swayze_controller.dart'; @@ -291,4 +294,293 @@ void main() async { ); }); }); + + group('resizing headers', () { + Future pumpWidget( + WidgetTester tester, { + int frozenColumns = 0, + int frozenRows = 0, + }) { + return tester.pumpWidget( + TestSwayzeVictim( + tables: [ + TestTableWrapper( + swayzeController: createSwayzeController( + tableDataController: createTableController( + tableColumnCount: 5, + tableRowCount: 5, + frozenColumns: frozenColumns, + frozenRows: frozenRows, + ), + ), + config: const SwayzeConfig(isResizingHeadersEnabled: true), + ), + ], + ), + ); + } + + Future createResizeColumnGesture(WidgetTester tester) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await tester.pump(); + + // test column header resize + final columnHeaders = find.descendant( + of: find.byType(Header).at(0), + matching: find.byType(HeaderItem), + ); + + final columnHeader = columnHeaders.at(1); + + await gesture.moveTo(tester.getTopRight(columnHeader)); + await tester.pump(); + + await gesture.down(tester.getTopRight(columnHeader)); + await tester.pump(); + + // drag vertically too to make sure that the resize line only + // moves horizontally. + await gesture.moveBy(const Offset(200, 100)); + await tester.pump(); + + return gesture; + } + + Future createResizeRowGesture(WidgetTester tester) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await tester.pump(); + + // test row header resize + final rowHeaders = find.descendant( + of: find.byType(Header).at(1), + matching: find.byType(HeaderItem), + ); + + final rowHeader = rowHeaders.at(1); + + await gesture.moveTo(tester.getBottomLeft(rowHeader)); + await tester.pump(); + + await gesture.down(tester.getBottomLeft(rowHeader)); + await tester.pump(); + + // drag horizontally too to make sure that the resize line only + // moves vertically. + await gesture.moveBy(const Offset(200, 25)); + await tester.pump(); + + return gesture; + } + + group('no freeze panes', () { + testWidgets( + 'works properly in columns', + (tester) async { + await pumpWidget(tester); + + final gesture = await createResizeColumnGesture(tester); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/column-header-resizing.png'), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/column-header-resize.png'), + ); + }, + ); + + testWidgets( + 'works properly in rows', + (tester) async { + await pumpWidget(tester); + + final gesture = await createResizeRowGesture(tester); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/row-header-resizing.png'), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/row-header-resize.png'), + ); + }, + ); + }); + + group('with freeze panes', () { + testWidgets( + 'works properly in columns', + (tester) async { + await pumpWidget(tester, frozenColumns: 5, frozenRows: 5); + + final gesture = await createResizeColumnGesture(tester); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile( + 'goldens/column-header-resizing-frozen-panes.png', + ), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/column-header-resize-frozen-panes.png'), + ); + }, + ); + + testWidgets( + 'works properly in rows', + (tester) async { + await pumpWidget(tester, frozenColumns: 5, frozenRows: 5); + + final gesture = await createResizeRowGesture(tester); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/row-header-resizing-frozen-panes.png'), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/row-header-resize-frozen-panes.png'), + ); + }, + ); + }); + }); + + group('Table Select', () { + Future configureSelectTable( + WidgetTester tester, TableSelectStyle style) async { + final verticalScrollController = ScrollController(); + final controller = createSwayzeController( + tableDataController: createTableController( + tableColumnCount: 5, + tableRowCount: 5, + ), + ); + + await tester.pumpWidget( + TestSwayzeVictim( + verticalScrollController: verticalScrollController, + tables: [ + TestTableWrapper( + verticalScrollController: verticalScrollController, + swayzeController: controller, + style: testStyle.copyWith( + tableSelectStyle: style, + ), + ), + ], + ), + ); + } + + testWidgets( + 'has a different background when set', + (WidgetTester tester) async { + await configureSelectTable( + tester, + const TableSelectStyle( + foregroundColor: Colors.transparent, + selectedForegroundColor: Colors.transparent, + backgroundFillColor: Colors.black, + ), + ); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/table-select-background.png'), + ); + }, + ); + + testWidgets( + 'has a triangle table select style when set ', + (WidgetTester tester) async { + await configureSelectTable( + tester, + const TableSelectStyle( + foregroundColor: Colors.black, + selectedForegroundColor: Colors.transparent, + backgroundFillColor: Colors.transparent, + ), + ); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/table-select-foreground.png'), + ); + }, + ); + testWidgets( + 'has a triangle hover when selection style when set ', + (WidgetTester tester) async { + await configureSelectTable( + tester, + const TableSelectStyle( + foregroundColor: Colors.yellow, + selectedForegroundColor: Colors.black, + backgroundFillColor: Colors.pink, + ), + ); + + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + testPointer.hover(tester.getCenter(find.byType(HeaderTableSelect))); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/table-select-hover.png'), + ); + }, + ); + + testWidgets( + 'has a triangle tap selected style when set ', + (WidgetTester tester) async { + await configureSelectTable( + tester, + const TableSelectStyle( + foregroundColor: Colors.yellow, + selectedForegroundColor: Colors.black, + backgroundFillColor: Colors.pink, + ), + ); + + await tester.tap(find.byType(HeaderTableSelect)); + + await expectLater( + find.byType(TestSwayzeVictim), + matchesGoldenFile('goldens/table-select-tap.png'), + ); + }, + ); + }); } diff --git a/packages/swayze/test/test_utils/create_test_victim.dart b/packages/swayze/test/test_utils/create_test_victim.dart index 10870e0..0a27b2a 100644 --- a/packages/swayze/test/test_utils/create_test_victim.dart +++ b/packages/swayze/test/test_utils/create_test_victim.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:swayze/controller.dart'; import 'package:swayze/widgets.dart'; import 'package:swayze_math/swayze_math.dart'; @@ -7,7 +6,7 @@ import 'package:swayze_math/swayze_math.dart'; import 'create_cell_delegate.dart'; import 'create_swayze_controller.dart'; -final myStyle = SwayzeStyle.defaultSwayzeStyle.copyWith( +final testStyle = SwayzeStyle.defaultSwayzeStyle.copyWith( userSelectionStyle: SelectionStyle.semiTransparent(color: Colors.amberAccent), headerTextStyle: const TextStyle( fontSize: 12, @@ -33,6 +32,8 @@ class TestTableWrapper extends StatefulWidget { final SwayzeController? swayzeController; final InlineEditorBuilder? editorBuilder; + final SwayzeConfig? config; + final SwayzeStyle? style; final Widget? header; @@ -43,6 +44,8 @@ class TestTableWrapper extends StatefulWidget { this.header, this.swayzeController, this.editorBuilder, + this.config, + this.style, }) : verticalScrollController = verticalScrollController ?? ScrollController(), autofocus = autofocus ?? false, @@ -64,11 +67,12 @@ class _TestTableWrapperState extends State { focusNode: myFocusNode, autofocus: widget.autofocus, controller: controller, - style: myStyle, + style: widget.style ?? testStyle, stickyHeader: widget.header, stickyHeaderSize: 70.0, inlineEditorBuilder: widget.editorBuilder ?? defaultCellEditorBuilder, verticalScrollController: widget.verticalScrollController, + config: widget.config, ); } } @@ -91,6 +95,7 @@ class TestSwayzeVictim extends StatelessWidget { debugShowCheckedModeBanner: false, theme: ThemeData( fontFamily: 'normal', + useMaterial3: false, ), home: Scaffold( body: Container( diff --git a/packages/swayze/test/test_utils/internal_widgets.dart b/packages/swayze/test/test_utils/internal_widgets.dart index d798dd1..df5433a 100644 --- a/packages/swayze/test/test_utils/internal_widgets.dart +++ b/packages/swayze/test/test_utils/internal_widgets.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; - import 'package:swayze/controller.dart'; +import 'package:swayze/src/core/config/config.dart'; import 'package:swayze/src/core/style/style.dart'; import 'package:swayze/src/widgets/internal_scope.dart'; @@ -41,6 +41,7 @@ Widget wrapWithScope( selection: createSelectionsController(), ), cellDelegate: TestCellDelegate(), + config: const SwayzeConfig(), ), ); } diff --git a/packages/swayze/test/test_utils/widget_tester_extension.dart b/packages/swayze/test/test_utils/widget_tester_extension.dart new file mode 100644 index 0000000..0e00ff3 --- /dev/null +++ b/packages/swayze/test/test_utils/widget_tester_extension.dart @@ -0,0 +1,17 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Util extensions for [WidgetTester]. +extension WidgetTesterExtension on WidgetTester { + /// Dispatch two pointer down / pointer up sequences at the given location, + /// with enough delay between sequences to trigger a double tap. + Future doubleTapAt( + Offset offset, { + Duration delayDuration = const Duration(milliseconds: 10), + }) async { + await tapAt(offset); + await pumpAndSettle(kDoubleTapMinTime + delayDuration); + await tapAt(offset); + await pumpAndSettle(); + } +} diff --git a/packages/swayze/test/widgets/headers/gestures/header_gesture_detector_test.dart b/packages/swayze/test/widgets/headers/gestures/header_gesture_detector_test.dart new file mode 100644 index 0000000..91d55b2 --- /dev/null +++ b/packages/swayze/test/widgets/headers/gestures/header_gesture_detector_test.dart @@ -0,0 +1,345 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/src/foundation/diagnostics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meta/meta.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:swayze/controller.dart'; +import 'package:swayze/intents.dart'; +import 'package:swayze/src/core/config/config.dart'; +import 'package:swayze/src/core/viewport_context/viewport_context.dart'; +import 'package:swayze/src/widgets/default_actions/default_swayze_action.dart'; +import 'package:swayze/src/widgets/headers/header.dart'; +import 'package:swayze/src/widgets/headers/header_item.dart'; +import 'package:swayze/src/widgets/internal_scope.dart'; +import 'package:swayze_math/swayze_math.dart'; + +import '../../../test_utils/create_swayze_controller.dart'; +import '../../../test_utils/create_table_data.dart'; +import '../../../test_utils/create_test_victim.dart'; + +class MockInternalScope extends Mock implements InternalScope {} + +class MockViewportContext extends Mock implements ViewportContext {} + +class _MockAction extends DefaultSwayzeAction { + _MockAction() : super(MockInternalScope(), MockViewportContext()); + + int invoked = 0; + T? lastIntent; + + @override + void invokeAction(T intent, BuildContext context) { + invoked++; + lastIntent = intent; + } +} + +void main() { + late _MockAction dragEndAction; + late _MockAction dragCancelAction; + late TestSwayzeController swayzeController; + + setUp(() { + dragEndAction = _MockAction(); + dragCancelAction = _MockAction(); + swayzeController = createSwayzeController( + tableDataController: createTableController( + tableColumnCount: 10, + tableRowCount: 10, + ), + ); + }); + + setUpAll(() { + registerFallbackValue( + const HeaderDragEndIntent( + header: 0, + axis: Axis.horizontal, + ), + ); + registerFallbackValue(const HeaderDragCancelIntent(Axis.horizontal)); + }); + + Future pumpTestWidget(WidgetTester tester) { + final verticalScrollController = ScrollController(); + return tester.pumpWidget( + Actions( + actions: { + HeaderDragEndIntent: dragEndAction, + HeaderDragCancelIntent: dragCancelAction, + }, + child: TestSwayzeVictim( + verticalScrollController: verticalScrollController, + tables: [ + TestTableWrapper( + config: const SwayzeConfig( + isHeaderDragAndDropEnabled: true, + ), + verticalScrollController: verticalScrollController, + swayzeController: swayzeController, + ), + ], + ), + ), + ); + } + + group('Header gesture detector', () { + group('Drag and drop', () { + testWidgets('Invokes HeaderDragEndIntent when a drag is completed', + (tester) async { + await pumpTestWidget(tester); + + final columnHeaders = tester.findColumnHeaders(); + + await tester.shiftSelectHeaders( + from: columnHeaders.at(1), + to: columnHeaders.at(3), + ); + + final firstLocation = tester.getCenter(columnHeaders.at(2)); + final gesture = await tester.startGesture( + firstLocation, + pointer: 1, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + + final secondLocation = tester.getCenter(columnHeaders.at(3)); + await gesture.moveTo(secondLocation); + await tester.pump(); + + var dragState = + swayzeController.tableDataController.columns.value.dragState; + expect(dragState, isNotNull); + expect(dragState!.headers, const Range(1, 4)); + expect(dragState.dropAtIndex, 1); + expect(dragState.isDropAllowed, isFalse); + + final thirdLocation = tester.getCenter(columnHeaders.at(5)); + await gesture.moveTo(thirdLocation); + await tester.pump(); + + dragState = + swayzeController.tableDataController.columns.value.dragState; + expect(dragState, isNotNull); + expect(dragState!.headers, const Range(1, 4)); + expect(dragState.dropAtIndex, 5); + expect(dragState.isDropAllowed, isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(dragCancelAction.invoked, 0); + expect(dragEndAction.invoked, 1); + expect( + dragEndAction.lastIntent, + const TypeMatcher(), + ); + final intent = dragEndAction.lastIntent!; + expect(intent.header, 5); + expect(intent.axis, Axis.horizontal); + }); + + testWidgets( + 'Invokes HeaderDragCancelIntent when dropping inside the ' + 'dragging headers Range', (tester) async { + await pumpTestWidget(tester); + + final columnHeaders = tester.findColumnHeaders(); + + await tester.shiftSelectHeaders( + from: columnHeaders.at(1), + to: columnHeaders.at(3), + ); + + final firstLocation = tester.getCenter(columnHeaders.at(2)); + final gesture = await tester.startGesture( + firstLocation, + pointer: 1, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + + final secondLocation = tester.getCenter(columnHeaders.at(3)); + await gesture.moveTo(secondLocation); + await tester.pump(); + + var dragState = + swayzeController.tableDataController.columns.value.dragState; + expect(dragState, isNotNull); + expect(dragState!.headers, const Range(1, 4)); + expect(dragState.dropAtIndex, 1); + expect(dragState.isDropAllowed, isFalse); + + final thirdLocation = tester.getCenter(columnHeaders.at(1)); + await gesture.moveTo(thirdLocation); + await tester.pump(); + + dragState = + swayzeController.tableDataController.columns.value.dragState; + expect(dragState, isNotNull); + expect(dragState!.headers, const Range(1, 4)); + expect(dragState.dropAtIndex, 1); + expect(dragState.isDropAllowed, isFalse); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(dragEndAction.invoked, 0); + expect(dragCancelAction.invoked, 1); + }); + }); + + group('Drag moves all adjacent selected headers', () { + /// Test method that does the required steps to assert that all adjacent + /// selections from the dragged selection are dragged as well. + @isTest + Future testAdjacentSelections( + String description, { + required Range expectedSelectedRange, + required List shiftSelectedHeaders, + required List modifierSelectedHeaders, + int startDragAtHeader = 1, + }) async { + return testWidgets(description, (tester) async { + await pumpTestWidget(tester); + + final columnHeaders = tester.findColumnHeaders(); + await tester.controlShiftSelectHeaders(shiftSelectedHeaders); + await tester.controlSelectHeaders( + modifierSelectedHeaders.map(columnHeaders.at), + ); + + final selections = swayzeController + .selection.userSelectionState.selections + .whereType(); + expect( + selections, + hasLength( + modifierSelectedHeaders.length + shiftSelectedHeaders.length, + ), + ); + + final firstLocation = tester.getCenter( + columnHeaders.at(startDragAtHeader), + ); + final gesture = await tester.startGesture( + firstLocation, + pointer: 1, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + + final secondLocation = tester.getCenter( + columnHeaders.at(startDragAtHeader + 1), + ); + await gesture.moveTo(secondLocation); + await tester.pump(); + final dragState = + swayzeController.tableDataController.columns.value.dragState; + expect(dragState, isNotNull); + expect(dragState!.headers, expectedSelectedRange); + }); + } + + testAdjacentSelections( + 'No adjacent selections', + startDragAtHeader: 2, + shiftSelectedHeaders: [const Range(2, 3)], + modifierSelectedHeaders: [0, 5], + expectedSelectedRange: const Range(2, 4), + ); + + testAdjacentSelections( + 'Adjacent before first selection', + startDragAtHeader: 2, + shiftSelectedHeaders: [const Range(2, 3)], + modifierSelectedHeaders: [1, 0], + expectedSelectedRange: const Range(0, 4), + ); + + testAdjacentSelections( + 'Adjacent after first selection', + shiftSelectedHeaders: [const Range(1, 2)], + modifierSelectedHeaders: [3, 4, 5], + expectedSelectedRange: const Range(1, 6), + ); + + testAdjacentSelections( + 'Adjacent with random selection order', + startDragAtHeader: 2, + shiftSelectedHeaders: [const Range(2, 3)], + modifierSelectedHeaders: [5, 1, 3, 2, 4], + expectedSelectedRange: const Range(1, 6), + ); + + testAdjacentSelections( + 'Overlapping shift selections', + startDragAtHeader: 2, // Use a header from the smaller range. + shiftSelectedHeaders: [const Range(2, 3), const Range(0, 5)], + modifierSelectedHeaders: [], + expectedSelectedRange: const Range(0, 6), + ); + }); + }); +} + +/// Extensions to help interacting with the headers. +extension _TesterGestureExtensions on WidgetTester { + /// Selects a range of headers holding the shift key. + Future shiftSelectHeaders({ + required Finder from, + required Finder to, + }) async { + await tapAt(getCenter(from)); + await pumpAndSettle(); + await sendKeyDownEvent(LogicalKeyboardKey.shift); + await tapAt(getCenter(to)); + await pumpAndSettle(); + await sendKeyUpEvent(LogicalKeyboardKey.shift); + } + + /// Selects multiple headers by holding a modifier key. + Future controlSelectHeaders(Iterable headers) async { + final modifier = + Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control; + + await sendKeyDownEvent(modifier); + for (final finder in headers) { + await tap(finder, warnIfMissed: false); + await pumpAndSettle(); + } + await sendKeyUpEvent(modifier); + } + + /// Selects multiple headers by holding a modifier key and selecting a range + /// by holding shift. + Future controlShiftSelectHeaders(Iterable ranges) async { + final modifier = + Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control; + final headers = findColumnHeaders(); + + for (final range in ranges) { + await sendKeyDownEvent(modifier); + await shiftSelectHeaders( + from: headers.at(range.start), + to: headers.at(range.end), + ); + await sendKeyUpEvent(modifier); + } + } + + /// Finds all column headers and returns all in a Finder object. + Finder findColumnHeaders() => find.descendant( + of: find.byType(Header).first, + matching: find.byType(HeaderItem), + ); +} diff --git a/packages/swayze/test/widgets/headers/gestures/header_table_select_test.dart b/packages/swayze/test/widgets/headers/gestures/header_table_select_test.dart new file mode 100644 index 0000000..7f10070 --- /dev/null +++ b/packages/swayze/test/widgets/headers/gestures/header_table_select_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:swayze/controller.dart'; + +import '../../../test_utils/create_swayze_controller.dart'; +import '../../../test_utils/create_table_data.dart'; +import '../../../test_utils/create_test_victim.dart'; +import '../../../test_utils/fonts.dart'; +import '../../../test_utils/get_cell_offset.dart'; + +void main() async { + await loadFonts(); + + group('Header gesture detector', () { + group('table select button', () { + testWidgets('default behavior', (tester) async { + final verticalScrollController = ScrollController(); + final controller = createSwayzeController( + tableDataController: createTableController( + tableColumnCount: 5, + tableRowCount: 5, + ), + ); + + await tester.pumpWidget( + TestSwayzeVictim( + verticalScrollController: verticalScrollController, + tables: [ + TestTableWrapper( + verticalScrollController: verticalScrollController, + swayzeController: controller, + ), + ], + ), + ); + + await tester.tapAt(getCellOffset(tester, column: 1, row: 1)); + await tester.pumpAndSettle(); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + expect( + controller.selection.userSelectionState.selections.first, + isA(), + ); + }); + }); + }); +} diff --git a/packages/swayze/test/widgets/table_body/cells/cells_test.dart b/packages/swayze/test/widgets/table_body/cells/cells_test.dart index 3f17044..93f289e 100644 --- a/packages/swayze/test/widgets/table_body/cells/cells_test.dart +++ b/packages/swayze/test/widgets/table_body/cells/cells_test.dart @@ -478,7 +478,8 @@ void main() { ), ); }); - await tester.pumpAndSettle(); + + await tester.pumpWidget(widgetUpdated); // has removed one cell expect( diff --git a/packages/swayze_math/lib/src/range.dart b/packages/swayze_math/lib/src/range.dart index e97acd4..e579c2d 100644 --- a/packages/swayze_math/lib/src/range.dart +++ b/packages/swayze_math/lib/src/range.dart @@ -138,7 +138,7 @@ const _kListEquality = ListEquality(); /// A list of ranges that keeps with the smallest possible size (no overlaps /// between its members) @immutable -class RangeCompactList extends IterableMixin implements Iterable { +class RangeCompactList extends IterableMixin { final List _ranges; RangeCompactList() : _ranges = []; @@ -265,7 +265,7 @@ class RangeIterable with IterableMixin implements Iterable { /// - [current] will return [Range.start] when [moveNext] is not yet called /// - After reaching the end of the iteration [current] will return the last /// element, one before [Range.end] -class RangeIterator extends Iterator { +class RangeIterator implements Iterator { final Range _range; int _position = -1; diff --git a/packages/swayze_math/pubspec.yaml b/packages/swayze_math/pubspec.yaml index 219ca02..cf536f1 100644 --- a/packages/swayze_math/pubspec.yaml +++ b/packages/swayze_math/pubspec.yaml @@ -1,19 +1,18 @@ name: swayze_math description: Classes for math operations in the context of a spreadsheet UI -version: 1.1.0 +version: 1.2.0 repository: https://github.com/rows/swayze issue_tracker: https://github.com/rows/swayze/issues homepage: https://github.com/rows/swayze environment: - sdk: ">=2.12.0 <3.0.0" + sdk: '>=3.2.3 <4.0.0' dependencies: - meta: ^1.3.0 - collection: ^1.15.0 + collection: ^1.18.0 dev_dependencies: - test: ^1.16.8 + test: ^1.25.0 rows_lint: 0.1.1 - dartdoc: ^2.0.0 \ No newline at end of file + dartdoc: ^6.3.0 \ No newline at end of file