diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart index 7913a8829479b..f070b4b8949b9 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_shortcuts_view.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -88,5 +89,80 @@ void main() { '', ); }); + + testWidgets('can reset an individual shortcut', (tester) async { + // In order to reset a shortcut, we must first override it. + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.shortcuts); + await tester.pumpAndSettle(); + + final pageUpCmdText = + LocaleKeys.settings_shortcutsPage_keybindings_pageUp.tr(); + final defaultPageUpCmd = pageUpCommand.command; + + // Input "Page Up text" into the search field + // This test works because we only have one input field on the shortcuts page. + await tester.enterText(find.byType(TextField), pageUpCmdText); + await tester.pumpAndSettle(); + + await tester.hoverOnWidget( + find.descendant( + of: find.byType(ShortcutSettingTile), + matching: find.text(pageUpCmdText), + ), + onHover: () async { + // changing the shortcut + await tester.tap(find.byFlowySvg(FlowySvgs.edit_s)); + await tester.pumpAndSettle(); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.backquote, + LogicalKeyboardKey.enter, + ], + tester: tester, + ); + await tester.pumpAndSettle(); + }, + ); + + // We expect the first ShortcutSettingTile to have one + // [KeyBadge] with `backquote` label + // which will confirm that we have changed the command + final theOnlyTile = tester.widget(find.byType(ShortcutSettingTile).first) + as ShortcutSettingTile; + expect( + theOnlyTile.command.command, + 'backquote', + ); + + // hover on the ShortcutSettingTile and click the restore button + await tester.hoverOnWidget( + find.descendant( + of: find.byType(ShortcutSettingTile), + matching: find.text(pageUpCmdText), + ), + onHover: () async { + await tester.tap( + find.descendant( + of: find.byType(ShortcutSettingTile).first, + matching: find.byFlowySvg(FlowySvgs.restore_s), + ), + ); + await tester.pumpAndSettle(); + }, + ); + + // We expect the first ShortcutSettingTile to have one + // [KeyBadge] with `page up` label + expect( + theOnlyTile.command.command, + defaultPageUpCmd, + ); + }); }); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart index 569b4a4ea4ff6..74eab64b64f7d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart @@ -45,6 +45,11 @@ class ShortcutsCubit extends Cubit { final SettingsShortcutService service; + /// Fetches and updates shortcut data. + /// + /// This method retrieves customizable shortcuts data from ShortcutService instance + /// and updates the command shortcuts based on the provided data and current state. + /// Future fetchShortcuts() async { emit( state.copyWith( @@ -82,6 +87,7 @@ class ShortcutsCubit extends Cubit { } } + /// Saves all updated shortcuts to the Shortcut Service instance we are using. Future updateAllShortcuts() async { emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); @@ -98,6 +104,7 @@ class ShortcutsCubit extends Cubit { } } + /// This method resets all shortcuts to their default commands. Future resetToDefault() async { emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); @@ -108,7 +115,39 @@ class ShortcutsCubit extends Cubit { emit( state.copyWith( status: ShortcutsStatus.failure, - error: LocaleKeys.settings_shortcutsPage_couldNotSaveErrorMsg.tr(), + error: LocaleKeys + .settings_shortcutsPage_couldNotResetShortcutsErrorMsg + .tr(), + ), + ); + } + } + + /// Resets an individual shortcut to its default shortcut command. + /// Takes in the shortcut to reset. + Future resetIndividualShortcut(CommandShortcutEvent shortcut) async { + emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); + + try { + // If no shortcut is found in the `defaultCommandShortcutEvents` then + // it will throw an error which will be handled by our catch block. + final defaultShortcut = defaultCommandShortcutEvents.firstWhere( + (el) => el.key == shortcut.key && el.handler == shortcut.handler, + ); + + // only update the shortcut if it is overidden + if (defaultShortcut.command != shortcut.command) { + shortcut.updateCommand(command: defaultShortcut.command); + await service.saveAllShortcuts(state.commandShortcutEvents); + } + + emit(state.copyWith(status: ShortcutsStatus.success, error: '')); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcutsPage_couldNotResetSingleErrorMsg + .tr(), ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart index 25b51c9a81499..b4a47c98554ae 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart @@ -25,7 +25,8 @@ class SettingsShortcutService { late final File _file; final _initCompleter = Completer(); - /// Takes in commandShortcuts as an input and saves them to the shortcuts.JSON file. + /// Takes in commandShortcuts as an input and saves them to the + /// `shortcuts.JSON` file. Future saveAllShortcuts( List commandShortcuts, ) async { @@ -39,9 +40,11 @@ class SettingsShortcutService { ); } - /// Checks the file for saved shortcuts. If shortcuts do NOT exist then returns - /// an empty list. If shortcuts exist - /// then calls an utility method i.e getShortcutsFromJson which returns the saved shortcuts. + /// Checks the file for saved shortcuts. + /// If shortcuts do NOT exist then returns an empty list. + /// If shortcuts exists, then calls an utility method: + /// + /// `getShortcutsFromJson` - which returns the saved shortcuts. Future> getCustomizeShortcuts() async { await _initCompleter.future; final shortcutsInJson = await _file.readAsString(); @@ -53,8 +56,10 @@ class SettingsShortcutService { } } - /// Extracts shortcuts from the saved json file. The shortcuts in the saved file consist of [List]. - /// This list needs to be converted to List. This function is intended to facilitate the same. + /// Extracts shortcuts from the saved json file. + /// The shortcuts in the saved file consist of [List]. + /// This list needs to be converted to List. + /// This function is intended to facilitate the same. List getShortcutsFromJson(String savedJson) { final shortcuts = EditorShortcuts.fromJson(jsonDecode(savedJson)); return shortcuts.commandShortcuts; @@ -77,13 +82,14 @@ class SettingsShortcutService { await saveAllShortcuts(defaultCommandShortcutEvents); } - // Accesses the shortcuts.json file within the default AppFlowy Document Directory or creates a new file if it already doesn't exist. + /// Accesses the `shortcuts.JSON` file within the default AppFlowy Document Directory + /// or creates a new file if it already doesn't exist. Future _initializeService(File? file) async { _file = file ?? await _defaultShortcutFile(); _initCompleter.complete(); } - //returns the default file for storing shortcuts + /// returns the default file for storing shortcuts. Future _defaultShortcutFile() async { final path = await getIt().getPath(); return File( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart index 87ea9d9260392..24e6552d96328 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart @@ -2,10 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; @@ -191,6 +188,7 @@ class _ResetButton extends StatelessWidget { horizontal: 6, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const FlowySvg( FlowySvgs.restore_s, @@ -331,6 +329,18 @@ class _ShortcutSettingTileState extends State { _finishEditing(); } + void _resetIndividualCommand(CommandShortcutEvent shortcut) { + context.read().resetIndividualShortcut(shortcut); + } + + bool canResetCommand(CommandShortcutEvent shortcut) { + final defaultShortcut = defaultCommandShortcutEvents.firstWhere( + (el) => el.key == shortcut.key && el.handler == shortcut.handler, + ); + + return defaultShortcut.command != shortcut.command; + } + @override void dispose() { focusNode.dispose(); @@ -383,40 +393,43 @@ class _ShortcutSettingTileState extends State { ); } - Widget _renderKeybindings(bool isHovering) => Row( - children: [ - if (widget.command.keybindings.isNotEmpty) ...[ - ..._toParts(widget.command.keybindings.first).map( - (key) => KeyBadge(keyLabel: key), - ), - ] else ...[ - const SizedBox(height: 24), - ], - const Spacer(), - if (isHovering) - GestureDetector( - onTap: () { - if (widget.canStartEditing()) { - setState(() { - widget.onStartEditing(); - isEditing = true; - }); - } - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: FlowyTooltip( - message: LocaleKeys.settings_shortcutsPage_editTooltip.tr(), - child: const FlowySvg( - FlowySvgs.edit_s, - size: Size.square(16), - ), - ), - ), - ), - const HSpace(8), + Widget _renderKeybindings(bool isHovering) { + final canReset = canResetCommand(widget.command); + + return Row( + children: [ + if (widget.command.keybindings.isNotEmpty) ...[ + ..._toParts(widget.command.keybindings.first).map( + (key) => KeyBadge(keyLabel: key), + ), + ] else ...[ + const SizedBox(height: 24), ], - ); + const Spacer(), + if (isHovering) + Row( + children: [ + _EditShortcutBtn( + onEdit: () { + if (widget.canStartEditing()) { + setState(() { + widget.onStartEditing(); + isEditing = true; + }); + } + }, + ), + const HSpace(16), + _ResetShortcutBtn( + onReset: () => _resetIndividualCommand(widget.command), + canReset: canReset, + ), + ], + ), + const HSpace(8), + ], + ); + } Widget _renderKeybindEditor() => TapRegion( onTapOutside: canClickOutside ? null : (_) => _finishEditing(), @@ -460,6 +473,65 @@ class _ShortcutSettingTileState extends State { } } +class _EditShortcutBtn extends StatelessWidget { + const _EditShortcutBtn({ + required this.onEdit, + }); + + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onEdit, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyTooltip( + message: LocaleKeys.settings_shortcutsPage_editTooltip.tr(), + child: const FlowySvg( + FlowySvgs.edit_s, + size: Size.square(16), + ), + ), + ), + ); + } +} + +class _ResetShortcutBtn extends StatelessWidget { + const _ResetShortcutBtn({ + required this.onReset, + required this.canReset, + }); + + final bool canReset; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: canReset ? 1 : 0.5, + child: GestureDetector( + onTap: canReset ? onReset : null, + child: MouseRegion( + cursor: canReset ? SystemMouseCursors.click : MouseCursor.defer, + child: FlowyTooltip( + message: canReset + ? LocaleKeys.settings_shortcutsPage_resetSingleTooltip.tr() + : LocaleKeys + .settings_shortcutsPage_unavailableResetSingleTooltip + .tr(), + child: const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(16), + ), + ), + ), + ), + ); + } +} + @visibleForTesting class KeyBadge extends StatelessWidget { const KeyBadge({super.key, required this.keyLabel}); diff --git a/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart index ce57c61bd7daf..c8f417a9922dc 100644 --- a/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart @@ -3,7 +3,9 @@ import 'dart:ffi'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; // ignore: depend_on_referenced_packages import 'package:mocktail/mocktail.dart'; @@ -18,6 +20,7 @@ void main() { setUp(() async { service = MockSettingsShortcutService(); + when( () => service.saveAllShortcuts(any()), ).thenAnswer((_) async => true); @@ -160,5 +163,93 @@ void main() { ], ); }); + + group('resetIndividualShortcut', () { + final dummyShortcut = CommandShortcutEvent( + key: 'dummy key event', + command: 'ctrl+alt+shift+arrow up', + handler: (_) { + return KeyEventResult.handled; + }, + getDescription: () => 'dummy key event', + ); + + blocTest( + 'does not call saveAllShortcuts() since dummyShortcut is not in defaultShortcuts', + build: () => shortcutsCubit, + act: (cubit) => cubit.resetIndividualShortcut(dummyShortcut), + verify: (_) { + verifyNever(() => service.saveAllShortcuts(any())); + }, + ); + + blocTest( + 'does not call saveAllShortcuts() when resetIndividualShortcut called redundantly', + build: () => shortcutsCubit, + // here we are using customCutCommand since it is a part of defaultShortcuts + act: (cubit) => cubit.resetIndividualShortcut(customCutCommand), + verify: (_) { + verifyNever(() => service.saveAllShortcuts(any())); + }, + ); + + blocTest( + 'calls saveAllShortcuts() once for shortcuts in defaultShortcuts', + build: () => shortcutsCubit, + // here we are using customCutCommand since it is a part of defaultShortcuts + // we have to override it, inorder to reset it. + act: (cubit) { + customCutCommand.updateCommand(command: 'ctrl+alt+shift+x'); + cubit.resetIndividualShortcut(customCutCommand); + }, + verify: (_) { + verify(() => service.saveAllShortcuts(any())).called(1); + }, + ); + + blocTest( + 'emits [updating, failure] when saveAllShortcuts() throws', + setUp: () { + when( + () => service.saveAllShortcuts(any()), + ).thenThrow(Exception('oops')); + }, + build: () => shortcutsCubit, + act: (cubit) { + customCutCommand.updateCommand(command: 'ctrl+alt+shift+x'); + cubit.resetIndividualShortcut(customCutCommand); + }, + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure), + ], + ); + + blocTest( + 'emits [updating, failure] when shortcut not found in defaultShortcuts', + build: () => shortcutsCubit, + act: (cubit) => cubit.resetIndividualShortcut(dummyShortcut), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure), + ], + ); + + blocTest( + 'emits [updating, success] when succesfully updates shortcut', + build: () => shortcutsCubit, + act: (cubit) { + customCutCommand.updateCommand(command: 'ctrl+alt+shift+x'); + cubit.resetIndividualShortcut(customCutCommand); + }, + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success), + ], + ); + }); }); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index cf5422764b0f5..514dad42ad2cf 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -706,6 +706,8 @@ "confirmLabel": "Continue" }, "editTooltip": "Press to start editing the keybinding", + "unavailableResetSingleTooltip": "This shortcut is already the default", + "resetSingleTooltip": "Reset this shortcut", "keybindings": { "toggleToDoList": "Toggle to do list", "insertNewParagraphInCodeblock": "Insert new paragraph", @@ -789,7 +791,9 @@ "textAlignRight": "Align text to the right" }, "couldNotLoadErrorMsg": "Could not load shortcuts, Try again", - "couldNotSaveErrorMsg": "Could not save shortcuts, Try again" + "couldNotSaveErrorMsg": "Could not save shortcuts, Try again", + "couldNotResetSingleErrorMsg": "Could not reset this shortcut, Try again", + "couldNotResetShortcutsErrorMsg": "Could not reset all shortcuts, Try again" }, "aiPage": { "title": "AI Settings",