diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 1d8ac5a47c..9a5f831633 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; @@ -31,6 +32,15 @@ class AppFlowyEditorPage extends StatefulWidget { State createState() => _AppFlowyEditorPageState(); } +final List commandShortcutEvents = [ + ...codeBlockCommands, + ...standardCommandShortcutEvents, +]; + +final List defaultCommandShortcutEvents = [ + ...commandShortcutEvents.map((e) => e.copyWith()).toList(), +]; + class _AppFlowyEditorPageState extends State { late final ScrollController effectiveScrollController; @@ -109,10 +119,10 @@ class _AppFlowyEditorPageState extends State { void initState() { super.initState(); + _initializeShortcuts(); indentableBlockTypes.add(ToggleListBlockKeys.type); convertibleBlockTypes.add(ToggleListBlockKeys.type); slashMenuItems = _customSlashMenuItems(); - effectiveScrollController = widget.scrollController ?? ScrollController(); } @@ -377,4 +387,16 @@ class _AppFlowyEditorPageState extends State { } return const (false, null); } + + Future _initializeShortcuts() async { + //TODO(Xazin): Refactor lazy initialization + defaultCommandShortcutEvents; + final settingsShortcutService = SettingsShortcutService(); + final customizeShortcuts = + await settingsShortcutService.getCustomizeShortcuts(); + await settingsShortcutService.updateCommandShortcuts( + standardCommandShortcutEvents, + customizeShortcuts, + ); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index bd2dbe1662..6350d236b6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -14,6 +14,7 @@ enum SettingsPage { files, user, supabaseSetting, + shortcuts, } class SettingsDialogBloc 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 new file mode 100644 index 0000000000..f8d4e5e0e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_shortcut_event.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_shortcuts_cubit.freezed.dart'; + +@freezed +class ShortcutsState with _$ShortcutsState { + const factory ShortcutsState({ + @Default([]) + List commandShortcutEvents, + @Default(ShortcutsStatus.initial) ShortcutsStatus status, + @Default('') String error, + }) = _ShortcutsState; +} + +enum ShortcutsStatus { initial, updating, success, failure } + +class ShortcutsCubit extends Cubit { + ShortcutsCubit(this.service) : super(const ShortcutsState()); + + final SettingsShortcutService service; + + Future fetchShortcuts() async { + emit( + state.copyWith( + status: ShortcutsStatus.updating, + error: '', + ), + ); + try { + final customizeShortcuts = await service.getCustomizeShortcuts(); + await service.updateCommandShortcuts( + commandShortcutEvents, + customizeShortcuts, + ); + //sort the shortcuts + commandShortcutEvents.sort((a, b) => a.key.compareTo(b.key)); + emit( + state.copyWith( + status: ShortcutsStatus.success, + commandShortcutEvents: commandShortcutEvents, + error: '', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcuts_couldNotLoadErrorMsg.tr(), + ), + ); + } + } + + Future updateAllShortcuts() async { + emit( + state.copyWith( + status: ShortcutsStatus.updating, + error: '', + ), + ); + try { + await service.saveAllShortcuts(state.commandShortcutEvents); + emit( + state.copyWith( + status: ShortcutsStatus.success, + error: '', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcuts_couldNotSaveErrorMsg.tr(), + ), + ); + } + } + + Future resetToDefault() async { + emit( + state.copyWith( + status: ShortcutsStatus.updating, + error: '', + ), + ); + try { + await service.saveAllShortcuts(defaultCommandShortcutEvents); + await fetchShortcuts(); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcuts_couldNotSaveErrorMsg.tr(), + ), + ); + } + } + + ///Checks if the new command is conflicting with other shortcut + ///We also check using the key, whether this command is a codeblock + ///shortcut, if so we only check a conflict with other codeblock shortcut. + String getConflict(CommandShortcutEvent currentShortcut, String command) { + //check if currentShortcut is a codeblock shortcut. + final isCodeBlockCommand = currentShortcut.isCodeBlockCommand; + + for (final e in state.commandShortcutEvents) { + if (e.command == command && e.isCodeBlockCommand == isCodeBlockCommand) { + return e.key; + } + } + return ''; + } +} + +extension on CommandShortcutEvent { + bool get isCodeBlockCommand => codeBlockCommands.contains(this); +} 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 new file mode 100644 index 0000000000..688fe563ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as p; + +import 'shortcuts_model.dart'; + +class SettingsShortcutService { + /// If file is non null then the SettingsShortcutService uses that + /// file to store all the shortcuts, otherwise uses the default + /// Document Directory. + /// Typically we only intend to pass a file during testing. + SettingsShortcutService({ + File? file, + }) { + _initializeService(file); + } + + late final File _file; + final _initCompleter = Completer(); + + /// Takes in commandShortcuts as an input and saves them to the shortcuts.JSON file. + Future saveAllShortcuts( + List commandShortcuts, + ) async { + final shortcuts = EditorShortcuts( + commandShortcuts: commandShortcuts.toCommandShortcutModelList(), + ); + + await _file.writeAsString( + jsonEncode(shortcuts.toJson()), + flush: true, + ); + } + + /// 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. + Future> getCustomizeShortcuts() async { + await _initCompleter.future; + final shortcutsInJson = await _file.readAsString(); + + if (shortcutsInJson.isEmpty) { + return []; + } else { + return getShortcutsFromJson(shortcutsInJson); + } + } + + /// 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; + } + + Future updateCommandShortcuts( + List commandShortcuts, + List customizeShortcuts, + ) async { + for (final shortcut in customizeShortcuts) { + final shortcutEvent = commandShortcuts.firstWhereOrNull( + (s) => (s.key == shortcut.key && s.command != shortcut.command), + ); + shortcutEvent?.updateCommand(command: shortcut.command); + } + } + + Future resetToDefaultShortcuts() async { + await _initCompleter.future; + 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. + Future _initializeService(File? file) async { + _file = file ?? await _defaultShortcutFile(); + _initCompleter.complete(); + } + + //returns the default file for storing shortcuts + Future _defaultShortcutFile() async { + final path = await getIt().getPath(); + return File( + p.join(path, 'shortcuts', 'shortcuts.json'), + )..createSync(recursive: true); + } +} + +extension on List { + /// Utility method for converting a CommandShortcutEvent List to a + /// CommandShortcutModal List. This is necessary for creating shortcuts + /// object, which is used for saving the shortcuts list. + List toCommandShortcutModelList() => + map((e) => CommandShortcutModel.fromCommandEvent(e)).toList(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart new file mode 100644 index 0000000000..4ad7f1d9f6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart @@ -0,0 +1,62 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +class EditorShortcuts { + EditorShortcuts({ + required this.commandShortcuts, + }); + + final List commandShortcuts; + + factory EditorShortcuts.fromJson(Map json) => + EditorShortcuts( + commandShortcuts: List.from( + json["commandShortcuts"].map( + (x) => CommandShortcutModel.fromJson(x), + ), + ), + ); + + Map toJson() => { + "commandShortcuts": + List.from(commandShortcuts.map((x) => x.toJson())), + }; +} + +class CommandShortcutModel { + const CommandShortcutModel({ + required this.key, + required this.command, + }); + + final String key; + final String command; + + factory CommandShortcutModel.fromJson(Map json) => + CommandShortcutModel( + key: json["key"], + command: (json["command"] ?? ''), + ); + + factory CommandShortcutModel.fromCommandEvent( + CommandShortcutEvent commandShortcutEvent, + ) => + CommandShortcutModel( + key: commandShortcutEvent.key, + command: commandShortcutEvent.command, + ); + + Map toJson() => { + "key": key, + "command": command, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CommandShortcutModel && + key == other.key && + command == other.command; + + @override + int get hashCode => key.hashCode ^ command.hashCode; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index c7fc281918..52da257151 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -2,6 +2,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; @@ -88,6 +89,8 @@ class SettingsDialog extends StatelessWidget { return SettingsUserView(user); case SettingsPage.supabaseSetting: return const SupabaseSettingView(); + case SettingsPage.shortcuts: + return const SettingsCustomizeShortcutsWrapper(); default: return Container(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart new file mode 100644 index 0000000000..5f1712d768 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart @@ -0,0 +1,257 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.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:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsCustomizeShortcutsWrapper extends StatelessWidget { + const SettingsCustomizeShortcutsWrapper({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + ShortcutsCubit(SettingsShortcutService())..fetchShortcuts(), + child: const SettingsCustomizeShortcutsView(), + ); + } +} + +class SettingsCustomizeShortcutsView extends StatelessWidget { + const SettingsCustomizeShortcutsView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + switch (state.status) { + case ShortcutsStatus.initial: + case ShortcutsStatus.updating: + return const Center(child: CircularProgressIndicator()); + case ShortcutsStatus.success: + return ShortcutsListView(shortcuts: state.commandShortcutEvents); + case ShortcutsStatus.failure: + return ShortcutsErrorView( + errorMessage: state.error, + ); + } + }, + ); + } +} + +class ShortcutsListView extends StatelessWidget { + const ShortcutsListView({ + super.key, + required this.shortcuts, + }); + + final List shortcuts; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: FlowyText.semibold( + LocaleKeys.settings_shortcuts_command.tr(), + overflow: TextOverflow.ellipsis, + ), + ), + FlowyText.semibold( + LocaleKeys.settings_shortcuts_keyBinding.tr(), + overflow: TextOverflow.ellipsis, + ), + ], + ), + const VSpace(10), + Expanded( + child: ListView.builder( + itemCount: shortcuts.length, + itemBuilder: (context, index) => ShortcutsListTile( + shortcutEvent: shortcuts[index], + ), + ), + ), + const VSpace(10), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Spacer(), + FlowyTextButton( + LocaleKeys.settings_shortcuts_resetToDefault.tr(), + onPressed: () { + context.read().resetToDefault(); + }, + ), + ], + ) + ], + ); + } +} + +class ShortcutsListTile extends StatelessWidget { + const ShortcutsListTile({ + super.key, + required this.shortcutEvent, + }); + + final CommandShortcutEvent shortcutEvent; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: FlowyText.medium( + key: Key(shortcutEvent.key), + shortcutEvent.key.capitalize(), + overflow: TextOverflow.ellipsis, + ), + ), + FlowyTextButton( + shortcutEvent.command, + fillColor: Colors.transparent, + onPressed: () { + showKeyListenerDialog(context); + }, + ) + ], + ), + Divider( + color: Theme.of(context).dividerColor, + ) + ], + ); + } + + void showKeyListenerDialog(BuildContext widgetContext) { + showDialog( + context: widgetContext, + builder: (builderContext) { + final controller = TextEditingController(text: shortcutEvent.command); + final formKey = GlobalKey(); + return AlertDialog( + title: Text(LocaleKeys.settings_shortcuts_updateShortcutStep.tr()), + content: RawKeyboardListener( + focusNode: FocusNode(), + onKey: (key) { + if (key is! RawKeyDownEvent) return; + if (key.logicalKey == LogicalKeyboardKey.enter && + !key.isShiftPressed) { + if (controller.text == shortcutEvent.command) { + _dismiss(builderContext); + } + if (formKey.currentState!.validate()) { + _updateKey(widgetContext, controller.text); + _dismiss(builderContext); + } + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + _dismiss(builderContext); + } else { + //extract the keybinding command from the rawkeyevent. + controller.text = key.convertToCommand; + } + }, + child: Form( + key: formKey, + child: TextFormField( + autofocus: true, + controller: controller, + readOnly: true, + maxLines: null, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + validator: (_) => _validateForConflicts( + widgetContext, + controller.text, + ), + ), + ), + ), + ); + }, + ); + } + + _validateForConflicts(BuildContext context, String command) { + final conflict = BlocProvider.of(context).getConflict( + shortcutEvent, + command, + ); + if (conflict.isEmpty) return null; + + return LocaleKeys.settings_shortcuts_shortcutIsAlreadyUsed.tr( + namedArgs: {'conflict': conflict}, + ); + } + + _updateKey(BuildContext context, String command) { + shortcutEvent.updateCommand(command: command); + BlocProvider.of(context).updateAllShortcuts(); + } + + _dismiss(BuildContext context) => Navigator.of(context).pop(); +} + +extension on RawKeyEvent { + String get convertToCommand { + String command = ''; + if (isAltPressed) { + command += 'alt+'; + } + if (isControlPressed) { + command += 'ctrl+'; + } + if (isShiftPressed) { + command += 'shift+'; + } + if (isMetaPressed) { + command += 'meta+'; + } + + final keyPressed = keyToCodeMapping.keys.firstWhere( + (k) => keyToCodeMapping[k] == logicalKey.keyId, + orElse: () => '', + ); + + return command += keyPressed; + } +} + +class ShortcutsErrorView extends StatelessWidget { + final String errorMessage; + const ShortcutsErrorView({super.key, required this.errorMessage}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyText.medium( + errorMessage, + overflow: TextOverflow.ellipsis, + ), + ), + FlowyIconButton( + icon: const Icon(Icons.replay_outlined), + onPressed: () { + BlocProvider.of(context).fetchShortcuts(); + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index ff2a98213c..5c5f45e835 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -70,6 +70,16 @@ class SettingsMenu extends StatelessWidget { icon: Icons.sync, changeSelectedPage: changeSelectedPage, ), + const SizedBox( + height: 10, + ), + SettingsMenuElement( + page: SettingsPage.shortcuts, + selectedPage: currentPage, + label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), + icon: Icons.cut, + changeSelectedPage: changeSelectedPage, + ), ], ); } 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 new file mode 100644 index 0000000000..3d88d5fbe5 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart @@ -0,0 +1,164 @@ +import 'dart:ffi'; + +import 'package:appflowy/plugins/document/presentation/editor_page.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:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: depend_on_referenced_packages +import 'package:mocktail/mocktail.dart'; + +class MockSettingsShortcutService extends Mock + implements SettingsShortcutService {} + +void main() { + group("ShortcutsCubit", () { + late SettingsShortcutService service; + late ShortcutsCubit shortcutsCubit; + + setUp(() async { + service = MockSettingsShortcutService(); + when( + () => service.saveAllShortcuts(any()), + ).thenAnswer((_) async => true); + when( + () => service.getCustomizeShortcuts(), + ).thenAnswer((_) async => []); + when( + () => service.updateCommandShortcuts(any(), any()), + ).thenAnswer((_) async => Void); + + shortcutsCubit = ShortcutsCubit(service); + }); + + test('initial state is correct', () { + final shortcutsCubit = ShortcutsCubit(service); + expect(shortcutsCubit.state, const ShortcutsState()); + }); + + group('fetchShortcuts', () { + blocTest( + 'calls getCustomizeShortcuts() once', + build: () => shortcutsCubit, + act: (cubit) => cubit.fetchShortcuts(), + verify: (_) { + verify(() => service.getCustomizeShortcuts()).called(1); + }, + ); + + blocTest( + 'emits [updating, failure] when getCustomizeShortcuts() throws', + setUp: () { + when( + () => service.getCustomizeShortcuts(), + ).thenThrow(Exception('oops')); + }, + build: () => shortcutsCubit, + act: (cubit) => cubit.fetchShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure) + ], + ); + + blocTest( + 'emits [updating, success] when getCustomizeShortcuts() returns shortcuts', + build: () => shortcutsCubit, + act: (cubit) => cubit.fetchShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success) + .having( + (w) => w.commandShortcutEvents, + 'shortcuts', + commandShortcutEvents, + ), + ], + ); + }); + + group('updateShortcut', () { + blocTest( + 'calls saveAllShortcuts() once', + build: () => shortcutsCubit, + act: (cubit) => cubit.updateAllShortcuts(), + 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) => cubit.updateAllShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure) + ], + ); + + blocTest( + 'emits [updating, success] when saveAllShortcuts() is successful', + build: () => shortcutsCubit, + act: (cubit) => cubit.updateAllShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success) + ], + ); + }); + + group('resetToDefault', () { + blocTest( + 'calls saveAllShortcuts() once', + build: () => shortcutsCubit, + act: (cubit) => cubit.resetToDefault(), + verify: (_) { + verify(() => service.saveAllShortcuts(any())).called(1); + verify(() => service.getCustomizeShortcuts()).called(1); + }, + ); + + blocTest( + 'emits [updating, failure] when saveAllShortcuts() throws', + setUp: () { + when( + () => service.saveAllShortcuts(any()), + ).thenThrow(Exception('oops')); + }, + build: () => shortcutsCubit, + act: (cubit) => cubit.resetToDefault(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure) + ], + ); + + blocTest( + 'emits [updating, success] when getCustomizeShortcuts() returns shortcuts', + build: () => shortcutsCubit, + act: (cubit) => cubit.resetToDefault(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success) + .having( + (w) => w.commandShortcutEvents, + 'shortcuts', + commandShortcutEvents, + ), + ], + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart b/frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart new file mode 100644 index 0000000000..4b6453a9e5 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; +import 'dart:io' show File; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/shortcuts_model.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: depend_on_referenced_packages +import 'package:file/memory.dart'; + +void main() { + late SettingsShortcutService service; + late File mockFile; + String shortcutsJson = ''; + + setUp(() async { + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + mockFile = await fileSystem.file("shortcuts.json").create(recursive: true); + service = SettingsShortcutService(file: mockFile); + shortcutsJson = """{ + "commandShortcuts":[ + { + "key":"move the cursor upward", + "command":"alt+arrow up" + }, + { + "key":"move the cursor forward one character", + "command":"alt+arrow left" + }, + { + "key":"move the cursor downward", + "command":"alt+arrow down" + } + ] +}"""; + }); + + group("Settings Shortcut Service", () { + test( + "returns default standard shortcuts if file is empty", + () async { + expect(await service.getCustomizeShortcuts(), []); + }, + ); + + test('returns updated shortcut event list from json', () { + final commandShortcuts = service.getShortcutsFromJson(shortcutsJson); + + final cursorUpShortcut = commandShortcuts + .firstWhere((el) => el.key == "move the cursor upward"); + + final cursorDownShortcut = commandShortcuts + .firstWhere((el) => el.key == "move the cursor downward"); + + expect( + commandShortcuts.length, + 3, + ); + expect(cursorUpShortcut.command, "alt+arrow up"); + expect(cursorDownShortcut.command, "alt+arrow down"); + }); + + test( + "saveAllShortcuts saves shortcuts", + () async { + //updating one of standard command shortcut events. + final currentCommandShortcuts = standardCommandShortcutEvents; + const kKey = "scroll one page down"; + const oldCommand = "page down"; + const newCommand = "alt+page down"; + final commandShortcutEvent = currentCommandShortcuts + .firstWhere((element) => element.key == kKey); + + expect(commandShortcutEvent.command, oldCommand); + + //updating the command. + commandShortcutEvent.updateCommand( + command: newCommand, + ); + + //saving the updated shortcuts + await service.saveAllShortcuts(currentCommandShortcuts); + + //reading from the mock file the saved shortcut list. + final savedDataInFile = await mockFile.readAsString(); + + //Check if the lists where properly converted to JSON and saved. + final shortcuts = EditorShortcuts( + commandShortcuts: + currentCommandShortcuts.toCommandShortcutModelList(), + ); + + expect(jsonEncode(shortcuts.toJson()), savedDataInFile); + + //now checking if the modified command of "move the cursor upward" is "arrow up" + final newCommandShortcuts = + service.getShortcutsFromJson(savedDataInFile); + + final updatedCommandEvent = + newCommandShortcuts.firstWhere((el) => el.key == kKey); + + expect(updatedCommandEvent.command, newCommand); + }, + ); + + test('load shortcuts from file', () async { + //updating one of standard command shortcut event. + const kKey = "scroll one page up"; + const oldCommand = "page up"; + const newCommand = "alt+page up"; + final currentCommandShortcuts = standardCommandShortcutEvents; + final commandShortcutEvent = + currentCommandShortcuts.firstWhere((element) => element.key == kKey); + + expect(commandShortcutEvent.command, oldCommand); + + //updating the command. + commandShortcutEvent.updateCommand(command: newCommand); + + //saving the updated shortcuts + service.saveAllShortcuts(currentCommandShortcuts); + + //now directly fetching the shortcuts from loadShortcuts + final commandShortcuts = await service.getCustomizeShortcuts(); + expect( + commandShortcuts, + currentCommandShortcuts.toCommandShortcutModelList(), + ); + + final updatedCommandEvent = + commandShortcuts.firstWhere((el) => el.key == kKey); + + expect(updatedCommandEvent.command, newCommand); + }); + + test('updateCommandShortcuts works properly', () async { + //updating one of standard command shortcut event. + const kKey = "move the cursor forward one character"; + const oldCommand = "arrow left"; + const newCommand = "alt+arrow left"; + final currentCommandShortcuts = standardCommandShortcutEvents; + + //check if the current shortcut event's key is set to old command. + final currentCommandEvent = + currentCommandShortcuts.firstWhere((el) => el.key == kKey); + + expect(currentCommandEvent.command, oldCommand); + + final commandShortcutModelList = + EditorShortcuts.fromJson(jsonDecode(shortcutsJson)).commandShortcuts; + + //now calling the updateCommandShortcuts method + await service.updateCommandShortcuts( + currentCommandShortcuts, + commandShortcutModelList, + ); + + //check if the shortcut event's key is updated. + final updatedCommandEvent = + currentCommandShortcuts.firstWhere((el) => el.key == kKey); + + expect(updatedCommandEvent.command, newCommand); + }); + }); +} + +extension on List { + List toCommandShortcutModelList() => + map((e) => CommandShortcutModel.fromCommandEvent(e)).toList(); +} diff --git a/frontend/appflowy_flutter/test/widget_test/workspace/settings/settings_customize_shortcuts_view_test.dart b/frontend/appflowy_flutter/test/widget_test/workspace/settings/settings_customize_shortcuts_view_test.dart new file mode 100644 index 0000000000..8db8da3bc7 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/workspace/settings/settings_customize_shortcuts_view_test.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: depend_on_referenced_packages +import 'package:mocktail/mocktail.dart'; + +class MockShortcutsCubit extends MockCubit + implements ShortcutsCubit {} + +void main() { + group( + "CustomizeShortcutsView", + () { + group( + "should be displayed in ViewState", + () { + late ShortcutsCubit mockShortcutsCubit; + + setUp(() { + mockShortcutsCubit = MockShortcutsCubit(); + }); + + testWidgets('Initial when cubit emits [ShortcutsStatus.Initial]', + (widgetTester) async { + when(() => mockShortcutsCubit.state) + .thenReturn(const ShortcutsState()); + + await widgetTester.pumpWidget( + BlocProvider.value( + value: mockShortcutsCubit, + child: + const MaterialApp(home: SettingsCustomizeShortcutsView()), + ), + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets( + 'Updating when cubit emits [ShortcutsStatus.updating]', + (widgetTester) async { + when(() => mockShortcutsCubit.state).thenReturn( + const ShortcutsState(status: ShortcutsStatus.updating), + ); + + await widgetTester.pumpWidget( + BlocProvider.value( + value: mockShortcutsCubit, + child: + const MaterialApp(home: SettingsCustomizeShortcutsView()), + ), + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }, + ); + + testWidgets( + 'Shows ShortcutsList when cubit emits [ShortcutsStatus.success]', + (widgetTester) async { + KeyEventResult dummyHandler(EditorState e) => + KeyEventResult.handled; + + final dummyShortcuts = [ + CommandShortcutEvent( + key: 'Copy', + command: 'ctrl+c', + handler: dummyHandler, + ), + CommandShortcutEvent( + key: 'Paste', + command: 'ctrl+v', + handler: dummyHandler, + ), + CommandShortcutEvent( + key: 'Undo', + command: 'ctrl+z', + handler: dummyHandler, + ), + CommandShortcutEvent( + key: 'Redo', + command: 'ctrl+y', + handler: dummyHandler, + ), + ]; + + when(() => mockShortcutsCubit.state).thenReturn( + ShortcutsState( + status: ShortcutsStatus.success, + commandShortcutEvents: dummyShortcuts, + ), + ); + await widgetTester.pumpWidget( + BlocProvider.value( + value: mockShortcutsCubit, + child: + const MaterialApp(home: SettingsCustomizeShortcutsView()), + ), + ); + + await widgetTester.pump(); + + final listViewFinder = find.byType(ShortcutsListView); + final foundShortcuts = widgetTester + .widget(listViewFinder) + .shortcuts; + + expect(listViewFinder, findsOneWidget); + expect(foundShortcuts, dummyShortcuts); + }, + ); + + testWidgets('Shows Error when cubit emits [ShortcutsStatus.failure]', + (tester) async { + when(() => mockShortcutsCubit.state).thenReturn( + const ShortcutsState( + status: ShortcutsStatus.failure, + ), + ); + await tester.pumpWidget( + BlocProvider.value( + value: mockShortcutsCubit, + child: + const MaterialApp(home: SettingsCustomizeShortcutsView()), + ), + ); + expect(find.byType(ShortcutsErrorView), findsOneWidget); + }); + }, + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_error_view_test.dart b/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_error_view_test.dart new file mode 100644 index 0000000000..31cc7ec53e --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_error_view_test.dart @@ -0,0 +1,21 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group("ShortcutsErrorView", () { + testWidgets("displays correctly", (widgetTester) async { + await widgetTester.pumpWidget( + const MaterialApp( + home: ShortcutsErrorView( + errorMessage: 'Error occured', + ), + ), + ); + + expect(find.byType(FlowyText), findsOneWidget); + expect(find.byType(FlowyIconButton), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_tile_test.dart b/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_tile_test.dart new file mode 100644 index 0000000000..e3003949b6 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_tile_test.dart @@ -0,0 +1,94 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + KeyEventResult dummyHandler(EditorState e) => KeyEventResult.handled; + + final shortcut = CommandShortcutEvent( + key: 'Copy', + command: 'ctrl+c', + handler: dummyHandler, + ); + + group("ShortcutsListTile", () { + group( + "should be displayed correctly", + () { + testWidgets('with key and command', (widgetTester) async { + final sKey = Key(shortcut.key); + + await widgetTester.pumpWidget( + MaterialApp( + home: ShortcutsListTile(shortcutEvent: shortcut), + ), + ); + + final commandTextFinder = find.byKey(sKey); + final foundCommand = + widgetTester.widget(commandTextFinder).text; + + expect(commandTextFinder, findsOneWidget); + expect(foundCommand, shortcut.key); + + final btnFinder = find.byType(FlowyTextButton); + final foundBtnText = + widgetTester.widget(btnFinder).text; + + expect(btnFinder, findsOneWidget); + expect(foundBtnText, shortcut.command); + }); + }, + ); + + group( + "taps the button", + () { + testWidgets("opens AlertDialog correctly", (widgetTester) async { + await widgetTester.pumpWidget( + MaterialApp( + home: ShortcutsListTile(shortcutEvent: shortcut), + ), + ); + + final btnFinder = find.byType(FlowyTextButton); + final foundBtnText = + widgetTester.widget(btnFinder).text; + + expect(btnFinder, findsOneWidget); + expect(foundBtnText, shortcut.command); + + await widgetTester.tap(btnFinder); + await widgetTester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(RawKeyboardListener), findsOneWidget); + }); + + testWidgets("updates the text with new key event", + (widgetTester) async { + await widgetTester.pumpWidget( + MaterialApp( + home: ShortcutsListTile(shortcutEvent: shortcut), + ), + ); + + final btnFinder = find.byType(FlowyTextButton); + + await widgetTester.tap(btnFinder); + await widgetTester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(RawKeyboardListener), findsOneWidget); + + await widgetTester.sendKeyEvent(LogicalKeyboardKey.keyC); + + expect(find.text('c'), findsOneWidget); + }); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_view_test.dart b/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_view_test.dart new file mode 100644 index 0000000000..a9682dd862 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_view_test.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + KeyEventResult dummyHandler(EditorState e) => KeyEventResult.handled; + + final dummyShortcuts = [ + CommandShortcutEvent( + key: 'Copy', + command: 'ctrl+c', + handler: dummyHandler, + ), + CommandShortcutEvent( + key: 'Paste', + command: 'ctrl+v', + handler: dummyHandler, + ), + CommandShortcutEvent( + key: 'Undo', + command: 'ctrl+z', + handler: dummyHandler, + ), + CommandShortcutEvent( + key: 'Redo', + command: 'ctrl+y', + handler: dummyHandler, + ), + ]; + + group("ShortcutsListView", () { + group("should be displayed correctly", () { + testWidgets("with empty shortcut list", (widgetTester) async { + await widgetTester.pumpWidget( + const MaterialApp( + home: ShortcutsListView(shortcuts: []), + ), + ); + + expect(find.byType(FlowyText), findsNWidgets(3)); + //we expect three text widgets which are keybinding, command, and reset + expect(find.byType(ListView), findsOneWidget); + expect(find.byType(ShortcutsListTile), findsNothing); + }); + + testWidgets("with 1 item in shortcut list", (widgetTester) async { + await widgetTester.pumpWidget( + MaterialApp( + home: ShortcutsListView(shortcuts: [dummyShortcuts[0]]), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.byType(FlowyText), findsAtLeastNWidgets(3)); + expect(find.byType(ListView), findsOneWidget); + expect(find.byType(ShortcutsListTile), findsOneWidget); + }); + + testWidgets("with populated shortcut list", (widgetTester) async { + await widgetTester.pumpWidget( + MaterialApp( + home: ShortcutsListView(shortcuts: dummyShortcuts), + ), + ); + + expect(find.byType(FlowyText), findsAtLeastNWidgets(3)); + expect(find.byType(ListView), findsOneWidget); + expect( + find.byType(ShortcutsListTile), + findsNWidgets(dummyShortcuts.length), + ); + }); + }); + }); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 79d4cb4bf1..82395b8144 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -277,6 +277,17 @@ "icon": "Icon", "selectAnIcon": "Select an icon", "pleaseInputYourOpenAIKey": "please input your OpenAI key" + }, + "shortcuts": { + "shortcutsLabel": "Shortcuts", + "command": "Command", + "keyBinding": "Keybinding", + "addNewCommand": "Add New Command", + "updateShortcutStep": "Press desired key combination and press ENTER", + "shortcutIsAlreadyUsed": "This shortcut is already used for: {conflict}", + "resetToDefault": "Reset to default keybindings", + "couldNotLoadErrorMsg": "Could not load shortcuts, Try again", + "couldNotSaveErrorMsg": "Could not save shortcuts, Try again" } }, "grid": {