From 824fcfc27cc50cb7005a9fe7c901dffbd1ecd0fa Mon Sep 17 00:00:00 2001 From: TheOneWithTheBraid Date: Tue, 1 Mar 2022 20:14:49 +0100 Subject: [PATCH] feat: implement keyboard shortcuts Added shortcuts for the following actions: - search chats - start chat - chat details - show widgets - cycle accounts - switch to account $i - toggle emoji picker - send file Related: #849 Signed-off-by: TheOneWithTheBraid --- assets/l10n/intl_en.arb | 11 +- lib/pages/chat/chat_input_row.dart | 205 ++++++++++-------- lib/pages/chat_list/chat_list_view.dart | 58 +++-- .../chat_list/client_chooser_button.dart | 145 +++++++++++-- lib/widgets/chat_settings_popup_menu.dart | 130 +++++++---- pubspec.lock | 24 +- pubspec.yaml | 7 + 7 files changed, 400 insertions(+), 180 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index bb70d735..e55a5267 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2750,5 +2750,14 @@ "experimentalVideoCalls": "Experimental video calls", "@experimentalVideoCalls": {}, "emailOrUsername": "Email or username", - "@emailOrUsername": {} + "@emailOrUsername": {}, + "switchToAccount": "Switch to account {number}", + "@switchToAccount": { + "type": "number", + "placeholders": { + "number": {} + } + }, + "nextAccount": "Next account", + "previousAccount": "Previous account" } diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 386572f3..19599f78 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; @@ -72,126 +74,143 @@ class ChatInputRow extends StatelessWidget { : Container(), ] : [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - height: 56, - width: controller.inputText.isEmpty ? 56 : 0, - alignment: Alignment.center, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: PopupMenuButton( - icon: const Icon(Icons.add_outlined), - onSelected: controller.onAddPopupMenuButtonSelected, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: 'file', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - child: Icon(Icons.attachment_outlined), - ), - title: Text(L10n.of(context)!.sendFile), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 'image', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - child: Icon(Icons.image_outlined), - ), - title: Text(L10n.of(context)!.sendImage), - contentPadding: const EdgeInsets.all(0), - ), - ), - if (PlatformInfos.isMobile) + KeyBoardShortcuts( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 56, + width: controller.inputText.isEmpty ? 56 : 0, + alignment: Alignment.center, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: PopupMenuButton( + icon: const Icon(Icons.add_outlined), + onSelected: controller.onAddPopupMenuButtonSelected, + itemBuilder: (BuildContext context) => + >[ PopupMenuItem( - value: 'camera', + value: 'file', child: ListTile( leading: const CircleAvatar( - backgroundColor: Colors.purple, + backgroundColor: Colors.green, foregroundColor: Colors.white, - child: Icon(Icons.camera_alt_outlined), + child: Icon(Icons.attachment_outlined), ), - title: Text(L10n.of(context)!.openCamera), + title: Text(L10n.of(context)!.sendFile), contentPadding: const EdgeInsets.all(0), ), ), - if (PlatformInfos.isMobile) PopupMenuItem( - value: 'camera-video', + value: 'image', child: ListTile( leading: const CircleAvatar( - backgroundColor: Colors.red, + backgroundColor: Colors.blue, foregroundColor: Colors.white, - child: Icon(Icons.videocam_outlined), + child: Icon(Icons.image_outlined), ), - title: Text(L10n.of(context)!.openVideoCamera), + title: Text(L10n.of(context)!.sendImage), contentPadding: const EdgeInsets.all(0), ), ), - if (controller.room! - .getImagePacks(ImagePackUsage.sticker) - .isNotEmpty) - PopupMenuItem( - value: 'sticker', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - child: Icon(Icons.emoji_emotions_outlined), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'camera', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + child: Icon(Icons.camera_alt_outlined), + ), + title: Text(L10n.of(context)!.openCamera), + contentPadding: const EdgeInsets.all(0), ), - title: Text(L10n.of(context)!.sendSticker), - contentPadding: const EdgeInsets.all(0), ), - ), - if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'location', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.brown, - foregroundColor: Colors.white, - child: Icon(Icons.gps_fixed_outlined), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'camera-video', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + child: Icon(Icons.videocam_outlined), + ), + title: Text(L10n.of(context)!.openVideoCamera), + contentPadding: const EdgeInsets.all(0), ), - title: Text(L10n.of(context)!.shareLocation), - contentPadding: const EdgeInsets.all(0), ), - ), - ], + if (controller.room! + .getImagePacks(ImagePackUsage.sticker) + .isNotEmpty) + PopupMenuItem( + value: 'sticker', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + child: Icon(Icons.emoji_emotions_outlined), + ), + title: Text(L10n.of(context)!.sendSticker), + contentPadding: const EdgeInsets.all(0), + ), + ), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'location', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.brown, + foregroundColor: Colors.white, + child: Icon(Icons.gps_fixed_outlined), + ), + title: Text(L10n.of(context)!.shareLocation), + contentPadding: const EdgeInsets.all(0), + ), + ), + ], + ), ), + keysToPress: { + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.keyA + }, + onKeysPressed: () => + controller.onAddPopupMenuButtonSelected('file'), + helpLabel: L10n.of(context)!.sendFile, ), Container( height: 56, alignment: Alignment.center, - child: IconButton( - tooltip: L10n.of(context)!.emojis, - icon: PageTransitionSwitcher( - transitionBuilder: ( - Widget child, - Animation primaryAnimation, - Animation secondaryAnimation, - ) { - return SharedAxisTransition( - animation: primaryAnimation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.scaled, - child: child, - fillColor: Colors.transparent, - ); - }, - child: Icon( - controller.showEmojiPicker - ? Icons.keyboard - : Icons.emoji_emotions_outlined, - key: ValueKey(controller.showEmojiPicker), + child: KeyBoardShortcuts( + child: IconButton( + tooltip: L10n.of(context)!.emojis, + icon: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.scaled, + child: child, + fillColor: Colors.transparent, + ); + }, + child: Icon( + controller.showEmojiPicker + ? Icons.keyboard + : Icons.emoji_emotions_outlined, + key: ValueKey(controller.showEmojiPicker), + ), ), + onPressed: controller.emojiPickerAction, ), - onPressed: controller.emojiPickerAction, + keysToPress: { + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.keyE + }, + onKeysPressed: controller.emojiPickerAction, + helpLabel: L10n.of(context)!.emojis, ), ), if (controller.matrix!.isMultiAccount && diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index b712e579..fbbc136d 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:matrix/matrix.dart'; import 'package:vrouter/vrouter.dart'; @@ -43,16 +45,16 @@ class ChatListView extends StatelessWidget { ? null : Theme.of(context).colorScheme.primary, ), - leading: selectMode == SelectMode.normal - ? Matrix.of(context).isMultiAccount - ? ClientChooserButton(controller) - : null - : IconButton( - tooltip: L10n.of(context)!.cancel, - icon: const Icon(Icons.close_outlined), - onPressed: controller.cancelAction, - color: Theme.of(context).colorScheme.primary, - ), + leading: Matrix.of(context).isMultiAccount + ? ClientChooserButton(controller) + : selectMode == SelectMode.normal + ? null + : IconButton( + tooltip: L10n.of(context)!.cancel, + icon: const Icon(Icons.close_outlined), + onPressed: controller.cancelAction, + color: Theme.of(context).colorScheme.primary, + ), centerTitle: false, actions: selectMode == SelectMode.share ? null @@ -93,11 +95,20 @@ class ChatListView extends StatelessWidget { ), ] : [ - IconButton( - icon: const Icon(Icons.search_outlined), - tooltip: L10n.of(context)!.search, - onPressed: () => + KeyBoardShortcuts( + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyF + }, + onKeysPressed: () => VRouter.of(context).to('/search'), + helpLabel: L10n.of(context)!.search, + child: IconButton( + icon: const Icon(Icons.search_outlined), + tooltip: L10n.of(context)!.search, + onPressed: () => + VRouter.of(context).to('/search'), + ), ), if (selectMode == SelectMode.normal) IconButton( @@ -213,12 +224,21 @@ class ChatListView extends StatelessWidget { Expanded(child: _ChatListViewBody(controller)), ]), floatingActionButton: selectMode == SelectMode.normal - ? FloatingActionButton.extended( - isExtended: controller.scrolledToTop, - onPressed: () => + ? KeyBoardShortcuts( + child: FloatingActionButton.extended( + isExtended: controller.scrolledToTop, + onPressed: () => + VRouter.of(context).to('/newprivatechat'), + icon: const Icon(CupertinoIcons.chat_bubble), + label: Text(L10n.of(context)!.newChat), + ), + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyN + }, + onKeysPressed: () => VRouter.of(context).to('/newprivatechat'), - icon: const Icon(CupertinoIcons.chat_bubble), - label: Text(L10n.of(context)!.newChat), + helpLabel: L10n.of(context)!.newChat, ) : null, bottomNavigationBar: Column( diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 7ecce505..86a512f8 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -8,6 +11,7 @@ import 'chat_list.dart'; class ClientChooserButton extends StatelessWidget { final ChatListController controller; + const ClientChooserButton(this.controller, {Key? key}) : super(key: key); List> _bundleMenuItems(BuildContext context) { @@ -81,26 +85,137 @@ class ClientChooserButton extends StatelessWidget { @override Widget build(BuildContext context) { final matrix = Matrix.of(context); + + int clientCount = 0; + matrix.accountBundles.forEach((key, value) => clientCount += value.length); return Center( child: FutureBuilder( future: matrix.client.ownProfile, - builder: (context, snapshot) => PopupMenuButton( - child: Avatar( - mxContent: snapshot.data?.avatarUrl, - name: snapshot.data?.displayName ?? matrix.client.userID!.localpart, - size: 28, - fontSize: 12, - ), - onSelected: (Object object) { - if (object is Client) { - controller.setActiveClient(object); - } else if (object is String) { - controller.setActiveBundle(object); - } - }, - itemBuilder: _bundleMenuItems, + builder: (context, snapshot) => Stack( + alignment: Alignment.center, + children: [ + ...List.generate( + clientCount, + (index) => KeyBoardShortcuts( + child: Container(), + keysToPress: _buildKeyboardShortcut(index + 1), + helpLabel: L10n.of(context)!.switchToAccount(index + 1), + onKeysPressed: () => _handleKeyboardShortcut(matrix, index), + ), + ), + KeyBoardShortcuts( + child: Container(), + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.tab + }, + helpLabel: L10n.of(context)!.nextAccount, + onKeysPressed: () => _nextAccount(matrix), + ), + KeyBoardShortcuts( + child: Container(), + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.tab + }, + helpLabel: L10n.of(context)!.previousAccount, + onKeysPressed: () => _previousAccount(matrix), + ), + PopupMenuButton( + child: Avatar( + mxContent: snapshot.data?.avatarUrl, + name: snapshot.data?.displayName ?? + matrix.client.userID!.localpart, + size: 28, + fontSize: 12, + ), + onSelected: _clientSelected, + itemBuilder: _bundleMenuItems, + ), + ], ), ), ); } + + Set? _buildKeyboardShortcut(int index) { + if (index > 0 && index < 10) { + return { + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey(0x00000000030 + index) + }; + } else { + return null; + } + } + + void _clientSelected(Object object) { + if (object is Client) { + controller.setActiveClient(object); + } else if (object is String) { + controller.setActiveBundle(object); + } + } + + void _handleKeyboardShortcut(MatrixState matrix, int index) { + final bundles = matrix.accountBundles.keys.toList() + ..sort((a, b) => a!.isValidMatrixId == b!.isValidMatrixId + ? 0 + : a.isValidMatrixId && !b.isValidMatrixId + ? -1 + : 1); + // beginning from end if negative + if (index < 0) { + int clientCount = 0; + matrix.accountBundles + .forEach((key, value) => clientCount += value.length); + _handleKeyboardShortcut(matrix, clientCount); + } + for (final bundleName in bundles) { + final bundle = matrix.accountBundles[bundleName]; + if (bundle != null) { + if (index < bundle.length) { + return _clientSelected(bundle[index]!); + } else { + index -= bundle.length; + } + } + } + // if index too high, restarting from 0 + _handleKeyboardShortcut(matrix, 0); + } + + int? _shortcutIndexOfClient(MatrixState matrix, Client client) { + int index = 0; + + final bundles = matrix.accountBundles.keys.toList() + ..sort((a, b) => a!.isValidMatrixId == b!.isValidMatrixId + ? 0 + : a.isValidMatrixId && !b.isValidMatrixId + ? -1 + : 1); + for (final bundleName in bundles) { + final bundle = matrix.accountBundles[bundleName]; + if (bundle == null) return null; + if (bundle.contains(client)) { + return index + bundle.indexOf(client); + } else { + index += bundle.length; + } + } + return null; + } + + void _nextAccount(MatrixState matrix) { + final client = matrix.client; + final lastIndex = _shortcutIndexOfClient(matrix, client); + _handleKeyboardShortcut(matrix, lastIndex! + 1); + } + + void _previousAccount(MatrixState matrix) { + final client = matrix.client; + final lastIndex = _shortcutIndexOfClient(matrix, client); + _handleKeyboardShortcut(matrix, lastIndex! - 1); + } } diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index 10f07ddb..db06f7ac 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:matrix/matrix.dart'; import 'package:vrouter/vrouter.dart'; @@ -16,6 +18,7 @@ import 'matrix.dart'; class ChatSettingsPopupMenu extends StatefulWidget { final Room room; final bool displayChatDetails; + const ChatSettingsPopupMenu(this.room, this.displayChatDetails, {Key? key}) : super(key: key); @@ -101,57 +104,88 @@ class _ChatSettingsPopupMenuState extends State { ), ); } - return PopupMenuButton( - onSelected: (String choice) async { - switch (choice) { - case 'widgets': - [TargetPlatform.iOS, TargetPlatform.macOS] - .contains(Theme.of(context).platform) - ? showCupertinoModalPopup( + return Stack( + alignment: Alignment.center, + children: [ + KeyBoardShortcuts( + child: Container(), + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyI + }, + helpLabel: L10n.of(context)!.chatDetails, + onKeysPressed: _showChatDetails, + ), + KeyBoardShortcuts( + child: Container(), + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyW + }, + helpLabel: L10n.of(context)!.matrixWidgets, + onKeysPressed: _showWidgets, + ), + PopupMenuButton( + onSelected: (String choice) async { + switch (choice) { + case 'widgets': + _showWidgets(); + break; + case 'leave': + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context)!.areYouSure, + okLabel: L10n.of(context)!.ok, + cancelLabel: L10n.of(context)!.cancel, + ); + if (confirmed == OkCancelResult.ok) { + final success = await showFutureLoadingDialog( + context: context, future: () => widget.room.leave()); + if (success.error == null) { + VRouter.of(context).to('/rooms'); + } + } + break; + case 'mute': + await showFutureLoadingDialog( context: context, - builder: (context) => - CupertinoWidgetsBottomSheet(room: widget.room), - ) - : showModalBottomSheet( + future: () => widget.room + .setPushRuleState(PushRuleState.mentionsOnly)); + break; + case 'unmute': + await showFutureLoadingDialog( context: context, - builder: (context) => WidgetsBottomSheet(room: widget.room), - ); - break; - case 'leave': - final confirmed = await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context)!.areYouSure, - okLabel: L10n.of(context)!.ok, - cancelLabel: L10n.of(context)!.cancel, - ); - if (confirmed == OkCancelResult.ok) { - final success = await showFutureLoadingDialog( - context: context, future: () => widget.room.leave()); - if (success.error == null) { - VRouter.of(context).to('/rooms'); - } + future: () => + widget.room.setPushRuleState(PushRuleState.notify)); + break; + case 'details': + _showChatDetails(); + break; } - break; - case 'mute': - await showFutureLoadingDialog( - context: context, - future: () => - widget.room.setPushRuleState(PushRuleState.mentionsOnly)); - break; - case 'unmute': - await showFutureLoadingDialog( - context: context, - future: () => - widget.room.setPushRuleState(PushRuleState.notify)); - break; - case 'details': - VRouter.of(context) - .toSegments(['rooms', widget.room.id, 'details']); - break; - } - }, - itemBuilder: (BuildContext context) => items, + }, + itemBuilder: (BuildContext context) => items, + ), + ], ); } + + void _showWidgets() => [TargetPlatform.iOS, TargetPlatform.macOS] + .contains(Theme.of(context).platform) + ? showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoWidgetsBottomSheet(room: widget.room), + ) + : showModalBottomSheet( + context: context, + builder: (context) => WidgetsBottomSheet(room: widget.room), + ); + + void _showChatDetails() { + if (VRouter.of(context).path.endsWith('/details')) { + VRouter.of(context).toSegments(['rooms', widget.room.id]); + } else { + VRouter.of(context).toSegments(['rooms', widget.room.id, 'details']); + } + } } diff --git a/pubspec.lock b/pubspec.lock index 5829b2dd..2d431a70 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -429,7 +429,7 @@ packages: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "4.5.0" file_picker_cross: dependency: "direct main" description: @@ -715,7 +715,7 @@ packages: name: flutter_webrtc url: "https://pub.dartlang.org" source: hosted - version: "0.8.2" + version: "0.8.3" frontend_server_client: dependency: transitive description: @@ -891,6 +891,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" + keyboard_shortcuts: + dependency: "direct main" + description: + path: "." + ref: null-safety + resolved-ref: "5aa8786475bca1b90ff35409eff3e0f5a4768601" + url: "https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git" + source: git + version: "0.1.4" latlong2: dependency: transitive description: @@ -960,7 +969,7 @@ packages: name: matrix url: "https://pub.dartlang.org" source: hosted - version: "0.8.11" + version: "0.8.12" matrix_api_lite: dependency: transitive description: @@ -1198,7 +1207,7 @@ packages: name: permission_handler_apple url: "https://pub.dartlang.org" source: hosted - version: "9.0.2" + version: "9.0.3" permission_handler_platform_interface: dependency: transitive description: @@ -1813,6 +1822,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.7" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fd9e1d31..cae78753 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: image: ^3.1.1 image_picker: ^0.8.4+8 intl: any + keyboard_shortcuts: ^0.1.4 localstorage: ^4.0.0+1 lottie: ^1.2.2 matrix: ^0.8.11 @@ -135,4 +136,10 @@ dependency_overrides: hosted: name: geolocator_android url: https://hanntech-gmbh.gitlab.io/free2pass/flutter-geolocator-floss + # waiting for null safety + # Upstream pull request: https://github.com/AntoineMarcel/keyboard_shortcuts/pull/13 + keyboard_shortcuts: + git: + url: https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git + ref: null-safety provider: 5.0.0