mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: customize command shortcuts (#2848)
This commit is contained in:
parent
5ab64f8835
commit
b1378b4545
@ -1,6 +1,7 @@
|
|||||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
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_plugins/plugins.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_style.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/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
@ -31,6 +32,15 @@ class AppFlowyEditorPage extends StatefulWidget {
|
|||||||
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
|
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> {
|
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||||
late final ScrollController effectiveScrollController;
|
late final ScrollController effectiveScrollController;
|
||||||
|
|
||||||
@ -109,10 +119,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
_initializeShortcuts();
|
||||||
indentableBlockTypes.add(ToggleListBlockKeys.type);
|
indentableBlockTypes.add(ToggleListBlockKeys.type);
|
||||||
convertibleBlockTypes.add(ToggleListBlockKeys.type);
|
convertibleBlockTypes.add(ToggleListBlockKeys.type);
|
||||||
slashMenuItems = _customSlashMenuItems();
|
slashMenuItems = _customSlashMenuItems();
|
||||||
|
|
||||||
effectiveScrollController = widget.scrollController ?? ScrollController();
|
effectiveScrollController = widget.scrollController ?? ScrollController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,4 +387,16 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
|||||||
}
|
}
|
||||||
return const (false, null);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ enum SettingsPage {
|
|||||||
files,
|
files,
|
||||||
user,
|
user,
|
||||||
supabaseSetting,
|
supabaseSetting,
|
||||||
|
shortcuts,
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsDialogBloc
|
class SettingsDialogBloc
|
||||||
|
@ -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);
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -2,6 +2,7 @@ import 'package:appflowy/startup/startup.dart';
|
|||||||
import 'package:appflowy/generated/locale_keys.g.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/setting_supabase_view.dart';
|
||||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_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_file_system_view.dart';
|
||||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
|
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
|
||||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
|
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
|
||||||
@ -88,6 +89,8 @@ class SettingsDialog extends StatelessWidget {
|
|||||||
return SettingsUserView(user);
|
return SettingsUserView(user);
|
||||||
case SettingsPage.supabaseSetting:
|
case SettingsPage.supabaseSetting:
|
||||||
return const SupabaseSettingView();
|
return const SupabaseSettingView();
|
||||||
|
case SettingsPage.shortcuts:
|
||||||
|
return const SettingsCustomizeShortcutsWrapper();
|
||||||
default:
|
default:
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -70,6 +70,16 @@ class SettingsMenu extends StatelessWidget {
|
|||||||
icon: Icons.sync,
|
icon: Icons.sync,
|
||||||
changeSelectedPage: changeSelectedPage,
|
changeSelectedPage: changeSelectedPage,
|
||||||
),
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 10,
|
||||||
|
),
|
||||||
|
SettingsMenuElement(
|
||||||
|
page: SettingsPage.shortcuts,
|
||||||
|
selectedPage: currentPage,
|
||||||
|
label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(),
|
||||||
|
icon: Icons.cut,
|
||||||
|
changeSelectedPage: changeSelectedPage,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -277,6 +277,17 @@
|
|||||||
"icon": "Icon",
|
"icon": "Icon",
|
||||||
"selectAnIcon": "Select an icon",
|
"selectAnIcon": "Select an icon",
|
||||||
"pleaseInputYourOpenAIKey": "please input your OpenAI key"
|
"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": {
|
"grid": {
|
||||||
|
Loading…
Reference in New Issue
Block a user