mirror of
https://github.com/authpass/authpass.git
synced 2025-05-17 14:26:08 +08:00
use flutter shortcuts/actions/intent framework instead of manually handling keyboard shortcuts.
This commit is contained in:
@ -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": {
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
25
authpass/lib/ui/widgets/shortcut/authpass_intents.dart
Normal file
25
authpass/lib/ui/widgets/shortcut/authpass_intents.dart
Normal 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 {}
|
128
authpass/lib/ui/widgets/shortcut/shortcuts.dart
Normal file
128
authpass/lib/ui/widgets/shortcut/shortcuts.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -64,6 +64,8 @@ class CharConstants {
|
||||
static const equalSign = '=';
|
||||
|
||||
static const star = '*';
|
||||
|
||||
static const questionMark = '?';
|
||||
}
|
||||
|
||||
class AssetConstants {
|
||||
|
Reference in New Issue
Block a user