Browse Source

feat: customize command shortcuts (#2848)

Mayur Mahajan 1 year ago
parent
commit
b1378b4545
15 changed files with 1253 additions and 1 deletions
  1. 23 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart
  2. 1 0
      frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart
  3. 124 0
      frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart
  4. 101 0
      frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart
  5. 62 0
      frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart
  6. 3 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart
  7. 257 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart
  8. 10 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart
  9. 164 0
      frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart
  10. 169 0
      frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart
  11. 135 0
      frontend/appflowy_flutter/test/widget_test/workspace/settings/settings_customize_shortcuts_view_test.dart
  12. 21 0
      frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_error_view_test.dart
  13. 94 0
      frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_tile_test.dart
  14. 78 0
      frontend/appflowy_flutter/test/widget_test/workspace/settings/shortcuts_list_view_test.dart
  15. 11 0
      frontend/resources/translations/en.json

+ 23 - 1
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<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
 }
 
+final List<CommandShortcutEvent> commandShortcutEvents = [
+  ...codeBlockCommands,
+  ...standardCommandShortcutEvents,
+];
+
+final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
+  ...commandShortcutEvents.map((e) => e.copyWith()).toList(),
+];
+
 class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   late final ScrollController effectiveScrollController;
 
@@ -109,10 +119,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   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<AppFlowyEditorPage> {
     }
     return const (false, null);
   }
+
+  Future<void> _initializeShortcuts() async {
+    //TODO(Xazin): Refactor lazy initialization
+    defaultCommandShortcutEvents;
+    final settingsShortcutService = SettingsShortcutService();
+    final customizeShortcuts =
+        await settingsShortcutService.getCustomizeShortcuts();
+    await settingsShortcutService.updateCommandShortcuts(
+      standardCommandShortcutEvents,
+      customizeShortcuts,
+    );
+  }
 }

+ 1 - 0
frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart

@@ -14,6 +14,7 @@ enum SettingsPage {
   files,
   user,
   supabaseSetting,
+  shortcuts,
 }
 
 class SettingsDialogBloc

+ 124 - 0
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(<CommandShortcutEvent>[])
+    List<CommandShortcutEvent> commandShortcutEvents,
+    @Default(ShortcutsStatus.initial) ShortcutsStatus status,
+    @Default('') String error,
+  }) = _ShortcutsState;
+}
+
+enum ShortcutsStatus { initial, updating, success, failure }
+
+class ShortcutsCubit extends Cubit<ShortcutsState> {
+  ShortcutsCubit(this.service) : super(const ShortcutsState());
+
+  final SettingsShortcutService service;
+
+  Future<void> 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<void> 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<void> 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);
+}

+ 101 - 0
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<void>();
+
+  /// Takes in commandShortcuts as an input and saves them to the shortcuts.JSON file.
+  Future<void> saveAllShortcuts(
+    List<CommandShortcutEvent> 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<List<CommandShortcutModel>> 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<CommandShortcutModel>].
+  /// This list needs to be converted to List<CommandShortcutEvent\>. This function is intended to facilitate the same.
+  List<CommandShortcutModel> getShortcutsFromJson(String savedJson) {
+    final shortcuts = EditorShortcuts.fromJson(jsonDecode(savedJson));
+    return shortcuts.commandShortcuts;
+  }
+
+  Future<void> updateCommandShortcuts(
+    List<CommandShortcutEvent> commandShortcuts,
+    List<CommandShortcutModel> 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<void> 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<void> _initializeService(File? file) async {
+    _file = file ?? await _defaultShortcutFile();
+    _initCompleter.complete();
+  }
+
+  //returns the default file for storing shortcuts
+  Future<File> _defaultShortcutFile() async {
+    final path = await getIt<ApplicationDataStorage>().getPath();
+    return File(
+      p.join(path, 'shortcuts', 'shortcuts.json'),
+    )..createSync(recursive: true);
+  }
+}
+
+extension on List<CommandShortcutEvent> {
+  /// 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<CommandShortcutModel> toCommandShortcutModelList() =>
+      map((e) => CommandShortcutModel.fromCommandEvent(e)).toList();
+}

+ 62 - 0
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<CommandShortcutModel> commandShortcuts;
+
+  factory EditorShortcuts.fromJson(Map<String, dynamic> json) =>
+      EditorShortcuts(
+        commandShortcuts: List<CommandShortcutModel>.from(
+          json["commandShortcuts"].map(
+            (x) => CommandShortcutModel.fromJson(x),
+          ),
+        ),
+      );
+
+  Map<String, dynamic> toJson() => {
+        "commandShortcuts":
+            List<dynamic>.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<String, dynamic> json) =>
+      CommandShortcutModel(
+        key: json["key"],
+        command: (json["command"] ?? ''),
+      );
+
+  factory CommandShortcutModel.fromCommandEvent(
+    CommandShortcutEvent commandShortcutEvent,
+  ) =>
+      CommandShortcutModel(
+        key: commandShortcutEvent.key,
+        command: commandShortcutEvent.command,
+      );
+
+  Map<String, dynamic> 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;
+}

+ 3 - 0
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();
     }

+ 257 - 0
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<ShortcutsCubit>(
+      create: (_) =>
+          ShortcutsCubit(SettingsShortcutService())..fetchShortcuts(),
+      child: const SettingsCustomizeShortcutsView(),
+    );
+  }
+}
+
+class SettingsCustomizeShortcutsView extends StatelessWidget {
+  const SettingsCustomizeShortcutsView({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocBuilder<ShortcutsCubit, ShortcutsState>(
+      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<CommandShortcutEvent> 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<ShortcutsCubit>().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<FormState>();
+        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<ShortcutsCubit>(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<ShortcutsCubit>(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<ShortcutsCubit>(context).fetchShortcuts();
+          },
+        ),
+      ],
+    );
+  }
+}

+ 10 - 0
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,
+        ),
       ],
     );
   }

+ 164 - 0
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<ShortcutsCubit, ShortcutsState>(
+        'calls getCustomizeShortcuts() once',
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.fetchShortcuts(),
+        verify: (_) {
+          verify(() => service.getCustomizeShortcuts()).called(1);
+        },
+      );
+
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'emits [updating, failure] when getCustomizeShortcuts() throws',
+        setUp: () {
+          when(
+            () => service.getCustomizeShortcuts(),
+          ).thenThrow(Exception('oops'));
+        },
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.fetchShortcuts(),
+        expect: () => <dynamic>[
+          const ShortcutsState(status: ShortcutsStatus.updating),
+          isA<ShortcutsState>()
+              .having((w) => w.status, 'status', ShortcutsStatus.failure)
+        ],
+      );
+
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'emits [updating, success] when getCustomizeShortcuts() returns shortcuts',
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.fetchShortcuts(),
+        expect: () => <dynamic>[
+          const ShortcutsState(status: ShortcutsStatus.updating),
+          isA<ShortcutsState>()
+              .having((w) => w.status, 'status', ShortcutsStatus.success)
+              .having(
+                (w) => w.commandShortcutEvents,
+                'shortcuts',
+                commandShortcutEvents,
+              ),
+        ],
+      );
+    });
+
+    group('updateShortcut', () {
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'calls saveAllShortcuts() once',
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.updateAllShortcuts(),
+        verify: (_) {
+          verify(() => service.saveAllShortcuts(any())).called(1);
+        },
+      );
+
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'emits [updating, failure] when saveAllShortcuts() throws',
+        setUp: () {
+          when(
+            () => service.saveAllShortcuts(any()),
+          ).thenThrow(Exception('oops'));
+        },
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.updateAllShortcuts(),
+        expect: () => <dynamic>[
+          const ShortcutsState(status: ShortcutsStatus.updating),
+          isA<ShortcutsState>()
+              .having((w) => w.status, 'status', ShortcutsStatus.failure)
+        ],
+      );
+
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'emits [updating, success] when saveAllShortcuts() is successful',
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.updateAllShortcuts(),
+        expect: () => <dynamic>[
+          const ShortcutsState(status: ShortcutsStatus.updating),
+          isA<ShortcutsState>()
+              .having((w) => w.status, 'status', ShortcutsStatus.success)
+        ],
+      );
+    });
+
+    group('resetToDefault', () {
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'calls saveAllShortcuts() once',
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.resetToDefault(),
+        verify: (_) {
+          verify(() => service.saveAllShortcuts(any())).called(1);
+          verify(() => service.getCustomizeShortcuts()).called(1);
+        },
+      );
+
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'emits [updating, failure] when saveAllShortcuts() throws',
+        setUp: () {
+          when(
+            () => service.saveAllShortcuts(any()),
+          ).thenThrow(Exception('oops'));
+        },
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.resetToDefault(),
+        expect: () => <dynamic>[
+          const ShortcutsState(status: ShortcutsStatus.updating),
+          isA<ShortcutsState>()
+              .having((w) => w.status, 'status', ShortcutsStatus.failure)
+        ],
+      );
+
+      blocTest<ShortcutsCubit, ShortcutsState>(
+        'emits [updating, success] when getCustomizeShortcuts() returns shortcuts',
+        build: () => shortcutsCubit,
+        act: (cubit) => cubit.resetToDefault(),
+        expect: () => <dynamic>[
+          const ShortcutsState(status: ShortcutsStatus.updating),
+          isA<ShortcutsState>()
+              .having((w) => w.status, 'status', ShortcutsStatus.success)
+              .having(
+                (w) => w.commandShortcutEvents,
+                'shortcuts',
+                commandShortcutEvents,
+              ),
+        ],
+      );
+    });
+  });
+}

+ 169 - 0
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<CommandShortcutEvent> {
+  List<CommandShortcutModel> toCommandShortcutModelList() =>
+      map((e) => CommandShortcutModel.fromCommandEvent(e)).toList();
+}

+ 135 - 0
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<ShortcutsState>
+    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>[
+                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<ShortcutsListView>(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);
+          });
+        },
+      );
+    },
+  );
+}

+ 21 - 0
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);
+    });
+  });
+}

+ 94 - 0
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<FlowyText>(commandTextFinder).text;
+
+          expect(commandTextFinder, findsOneWidget);
+          expect(foundCommand, shortcut.key);
+
+          final btnFinder = find.byType(FlowyTextButton);
+          final foundBtnText =
+              widgetTester.widget<FlowyTextButton>(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<FlowyTextButton>(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);
+        });
+      },
+    );
+  });
+}

+ 78 - 0
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),
+        );
+      });
+    });
+  });
+}

+ 11 - 0
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": {