From a4b4b20cfc95052622693db8d248d0fc03f3a42c Mon Sep 17 00:00:00 2001 From: appflowy Date: Wed, 3 Aug 2022 10:24:14 +0800 Subject: [PATCH] chore: config board ui --- .../flowy_board/example/.vscode/launch.json | 37 ++ .../flowy_board/example/lib/main.dart | 68 ++- .../example/lib/multi_board_list_example.dart | 78 +++ .../lib/single_board_list_example.dart | 60 ++ .../flowy_board/example/macos/Podfile.lock | 22 + .../macos/Runner.xcodeproj/project.pbxproj | 62 ++- .../contents.xcworkspacedata | 3 + .../packages/flowy_board/example/pubspec.yaml | 2 +- .../packages/flowy_board/lib/flowy_board.dart | 11 +- .../{widgets => rendering}/board_overlay.dart | 0 .../flowy_board/lib/src/utils/log.dart | 28 + .../flowy_board/lib/src/widgets/board.dart | 149 +++++ .../widgets/board_column/board_column.dart | 142 +++++ .../widgets/board_column/data_controller.dart | 85 +++ .../lib/src/widgets/column_container.dart | 91 +++ .../lib/src/widgets/flex/drag_state.dart | 171 ++++++ .../lib/src/widgets/flex/drag_target.dart | 369 ++++++++++++ .../lib/src/widgets/flex/reorder_flex.dart | 524 ++++++++++++++++++ .../src/widgets/flex/reorder_flex_ext.dart | 78 +++ .../lib/src/widgets/flex/reorder_mixin.dart | 65 +++ .../widgets/phantom/phantom_controller.dart | 338 +++++++++++ .../src/widgets/phantom/phantom_state.dart | 109 ++++ .../packages/flowy_board/pubspec.yaml | 4 +- .../test/flowy_board_method_channel_test.dart | 24 - .../flowy_board/test/flowy_board_test.dart | 29 - 25 files changed, 2450 insertions(+), 99 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_board/example/.vscode/launch.json create mode 100644 frontend/app_flowy/packages/flowy_board/example/lib/multi_board_list_example.dart create mode 100644 frontend/app_flowy/packages/flowy_board/example/lib/single_board_list_example.dart create mode 100644 frontend/app_flowy/packages/flowy_board/example/macos/Podfile.lock rename frontend/app_flowy/packages/flowy_board/lib/src/{widgets => rendering}/board_overlay.dart (100%) create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/utils/log.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/board.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/board_column.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/data_controller.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/column_container.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_state.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_target.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex_ext.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_mixin.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_controller.dart create mode 100644 frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_state.dart delete mode 100644 frontend/app_flowy/packages/flowy_board/test/flowy_board_method_channel_test.dart delete mode 100644 frontend/app_flowy/packages/flowy_board/test/flowy_board_test.dart diff --git a/frontend/app_flowy/packages/flowy_board/example/.vscode/launch.json b/frontend/app_flowy/packages/flowy_board/example/.vscode/launch.json new file mode 100644 index 0000000000..3652b92f27 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/example/.vscode/launch.json @@ -0,0 +1,37 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "cwd": "example", + "request": "launch", + "env": { + "Dart_LOG": "true" + }, + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "env": { + "Dart_LOG": "true" + }, + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "env": { + "Dart_LOG": "true" + }, + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_board/example/lib/main.dart b/frontend/app_flowy/packages/flowy_board/example/lib/main.dart index eecf56057f..975cbb6ca8 100644 --- a/frontend/app_flowy/packages/flowy_board/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_board/example/lib/main.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:flowy_board/flowy_board.dart'; +import 'single_board_list_example.dart'; +import 'multi_board_list_example.dart'; void main() { runApp(const MyApp()); @@ -16,48 +14,46 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - String _platformVersion = 'Unknown'; - final _flowyBoardPlugin = FlowyBoard(); + int _currentIndex = 0; + final _bottomNavigationColor = Colors.blue; + + final List _examples = [ + const MultiBoardListExample(), + const SingleBoardListExample(), + ]; @override void initState() { super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - // We also handle the message potentially returning null. - try { - platformVersion = - await _flowyBoardPlugin.getPlatformVersion() ?? 'Unknown platform version'; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; - - setState(() { - _platformVersion = platformVersion; - }); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( - appBar: AppBar( - title: const Text('Plugin example app'), - ), - body: Center( - child: Text('Running on: $_platformVersion\n'), - ), - ), + appBar: AppBar( + title: const Text('FlowyBoard example'), + ), + body: _examples[_currentIndex], + bottomNavigationBar: BottomNavigationBar( + fixedColor: _bottomNavigationColor, + showSelectedLabels: true, + showUnselectedLabels: false, + currentIndex: _currentIndex, + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.grid_on, color: _bottomNavigationColor), + label: "MultiBoardList"), + BottomNavigationBarItem( + icon: Icon(Icons.grid_on, color: _bottomNavigationColor), + label: "SingleBoardList"), + ], + onTap: (int index) { + setState(() { + _currentIndex = index; + }); + }, + )), ); } } diff --git a/frontend/app_flowy/packages/flowy_board/example/lib/multi_board_list_example.dart b/frontend/app_flowy/packages/flowy_board/example/lib/multi_board_list_example.dart new file mode 100644 index 0000000000..3574745ae2 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/example/lib/multi_board_list_example.dart @@ -0,0 +1,78 @@ +import 'package:flowy_board/flowy_board.dart'; +import 'package:flutter/material.dart'; + +class MultiBoardListExample extends StatefulWidget { + const MultiBoardListExample({Key? key}) : super(key: key); + + @override + State createState() => _MultiBoardListExampleState(); +} + +class _MultiBoardListExampleState extends State { + final BoardDataController boardData = BoardDataController(); + + @override + void initState() { + final column1 = BoardColumnData(id: "1", items: [ + TextItem("a"), + TextItem("b"), + TextItem("c"), + TextItem("d"), + ]); + final column2 = BoardColumnData(id: "2", items: [ + TextItem("1"), + TextItem("2"), + TextItem("3"), + TextItem("4"), + TextItem("5"), + ]); + + final column3 = BoardColumnData(id: "3", items: [ + TextItem("A"), + TextItem("B"), + TextItem("C"), + TextItem("D"), + ]); + + boardData.setColumnData(column1); + boardData.setColumnData(column2); + boardData.setColumnData(column3); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Board( + dataController: boardData, + background: Container(color: Colors.red), + builder: (context, item) { + return _RowWidget(item: item as TextItem, key: ObjectKey(item)); + }, + ); + } +} + +class _RowWidget extends StatelessWidget { + final TextItem item; + const _RowWidget({Key? key, required this.item}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + key: ObjectKey(item), + height: 60, + color: Colors.green, + child: Center(child: Text(item.s)), + ); + } +} + +class TextItem extends ColumnItem { + final String s; + + TextItem(this.s); + + @override + String get id => s; +} diff --git a/frontend/app_flowy/packages/flowy_board/example/lib/single_board_list_example.dart b/frontend/app_flowy/packages/flowy_board/example/lib/single_board_list_example.dart new file mode 100644 index 0000000000..fd61b5b836 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/example/lib/single_board_list_example.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flowy_board/flowy_board.dart'; + +class SingleBoardListExample extends StatefulWidget { + const SingleBoardListExample({Key? key}) : super(key: key); + + @override + State createState() => _SingleBoardListExampleState(); +} + +class _SingleBoardListExampleState extends State { + final BoardDataController boardData = BoardDataController(); + + @override + void initState() { + final column = BoardColumnData(id: "1", items: [ + TextItem("a"), + TextItem("b"), + TextItem("c"), + TextItem("d"), + ]); + + boardData.setColumnData(column); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Board( + dataController: boardData, + builder: (context, item) { + return _RowWidget(item: item as TextItem, key: ObjectKey(item)); + }, + ); + } +} + +class _RowWidget extends StatelessWidget { + final TextItem item; + const _RowWidget({Key? key, required this.item}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + key: ObjectKey(item), + height: 60, + color: Colors.green, + child: Center(child: Text(item.s)), + ); + } +} + +class TextItem extends ColumnItem { + final String s; + + TextItem(this.s); + + @override + String get id => throw UnimplementedError(); +} diff --git a/frontend/app_flowy/packages/flowy_board/example/macos/Podfile.lock b/frontend/app_flowy/packages/flowy_board/example/macos/Podfile.lock new file mode 100644 index 0000000000..18dcf94df9 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/example/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - flowy_board (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + +DEPENDENCIES: + - flowy_board (from `Flutter/ephemeral/.symlinks/plugins/flowy_board/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + +EXTERNAL SOURCES: + flowy_board: + :path: Flutter/ephemeral/.symlinks/plugins/flowy_board/macos + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + flowy_board: e93adfa305df65f1ac860f2cf9dc7188f50c9b66 + FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + +PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c + +COCOAPODS: 1.11.3 diff --git a/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcodeproj/project.pbxproj index 9ce6f7ca08..d60feedcfe 100644 --- a/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 2AD48017E3EAE6142B6E265B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 368626E4047E7783820AEC34 /* Pods_Runner.framework */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; @@ -52,9 +53,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 02BDE4CD9C63CA2562B2FDD1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* flowy_board_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "flowy_board_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* flowy_board_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flowy_board_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -66,8 +68,11 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 368626E4047E7783820AEC34 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 59D896B3478D0A2144E570BB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + F368AC3EE3CE4F2FCEC85166 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,6 +80,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2AD48017E3EAE6142B6E265B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -99,6 +105,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 6BBA375F5E43A645EC061EA0 /* Pods */, ); sourceTree = ""; }; @@ -145,9 +152,21 @@ path = Runner; sourceTree = ""; }; + 6BBA375F5E43A645EC061EA0 /* Pods */ = { + isa = PBXGroup; + children = ( + F368AC3EE3CE4F2FCEC85166 /* Pods-Runner.debug.xcconfig */, + 02BDE4CD9C63CA2562B2FDD1 /* Pods-Runner.release.xcconfig */, + 59D896B3478D0A2144E570BB /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 368626E4047E7783820AEC34 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -159,11 +178,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + DF704441B638BDD90A6EF3BC /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + B89DDA2E6C13BED8F107400D /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -270,6 +291,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + B89DDA2E6C13BED8F107400D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + DF704441B638BDD90A6EF3BC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16ed..21a3cc14c7 100644 --- a/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/frontend/app_flowy/packages/flowy_board/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/frontend/app_flowy/packages/flowy_board/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_board/example/pubspec.yaml index 364ab9918c..8504fc5208 100644 --- a/frontend/app_flowy/packages/flowy_board/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_board/example/pubspec.yaml @@ -6,7 +6,7 @@ description: Demonstrates how to use the flowy_board plugin. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: - sdk: ">=2.17.6 <3.0.0" + sdk: ">=2.17.1 <3.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions diff --git a/frontend/app_flowy/packages/flowy_board/lib/flowy_board.dart b/frontend/app_flowy/packages/flowy_board/lib/flowy_board.dart index f4bed642b6..0611432b4a 100644 --- a/frontend/app_flowy/packages/flowy_board/lib/flowy_board.dart +++ b/frontend/app_flowy/packages/flowy_board/lib/flowy_board.dart @@ -1,8 +1,5 @@ +library flowy_board; -import 'flowy_board_platform_interface.dart'; - -class FlowyBoard { - Future getPlatformVersion() { - return FlowyBoardPlatform.instance.getPlatformVersion(); - } -} +export 'src/widgets/board_column/board_column.dart'; +export 'src/widgets/board_column/data_controller.dart'; +export 'src/widgets/board.dart'; diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_overlay.dart b/frontend/app_flowy/packages/flowy_board/lib/src/rendering/board_overlay.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_overlay.dart rename to frontend/app_flowy/packages/flowy_board/lib/src/rendering/board_overlay.dart diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/utils/log.dart b/frontend/app_flowy/packages/flowy_board/lib/src/utils/log.dart new file mode 100644 index 0000000000..b9f766f961 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/utils/log.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +// ignore: constant_identifier_names +const DART_LOG = "Dart_LOG"; + +class Log { + // static const enableLog = bool.hasEnvironment(DART_LOG); + // static final shared = Log(); + static const enableLog = true; + + static void info(String? message) { + if (enableLog) { + debugPrint('ℹī¸[Info]=> $message'); + } + } + + static void debug(String? message) { + if (enableLog) { + debugPrint('🐛[Debug]=> $message'); + } + } + + static void trace(String? message) { + if (enableLog) { + // debugPrint('❗ī¸[Trace]=> $message'); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board.dart new file mode 100644 index 0000000000..9305ba760d --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board.dart @@ -0,0 +1,149 @@ +import 'dart:collection'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../flowy_board.dart'; + +import 'column_container.dart'; +import 'flex/reorder_flex.dart'; +import 'phantom/phantom_controller.dart'; + +class Board extends StatelessWidget { + /// The direction to use as the main axis. + final Axis direction = Axis.vertical; + + /// How much space to place between children in a run in the main axis. + /// Defaults to 10.0. + final double spacing; + + /// How much space to place between the runs themselves in the cross axis. + /// Defaults to 0.0. + final double runSpacing; + + final Widget? background; + + final BoardColumnItemWidgetBuilder builder; + + /// + final BoardDataController dataController; + + /// + final BoardPhantomController passthroughPhantomContorller; + + Board({ + required this.dataController, + required this.builder, + this.spacing = 10.0, + this.runSpacing = 0.0, + this.background, + Key? key, + }) : passthroughPhantomContorller = + BoardPhantomController(delegate: dataController), + super(key: key); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: dataController, + child: Consumer( + builder: (context, notifier, child) { + List children = []; + List acceptColumns = + dataController.columnControllers.keys.toList(); + + dataController.columnControllers.forEach((columnId, dataController) { + Widget child = + buildBoardColumn(columnId, acceptColumns, dataController); + if (children.isEmpty) { + // children.add(SizedBox(width: spacing)); + } + // if (background != null) { + // child = Stack(children: [ + // background!, + // child, + // ]); + // } + // children.add(Expanded(key: ValueKey(columnId), child: child)); + children.add(child); + // children.add(SizedBox(width: spacing)); + }); + + return BoardColumnContainer( + onReorder: (fromIndex, toIndex) {}, + boardDataController: dataController, + children: children, + ); + + // return Row( + // crossAxisAlignment: CrossAxisAlignment.start, + // mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // children: children, + // ); + }, + ), + ); + } + + /// + Widget buildBoardColumn( + String columnId, + List acceptColumns, + BoardColumnDataController dataController, + ) { + return ChangeNotifierProvider.value( + key: ValueKey(columnId), + value: dataController, + child: Consumer( + builder: (context, value, child) { + return SizedBox( + width: 200, + child: BoardColumnWidget( + header: Container(color: Colors.yellow, height: 30), + builder: builder, + acceptColumns: acceptColumns, + dataController: dataController, + scrollController: ScrollController(), + onReorder: (_, int fromIndex, int toIndex) { + dataController.move(fromIndex, toIndex); + }, + phantomController: passthroughPhantomContorller, + ), + ); + }, + ), + ); + } +} + +class BoardDataController extends ChangeNotifier + with EquatableMixin, BoardPhantomControllerDelegate, ReoderFlextDataSource { + final LinkedHashMap columnDatas = LinkedHashMap(); + final LinkedHashMap columnControllers = + LinkedHashMap(); + + BoardDataController(); + + void setColumnData(BoardColumnData columnData) { + final controller = BoardColumnDataController(columnData: columnData); + columnDatas[columnData.id] = columnData; + columnControllers[columnData.id] = controller; + } + + @override + List get props { + return [columnDatas.values]; + } + + @override + BoardColumnDataController? controller(String columnId) { + return columnControllers[columnId]; + } + + @override + String get identifier => '$BoardDataController'; + + @override + List get items => columnDatas.values.toList(); +} + +class BoardDataIdentifier {} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/board_column.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/board_column.dart new file mode 100644 index 0000000000..4d14d2d521 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/board_column.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../../rendering/board_overlay.dart'; +import '../../utils/log.dart'; +import '../phantom/phantom_controller.dart'; +import '../flex/reorder_flex.dart'; +import '../flex/drag_state.dart'; +import '../flex/reorder_flex_ext.dart'; +import 'data_controller.dart'; + +typedef OnDragStarted = void Function(int index); +typedef OnDragEnded = void Function(String listId); +typedef OnReorder = void Function(String listId, int fromIndex, int toIndex); +typedef OnDeleted = void Function(String listId, int deletedIndex); +typedef OnInserted = void Function(String listId, int insertedIndex); +typedef OnPassedInPhantom = void Function( + String listId, + FlexDragTargetData dragTargetData, + int phantomIndex, +); + +typedef BoardColumnItemWidgetBuilder = Widget Function( + BuildContext context, ColumnItem item); + +class BoardColumnWidget extends StatefulWidget { + final Widget? header; + final Widget? footer; + final BoardColumnDataController dataController; + final ScrollController? scrollController; + final ReorderFlexConfig config; + + final OnDragStarted? onDragStarted; + final OnReorder onReorder; + final OnDragEnded? onDragEnded; + + final BoardPhantomController phantomController; + + String get columnId => dataController.identifier; + + final List acceptColumns; + + final BoardColumnItemWidgetBuilder builder; + + const BoardColumnWidget({ + Key? key, + this.header, + this.footer, + required this.builder, + required this.onReorder, + required this.dataController, + required this.phantomController, + required this.acceptColumns, + this.config = const ReorderFlexConfig(), + this.onDragStarted, + this.scrollController, + this.onDragEnded, + }) : super(key: key); + + @override + State createState() => _BoardColumnWidgetState(); +} + +class _BoardColumnWidgetState extends State { + final GlobalKey _columnOverlayKey = + GlobalKey(debugLabel: '$BoardColumnWidget overlay key'); + + late BoardOverlayEntry _overlayEntry; + + @override + void initState() { + _overlayEntry = BoardOverlayEntry( + builder: (BuildContext context) { + final children = widget.dataController.items + .map((item) => _buildWidget(context, item)) + .toList(); + + final dragTargetExtension = ReorderFlextDragTargetExtension( + reorderFlexId: widget.columnId, + delegate: widget.phantomController, + acceptReorderFlexIds: widget.acceptColumns, + draggableTargetBuilder: PhantomReorderDraggableBuilder(), + ); + + return ReorderFlex( + key: widget.key, + header: widget.header, + footer: widget.footer, + scrollController: widget.scrollController, + config: widget.config, + onDragStarted: (index) { + widget.phantomController.columnStartDragging(widget.columnId); + widget.onDragStarted?.call(index); + }, + onReorder: ((fromIndex, toIndex) { + if (widget.phantomController.isFromColumn(widget.columnId)) { + widget.onReorder(widget.columnId, fromIndex, toIndex); + widget.phantomController.transformIndex(fromIndex, toIndex); + } + }), + onDragEnded: () { + widget.phantomController.columnEndDragging(widget.columnId); + widget.onDragEnded?.call(widget.columnId); + _printItems(widget.dataController); + }, + dataSource: widget.dataController, + dragTargetExtension: dragTargetExtension, + children: children, + ); + }, + opaque: false); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BoardOverlay( + key: _columnOverlayKey, + initialEntries: [_overlayEntry], + ); + } + + Widget _buildWidget(BuildContext context, ColumnItem item) { + if (item is PhantomColumnItem) { + return PassthroughPhantomWidget( + key: UniqueKey(), + opacity: widget.config.draggingWidgetOpacity, + passthroughPhantomContext: item.phantomContext, + ); + } else { + return widget.builder(context, item); + } + } +} + +void _printItems(BoardColumnDataController dataController) { + String msg = ''; + for (var element in dataController.items) { + msg = '$msg$element,'; + } + + Log.debug(msg); +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/data_controller.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/data_controller.dart new file mode 100644 index 0000000000..b890c7da73 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/board_column/data_controller.dart @@ -0,0 +1,85 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import '../../utils/log.dart'; +import '../flex/reorder_flex.dart'; + +abstract class ColumnItem extends ReoderFlextItem { + String get id; + + bool get isPhantom => false; + + @override + String toString() { + return id; + } +} + +class BoardColumnData extends ReoderFlextItem with EquatableMixin { + final String id; + final List items; + + BoardColumnData({ + required this.id, + required this.items, + }); + + @override + List get props => [id, ...items]; + + @override + String toString() { + return 'Column$id'; + } +} + +class BoardColumnDataController extends ChangeNotifier + with EquatableMixin, ReoderFlextDataSource { + final BoardColumnData columnData; + + BoardColumnDataController({ + required this.columnData, + }); + + @override + List get props => columnData.props; + + ColumnItem removeAt(int index) { + Log.debug('[$BoardColumnDataController] $columnData remove item at $index'); + final item = columnData.items.removeAt(index); + notifyListeners(); + return item; + } + + void move(int fromIndex, int toIndex) { + if (fromIndex == toIndex) { + return; + } + Log.debug( + '[$BoardColumnDataController] $columnData move item from $fromIndex to $toIndex'); + final item = columnData.items.removeAt(fromIndex); + columnData.items.insert(toIndex, item); + notifyListeners(); + } + + void insert(int index, ColumnItem item, {bool notify = true}) { + Log.debug('[$BoardColumnDataController] $columnData insert item at $index'); + columnData.items.insert(index, item); + if (notify) { + notifyListeners(); + } + } + + void replace(int index, ColumnItem item) { + Log.debug( + '[$BoardColumnDataController] $columnData replace item at $index'); + columnData.items.removeAt(index); + columnData.items.insert(index, item); + notifyListeners(); + } + + @override + List get items => columnData.items; + + @override + String get identifier => columnData.id; +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/column_container.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/column_container.dart new file mode 100644 index 0000000000..f8057fb1c2 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/column_container.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import '../rendering/board_overlay.dart'; +import 'flex/reorder_flex.dart'; +import 'board.dart'; + +class BoardColumnContainer extends StatefulWidget { + final Widget? header; + final Widget? footer; + final ScrollController? scrollController; + final OnDragStarted? onDragStarted; + final OnReorder onReorder; + final OnDragEnded? onDragEnded; + final BoardDataController boardDataController; + final List children; + final EdgeInsets? padding; + final Widget? background; + final ReorderFlexConfig config; + + const BoardColumnContainer({ + required this.boardDataController, + required this.onReorder, + required this.children, + this.onDragStarted, + this.onDragEnded, + this.header, + this.footer, + this.scrollController, + this.padding, + this.background, + this.config = const ReorderFlexConfig(), + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardColumnContainerState(); +} + +class _BoardColumnContainerState extends State { + final GlobalKey _columnContainerOverlayKey = + GlobalKey(debugLabel: '$BoardColumnContainer overlay key'); + late BoardOverlayEntry _overlayEntry; + + @override + void initState() { + _overlayEntry = BoardOverlayEntry( + builder: (BuildContext context) { + Widget reorderFlex = ReorderFlex( + key: widget.key, + header: widget.header, + footer: widget.footer, + scrollController: widget.scrollController, + config: widget.config, + onDragStarted: (index) {}, + onReorder: ((fromIndex, toIndex) {}), + onDragEnded: () {}, + dataSource: widget.boardDataController, + direction: Axis.horizontal, + children: widget.children, + ); + + if (widget.padding != null) { + reorderFlex = Padding( + padding: widget.padding!, + child: reorderFlex, + ); + } + + return Expanded( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + Container( + color: Colors.red, + ), + reorderFlex + ], + )); + }, + opaque: false); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BoardOverlay( + key: _columnContainerOverlayKey, + initialEntries: [_overlayEntry], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_state.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_state.dart new file mode 100644 index 0000000000..e75d1f74cd --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_state.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; + +import '../../utils/log.dart'; +import 'drag_target.dart'; +import 'reorder_flex.dart'; + +/// [FlexDragTargetData] is used to store the custom dragging data. It can be used to +/// locate the index of the dragging widget in the [BoardList]. +class FlexDragTargetData extends DragTargetData { + /// The index of the dragging target in the boardList. + @override + final int draggingIndex; + + final DraggingState state; + + Widget? get draggingWidget => state.draggingWidget; + + Size? get draggingFeedbackSize => state.feedbackSize; + + /// Indicate the dargging come from which [ReorderFlex]. + final DraggingReorderFlex draggingReorderFlex; + + ReoderFlextItem get columnItem => + draggingReorderFlex.itemAtIndex(draggingIndex); + + String get reorderFlexId => draggingReorderFlex.id; + + FlexDragTargetData({ + required this.draggingIndex, + required this.state, + required this.draggingReorderFlex, + }); +} + +abstract class DraggingReorderFlex { + String get id; + ReoderFlextItem itemAtIndex(int index); +} + +class DraggingState { + final String id; + + /// The member of widget.children currently being dragged. + /// + /// Null if no drag is underway. + Widget? _draggingWidget; + + Widget? get draggingWidget => _draggingWidget; + + /// The last computed size of the feedback widget being dragged. + Size? _draggingFeedbackSize = Size.zero; + + Size? get feedbackSize => _draggingFeedbackSize; + + /// The location that the dragging widget occupied before it started to drag. + int dragStartIndex = -1; + + /// The index that the dragging widget most recently left. + /// This is used to show an animation of the widget's position. + int phantomIndex = -1; + + /// The index that the dragging widget currently occupies. + int currentIndex = -1; + + /// The widget to move the dragging widget too after the current index. + int nextIndex = 0; + + /// Whether or not we are currently scrolling this view to show a widget. + bool scrolling = false; + + /// The additional margin to place around a computed drop area. + static const double _dropAreaMargin = 0.0; + + DraggingState(this.id); + + Size get dropAreaSize { + if (_draggingFeedbackSize == null) { + return Size.zero; + } + return _draggingFeedbackSize! + + const Offset(_dropAreaMargin, _dropAreaMargin); + } + + void startDragging(Widget draggingWidget, int draggingWidgetIndex, + Size? draggingWidgetSize) { + /// + assert(draggingWidgetIndex >= 0); + + _draggingWidget = draggingWidget; + phantomIndex = draggingWidgetIndex; + dragStartIndex = draggingWidgetIndex; + currentIndex = draggingWidgetIndex; + _draggingFeedbackSize = draggingWidgetSize; + } + + void endDragging() { + dragStartIndex = -1; + phantomIndex = -1; + currentIndex = -1; + _draggingWidget = null; + } + + /// When the phantomIndex and currentIndex are the same, it means the dragging + /// widget did move to the destination location. + void removePhantom() { + phantomIndex = currentIndex; + } + + /// The dragging widget overlaps with the phantom widget. + bool isOverlapWithPhantom() { + return currentIndex != phantomIndex; + } + + bool isPhantomAboveDragTarget() { + return currentIndex > phantomIndex; + } + + bool isPhantomBelowDragTarget() { + return currentIndex < phantomIndex; + } + + bool didDragTargetMoveToNext() { + return currentIndex == nextIndex; + } + + /// Set the currentIndex to nextIndex + void moveDragTargetToNext() { + Log.trace('moveDragTargetToNext: $nextIndex'); + currentIndex = nextIndex; + } + + void updateNextIndex(int index) { + assert(index >= 0); + Log.trace('updateNextIndex: $index'); + nextIndex = index; + } + + bool isNotDragging() { + return dragStartIndex == -1; + } + + bool isDragging() { + return !isNotDragging(); + } + + /// When the _dragStartIndex less than the _currentIndex, it means the + /// dragTarget is going down to the end of the list. + bool isDragTargetMovingDown() { + return dragStartIndex < currentIndex; + } + + /// The index represents the widget original index of the list. + int calculateShiftedIndex(int index) { + int shiftedIndex = index; + if (index == dragStartIndex) { + shiftedIndex = phantomIndex; + } else if (index > dragStartIndex && index <= phantomIndex) { + /// phantom move up + shiftedIndex--; + } else if (index < dragStartIndex && index >= phantomIndex) { + /// phantom move down + shiftedIndex++; + } + return shiftedIndex; + } + + @override + String toString() { + return 'DragStartIndex: $dragStartIndex, PhantomIndex: $phantomIndex, CurrentIndex: $currentIndex, NextIndex: $nextIndex'; + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_target.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_target.dart new file mode 100644 index 0000000000..6bd6015111 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/drag_target.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; + +abstract class DragTargetData { + int get draggingIndex; +} + +abstract class ReorderDraggableTargetBuilder { + Widget? build( + BuildContext context, + Widget child, + DragTargetOnStarted onDragStarted, + DragTargetOnEnded onDragEnded, + DragTargetWillAccpet onWillAccept); +} + +typedef DragTargetWillAccpet = bool Function( + T dragTargetData); +typedef DragTargetOnStarted = void Function(Widget, int, Size?); +typedef DragTargetOnEnded = void Function( + T dragTargetData); + +/// [ReorderDragTarget] is a [DragTarget] that carries the index information of +/// the child. +/// +/// The size of the [ReorderDragTarget] will become zero when it start dragging. +/// +class ReorderDragTarget extends StatefulWidget { + final Widget child; + final T dragTargetData; + + final GlobalObjectKey _indexGlobalKey; + + /// Called when dragTarget is being dragging. + final DragTargetOnStarted onDragStarted; + + final DragTargetOnEnded onDragEnded; + + /// Called to determine whether this widget is interested in receiving a given + /// piece of data being dragged over this drag target. + /// + /// [toAccept] represents the dragTarget index, which is the value passed in + /// when creating the [ReorderDragTarget]. + final DragTargetWillAccpet onWillAccept; + + /// Called when an acceptable piece of data was dropped over this drag target. + /// + /// Equivalent to [onAcceptWithDetails], but only includes the data. + final void Function(T dragTargetData)? onAccept; + + /// Called when a given piece of data being dragged over this target leaves + /// the target. + final void Function(T dragTargetData)? onLeave; + + final ReorderDraggableTargetBuilder? draggableTargetBuilder; + + ReorderDragTarget({ + Key? key, + required this.child, + required this.dragTargetData, + required this.onDragStarted, + required this.onDragEnded, + required this.onWillAccept, + this.onAccept, + this.onLeave, + this.draggableTargetBuilder, + }) : _indexGlobalKey = GlobalObjectKey(child.key!), + super(key: key); + + @override + State> createState() => _ReorderDragTargetState(); +} + +class _ReorderDragTargetState + extends State> { + /// Return the dragTarget's size + Size? _draggingFeedbackSize = Size.zero; + + @override + Widget build(BuildContext context) { + Widget dragTarget = DragTarget( + builder: _buildDraggableWidget, + onWillAccept: (dragTargetData) { + assert(dragTargetData != null); + if (dragTargetData == null) return false; + return widget.onWillAccept(dragTargetData); + }, + onAccept: widget.onAccept, + onLeave: (dragTargetData) { + assert(dragTargetData != null); + if (dragTargetData != null) { + widget.onLeave?.call(dragTargetData); + } + }, + ); + + dragTarget = KeyedSubtree(key: widget._indexGlobalKey, child: dragTarget); + return dragTarget; + } + + Widget _buildDraggableWidget( + BuildContext context, + List acceptedCandidates, + List rejectedCandidates, + ) { + Widget feedbackBuilder = Builder(builder: (BuildContext context) { + BoxConstraints contentSizeConstraints = + BoxConstraints.loose(_draggingFeedbackSize!); + return _buildDraggableFeedback( + context, + contentSizeConstraints, + widget.child, + ); + }); + + final draggableWidget = widget.draggableTargetBuilder?.build( + context, + widget.child, + widget.onDragStarted, + widget.onDragEnded, + widget.onWillAccept, + ) ?? + LongPressDraggable( + maxSimultaneousDrags: 1, + data: widget.dragTargetData, + ignoringFeedbackSemantics: false, + feedback: feedbackBuilder, + childWhenDragging: IgnorePointerWidget(child: widget.child), + onDragStarted: () { + _draggingFeedbackSize = widget._indexGlobalKey.currentContext?.size; + widget.onDragStarted( + widget.child, + widget.dragTargetData.draggingIndex, + _draggingFeedbackSize, + ); + }, + dragAnchorStrategy: childDragAnchorStrategy, + + /// When the drag ends inside a DragTarget widget, the drag + /// succeeds, and we reorder the widget into position appropriately. + onDragCompleted: () { + widget.onDragEnded(widget.dragTargetData); + }, + + /// When the drag does not end inside a DragTarget widget, the + /// drag fails, but we still reorder the widget to the last position it + /// had been dragged to. + onDraggableCanceled: (Velocity velocity, Offset offset) => + widget.onDragEnded(widget.dragTargetData), + child: widget.child, + ); + + return draggableWidget; + } + + Widget _buildDraggableFeedback( + BuildContext context, BoxConstraints constraints, Widget child) { + return Transform( + transform: Matrix4.rotationZ(0), + alignment: FractionalOffset.topLeft, + child: Material( + elevation: 3.0, + color: Colors.transparent, + borderRadius: BorderRadius.zero, + child: ConstrainedBox(constraints: constraints, child: child), + ), + ); + } +} + +class DragAnimationController { + // How long an animation to reorder an element in the list takes. + final Duration reorderAnimationDuration; + + // How long an animation to scroll to an off-screen element in the + // list takes. + final Duration scrollAnimationDuration; + + // This controls the entrance of the dragging widget into a new place. + late AnimationController entranceController; + + // This controls the 'phantom' of the dragging widget, which is left behind + // where the widget used to be. + late AnimationController phantomController; + + DragAnimationController({ + required this.reorderAnimationDuration, + required this.scrollAnimationDuration, + required TickerProvider vsync, + required void Function(AnimationStatus) entranceAnimateStatusChanged, + }) { + entranceController = AnimationController( + value: 1.0, vsync: vsync, duration: reorderAnimationDuration); + phantomController = AnimationController( + value: 0, vsync: vsync, duration: reorderAnimationDuration); + entranceController.addStatusListener(entranceAnimateStatusChanged); + } + + bool get isEntranceAnimationCompleted => entranceController.isCompleted; + + void startDargging() { + entranceController.value = 1.0; + } + + void animateToNext() { + phantomController.reverse(from: 1.0); + entranceController.forward(from: 0.0); + } + + void reverseAnimation() { + phantomController.reverse(from: 0.1); + entranceController.reverse(from: 0.0); + } + + void dispose() { + entranceController.dispose(); + phantomController.dispose(); + } +} + +class IgnorePointerWidget extends StatelessWidget { + final Widget? child; + final bool useIntrinsicSize; + const IgnorePointerWidget({ + required this.child, + this.useIntrinsicSize = false, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final sizedChild = useIntrinsicSize + ? child + : SizedBox(width: 0.0, height: 0.0, child: child); + return IgnorePointer( + ignoring: true, + child: Opacity( + opacity: 0, + child: sizedChild, + ), + ); + } +} + +class PhantomWidget extends StatelessWidget { + final Widget? child; + final double opacity; + const PhantomWidget({ + this.child, + this.opacity = 1.0, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: opacity, + child: child, + ); + } +} + +class PhantomAnimateContorller { + // How long an animation to reorder an element in the list takes. + final Duration reorderAnimationDuration; + late AnimationController appearController; + late AnimationController disappearController; + + PhantomAnimateContorller({ + required TickerProvider vsync, + required this.reorderAnimationDuration, + required void Function(AnimationStatus) appearAnimateStatusChanged, + }) { + appearController = AnimationController( + value: 1.0, vsync: vsync, duration: reorderAnimationDuration); + disappearController = AnimationController( + value: 0, vsync: vsync, duration: reorderAnimationDuration); + appearController.addStatusListener(appearAnimateStatusChanged); + } + + bool get isAppearAnimationCompleted => appearController.isCompleted; + + void animateToNext() { + disappearController.reverse(from: 1.0); + appearController.forward(from: 0.0); + } + + void performReorderAnimation() { + disappearController.reverse(from: 0.1); + appearController.reverse(from: 0.0); + } + + void dispose() { + appearController.dispose(); + disappearController.dispose(); + } +} + +abstract class FakeDragTargetEventTrigger { + void fakeOnDragStarted(VoidCallback callback); + void fakeOnDragEnded(VoidCallback callback); +} + +abstract class FakeDragTargetEventData { + Size? get feedbackSize; + int get index; + DragTargetData get dragTargetData; +} + +class FakeDragTarget extends StatefulWidget { + final FakeDragTargetEventTrigger eventTrigger; + final FakeDragTargetEventData eventData; + final DragTargetOnStarted onDragStarted; + final DragTargetOnEnded onDragEnded; + final DragTargetWillAccpet onWillAccept; + final Widget child; + const FakeDragTarget({ + Key? key, + required this.eventTrigger, + required this.eventData, + required this.onDragStarted, + required this.onDragEnded, + required this.onWillAccept, + required this.child, + }) : super(key: key); + + @override + State> createState() => _FakeDragTargetState(); +} + +class _FakeDragTargetState + extends State> { + bool isDragging = false; + + @override + void initState() { + widget.eventTrigger.fakeOnDragStarted(() { + if (mounted) { + setState(() { + widget.onWillAccept(widget.eventData.dragTargetData as T); + + widget.onDragStarted( + widget.child, + widget.eventData.index, + widget.eventData.feedbackSize, + ); + + isDragging = true; + }); + } + }); + + widget.eventTrigger.fakeOnDragEnded(() { + if (mounted) { + widget.onDragEnded(widget.eventData.dragTargetData as T); + } + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (isDragging) { + return IgnorePointerWidget(child: widget.child); + } else { + return IgnorePointerWidget(useIntrinsicSize: true, child: widget.child); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex.dart new file mode 100644 index 0000000000..5de75526d1 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex.dart @@ -0,0 +1,524 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../utils/log.dart'; +import 'reorder_mixin.dart'; +import 'drag_target.dart'; +import 'drag_state.dart'; +import 'reorder_flex_ext.dart'; + +typedef OnDragStarted = void Function(int index); +typedef OnDragEnded = void Function(); +typedef OnReorder = void Function(int fromIndex, int toIndex); +typedef OnDeleted = void Function(int deletedIndex); +typedef OnInserted = void Function(int insertedIndex); +typedef OnReveivePassedInPhantom = void Function( + FlexDragTargetData dragTargetData, int phantomIndex); + +abstract class ReoderFlextDataSource { + String get identifier; + List get items; +} + +abstract class ReoderFlextItem {} + +class ReorderFlexConfig { + final bool needsLongPressDraggable = true; + final double draggingWidgetOpacity = 0.2; + final Duration reorderAnimationDuration = const Duration(milliseconds: 250); + final Duration scrollAnimationDuration = const Duration(milliseconds: 250); + const ReorderFlexConfig(); +} + +class ReorderFlex extends StatefulWidget with DraggingReorderFlex { + final Widget? header; + final Widget? footer; + final ReorderFlexConfig config; + + final List children; + final EdgeInsets? padding; + final Axis direction; + final MainAxisAlignment mainAxisAlignment = MainAxisAlignment.spaceEvenly; + final ScrollController? scrollController; + + final OnDragStarted? onDragStarted; + final OnReorder onReorder; + final OnDragEnded? onDragEnded; + + final ReoderFlextDataSource dataSource; + + final ReorderFlextDragTargetExtension? dragTargetExtension; + + const ReorderFlex({ + Key? key, + this.header, + this.footer, + this.scrollController, + required this.dataSource, + required this.children, + required this.config, + required this.onReorder, + this.onDragStarted, + this.onDragEnded, + this.dragTargetExtension, + // ignore: unused_element + this.padding, + this.direction = Axis.vertical, + }) : super(key: key); + + @override + State createState() => ReorderFlexState(); + + @override + String get id => dataSource.identifier; + + @override + ReoderFlextItem itemAtIndex(int index) { + return dataSource.items[index]; + } +} + +class ReorderFlexState extends State + with ReorderFlexMinxi, TickerProviderStateMixin { + /// Controls scrolls and measures scroll progress. + late ScrollController _scrollController; + ScrollPosition? _attachedScrollPosition; + + /// Whether or not we are currently scrolling this view to show a widget. + bool _scrolling = false; + + late DraggingState dragState; + late DragAnimationController _dragAnimationController; + + @override + void initState() { + dragState = DraggingState(widget.id); + + _dragAnimationController = DragAnimationController( + reorderAnimationDuration: widget.config.reorderAnimationDuration, + scrollAnimationDuration: widget.config.scrollAnimationDuration, + entranceAnimateStatusChanged: (status) { + if (status == AnimationStatus.completed) { + setState(() => _requestAnimationToNextIndex()); + } + }, + vsync: this, + ); + + super.initState(); + } + + @override + void didChangeDependencies() { + if (_attachedScrollPosition != null) { + _scrollController.detach(_attachedScrollPosition!); + _attachedScrollPosition = null; + } + + _scrollController = widget.scrollController ?? + PrimaryScrollController.of(context) ?? + ScrollController(); + + if (_scrollController.hasClients) { + _attachedScrollPosition = Scrollable.of(context)?.position; + } else { + _attachedScrollPosition = null; + } + + if (_attachedScrollPosition != null) { + _scrollController.attach(_attachedScrollPosition!); + } + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + final List children = []; + if (widget.header != null) { + children.add(widget.header!); + } + + for (int i = 0; i < widget.children.length; i += 1) { + Widget child = widget.children[i]; + final wrapChild = _wrap(child, i); + children.add(wrapChild); + } + + if (widget.footer != null) { + children.add(widget.footer!); + } + + return _wrapScrollView( + child: _wrapContainer(children), + ); + } + + @override + void dispose() { + if (_attachedScrollPosition != null) { + _scrollController.detach(_attachedScrollPosition!); + _attachedScrollPosition = null; + } + + _dragAnimationController.dispose(); + super.dispose(); + } + + void _requestAnimationToNextIndex({bool isAcceptingNewTarget = false}) { + /// Update the dragState and animate to the next index if the current + /// dragging animation is completed. Otherwise, it will get called again + /// when the animation finishs. + + if (_dragAnimationController.isEntranceAnimationCompleted) { + dragState.removePhantom(); + + if (!isAcceptingNewTarget && dragState.didDragTargetMoveToNext()) { + return; + } + + dragState.moveDragTargetToNext(); + _dragAnimationController.animateToNext(); + } + } + + /// [child]: the child will be wrapped with dartTarget + /// [childIndex]: the index of the child in a list + Widget _wrap(Widget child, int childIndex) { + return Builder(builder: (context) { + final dragTarget = _buildDragTarget(context, child, childIndex); + int shiftedIndex = childIndex; + + if (dragState.isOverlapWithPhantom()) { + shiftedIndex = dragState.calculateShiftedIndex(childIndex); + } + + Log.trace( + 'Rebuild: Column${dragState.id} ${dragState.toString()}, childIndex: $childIndex shiftedIndex: $shiftedIndex'); + final currentIndex = dragState.currentIndex; + final dragPhantomIndex = dragState.phantomIndex; + + if (shiftedIndex == currentIndex || childIndex == dragPhantomIndex) { + Widget dragSpace; + if (dragState.draggingWidget != null) { + if (dragState.draggingWidget is PhantomWidget) { + dragSpace = dragState.draggingWidget!; + } else { + dragSpace = PhantomWidget( + opacity: widget.config.draggingWidgetOpacity, + child: dragState.draggingWidget, + ); + } + } else { + dragSpace = SizedBox.fromSize(size: dragState.dropAreaSize); + } + + /// Return the dragTarget it is not start dragging. The size of the + /// dragTarget is the same as the the passed in child. + /// + if (dragState.isNotDragging()) { + return _buildDraggingContainer(children: [dragTarget]); + } + + /// Determine the size of the drop area to show under the dragging widget. + final feedbackSize = dragState.feedbackSize; + Widget appearSpace = _makeAppearSpace(dragSpace, feedbackSize); + Widget disappearSpace = _makeDisappearSpace(dragSpace, feedbackSize); + + /// When start dragging, the dragTarget, [BoardDragTarget], will + /// return a [IgnorePointerWidget] which size is zero. + if (dragState.isPhantomAboveDragTarget()) { + //the phantom is moving down, i.e. the tile below the phantom is moving up + Log.trace('index:$childIndex item moving up / phantom moving down'); + if (shiftedIndex == currentIndex && childIndex == dragPhantomIndex) { + return _buildDraggingContainer(children: [ + disappearSpace, + dragTarget, + appearSpace, + ]); + } else if (shiftedIndex == currentIndex) { + return _buildDraggingContainer(children: [ + dragTarget, + appearSpace, + ]); + } else if (childIndex == dragPhantomIndex) { + return _buildDraggingContainer( + children: shiftedIndex <= childIndex + ? [dragTarget, disappearSpace] + : [disappearSpace, dragTarget]); + } + } + + /// + if (dragState.isPhantomBelowDragTarget()) { + //the phantom is moving up, i.e. the tile above the phantom is moving down + Log.trace('index:$childIndex item moving down / phantom moving up'); + if (shiftedIndex == currentIndex && childIndex == dragPhantomIndex) { + return _buildDraggingContainer(children: [ + appearSpace, + dragTarget, + disappearSpace, + ]); + } else if (shiftedIndex == currentIndex) { + return _buildDraggingContainer(children: [ + appearSpace, + dragTarget, + ]); + } else if (childIndex == dragPhantomIndex) { + return _buildDraggingContainer( + children: shiftedIndex >= childIndex + ? [disappearSpace, dragTarget] + : [dragTarget, disappearSpace]); + } + } + + assert(!dragState.isOverlapWithPhantom()); + + List children = []; + if (dragState.isDragTargetMovingDown()) { + children.addAll([dragTarget, appearSpace]); + } else { + children.addAll([appearSpace, dragTarget]); + } + return _buildDraggingContainer(children: children); + } + + /// We still wrap dragTarget with a container so that widget's depths are + /// the same and it prevent's layout alignment issue + return _buildDraggingContainer(children: [dragTarget]); + }); + } + + ReorderDragTarget _buildDragTarget( + BuildContext builderContext, Widget child, int childIndex) { + return ReorderDragTarget( + dragTargetData: FlexDragTargetData( + draggingIndex: childIndex, + state: dragState, + draggingReorderFlex: widget, + ), + onDragStarted: (draggingWidget, draggingIndex, size) { + Log.debug("Column${widget.dataSource.identifier} start dragging"); + _startDragging(draggingWidget, draggingIndex, size); + widget.onDragStarted?.call(draggingIndex); + }, + onDragEnded: (dragTargetData) { + Log.debug("Column${widget.dataSource.identifier} end dragging"); + + setState(() { + _onReordered( + dragState.dragStartIndex, + dragState.currentIndex, + ); + dragState.endDragging(); + widget.onDragEnded?.call(); + }); + }, + onWillAccept: (FlexDragTargetData dragTargetData) { + Log.debug( + '[$ReorderDragTarget] ${widget.dataSource.identifier} on will accept, count: ${widget.dataSource.items.length}'); + assert(widget.dataSource.items.length > childIndex); + + if (_requestDragExtensionToHanlder( + dragTargetData, + (extension) { + extension.onWillAccept( + this, + builderContext, + dragTargetData, + dragState.isDragging(), + dragTargetData.draggingIndex, + childIndex, + ); + }, + )) { + return true; + } else { + final dragIndex = dragTargetData.draggingIndex; + return onWillAccept(builderContext, dragIndex, childIndex); + } + }, + onAccept: (dragTargetData) { + _requestDragExtensionToHanlder( + dragTargetData, + (extension) => extension.onAccept(dragTargetData), + ); + }, + onLeave: (dragTargetData) { + _requestDragExtensionToHanlder( + dragTargetData, + (extension) => extension.onLeave(dragTargetData), + ); + }, + draggableTargetBuilder: + widget.dragTargetExtension?.draggableTargetBuilder, + child: child, + ); + } + + bool _requestDragExtensionToHanlder( + FlexDragTargetData dragTargetData, + void Function(ReorderFlextDragTargetExtension) callback, + ) { + final extension = widget.dragTargetExtension; + if (extension != null && extension.canHandler(dragTargetData)) { + callback(extension); + return true; + } else { + return false; + } + } + + Widget _makeAppearSpace(Widget child, Size? feedbackSize) { + return makeAppearingWidget( + child, + _dragAnimationController.entranceController, + feedbackSize, + widget.direction, + ); + } + + Widget _makeDisappearSpace(Widget child, Size? feedbackSize) { + return makeDisappearingWidget( + child, + _dragAnimationController.phantomController, + feedbackSize, + widget.direction, + ); + } + + void _startDragging( + Widget draggingWidget, + int dragIndex, + Size? feedbackSize, + ) { + setState(() { + dragState.startDragging(draggingWidget, dragIndex, feedbackSize); + _dragAnimationController.startDargging(); + }); + } + + bool onWillAccept(BuildContext context, int? dragIndex, int childIndex) { + /// The [willAccept] will be true if the dargTarget is the widget that gets + /// dragged and it is dragged on top of the other dragTargets. + bool willAccept = + dragState.dragStartIndex == dragIndex && dragIndex != childIndex; + setState(() { + if (willAccept) { + int shiftedIndex = dragState.calculateShiftedIndex(childIndex); + dragState.updateNextIndex(shiftedIndex); + } else { + dragState.updateNextIndex(childIndex); + } + + _requestAnimationToNextIndex(isAcceptingNewTarget: true); + }); + + _scrollTo(context); + + /// If the target is not the original starting point, then we will accept the drop. + return willAccept; + } + + void _onReordered(int fromIndex, int toIndex) { + if (fromIndex != toIndex) { + widget.onReorder.call(fromIndex, toIndex); + } + + _dragAnimationController.reverseAnimation(); + } + + Widget _wrapScrollView({required Widget child}) { + if (widget.scrollController != null && + PrimaryScrollController.of(context) == null) { + return child; + } else { + return SingleChildScrollView( + scrollDirection: widget.direction, + padding: widget.padding, + controller: _scrollController, + child: child, + ); + } + } + + Widget _wrapContainer(List children) { + switch (widget.direction) { + case Axis.horizontal: + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: widget.mainAxisAlignment, + children: children, + ); + case Axis.vertical: + default: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: widget.mainAxisAlignment, + children: children, + ); + } + } + + Widget _buildDraggingContainer({required List children}) { + switch (widget.direction) { + case Axis.horizontal: + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: children, + ); + case Axis.vertical: + default: + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: children, + ); + } + } + +// Scrolls to a target context if that context is not on the screen. + void _scrollTo(BuildContext context) { + if (_scrolling) return; + final RenderObject contextObject = context.findRenderObject()!; + final RenderAbstractViewport viewport = + RenderAbstractViewport.of(contextObject)!; + // If and only if the current scroll offset falls in-between the offsets + // necessary to reveal the selected context at the top or bottom of the + // screen, then it is already on-screen. + final double margin = widget.direction == Axis.horizontal + ? dragState.dropAreaSize.width + : dragState.dropAreaSize.height; + if (_scrollController.hasClients) { + final double scrollOffset = _scrollController.offset; + final double topOffset = max( + _scrollController.position.minScrollExtent, + viewport.getOffsetToReveal(contextObject, 0.0).offset - margin, + ); + final double bottomOffset = min( + _scrollController.position.maxScrollExtent, + viewport.getOffsetToReveal(contextObject, 1.0).offset + margin, + ); + final bool onScreen = + scrollOffset <= topOffset && scrollOffset >= bottomOffset; + + // If the context is off screen, then we request a scroll to make it visible. + if (!onScreen) { + _scrolling = true; + _scrollController.position + .animateTo( + scrollOffset < bottomOffset ? bottomOffset : topOffset, + duration: _dragAnimationController.scrollAnimationDuration, + curve: Curves.easeInOut, + ) + .then((void value) { + setState(() { + _scrolling = false; + }); + }); + } + } + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex_ext.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex_ext.dart new file mode 100644 index 0000000000..00ecf6fd1b --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_flex_ext.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../../utils/log.dart'; +import 'drag_state.dart'; +import 'drag_target.dart'; +import 'reorder_flex.dart'; + +abstract class DragTargetExtensionDelegate { + bool acceptNewDragTargetData( + String columnId, + FlexDragTargetData dragTargetData, + int index, + ); + void updateDragTargetData( + String columnId, + FlexDragTargetData dragTargetData, + int index, + ); +} + +class ReorderFlextDragTargetExtension { + final String reorderFlexId; + final List acceptReorderFlexIds; + final DragTargetExtensionDelegate delegate; + final ReorderDraggableTargetBuilder? draggableTargetBuilder; + + ReorderFlextDragTargetExtension({ + required this.reorderFlexId, + required this.delegate, + required this.acceptReorderFlexIds, + this.draggableTargetBuilder, + }); + + bool canHandler(FlexDragTargetData dragTargetData) { + /// If the columnId equal to the dragTargetData's columnId, + /// it means the dragTarget is dragging on the top of its own list. + /// Otherwise, it means the dargTarget was moved to another list. + /// + if (!acceptReorderFlexIds.contains(dragTargetData.reorderFlexId)) { + return false; + } + + return reorderFlexId != dragTargetData.reorderFlexId; + } + + bool onWillAccept( + ReorderFlexState reorderFlexState, + BuildContext context, + FlexDragTargetData dragTargetData, + bool isDragging, + int dragIndex, + int itemIndex, + ) { + final isNewDragTarget = delegate.acceptNewDragTargetData( + reorderFlexId, dragTargetData, itemIndex); + + if (isNewDragTarget == false) { + delegate.updateDragTargetData(reorderFlexId, dragTargetData, itemIndex); + reorderFlexState.onWillAccept(context, dragIndex, itemIndex); + } else { + Log.debug( + '[$ReorderFlextDragTargetExtension] move Column${dragTargetData.reorderFlexId}:${dragTargetData.draggingIndex} ' + 'to Column$reorderFlexId:$itemIndex'); + } + + return true; + } + + void onAccept(FlexDragTargetData dragTargetData) { + Log.trace( + '[$ReorderFlextDragTargetExtension] Column$reorderFlexId on onAccept'); + } + + void onLeave(FlexDragTargetData dragTargetData) { + Log.trace( + '[$ReorderFlextDragTargetExtension] Column$reorderFlexId on leave'); + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_mixin.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_mixin.dart new file mode 100644 index 0000000000..76b7d834a4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/flex/reorder_mixin.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; + +import '../transitions.dart'; + +mixin ReorderFlexMinxi { + @protected + Widget makeAppearingWidget( + Widget child, + AnimationController entranceController, + Size? draggingFeedbackSize, + Axis direction, + ) { + if (null == draggingFeedbackSize) { + return SizeTransitionWithIntrinsicSize( + sizeFactor: entranceController, + axis: direction, + child: FadeTransition( + opacity: entranceController, + child: child, + ), + ); + } else { + var transition = SizeTransition( + sizeFactor: entranceController, + axis: direction, + child: FadeTransition(opacity: entranceController, child: child), + ); + + BoxConstraints contentSizeConstraints = + BoxConstraints.loose(draggingFeedbackSize); + return ConstrainedBox( + constraints: contentSizeConstraints, child: transition); + } + } + + @protected + Widget makeDisappearingWidget( + Widget child, + AnimationController phantomController, + Size? draggingFeedbackSize, + Axis direction, + ) { + if (null == draggingFeedbackSize) { + return SizeTransitionWithIntrinsicSize( + sizeFactor: phantomController, + axis: direction, + child: FadeTransition( + opacity: phantomController, + child: child, + ), + ); + } else { + var transition = SizeTransition( + sizeFactor: phantomController, + axis: direction, + child: FadeTransition(opacity: phantomController, child: child), + ); + + BoxConstraints contentSizeConstraints = + BoxConstraints.loose(draggingFeedbackSize); + return ConstrainedBox( + constraints: contentSizeConstraints, child: transition); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_controller.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_controller.dart new file mode 100644 index 0000000000..fa11597428 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_controller.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import '../../../flowy_board.dart'; +import '../../utils/log.dart'; +import '../flex/drag_state.dart'; +import '../flex/drag_target.dart'; +import '../flex/reorder_flex_ext.dart'; +import 'phantom_state.dart'; + +abstract class BoardPhantomControllerDelegate { + BoardColumnDataController? controller(String columnId); +} + +mixin ColumnDataPhantomMixim { + BoardColumnDataController? get; +} + +class BoardPhantomController extends DragTargetExtensionDelegate { + final BoardPhantomControllerDelegate delegate; + + PhantomRecord? phantomRecord; + + final columnsState = ColumnPassthroughStateController(); + + BoardPhantomController({required this.delegate}); + + bool get hasPhantom => phantomRecord != null; + + bool isFromColumn(String columnId) { + if (phantomRecord != null) { + return phantomRecord!.fromColumnId == columnId; + } else { + return true; + } + } + + void transformIndex(int fromIndex, int toIndex) { + if (phantomRecord == null) { + return; + } + assert(phantomRecord!.fromColumnIndex == fromIndex); + phantomRecord?.updateFromColumnIndex(toIndex); + } + + void columnStartDragging(String columnId) { + columnsState.setColumnIsDragging(columnId, false); + } + + void columnEndDragging(String columnId) { + columnsState.setColumnIsDragging(columnId, true); + if (phantomRecord != null) { + if (phantomRecord!.fromColumnId == columnId) { + columnsState.notifyDidRemovePhantom(phantomRecord!.toColumnId); + } + } + _swapColumnData(); + } + + void _swapColumnData() { + if (phantomRecord == null) { + return; + } + + if (columnsState.isDragging(phantomRecord!.fromColumnId) == false) { + return; + } + + Log.debug("[$BoardPhantomController] move ${phantomRecord.toString()}"); + + final item = delegate + .controller(phantomRecord!.fromColumnId) + ?.removeAt(phantomRecord!.fromColumnIndex); + + assert(item != null); + assert(delegate + .controller(phantomRecord!.toColumnId) + ?.items[phantomRecord!.toColumnIndex] is PhantomColumnItem); + + delegate + .controller(phantomRecord!.toColumnId) + ?.replace(phantomRecord!.toColumnIndex, item!); + + phantomRecord = null; + } + + @override + bool acceptNewDragTargetData( + String columnId, FlexDragTargetData dragTargetData, int index) { + if (phantomRecord == null) { + _updatePhantomRecord(columnId, dragTargetData, index); + _insertPhantom(columnId, dragTargetData, index); + + return true; + } + + final isDifferentDragTarget = phantomRecord!.toColumnId != columnId; + Log.debug( + '[$BoardPhantomController] Set inserted column id: $columnId, different target: $isDifferentDragTarget'); + if (isDifferentDragTarget) { + /// Remove the phantom in the previous column. + _removePhantom(phantomRecord!.toColumnId); + + /// Update the record and insert the phantom to new column. + _updatePhantomRecord(columnId, dragTargetData, index); + _insertPhantom(columnId, dragTargetData, index); + } + + return isDifferentDragTarget; + } + + @override + void updateDragTargetData( + String columnId, FlexDragTargetData dragTargetData, int index) { + phantomRecord?.updateInsertedIndex(index); + + assert(phantomRecord != null); + if (phantomRecord!.toColumnId == columnId) { + /// Update the existing phantom index + _updatePhantom(phantomRecord!.toColumnId, dragTargetData, index); + } + } + + void _updatePhantom( + String toColumnId, + FlexDragTargetData dragTargetData, + int phantomIndex, + ) { + final items = delegate.controller(toColumnId)?.items; + if (items == null) { + return; + } + + final index = items.indexWhere((item) => item.isPhantom); + assert(index != -1); + if (index != -1) { + if (index != phantomIndex) { + Log.debug( + '[$BoardPhantomController] move phantom $toColumnId:$index to $toColumnId:$phantomIndex'); + final item = items.removeAt(index); + items.insert(phantomIndex, item); + } + } + } + + void _removePhantom(String columnId) { + final items = delegate.controller(columnId)?.items; + if (items == null) { + return; + } + + final index = items.indexWhere((item) => item.isPhantom); + assert(index != -1); + if (index != -1) { + items.removeAt(index); + Log.debug( + '[$BoardPhantomController] Column$columnId remove phantom, current count: ${items.length}'); + columnsState.notifyDidRemovePhantom(columnId); + columnsState.removeColumnListener(columnId); + } + } + + void _insertPhantom( + String toColumnId, + FlexDragTargetData dragTargetData, + int phantomIndex, + ) { + final items = delegate.controller(toColumnId)?.items; + if (items == null) { + return; + } + + final phantomContext = PassthroughPhantomContext( + index: phantomIndex, + dragTargetData: dragTargetData, + ); + columnsState.addColumnListener(toColumnId, phantomContext); + + Log.debug( + '[$BoardPhantomController] Column$toColumnId insert phantom at $phantomIndex'); + delegate + .controller(toColumnId) + ?.insert(phantomIndex, PhantomColumnItem(phantomContext)); + + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(microseconds: 100), () { + columnsState.notifyDidInsertPhantom(toColumnId); + }); + }); + } + + void _updatePhantomRecord( + String columnId, + FlexDragTargetData dragTargetData, + int index, + ) { + phantomRecord = PhantomRecord( + toColumnId: columnId, + toColumnIndex: index, + item: dragTargetData.columnItem as ColumnItem, + fromColumnId: dragTargetData.reorderFlexId, + fromColumnIndex: dragTargetData.draggingIndex, + ); + } +} + +class PhantomRecord { + final ColumnItem item; + final String fromColumnId; + int fromColumnIndex; + + final String toColumnId; + int toColumnIndex; + + PhantomRecord({ + required this.item, + required this.toColumnId, + required this.toColumnIndex, + required this.fromColumnId, + required this.fromColumnIndex, + }); + + void updateFromColumnIndex(int index) { + if (fromColumnIndex == index) { + return; + } + Log.info( + '[$PhantomRecord] Update Column$fromColumnId remove position to $index'); + fromColumnIndex = index; + } + + void updateInsertedIndex(int index) { + if (toColumnIndex == index) { + return; + } + + Log.info( + '[$PhantomRecord] Update Column$toColumnId phantom position to $index'); + toColumnIndex = index; + } + + @override + String toString() { + return '$fromColumnId:$fromColumnIndex to $toColumnId:$toColumnIndex'; + } +} + +class PhantomColumnItem extends ColumnItem { + final PassthroughPhantomContext phantomContext; + + PhantomColumnItem(PassthroughPhantomContext insertedPhantom) + : phantomContext = insertedPhantom; + + @override + bool get isPhantom => true; + + @override + String get id => phantomContext.itemData.id; + + Size? get feedbackSize => phantomContext.feedbackSize; + + Widget get draggingWidget => phantomContext.draggingWidget == null + ? const SizedBox() + : phantomContext.draggingWidget!; +} + +class PassthroughPhantomContext extends FakeDragTargetEventTrigger + with FakeDragTargetEventData, PassthroughPhantomListener { + @override + int index; + + @override + final FlexDragTargetData dragTargetData; + + @override + Size? get feedbackSize => dragTargetData.state.feedbackSize; + + Widget? get draggingWidget => dragTargetData.draggingWidget; + + ColumnItem get itemData => dragTargetData.columnItem as ColumnItem; + + @override + VoidCallback? onInserted; + + @override + VoidCallback? onDragEnded; + + PassthroughPhantomContext({ + required this.index, + required this.dragTargetData, + }); + + @override + void fakeOnDragEnded(VoidCallback callback) { + onDragEnded = callback; + } + + @override + void fakeOnDragStarted(VoidCallback callback) { + onInserted = callback; + } +} + +class PassthroughPhantomWidget extends PhantomWidget { + final PassthroughPhantomContext passthroughPhantomContext; + + PassthroughPhantomWidget({ + required double opacity, + required this.passthroughPhantomContext, + Key? key, + }) : super( + child: passthroughPhantomContext.draggingWidget, + opacity: opacity, + key: key, + ); +} + +class PhantomReorderDraggableBuilder extends ReorderDraggableTargetBuilder { + @override + Widget? build( + BuildContext context, + Widget child, + DragTargetOnStarted onDragStarted, + DragTargetOnEnded onDragEnded, + DragTargetWillAccpet onWillAccept, + ) { + if (child is PassthroughPhantomWidget) { + return FakeDragTarget( + eventTrigger: child.passthroughPhantomContext, + eventData: child.passthroughPhantomContext, + onDragStarted: onDragStarted, + onDragEnded: onDragEnded, + onWillAccept: onWillAccept, + child: child, + ); + } else { + return null; + } + } +} diff --git a/frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_state.dart b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_state.dart new file mode 100644 index 0000000000..b78c567d81 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_board/lib/src/widgets/phantom/phantom_state.dart @@ -0,0 +1,109 @@ +import 'phantom_controller.dart'; +import 'package:flutter/material.dart'; + +class ColumnPassthroughStateController { + final _states = {}; + + void setColumnIsDragging(String columnId, bool isDragging) { + _stateWithId(columnId).isDragging = isDragging; + } + + bool isDragging(String columnId) { + return _stateWithId(columnId).isDragging; + } + + void addColumnListener(String columnId, PassthroughPhantomListener listener) { + _stateWithId(columnId).notifier.addListener( + onInserted: (c) => listener.onInserted?.call(), + onDeleted: () => listener.onDragEnded?.call(), + ); + } + + void removeColumnListener(String columnId) { + _stateWithId(columnId).notifier.dispose(); + _states.remove(columnId); + } + + void notifyDidInsertPhantom(String columnId) { + _stateWithId(columnId).notifier.insert(); + } + + void notifyDidRemovePhantom(String columnId) { + _stateWithId(columnId).notifier.remove(); + } + + ColumnPassthrougPhantomhState _stateWithId(String columnId) { + var state = _states[columnId]; + if (state == null) { + state = ColumnPassthrougPhantomhState(); + _states[columnId] = state; + } + return state; + } +} + +class ColumnPassthrougPhantomhState { + bool isDragging = false; + final notifier = PassthroughPhantomNotifier(); +} + +abstract class PassthroughPhantomListener { + VoidCallback? get onInserted; + VoidCallback? get onDragEnded; +} + +class PassthroughPhantomNotifier { + final insertNotifier = PhantomInsertNotifier(); + + final removeNotifier = PhantomDeleteNotifier(); + + void insert() { + insertNotifier.insert(); + } + + void remove() { + removeNotifier.remove(); + } + + void addListener({ + void Function(PassthroughPhantomContext? insertedPhantom)? onInserted, + void Function()? onDeleted, + }) { + if (onInserted != null) { + insertNotifier.addListener(() { + onInserted(insertNotifier.insertedPhantom); + }); + } + + if (onDeleted != null) { + removeNotifier.addListener(() { + onDeleted(); + }); + } + } + + void dispose() { + insertNotifier.dispose(); + removeNotifier.dispose(); + } +} + +class PhantomInsertNotifier extends ChangeNotifier { + PassthroughPhantomContext? insertedPhantom; + + void insert() { + notifyListeners(); + } +} + +class PhantomDeleteNotifier extends ChangeNotifier { + // int deletedIndex = -1; + + void remove() { + // if (this.deletedIndex != deletedIndex) { + // this.deletedIndex = deletedIndex; + // notifyListeners(); + // } + notifyListeners(); + } +} diff --git a/frontend/app_flowy/packages/flowy_board/pubspec.yaml b/frontend/app_flowy/packages/flowy_board/pubspec.yaml index 52600bde71..f38763fc04 100644 --- a/frontend/app_flowy/packages/flowy_board/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_board/pubspec.yaml @@ -4,13 +4,15 @@ version: 0.0.1 homepage: environment: - sdk: ">=2.17.6 <3.0.0" + sdk: ">=2.17.1 <3.0.0" flutter: ">=2.5.0" dependencies: flutter: sdk: flutter plugin_platform_interface: ^2.0.2 + equatable: ^2.0.3 + provider: ^6.0.1 dev_dependencies: flutter_test: diff --git a/frontend/app_flowy/packages/flowy_board/test/flowy_board_method_channel_test.dart b/frontend/app_flowy/packages/flowy_board/test/flowy_board_method_channel_test.dart deleted file mode 100644 index 52e5d5713d..0000000000 --- a/frontend/app_flowy/packages/flowy_board/test/flowy_board_method_channel_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flowy_board/flowy_board_method_channel.dart'; - -void main() { - MethodChannelFlowyBoard platform = MethodChannelFlowyBoard(); - const MethodChannel channel = MethodChannel('flowy_board'); - - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); - }); - - tearDown(() { - channel.setMockMethodCallHandler(null); - }); - - test('getPlatformVersion', () async { - expect(await platform.getPlatformVersion(), '42'); - }); -} diff --git a/frontend/app_flowy/packages/flowy_board/test/flowy_board_test.dart b/frontend/app_flowy/packages/flowy_board/test/flowy_board_test.dart deleted file mode 100644 index 1158119a60..0000000000 --- a/frontend/app_flowy/packages/flowy_board/test/flowy_board_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flowy_board/flowy_board.dart'; -import 'package:flowy_board/flowy_board_platform_interface.dart'; -import 'package:flowy_board/flowy_board_method_channel.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -class MockFlowyBoardPlatform - with MockPlatformInterfaceMixin - implements FlowyBoardPlatform { - - @override - Future getPlatformVersion() => Future.value('42'); -} - -void main() { - final FlowyBoardPlatform initialPlatform = FlowyBoardPlatform.instance; - - test('$MethodChannelFlowyBoard is the default instance', () { - expect(initialPlatform, isInstanceOf()); - }); - - test('getPlatformVersion', () async { - FlowyBoard flowyBoardPlugin = FlowyBoard(); - MockFlowyBoardPlatform fakePlatform = MockFlowyBoardPlatform(); - FlowyBoardPlatform.instance = fakePlatform; - - expect(await flowyBoardPlugin.getPlatformVersion(), '42'); - }); -}