feat: customize command shortcuts (#2848)

This commit is contained in:
Mayur Mahajan 2023-07-20 13:41:00 +05:30 committed by GitHub
parent 5ab64f8835
commit b1378b4545
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1253 additions and 1 deletions

View File

@ -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,
);
}
}

View File

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

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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();
},
),
],
);
}
}

View File

@ -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,
),
],
);
}

View File

@ -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,
),
],
);
});
});
}

View File

@ -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();
}

View File

@ -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);
});
},
);
},
);
}

View File

@ -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);
});
});
}

View File

@ -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);
});
},
);
});
}

View File

@ -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),
);
});
});
});
}

View File

@ -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": {