use flutter shortcuts/actions/intent framework instead of manually handling keyboard shortcuts.

This commit is contained in:
Herbert Poul
2021-09-09 12:36:05 +02:00
parent ac4da0edb1
commit 294d05705d
7 changed files with 536 additions and 336 deletions

View File

@ -1671,6 +1671,50 @@
"@getHelpButton": {
"description": ""
},
"shortcutCopyUsername": "Copy Username",
"@shortcutCopyUsername": {
"description": ""
},
"shortcutCopyPassword": "Copy Password",
"@shortcutCopyPassword": {
"description": ""
},
"shortcutCopyTotp": "Copy TOTP",
"@shortcutCopyTotp": {
"description": ""
},
"shortcutMoveUp": "Select the previous password",
"@shortcutMoveUp": {
"description": ""
},
"shortcutMoveDown": "Select the next password",
"@shortcutMoveDown": {
"description": ""
},
"shortcutGeneratePassword": "Generate Password",
"@shortcutGeneratePassword": {
"description": ""
},
"shortcutCopyUrl": "Copy URL",
"@shortcutCopyUrl": {
"description": ""
},
"shortcutOpenUrl": "Open URL",
"@shortcutOpenUrl": {
"description": ""
},
"shortcutCancelSearch": "Cancel Search",
"@shortcutCancelSearch": {
"description": ""
},
"shortcutShortcutHelp": "Keyboard Shortcut Help",
"@shortcutShortcutHelp": {
"description": ""
},
"shortcutHelpTitle": "Keyboard Shortcuts",
"@shortcutHelpTitle": {
"description": "title of dialog showing keyboard shortcuts."
},
"unexpectedError": "Unexpected Error: {error}",
"@unexpectedError": {
"placeholders": {

View File

@ -20,6 +20,7 @@ import 'package:authpass/ui/widgets/icon_selector.dart';
import 'package:authpass/ui/widgets/keyboard_handler.dart';
import 'package:authpass/ui/widgets/link_button.dart';
import 'package:authpass/ui/widgets/primary_button.dart';
import 'package:authpass/ui/widgets/shortcut/authpass_intents.dart';
import 'package:authpass/utils/constants.dart';
import 'package:authpass/utils/dialog_utils.dart';
import 'package:authpass/utils/extension_methods.dart';
@ -343,11 +344,19 @@ class _EntryDetailsState extends State<EntryDetails>
with StreamSubscriberMixin {
List<Tuple3<GlobalKey<_EntryFieldState>, KdbxKey, CommonField?>>? _fieldKeys;
IntentActionRegistration? _shortcutRegistration;
@override
void dispose() {
_shortcutRegistration?.dispose();
super.dispose();
}
void _initShortcutListener(
KeyboardShortcutEvents events, CommonFields commonFields) {
handleSubscription(events.shortcutEvents.listen((event) {
_logger.fine('shortcut event: $event //// ${event.type}');
if (event.type == KeyboardShortcutType.copyPassword) {
_shortcutRegistration?.dispose();
_shortcutRegistration = events.registerActions({
CopyPasswordIntent: CallbackAction(onInvoke: (_) {
final context = FocusManager.instance.primaryFocus?.context;
if (context != null) {
_logger.fine('context: $context');
@ -377,24 +386,35 @@ class _EntryDetailsState extends State<EntryDetails>
return;
}
}
_copyField([commonFields.password]);
} else if (event.type == KeyboardShortcutType.copyUsername) {
}),
CopyUsernameIntent: CallbackAction(onInvoke: (_) {
_copyField([commonFields.userName]);
} else if (event.type == KeyboardShortcutType.copyTotp) {
}),
CopyTotpIntent: CallbackAction(onInvoke: (_) {
_logger.fine('Copying ${commonFields.otpAuth}');
_copyField([
commonFields.otpAuth,
commonFields.otpAuthCompat2,
commonFields.otpAuthCompat1,
]);
} else if (event.type == KeyboardShortcutType.escape) {
FocusManager.instance.primaryFocus?.unfocus();
} else if (event.type == KeyboardShortcutType.openUrl) {
}),
OpenUrlIntent: CallbackAction(onInvoke: (_) {
_fieldStateFor(commonFields.url)?.openUrl();
} else if (event.type == KeyboardShortcutType.copyUrl) {
}),
CopyUrlIntent: CallbackAction(onInvoke: (_) {
_fieldStateFor(commonFields.url)?.copyValue();
}
}));
}),
// DismissIntent: CallbackAction(onInvoke: (_) {
// FocusManager.instance.primaryFocus?.unfocus();
// }),
// CancelSearchFilterIntent: CallbackAction(onInvoke: (_) {
// final primaryFocus = FocusManager.instance.primaryFocus;
// if (primaryFocus)
// ?.unfocus();
// }),
});
}
_EntryFieldState? _fieldStateFor(CommonField commonField) => _fieldKeys!
@ -1056,6 +1076,8 @@ class _EntryFieldState extends State<EntryField>
final FocusNode _focusNode = FocusNode();
late CommonFields _commonFields;
IntentActionRegistration? _keyboardRegistration;
StringValue? get _fieldValue => widget.entry.getString(widget.fieldKey);
set _fieldValue(StringValue? value) {
@ -1097,13 +1119,11 @@ class _EntryFieldState extends State<EntryField>
super.didChangeDependencies();
_commonFields = Provider.of<CommonFields>(context);
if (widget.fieldKey == _commonFields.password.key) {
handleSubscription(Provider.of<KeyboardShortcutEvents>(context)
.shortcutEvents
.listen((event) {
if (event.type == KeyboardShortcutType.generatePassword) {
_generatePassword();
}
}));
_keyboardRegistration?.dispose();
final keyboardShortcutEvents = context.watch<KeyboardShortcutEvents>();
_keyboardRegistration = keyboardShortcutEvents.registerActions({
GeneratePassword: CallbackAction(onInvoke: (_) => _generatePassword()),
});
}
}
@ -1463,6 +1483,7 @@ class _EntryFieldState extends State<EntryField>
void dispose() {
_logger
.fine('EntryFieldState.dispose() - ${widget.key} (${widget.fieldKey})');
_keyboardRegistration?.dispose();
_controller.dispose();
_focusNode.dispose();
super.dispose();

View File

@ -22,6 +22,7 @@ import 'package:authpass/ui/widgets/backup_warning_banner.dart';
import 'package:authpass/ui/widgets/keyboard_handler.dart';
import 'package:authpass/ui/widgets/primary_button.dart';
import 'package:authpass/ui/widgets/savefile/save_file_diag_button.dart';
import 'package:authpass/ui/widgets/shortcut/authpass_intents.dart';
import 'package:authpass/utils/cache_manager.dart';
import 'package:authpass/utils/constants.dart';
import 'package:authpass/utils/dialog_utils.dart';
@ -382,16 +383,55 @@ class GroupFilter {
}
}
class _CancelSearchFilterAction extends Action<CancelSearchFilterIntent> {
_CancelSearchFilterAction(this.state);
final _PasswordListContentState state;
@override
bool isActionEnabled = false;
void updateEnabled() {
final isEnabled = state._filterQuery != null;
if (isActionEnabled != isEnabled) {
isActionEnabled = isEnabled;
notifyActionListeners();
}
}
@override
bool consumesKey(CancelSearchFilterIntent intent) {
_logger.fine('_CancelSearchFilterAction.consumesKey() = $isActionEnabled');
return isActionEnabled;
}
@override
Object? invoke(CancelSearchFilterIntent intent) {
state._cancelFilter();
}
}
class _PasswordListContentState extends State<PasswordListContent>
with StreamSubscriberMixin, WidgetsBindingObserver, FutureTaskStateMixin {
List<EntryViewModel>? _filteredEntries;
String? _filterQuery;
String? get _filterQuery => __filterQuery;
set _filterQuery(String? filterQuery) {
__filterQuery = filterQuery;
cancelSearchFilterAction.updateEnabled();
}
String? __filterQuery;
final _filterTextEditingController = TextEditingController();
final FocusNode _filterFocusNode = FocusNode();
bool _speedDialOpen = false;
final ValueNotifier<GroupFilter> _groupFilterNotifier =
ValueNotifier(GroupFilter.defaultGroupFilter);
IntentActionRegistration? _actionsRegistration;
late final _CancelSearchFilterAction cancelSearchFilterAction =
_CancelSearchFilterAction(this);
GroupFilter get _groupFilter => _groupFilterNotifier.value;
// List<EntryViewModel> get _allEntries => _groupFilter == null
@ -518,9 +558,10 @@ class _PasswordListContentState extends State<PasswordListContent>
super.didChangeDependencies();
subscriptions.cancelSubscriptions();
final shortcuts = Provider.of<KeyboardShortcutEvents>(context);
handleSubscription(shortcuts.shortcutEvents.listen((event) {
if (event.type == KeyboardShortcutType.search) {
setState(() {
_actionsRegistration?.dispose();
_actionsRegistration = shortcuts.registerActions({
SearchIntent: CallbackAction(
onInvoke: (intent) => setState(() {
if (_filterQuery == null || _filteredEntries == null) {
_filterQuery ??= CharConstants.empty;
_filteredEntries = _allEntries;
@ -529,23 +570,18 @@ class _PasswordListContentState extends State<PasswordListContent>
WidgetsBinding.instance!.addPostFrameCallback((_) {
_filterFocusNode.requestFocus();
});
});
} else if (event.type == KeyboardShortcutType.moveUp) {
if (!_isFocusInForeignTextField()) {
_selectNextEntry(-1);
}
} else if (event.type == KeyboardShortcutType.moveDown) {
if (!_isFocusInForeignTextField()) {
_selectNextEntry(1);
}
} else if (event.type == KeyboardShortcutType.escape) {
if (!_isFocusInForeignTextField()) {
_cancelFilter();
}
}
}));
}),
),
MoveUpIntent: CallbackAction(onInvoke: (intent) => _selectNextEntry(-1)),
MoveDownIntent: CallbackAction(onInvoke: (intent) => _selectNextEntry(1)),
// CancelSearchFilterIntent:
// CallbackAction(onInvoke: (intent) => _cancelFilter()),
CancelSearchFilterIntent: cancelSearchFilterAction,
});
}
@Deprecated('was only a workaround needed for manual shortcut handling')
// ignore: unused_element
bool _isFocusInForeignTextField() {
final widget =
WidgetsBinding.instance!.focusManager.primaryFocus?.context?.widget;
@ -588,6 +624,7 @@ class _PasswordListContentState extends State<PasswordListContent>
WidgetsBinding.instance!.removeObserver(this);
_groupFilterNotifier.removeListener(_updateAllEntries);
_groupFilterNotifier.dispose();
_actionsRegistration?.dispose();
super.dispose();
}
@ -1037,115 +1074,123 @@ class _PasswordListContentState extends State<PasswordListContent>
final theme = Theme.of(context);
final kdbxBloc = Provider.of<KdbxBloc>(context);
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: _filteredEntries == null
? _buildDefaultAppBar(context)
: _buildFilterAppBar(context),
drawer: Drawer(
child: PasswordListDrawer(
initialSelection: _groupFilter.groups.map((e) => e.group).toSet(),
selectionChanged: (Set<KdbxGroup> selection) {
_createGroupFilter(loc, selection);
},
return Actions(
dispatcher: LoggingActionDispatcher(),
actions: {
SearchIntent: SearchAction(this),
},
child: Scaffold(
appBar: _filteredEntries == null
? _buildDefaultAppBar(context)
: _buildFilterAppBar(context),
drawer: Drawer(
child: PasswordListDrawer(
initialSelection: _groupFilter.groups.map((e) => e.group).toSet(),
selectionChanged: (Set<KdbxGroup> selection) {
_createGroupFilter(loc, selection);
},
),
),
),
body: ProgressOverlay(
task: task,
child: _allEntries!.isEmpty
? NoPasswordsEmptyView(
listPrefix: listPrefix,
onPrimaryButtonPressed: () {
final kdbxBloc =
Provider.of<KdbxBloc>(context, listen: false);
final entry = kdbxBloc.createEntry();
body: ProgressOverlay(
task: task,
child: _allEntries!.isEmpty
? NoPasswordsEmptyView(
listPrefix: listPrefix,
onPrimaryButtonPressed: () {
final kdbxBloc =
Provider.of<KdbxBloc>(context, listen: false);
final entry = kdbxBloc.createEntry();
// Navigator.of(context).push(EntryDetailsScreen.route(entry: entry));
widget.onEntrySelected(
context, entry, EntrySelectionType.activeOpen);
},
)
: Scrollbar(
child: ListView.builder(
itemCount: entries!.length + 1,
itemBuilder: (context, index) {
// handle [listPrefix]
if (index == 0) {
if (listPrefix.isEmpty) {
return const SizedBox();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: listPrefix,
);
}
index--;
final entry = entries[index];
final openedFile =
kdbxBloc.fileForKdbxFile(entry.entry.file);
final fileColor = openedFile.openedFile.color;
// _logger.finer('listview item. selectedEntry: ${widget.selectedEntry}');
return PasswordEntryListTileWrapper(
entry: entry,
fileColor: fileColor,
filterQuery: _filterQuery,
selectedEntry: widget.selectedEntry,
onEntrySelected:
(KdbxEntry entry, EntrySelectionType type) {
widget.onEntrySelected(context, entry, type);
},
);
},
),
),
),
floatingActionButton: _allEntries!.isEmpty ||
_filterQuery != null ||
_autofillMetadata != null
? null
: kdbxBloc.openedFiles.length == 1 || _groupFilter.groups.length == 1
? FloatingActionButton(
tooltip: loc.addNewPassword,
onPressed: () {
final group = _groupFilter.groups.isEmpty
? null
: _groupFilter.groups.first.group;
final entry = kdbxBloc.createEntry(
file: group?.file,
group: group,
);
widget.onEntrySelected(
context, entry, EntrySelectionType.activeOpen);
},
child: const Icon(Icons.add),
)
: SpeedDial(
tooltip:
_speedDialOpen ? CharConstants.empty : loc.addNewPassword,
onOpen: () => setState(() => _speedDialOpen = true),
onClose: () => setState(() => _speedDialOpen = false),
overlayColor: theme.brightness == Brightness.dark
? Colors.black
: Colors.white,
children: kdbxBloc.openedFiles.values
.map(
(file) => SpeedDialChild(
label: file.fileSource.displayName,
child: Icon(file.fileSource.displayIcon.iconData),
labelBackgroundColor: Theme.of(context).cardColor,
backgroundColor: file.openedFile.colorCode == null
? null
: Color(file.openedFile.colorCode!),
onTap: () {
final entry =
kdbxBloc.createEntry(file: file.kdbxFile);
widget.onEntrySelected(context, entry,
EntrySelectionType.activeOpen);
}),
)
.toList(),
child: Icon(_speedDialOpen ? Icons.close : Icons.add),
: Scrollbar(
child: ListView.builder(
itemCount: entries!.length + 1,
itemBuilder: (context, index) {
// handle [listPrefix]
if (index == 0) {
if (listPrefix.isEmpty) {
return const SizedBox();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: listPrefix,
);
}
index--;
final entry = entries[index];
final openedFile =
kdbxBloc.fileForKdbxFile(entry.entry.file);
final fileColor = openedFile.openedFile.color;
// _logger.finer('listview item. selectedEntry: ${widget.selectedEntry}');
return PasswordEntryListTileWrapper(
entry: entry,
fileColor: fileColor,
filterQuery: _filterQuery,
selectedEntry: widget.selectedEntry,
onEntrySelected:
(KdbxEntry entry, EntrySelectionType type) {
widget.onEntrySelected(context, entry, type);
},
);
},
),
),
),
floatingActionButton: _allEntries!.isEmpty ||
_filterQuery != null ||
_autofillMetadata != null
? null
: kdbxBloc.openedFiles.length == 1 ||
_groupFilter.groups.length == 1
? FloatingActionButton(
tooltip: loc.addNewPassword,
onPressed: () {
final group = _groupFilter.groups.isEmpty
? null
: _groupFilter.groups.first.group;
final entry = kdbxBloc.createEntry(
file: group?.file,
group: group,
);
widget.onEntrySelected(
context, entry, EntrySelectionType.activeOpen);
},
child: const Icon(Icons.add),
)
: SpeedDial(
tooltip: _speedDialOpen
? CharConstants.empty
: loc.addNewPassword,
onOpen: () => setState(() => _speedDialOpen = true),
onClose: () => setState(() => _speedDialOpen = false),
overlayColor: theme.brightness == Brightness.dark
? Colors.black
: Colors.white,
children: kdbxBloc.openedFiles.values
.map(
(file) => SpeedDialChild(
label: file.fileSource.displayName,
child: Icon(file.fileSource.displayIcon.iconData),
labelBackgroundColor: Theme.of(context).cardColor,
backgroundColor: file.openedFile.colorCode == null
? null
: Color(file.openedFile.colorCode!),
onTap: () {
final entry =
kdbxBloc.createEntry(file: file.kdbxFile);
widget.onEntrySelected(context, entry,
EntrySelectionType.activeOpen);
}),
)
.toList(),
child: Icon(_speedDialOpen ? Icons.close : Icons.add),
),
),
);
}
@ -1222,6 +1267,16 @@ class _PasswordListContentState extends State<PasswordListContent>
}
}
class SearchAction extends Action<SearchIntent> {
SearchAction(this.state);
final _PasswordListContentState state;
@override
Object? invoke(SearchIntent intent) {
_logger.info('We should start search.');
return null;
}
}
class PasswordEntryListTileWrapper extends StatelessWidget {
const PasswordEntryListTileWrapper({
Key? key,

View File

@ -1,12 +1,14 @@
import 'dart:async';
import 'package:authpass/bloc/app_data.dart';
import 'package:authpass/ui/widgets/shortcut/authpass_intents.dart';
import 'package:authpass/ui/widgets/shortcut/shortcuts.dart';
import 'package:authpass/utils/constants.dart';
import 'package:authpass/utils/dialog_utils.dart';
import 'package:authpass/utils/platform.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_async_utils/flutter_async_utils.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
@ -15,59 +17,14 @@ import 'package:window_manager/window_manager.dart';
final _logger = Logger('keyboard_handler');
// Seems i can't figure out how to get `Shortcuts` & co to work.. workaround it for now
// also see https://github.com/flutter/flutter/issues/38076
//// very much copied from the example at
//// https://github.com/flutter/flutter/blob/master/dev/manual_tests/lib/actions.dart
//// not sure if i know what i'm doing.
//
//class KeyboardHandler extends StatelessWidget {
// const KeyboardHandler({Key key, this.child}) : super(key: key);
//
// final Widget child;
//
// @override
// Widget build(BuildContext context) {
// return Shortcuts(
// shortcuts: <LogicalKeySet, Intent>{
// LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): const Intent(AuthpassIntents.searchKey),
// LogicalKeySet(LogicalKeyboardKey.keyA): const Intent(AuthpassIntents.searchKey),
// },
// child: Actions(
// actions: {
// AuthpassIntents.searchKey: () => CallbackAction(AuthpassIntents.searchKey, onInvoke: (focusNode, _) {
// _logger.info('search key action was invoked.');
// }),
// },
// child: DefaultFocusTraversal(
// policy: ReadingOrderTraversalPolicy(),
// child: Shortcuts(
// shortcuts: <LogicalKeySet, Intent>{
// LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
// const Intent(AuthpassIntents.searchKey),
// LogicalKeySet(LogicalKeyboardKey.keyA): const Intent(AuthpassIntents.searchKey),
// LogicalKeySet(LogicalKeyboardKey.tab): const Intent(AuthpassIntents.searchKey),
// },
// child: FocusScope(
// autofocus: true,
// child: child,
// ),
// ),
// ),
// ),
// );
// }
//}
class KeyboardHandler extends StatefulWidget {
const KeyboardHandler({
Key? key,
required this.systemWideShortcuts,
this.child,
required this.child,
}) : super(key: key);
final Widget? child;
final Widget child;
final bool systemWideShortcuts;
static bool get supportsSystemWideShortcuts =>
@ -79,18 +36,17 @@ class KeyboardHandler extends StatefulWidget {
_KeyboardHandlerState createState() => _KeyboardHandlerState();
}
const keyboardShortcut = KeyboardShortcut(type: KeyboardShortcutType.search);
class _KeyboardHandlerState extends State<KeyboardHandler> {
final _keyboardShortcutEvents = KeyboardShortcutEvents();
final _actionsKey = GlobalKey();
late HotKey _hotKey;
final FocusNode _focusNode = FocusNode(
debugLabel: nonNls('AuthPassKeyboardFocus'),
onKey: (focusNode, rawKeyEvent) {
// _logger.info('got onKey: ($focusNode) $rawKeyEvent');
return KeyEventResult.ignored;
},
// onKey: (focusNode, rawKeyEvent) {
// // _logger.info('got onKey: ($focusNode) $rawKeyEvent');
// return KeyEventResult.ignored;
// },
);
@override
@ -106,6 +62,14 @@ class _KeyboardHandlerState extends State<KeyboardHandler> {
if (widget.systemWideShortcuts) {
_registerSystemWideShortcuts();
}
_keyboardShortcutEvents._changeNotifier.addListener(() {
SchedulerBinding.instance?.addPostFrameCallback((timeStamp) {
setState(() {
_logger.fine('actions changed.');
});
});
});
}
@override
@ -125,7 +89,13 @@ class _KeyboardHandlerState extends State<KeyboardHandler> {
HotKeyManager.instance.register(_hotKey, keyDownHandler: (hotKey) {
_logger.fine('received global hotkey $hotKey');
WindowManager.instance.show();
_keyboardShortcutEvents._shortcutEvents.add(keyboardShortcut);
final context = _actionsKey.currentContext;
if (context == null) {
_logger.warning('Unable to find context to invoke global action.');
return;
}
Actions.maybeInvoke(context, const SearchIntent());
// _keyboardShortcutEvents._shortcutEvents.add(searchKeyboardShortcut);
});
}
}
@ -144,112 +114,45 @@ class _KeyboardHandlerState extends State<KeyboardHandler> {
@override
Widget build(BuildContext context) {
return Provider<KeyboardShortcutEvents>.value(
value: _keyboardShortcutEvents,
child: RawKeyboardListener(
focusNode: _focusNode,
onKey: (key) {
if (key is RawKeyDownEvent) {
// final primaryFocus = WidgetsBinding.instance.focusManager.primaryFocus;
// if (primaryFocus is FocusScopeNode) {
// final focusedChild = primaryFocus.focusedChild;
// _logger.info(
// '(me: ${_focusNode.hashCode}, ${_focusNode.hasFocus}, ${_focusNode.hasPrimaryFocus}) primaryFocus(${primaryFocus.hashCode}: ${primaryFocus.hasFocus}, ${primaryFocus.hasPrimaryFocus} /// (${focusedChild.hashCode}) ${focusedChild.hasFocus}, ${focusedChild.hasPrimaryFocus}');
// } else {
// _logger.info(
// '(me: ${_focusNode.hashCode}, ${_focusNode.hasFocus}, ${_focusNode.hasPrimaryFocus}) primaryFocus(${primaryFocus.hashCode}: ${primaryFocus.hasFocus}, ${primaryFocus.hasPrimaryFocus}');
// }
// for now do everything hard coded, until flutters actions & co get a bit easier to understand.. :-)
final modifiers = key.data.modifiersPressed.keys
// we don't care about a few modifiers..
.where((modifier) => ![
ModifierKey.functionModifier,
ModifierKey.numLockModifier
].contains(modifier))
.toList();
final character = key.logicalKey;
_logger.info(
'RawKeyboardListener.onKey: $modifiers + $character ($key)');
final hasControlModifier =
modifiers.contains(ModifierKey.controlModifier) ||
modifiers.contains(ModifierKey.metaModifier);
if (modifiers.length == 1 && hasControlModifier) {
final mapping = {
LogicalKeyboardKey.keyF:
const KeyboardShortcut(type: KeyboardShortcutType.search),
LogicalKeyboardKey.keyB: const KeyboardShortcut(
type: KeyboardShortcutType.copyUsername),
LogicalKeyboardKey.keyC: const KeyboardShortcut(
type: KeyboardShortcutType.copyPassword),
LogicalKeyboardKey.keyT:
const KeyboardShortcut(type: KeyboardShortcutType.copyTotp),
LogicalKeyboardKey.keyP:
const KeyboardShortcut(type: KeyboardShortcutType.moveUp),
LogicalKeyboardKey.keyN:
const KeyboardShortcut(type: KeyboardShortcutType.moveDown),
LogicalKeyboardKey.keyG: const KeyboardShortcut(
type: KeyboardShortcutType.generatePassword),
LogicalKeyboardKey.keyU:
const KeyboardShortcut(type: KeyboardShortcutType.copyUrl),
};
final shortcut = mapping[character];
if (shortcut != null) {
_keyboardShortcutEvents._shortcutEvents.add(shortcut);
} else {
if (character == LogicalKeyboardKey.bracketRight) {
final t = context.read<AppDataBloc>().updateNextTheme();
_logger.fine('Switching theme to $t');
} else if (character == LogicalKeyboardKey.keyV) {
if (AuthPassPlatform.isLinux) {
final w = WidgetsBinding
.instance!.focusManager.primaryFocus!.context!.widget;
Clipboard.getData(AppConstants.contentTypeTextPlain)
.then((value) async {
// await Future<void>.delayed(const Duration(seconds: 2));
final newContent = value?.text ?? CharConstants.empty;
if (w is EditableText) {
final s = w.controller.selection;
final oldContent = w.controller.text;
final before = s.textBefore(oldContent);
final after = s.textAfter(oldContent);
w.controller.text = before + newContent + after;
w.controller.selection = TextSelection.collapsed(
offset: s.start + newContent.length);
}
});
}
}
}
} else if (hasControlModifier &&
modifiers.contains(ModifierKey.shiftModifier)) {
if (character == LogicalKeyboardKey.keyO) {
_keyboardShortcutEvents._shortcutEvents.add(
const KeyboardShortcut(type: KeyboardShortcutType.openUrl));
}
} else if (modifiers.isEmpty) {
_logger.finer('modifiers is empty.. now check which key it is.');
if (character == LogicalKeyboardKey.tab) {
WidgetsBinding.instance!.focusManager.primaryFocus!.nextFocus();
// TODO(flutterbug) see https://github.com/flutter/flutter/issues/36976
} else if (character == LogicalKeyboardKey.arrowUp ||
character.keyId == 0x0000f700) {
_keyboardShortcutEvents._shortcutEvents.add(
const KeyboardShortcut(type: KeyboardShortcutType.moveUp));
} else if (character == LogicalKeyboardKey.arrowDown ||
character.keyId == 0x0000f701) {
_logger.info('moving down.');
_keyboardShortcutEvents._shortcutEvents.add(
const KeyboardShortcut(
type: KeyboardShortcutType.moveDown));
} else if (character == LogicalKeyboardKey.escape) {
_keyboardShortcutEvents._shortcutEvents.add(
const KeyboardShortcut(type: KeyboardShortcutType.escape));
}
}
}
},
child: widget.child!,
final s = defaultAuthPassShortcuts;
final theme = Theme.of(context);
final shortcuts = Map.fromEntries(
s.map((e) => MapEntry(e.triggerForPlatform(theme.platform), e.intent)));
final loc = AppLocalizations.of(context);
final showHelpShortcut = <Type, Action<Intent>>{
KeyboardShortcutHelpIntent: CallbackAction(onInvoke: (_) {
final descr = s
.map((e) => [
e.triggerForPlatform(theme.platform).debugDescribeKeys(),
e.label(loc)
].join(CharConstants.colon + CharConstants.space))
.join(CharConstants.newLine);
DialogUtils.showSimpleAlertDialog(
context,
loc.shortcutHelpTitle,
descr,
routeAppend: 'shortcuts',
);
})
};
return Shortcuts(
manager: LoggingShortcutManager(),
shortcuts: shortcuts,
child: Provider<KeyboardShortcutEvents>.value(
value: _keyboardShortcutEvents,
child: Actions(
key: _actionsKey,
actions: {
..._keyboardShortcutEvents._intentActionsMap,
...showHelpShortcut,
},
dispatcher: LoggingActionDispatcher(),
child: Focus(
focusNode: _focusNode,
autofocus: true,
child: widget.child,
),
),
),
);
}
@ -262,45 +165,67 @@ class _KeyboardHandlerState extends State<KeyboardHandler> {
}
}
enum KeyboardShortcutType {
search,
copyPassword,
copyUsername,
copyTotp,
moveUp,
moveDown,
generatePassword,
copyUrl,
openUrl,
escape,
}
class KeyboardShortcut {
const KeyboardShortcut({required this.type});
final KeyboardShortcutType type;
@NonNls
@override
String toString() {
return 'KeyboardShortcut{type: $type}';
}
}
class KeyboardShortcutEvents with StreamSubscriberBase {
KeyboardShortcutEvents() {
handle(shortcutEvents.listen((event) {
_logger.finer('Got keyboard event $event');
}));
KeyboardShortcutEvents();
final _changeNotifier = ChangeNotifier();
final List<MapEntry<Type, Action<Intent>>> _intentActions = [];
Map<Type, Action<Intent>> _intentActionsMap = {};
IntentActionRegistration registerActions(Map<Type, Action<Intent>> actions) {
final newEntries = actions.entries.toList();
_intentActions.addAll(newEntries);
_intentActionsMap = Map.fromEntries(_intentActions);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
_changeNotifier.notifyListeners();
return IntentActionRegistration._(this, newEntries);
}
final StreamController<KeyboardShortcut> _shortcutEvents =
StreamController<KeyboardShortcut>.broadcast();
Stream<KeyboardShortcut> get shortcutEvents => _shortcutEvents.stream;
void _removeActions(IntentActionRegistration registration) {
registration._registeredActions.forEach(_intentActions.remove);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
_changeNotifier.notifyListeners();
}
void dispose() {
_shortcutEvents.close();
cancelSubscriptions();
}
}
class IntentActionRegistration {
IntentActionRegistration._(this._registrar, this._registeredActions);
final KeyboardShortcutEvents _registrar;
final List<MapEntry<Type, Action<Intent>>> _registeredActions;
void dispose() {
_registrar._removeActions(this);
}
}
/// A ShortcutManager that logs all keys that it handles.
class LoggingShortcutManager extends ShortcutManager {
@override
KeyEventResult handleKeypress(BuildContext context, RawKeyEvent event,
{LogicalKeySet? keysPressed}) {
final KeyEventResult result = super.handleKeypress(context, event);
_logger.info('handleKeyPress($event, $keysPressed) result: $result');
// if (result == KeyEventResult.handled) {
// _logger.fine('Handled shortcut $event in $context');
// }
return result;
}
}
/// An ActionDispatcher that logs all the actions that it invokes.
class LoggingActionDispatcher extends ActionDispatcher {
@override
Object? invokeAction(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
_logger.fine('Action invoked: $action($intent) from $context');
return super.invokeAction(action, intent, context);
}
}

View File

@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
class SearchIntent extends Intent {
const SearchIntent();
}
class CopyPasswordIntent extends Intent {}
class CopyUsernameIntent extends Intent {}
class CopyTotpIntent extends Intent {}
class MoveUpIntent extends Intent {}
class MoveDownIntent extends Intent {}
class GeneratePasswordIntent extends Intent {}
class CopyUrlIntent extends Intent {}
class OpenUrlIntent extends Intent {}
class CancelSearchFilterIntent extends Intent {}
class KeyboardShortcutHelpIntent extends Intent {}

View File

@ -0,0 +1,128 @@
import 'package:authpass/ui/widgets/shortcut/authpass_intents.dart';
import 'package:authpass/utils/constants.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
typedef LabelProvider = String Function(AppLocalizations loc);
late final defaultAuthPassShortcuts = [
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyF,
intent: const SearchIntent(),
label: (loc) => loc.searchButtonLabel,
),
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyB,
intent: CopyUsernameIntent(),
label: (loc) => loc.shortcutCopyUsername,
),
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyC,
intent: CopyPasswordIntent(),
label: (loc) => loc.shortcutCopyPassword,
),
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyT,
intent: CopyTotpIntent(),
label: (loc) => loc.shortcutCopyTotp,
),
AuthPassShortcut.all(
key: const SingleActivator(LogicalKeyboardKey.arrowUp),
intent: MoveUpIntent(),
label: (loc) => loc.shortcutMoveUp,
),
AuthPassShortcut.all(
key: const SingleActivator(LogicalKeyboardKey.keyP, control: true),
intent: MoveUpIntent(),
label: (loc) => loc.shortcutMoveUp,
),
AuthPassShortcut.all(
key: const SingleActivator(LogicalKeyboardKey.arrowDown),
intent: MoveDownIntent(),
label: (loc) => loc.shortcutMoveDown,
),
AuthPassShortcut.all(
key: const SingleActivator(LogicalKeyboardKey.keyN, control: true),
intent: MoveDownIntent(),
label: (loc) => loc.shortcutMoveDown,
),
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyG,
intent: GeneratePasswordIntent(),
label: (loc) => loc.shortcutGeneratePassword,
),
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyU,
intent: CopyUrlIntent(),
label: (loc) => loc.shortcutCopyUrl,
),
AuthPassShortcut.def(
key: LogicalKeyboardKey.keyO,
intent: OpenUrlIntent(),
label: (loc) => loc.shortcutOpenUrl,
),
AuthPassShortcut.all(
key: const SingleActivator(LogicalKeyboardKey.escape),
intent: CancelSearchFilterIntent(),
label: (loc) => loc.shortcutCancelSearch,
),
AuthPassShortcut.all(
key: const CharacterActivator(CharConstants.questionMark),
intent: KeyboardShortcutHelpIntent(),
label: (loc) => loc.shortcutShortcutHelp,
),
];
class AuthPassShortcut {
const AuthPassShortcut({
required this.mac,
required this.linux,
required this.windows,
required this.intent,
required this.label,
});
AuthPassShortcut.def({
required LogicalKeyboardKey key,
required Intent intent,
required LabelProvider label,
}) : this(
mac: SingleActivator(key, meta: true),
linux: SingleActivator(key, control: true),
windows: SingleActivator(key, control: true),
intent: intent,
label: label,
);
AuthPassShortcut.all({
required ShortcutActivator key,
required Intent intent,
required LabelProvider label,
}) : this(
mac: key,
linux: key,
windows: key,
intent: intent,
label: label,
);
final ShortcutActivator mac;
final ShortcutActivator linux;
final ShortcutActivator windows;
final Intent intent;
final LabelProvider label;
ShortcutActivator triggerForPlatform(TargetPlatform platform) {
switch (platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
return linux;
case TargetPlatform.iOS:
return mac;
case TargetPlatform.macOS:
return mac;
case TargetPlatform.windows:
return windows;
}
}
}

View File

@ -64,6 +64,8 @@ class CharConstants {
static const equalSign = '=';
static const star = '*';
static const questionMark = '?';
}
class AssetConstants {