From 8bc547d0dd665e2ad7552f48e24faa261ccdf072 Mon Sep 17 00:00:00 2001 From: DanPeled <98838880+DanPeled@users.noreply.github.com> Date: Fri, 12 Jul 2024 23:15:26 +0300 Subject: [PATCH] Copy & paste feature (#62) Added copy & paste features for tabs and widgets --------- Co-authored-by: Gold87 <91761103+Gold872@users.noreply.github.com> --- lib/pages/dashboard_page.dart | 78 ++++++++++++++------ lib/widgets/editable_tab_bar.dart | 14 ++++ lib/widgets/tab_grid.dart | 95 +++++++++++++++++++++---- test/pages/dashboard_page_test.dart | 32 +++++++++ test/widgets/editable_tab_bar_test.dart | 49 ++++++++----- test/widgets/tab_grid_test.dart | 59 +++++++++++++++ 6 files changed, 277 insertions(+), 50 deletions(-) diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index c1a37977..9cc5ab49 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -71,7 +72,6 @@ class _DashboardPageState extends State with WindowListener { @override void initState() { super.initState(); - _preferences = widget.preferences; _updateChecker = UpdateChecker(currentVersion: widget.version); @@ -1317,8 +1317,13 @@ class _DashboardPageState extends State with WindowListener { (!Settings.layoutLocked) ? () => _importLayout() : null, shortcut: const SingleActivator(LogicalKeyboardKey.keyO, control: true), - child: const Text( - 'Open Layout', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder_open_outlined), + SizedBox(width: 8), + Text('Open Layout'), + ], ), ), // Save @@ -1329,22 +1334,32 @@ class _DashboardPageState extends State with WindowListener { }, shortcut: const SingleActivator(LogicalKeyboardKey.keyS, control: true), - child: const Text( - 'Save', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.save_outlined), + SizedBox(width: 8), + Text('Save'), + ], ), ), + // Export layout MenuItemButton( - style: menuButtonStyle, - onPressed: () { - _exportLayout(); - }, - shortcut: const SingleActivator(LogicalKeyboardKey.keyS, - shift: true, control: true), - child: const Text( - 'Save As', - ), - ), + style: menuButtonStyle, + onPressed: () { + _exportLayout(); + }, + shortcut: const SingleActivator(LogicalKeyboardKey.keyS, + shift: true, control: true), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.save_as_outlined), + SizedBox(width: 8), + Text('Save As'), + ], + )), ], child: const Text( 'File', @@ -1399,8 +1414,13 @@ class _DashboardPageState extends State with WindowListener { onPressed: () { _displayAboutDialog(context); }, - child: const Text( - 'About', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline), + SizedBox(width: 8), + Text('About'), + ], ), ), // Check for Updates @@ -1409,8 +1429,13 @@ class _DashboardPageState extends State with WindowListener { onPressed: () { _checkForUpdates(); }, - child: const Text( - 'Check for Updates', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.update_outlined), + SizedBox(width: 8), + Text('Check for updates'), + ], ), ), ], @@ -1522,6 +1547,20 @@ class _DashboardPageState extends State with WindowListener { onTabChanged: (index) { setState(() => _currentTabIndex = index); }, + onTabDuplicate: (index, tab) { + setState(() { + _tabData.insert(index + 1, tab); + Map tabJson = _grids[index].toJson(); + _grids.insert( + index + 1, + TabGrid.fromJson( + key: GlobalKey(), + jsonData: tabJson, + onAddWidgetPressed: _displayAddWidgetDialog, + onJsonLoadingWarning: _showJsonLoadingWarning, + )); + }); + }, tabData: _tabData, tabViews: _grids, ), @@ -1641,7 +1680,6 @@ class _AddWidgetDialog extends StatefulWidget { class _AddWidgetDialogState extends State<_AddWidgetDialog> { bool _hideMetadata = true; - @override Widget build(BuildContext context) { return Visibility( diff --git a/lib/widgets/editable_tab_bar.dart b/lib/widgets/editable_tab_bar.dart index c7705a98..61df2898 100644 --- a/lib/widgets/editable_tab_bar.dart +++ b/lib/widgets/editable_tab_bar.dart @@ -25,6 +25,7 @@ class EditableTabBar extends StatelessWidget { final Function() onTabMoveRight; final Function(int index, TabData newData) onTabRename; final Function(int index) onTabChanged; + final Function(int index, TabData newData) onTabDuplicate; final int currentIndex; @@ -39,6 +40,7 @@ class EditableTabBar extends StatelessWidget { required this.onTabMoveRight, required this.onTabRename, required this.onTabChanged, + required this.onTabDuplicate, }); void renameTab(BuildContext context, int index) { @@ -74,6 +76,13 @@ class EditableTabBar extends StatelessWidget { ); } + void duplicateTab(BuildContext context, int index) { + String tabName = '${tabData[index].name} (Copy)'; + TabData data = TabData(name: tabName); + + onTabDuplicate.call(index, data); + } + void createTab() { String tabName = 'Tab ${tabData.length + 1}'; TabData data = TabData(name: tabName); @@ -142,6 +151,11 @@ class EditableTabBar extends StatelessWidget { icon: Icons.drive_file_rename_outline_outlined, onSelected: () => renameTab(context, index), ), + MenuItem( + label: 'Duplicate', + icon: Icons.control_point_duplicate_sharp, + onSelected: () => duplicateTab(context, index), + ), MenuItem( label: 'Close', icon: Icons.close, diff --git a/lib/widgets/tab_grid.dart b/lib/widgets/tab_grid.dart index 905fef69..91d50236 100644 --- a/lib/widgets/tab_grid.dart +++ b/lib/widgets/tab_grid.dart @@ -27,6 +27,7 @@ class TabGridModel extends ChangeNotifier { } class TabGrid extends StatelessWidget { + static Map? _copyJsonData; final List _widgetModels = []; MapEntry? _containerDraggingIn; @@ -460,6 +461,7 @@ class TabGrid extends StatelessWidget { widget.draggingRect.height, ), ); + _containerDraggingIn = MapEntry(widget, globalPosition); refresh(); } @@ -516,7 +518,6 @@ class TabGrid extends StatelessWidget { _containerDraggingIn = null; widget.disposeModel(); - refresh(); } @@ -648,6 +649,10 @@ class TabGrid extends StatelessWidget { ); } + void copyWidget(WidgetContainerModel widget) { + _copyJsonData = widget.toJson(); + } + void lockLayout() { for (WidgetContainerModel container in _widgetModels) { container.setDraggable(false); @@ -782,7 +787,6 @@ class TabGrid extends StatelessWidget { dashboardWidgets.add( GestureDetector( - onTap: () {}, onSecondaryTapUp: (details) { if (Settings.layoutLocked) { return; @@ -801,6 +805,12 @@ class TabGrid extends StatelessWidget { }, ), ...container.getContextMenuItems(), + MenuItem( + label: 'Copy', + icon: Icons.copy_outlined, + onSelected: () { + copyWidget(container); + }), MenuItem( label: 'Remove', icon: Icons.delete_outlined, @@ -898,22 +908,37 @@ class TabGrid extends StatelessWidget { if (Settings.layoutLocked) { return; } + + List contextMenuEntries = [ + MenuItem( + label: 'Add Widget', + icon: Icons.add, + onSelected: () => onAddWidgetPressed.call(), + ), + MenuItem( + label: 'Clear Layout', + icon: Icons.clear, + onSelected: () => clearWidgets(context), + ), + ]; + + if (_copyJsonData != null) { + contextMenuEntries.add( + MenuItem( + label: 'Paste', + icon: Icons.paste_outlined, + onSelected: () { + pasteWidget(_copyJsonData, details.localPosition); + }, + ), + ); + } + ContextMenu contextMenu = ContextMenu( position: details.globalPosition, borderRadius: BorderRadius.circular(5.0), padding: const EdgeInsets.all(4.0), - entries: [ - MenuItem( - label: 'Add Widget', - icon: Icons.add, - onSelected: () => onAddWidgetPressed.call(), - ), - MenuItem( - label: 'Clear Layout', - icon: Icons.clear, - onSelected: () => clearWidgets(context), - ), - ], + entries: contextMenuEntries, ); showContextMenu( @@ -939,4 +964,46 @@ class TabGrid extends StatelessWidget { ), ); } + + void pasteWidget(Map? widgetJson, Offset localPosition) { + if (widgetJson == null) return; + + // Put the top left corner of the widget in the square the user pastes it in + double snappedX = + (localPosition.dx ~/ Settings.gridSize) * Settings.gridSize.toDouble(); + double snappedY = + (localPosition.dy ~/ Settings.gridSize) * Settings.gridSize.toDouble(); + + widgetJson['x'] = snappedX; + widgetJson['y'] = snappedY; + + Rect pasteLocation = Rect.fromLTWH( + snappedX, + snappedY, + widgetJson['width'], + widgetJson['height'], + ); + + if (isValidLocation(pasteLocation)) { + WidgetContainerModel copiedWidget = createWidgetFromJson(widgetJson); + + _widgetModels.add(copiedWidget); + refresh(); + } + } + + WidgetContainerModel createWidgetFromJson(Map json) { + if (json['type'] == 'List Layout') { + return ListLayoutModel.fromJson( + jsonData: json, + tabGrid: this, + onDragCancel: _layoutContainerOnDragCancel, + ); + } else { + return NTWidgetContainerModel.fromJson( + enabled: ntConnection.isNT4Connected, + jsonData: json, + ); + } + } } diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index 2079816c..77e6da29 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -720,6 +720,38 @@ void main() { findsOneWidget); }); + testWidgets('Duplicating tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + setupMockOfflineNT4(); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + preferences: preferences, + version: '0.0.0.0', + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); + + expect(teleopTab, findsOneWidget); + + await widgetTester.tap(teleopTab, buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); + + final duplicateButton = find.text('Duplicate'); + + expect(duplicateButton, findsOneWidget); + + await widgetTester.tap(duplicateButton); + await widgetTester.pumpAndSettle(); + + expect(find.text('Teleoperated (Copy)'), findsOneWidget); + }); + testWidgets('Minimizing window', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; setupMockOfflineNT4(); diff --git a/test/widgets/editable_tab_bar_test.dart b/test/widgets/editable_tab_bar_test.dart index dccb8583..e80379cd 100644 --- a/test/widgets/editable_tab_bar_test.dart +++ b/test/widgets/editable_tab_bar_test.dart @@ -24,6 +24,8 @@ class FakeTabBarFunctions { void onTabRename() {} void onTabChanged() {} + + void onTabDuplicate() {} } void main() { @@ -40,22 +42,22 @@ void main() { MaterialApp( home: Scaffold( body: EditableTabBar( - currentIndex: 0, - tabData: [ - TabData(name: 'Teleoperated'), - TabData(name: 'Autonomous'), - ], - tabViews: [ - TabGrid(onAddWidgetPressed: () {}), - TabGrid(onAddWidgetPressed: () {}), - ], - onTabCreate: (tab) {}, - onTabDestroy: (index) {}, - onTabMoveLeft: () {}, - onTabMoveRight: () {}, - onTabRename: (tab, grid) {}, - onTabChanged: (index) {}, - ), + currentIndex: 0, + tabData: [ + TabData(name: 'Teleoperated'), + TabData(name: 'Autonomous'), + ], + tabViews: [ + TabGrid(onAddWidgetPressed: () {}), + TabGrid(onAddWidgetPressed: () {}), + ], + onTabCreate: (tab) {}, + onTabDestroy: (index) {}, + onTabMoveLeft: () {}, + onTabMoveRight: () {}, + onTabRename: (tab, grid) {}, + onTabChanged: (index) {}, + onTabDuplicate: (index, newData) {}), ), ), ); @@ -107,6 +109,9 @@ void main() { onTabChanged: (index) { tabBarFunctions.onTabChanged(); }, + onTabDuplicate: (index, tab) { + tabBarFunctions.onTabDuplicate(); + }, ), ), ), @@ -158,6 +163,9 @@ void main() { onTabChanged: (index) { tabBarFunctions.onTabChanged(); }, + onTabDuplicate: (index, tab) { + tabBarFunctions.onTabDuplicate(); + }, ), ), ), @@ -209,6 +217,9 @@ void main() { onTabChanged: (index) { tabBarFunctions.onTabChanged(); }, + onTabDuplicate: (index, tab) { + tabBarFunctions.onTabDuplicate(); + }, ), ), ), @@ -273,6 +284,9 @@ void main() { onTabChanged: (index) { tabBarFunctions.onTabChanged(); }, + onTabDuplicate: (index, tab) { + tabBarFunctions.onTabDuplicate(); + }, ), ), ), @@ -348,6 +362,9 @@ void main() { onTabChanged: (index) { tabBarFunctions.onTabChanged(); }, + onTabDuplicate: (index, tab) { + tabBarFunctions.onTabDuplicate(); + }, ), ), ), diff --git a/test/widgets/tab_grid_test.dart b/test/widgets/tab_grid_test.dart index c0c21897..642cf823 100644 --- a/test/widgets/tab_grid_test.dart +++ b/test/widgets/tab_grid_test.dart @@ -228,6 +228,65 @@ void main() async { await widgetTester.pumpAndSettle(); }); + testWidgets('Editing properties', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + widgetTester.view.physicalSize = const Size(1920, 1080); + widgetTester.view.devicePixelRatio = 1.0; + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ChangeNotifierProvider( + create: (context) => TabGridModel(), + child: TabGrid.fromJson( + key: GlobalKey(), + jsonData: jsonData['tabs'][0]['grid_layout'], + onAddWidgetPressed: () {}, + ), + ), + ), + ), + ); + + await widgetTester.pump(Duration.zero); + + await widgetTester.ensureVisible(find.text('Test Number')); + + await widgetTester.pumpAndSettle(); + + await widgetTester.tapAt(const Offset(320.0, 64.0), + buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); + + expect(find.text('Paste'), findsNothing); + + // Dismiss context menu + await widgetTester.tapAt(const Offset(320.0, 64.0)); + await widgetTester.pumpAndSettle(); + + await widgetTester.tap(find.text('Test Number'), + buttons: kSecondaryMouseButton); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Number'), findsAtLeastNWidgets(2)); + expect(find.text('Copy'), findsOneWidget); + + await widgetTester.tap(find.text('Copy')); + + await widgetTester.pumpAndSettle(); + + await widgetTester.tapAt(const Offset(320.0, 64.0), + buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); + + expect(find.text('Paste'), findsOneWidget); + await widgetTester.tap(find.text('Paste')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Number'), findsNWidgets(2)); + }); + testWidgets('Dragging widgets', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; widgetTester.view.physicalSize = const Size(1920, 1080);