Refactor Intents/Actions.

Big changes to Intents/Actions where we now have an argument for several
of the intents to indicate the target. This allows for handling these
actions on a higher level.
This commit is contained in:
Dain Nilsson 2024-01-17 16:29:28 +01:00
parent 50dd72b83b
commit 5d286de0a0
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
21 changed files with 1075 additions and 928 deletions

View File

@ -31,14 +31,6 @@ import 'state.dart';
import 'views/keys.dart';
import 'views/settings_page.dart';
class OpenIntent extends Intent {
const OpenIntent();
}
class CopyIntent extends Intent {
const CopyIntent();
}
class CloseIntent extends Intent {
const CloseIntent();
}
@ -51,6 +43,10 @@ class SearchIntent extends Intent {
const SearchIntent();
}
class EscapeIntent extends Intent {
const EscapeIntent();
}
class NextDeviceIntent extends Intent {
const NextDeviceIntent();
}
@ -63,26 +59,45 @@ class AboutIntent extends Intent {
const AboutIntent();
}
class EditIntent extends Intent {
const EditIntent();
class OpenIntent<T> extends Intent {
final T target;
const OpenIntent(this.target);
}
class DeleteIntent extends Intent {
const DeleteIntent();
class CopyIntent<T> extends Intent {
final T target;
const CopyIntent(this.target);
}
class RefreshIntent extends Intent {
const RefreshIntent();
class EditIntent<T> extends Intent {
final T target;
const EditIntent(this.target);
}
class EscapeIntent extends Intent {
const EscapeIntent();
class DeleteIntent<T> extends Intent {
final T target;
const DeleteIntent(this.target);
}
class RefreshIntent<T> extends Intent {
final T target;
const RefreshIntent(this.target);
}
/// Use cmd on macOS, use ctrl on the other platforms
SingleActivator ctrlOrCmd(LogicalKeyboardKey key) =>
SingleActivator(key, meta: Platform.isMacOS, control: !Platform.isMacOS);
/// Common shortcuts for items
Map<ShortcutActivator, Intent> itemShortcuts<T>(T item) => {
ctrlOrCmd(LogicalKeyboardKey.keyR): RefreshIntent<T>(item),
ctrlOrCmd(LogicalKeyboardKey.keyC): CopyIntent<T>(item),
const SingleActivator(LogicalKeyboardKey.copy): CopyIntent<T>(item),
const SingleActivator(LogicalKeyboardKey.delete): DeleteIntent<T>(item),
const SingleActivator(LogicalKeyboardKey.enter): OpenIntent<T>(item),
const SingleActivator(LogicalKeyboardKey.space): OpenIntent<T>(item),
};
Widget registerGlobalShortcuts(
{required WidgetRef ref, required Widget child}) =>
Actions(
@ -162,10 +177,7 @@ Widget registerGlobalShortcuts(
},
child: Shortcuts(
shortcuts: {
ctrlOrCmd(LogicalKeyboardKey.keyC): const CopyIntent(),
const SingleActivator(LogicalKeyboardKey.copy): const CopyIntent(),
ctrlOrCmd(LogicalKeyboardKey.keyF): const SearchIntent(),
ctrlOrCmd(LogicalKeyboardKey.keyR): const RefreshIntent(),
const SingleActivator(LogicalKeyboardKey.escape):
const EscapeIntent(),
if (isDesktop) ...{

View File

@ -15,7 +15,6 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/state.dart';
@ -23,18 +22,20 @@ import '../models.dart';
import '../shortcuts.dart';
import 'action_popup_menu.dart';
class AppListItem extends ConsumerStatefulWidget {
class AppListItem<T> extends ConsumerStatefulWidget {
final T item;
final Widget? leading;
final String title;
final String? subtitle;
final String? semanticTitle;
final Widget? trailing;
final List<ActionItem> Function(BuildContext context)? buildPopupActions;
final Intent? activationIntent;
final Intent? tapIntent;
final Intent? doubleTapIntent;
final bool selected;
final bool openOnSingleTap;
const AppListItem({
const AppListItem(
this.item, {
super.key,
this.leading,
required this.title,
@ -42,16 +43,16 @@ class AppListItem extends ConsumerStatefulWidget {
this.subtitle,
this.trailing,
this.buildPopupActions,
this.activationIntent,
this.tapIntent,
this.doubleTapIntent,
this.selected = false,
this.openOnSingleTap = false,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AppListItemState();
ConsumerState<ConsumerStatefulWidget> createState() => _AppListItemState<T>();
}
class _AppListItemState extends ConsumerState<AppListItem> {
class _AppListItemState<T> extends ConsumerState<AppListItem> {
final FocusNode _focusNode = FocusNode();
int _lastTap = 0;
@ -65,17 +66,15 @@ class _AppListItemState extends ConsumerState<AppListItem> {
Widget build(BuildContext context) {
final subtitle = widget.subtitle;
final buildPopupActions = widget.buildPopupActions;
final activationIntent = widget.activationIntent;
final tapIntent = widget.tapIntent;
final doubleTapIntent = widget.doubleTapIntent;
final trailing = widget.trailing;
final hasFeature = ref.watch(featureProvider);
return Semantics(
label: widget.semanticTitle ?? widget.title,
child: Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
},
shortcuts: itemShortcuts<T>(widget.item),
child: InkWell(
focusNode: _focusNode,
borderRadius: BorderRadius.circular(30),
@ -95,27 +94,28 @@ class _AppListItemState extends ConsumerState<AppListItem> {
}
},
onTap: () {
if (isDesktop && !widget.openOnSingleTap) {
if (tapIntent != null) {
Actions.invoke(context, tapIntent);
}
if (isDesktop && doubleTapIntent != null) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastTap < 500) {
setState(() {
_lastTap = 0;
});
Actions.invoke(context, activationIntent ?? const OpenIntent());
Actions.invoke(context, doubleTapIntent);
} else {
_focusNode.requestFocus();
setState(() {
_lastTap = now;
});
}
} else {
Actions.invoke<OpenIntent>(context, const OpenIntent());
}
},
onLongPress: activationIntent == null
onLongPress: doubleTapIntent == null
? null
: () {
Actions.invoke(context, activationIntent);
Actions.invoke(context, doubleTapIntent);
},
child: Stack(
alignment: AlignmentDirectional.center,
@ -123,7 +123,7 @@ class _AppListItemState extends ConsumerState<AppListItem> {
const SizedBox(height: 64),
ListTile(
mouseCursor:
widget.openOnSingleTap ? SystemMouseCursors.click : null,
widget.tapIntent != null ? SystemMouseCursors.click : null,
selected: widget.selected,
leading: widget.leading,
title: Text(

View File

@ -21,8 +21,10 @@ import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
List<ActionItem> buildFingerprintActions(
Fingerprint fingerprint, AppLocalizations l10n) {
return [
ActionItem(
key: keys.editFingerintAction,
@ -30,7 +32,7 @@ List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
icon: const Icon(Icons.edit),
title: l10n.s_rename_fp,
subtitle: l10n.l_rename_fp_desc,
intent: const EditIntent(),
intent: EditIntent(fingerprint),
),
ActionItem(
key: keys.deleteFingerprintAction,
@ -39,12 +41,13 @@ List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
icon: const Icon(Icons.delete),
title: l10n.s_delete_fingerprint,
subtitle: l10n.l_delete_fingerprint_desc,
intent: const DeleteIntent(),
intent: DeleteIntent(fingerprint),
),
];
}
List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
List<ActionItem> buildCredentialActions(
FidoCredential credential, AppLocalizations l10n) {
return [
ActionItem(
key: keys.deleteCredentialAction,
@ -53,7 +56,7 @@ List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
icon: const Icon(Icons.delete),
title: l10n.s_delete_passkey,
subtitle: l10n.l_delete_account_desc,
intent: const DeleteIntent(),
intent: DeleteIntent(credential),
),
];
}

View File

@ -33,7 +33,9 @@ class CredentialDialog extends ConsumerWidget {
return Actions(
actions: {
if (hasFeature(features.credentialsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
DeleteIntent<FidoCredential>:
CallbackAction<DeleteIntent<FidoCredential>>(
onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
@ -41,7 +43,7 @@ class CredentialDialog extends ConsumerWidget {
context: context,
builder: (context) => DeleteCredentialDialog(
node.path,
credential,
intent.target,
),
) ??
false);
@ -55,6 +57,8 @@ class CredentialDialog extends ConsumerWidget {
return deleted;
}),
},
child: Shortcuts(
shortcuts: itemShortcuts(credential),
child: FocusScope(
autofocus: true,
child: FsDialog(
@ -76,7 +80,8 @@ class CredentialDialog extends ConsumerWidget {
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.bodySmall!.color,
color:
Theme.of(context).textTheme.bodySmall!.color,
),
),
const SizedBox(height: 16),
@ -87,12 +92,13 @@ class CredentialDialog extends ConsumerWidget {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildCredentialActions(l10n),
actions: buildCredentialActions(credential, l10n),
),
],
),
),
),
),
);
}
}

View File

@ -33,14 +33,15 @@ class FingerprintDialog extends ConsumerWidget {
return Actions(
actions: {
if (hasFeature(features.fingerprintsEdit))
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
EditIntent<Fingerprint>:
CallbackAction<EditIntent<Fingerprint>>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final Fingerprint? renamed =
await withContext((context) async => await showBlurDialog(
context: context,
builder: (context) => RenameFingerprintDialog(
node.path,
fingerprint,
intent.target,
),
));
if (renamed != null) {
@ -58,7 +59,8 @@ class FingerprintDialog extends ConsumerWidget {
return renamed;
}),
if (hasFeature(features.fingerprintsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
DeleteIntent<Fingerprint>: CallbackAction<DeleteIntent<Fingerprint>>(
onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
@ -66,7 +68,7 @@ class FingerprintDialog extends ConsumerWidget {
context: context,
builder: (context) => DeleteFingerprintDialog(
node.path,
fingerprint,
intent.target,
),
) ??
false);
@ -80,6 +82,8 @@ class FingerprintDialog extends ConsumerWidget {
return deleted;
}),
},
child: Shortcuts(
shortcuts: itemShortcuts(fingerprint),
child: FocusScope(
autofocus: true,
child: FsDialog(
@ -103,12 +107,13 @@ class FingerprintDialog extends ConsumerWidget {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildFingerprintActions(l10n),
actions: buildFingerprintActions(fingerprint, l10n),
),
],
),
),
),
),
);
}
}

View File

@ -53,66 +53,79 @@ class FidoUnlockedPage extends ConsumerStatefulWidget {
class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
Object? _selected;
Widget _registerFingerprintActions(
Fingerprint fingerprint, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final selected = _selected;
List<Widget Function(bool expanded)> children = [];
if (widget.state.credMgmt) {
final data = ref.watch(credentialProvider(widget.node.path)).asData;
if (data == null) {
return _buildLoadingPage(context);
}
final creds = data.value;
if (creds.isNotEmpty) {
children.add((_) => ListTitle(l10n.s_passkeys));
children.addAll(creds.map(
(cred) => (expanded) => _CredentialListItem(
cred,
expanded: expanded,
selected: selected == cred,
),
));
}
}
int nFingerprints = 0;
if (widget.state.bioEnroll != null) {
final data = ref.watch(fingerprintProvider(widget.node.path)).asData;
if (data == null) {
return _buildLoadingPage(context);
}
final fingerprints = data.value;
if (fingerprints.isNotEmpty) {
nFingerprints = fingerprints.length;
children.add((_) => ListTitle(l10n.s_fingerprints));
children.addAll(fingerprints.map(
(fp) => (expanded) => _FingerprintListItem(
fp,
expanded: expanded,
selected: fp == selected,
),
));
}
}
final hasFeature = ref.watch(featureProvider);
final hasActions = hasFeature(features.actions);
if (children.isNotEmpty) {
return Actions(
actions: {
if (hasFeature(features.fingerprintsEdit))
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final renamed = await ref.read(withContextProvider)(
(context) => showBlurDialog<Fingerprint>(
context: context,
builder: (context) => RenameFingerprintDialog(
widget.node.path,
fingerprint,
),
));
if (_selected == fingerprint && renamed != null) {
setState(() {
_selected = renamed;
});
}
return renamed;
}),
if (hasFeature(features.fingerprintsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final deleted = await ref.read(withContextProvider)(
(context) => showBlurDialog<bool?>(
context: context,
builder: (context) => DeleteFingerprintDialog(
widget.node.path,
fingerprint,
),
));
if (_selected == fingerprint && deleted == true) {
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (selected != null) {
setState(() {
_selected = null;
});
} else {
Actions.invoke(context, intent);
}
return deleted;
return false;
}),
...actions,
},
child: Builder(builder: builder),
OpenIntent<FidoCredential>:
CallbackAction<OpenIntent<FidoCredential>>(onInvoke: (intent) {
return showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => CredentialDialog(intent.target),
);
}
Widget _registerCredentialActions(
FidoCredential credential, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) {
final hasFeature = ref.watch(featureProvider);
return Actions(
actions: {
}),
if (hasFeature(features.credentialsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
DeleteIntent<FidoCredential>:
CallbackAction<DeleteIntent<FidoCredential>>(
onInvoke: (intent) async {
final credential = intent.target;
final deleted = await ref.read(withContextProvider)(
(context) => showBlurDialog<bool?>(
context: context,
@ -129,117 +142,58 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
}
return deleted;
}),
...actions,
},
child: Builder(builder: builder),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final selected = _selected;
List<Widget Function(bool expanded)> children = [];
if (widget.state.credMgmt) {
final data = ref.watch(credentialProvider(widget.node.path)).asData;
if (data == null) {
return _buildLoadingPage(context);
}
final creds = data.value;
if (creds.isNotEmpty) {
children.add((_) => ListTitle(l10n.s_passkeys));
children.addAll(
creds.map((cred) => (expanded) => _registerCredentialActions(
cred,
ref: ref,
actions: {
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) {
if (expanded) {
setState(() {
_selected = cred;
});
return null;
} else {
OpenIntent<Fingerprint>:
CallbackAction<OpenIntent<Fingerprint>>(onInvoke: (intent) {
return showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => CredentialDialog(cred),
builder: (context) => FingerprintDialog(intent.target),
);
}
}),
},
builder: (context) => _CredentialListItem(
cred,
expanded: expanded,
selected: selected == cred,
),
)));
}
}
int nFingerprints = 0;
if (widget.state.bioEnroll != null) {
final data = ref.watch(fingerprintProvider(widget.node.path)).asData;
if (data == null) {
return _buildLoadingPage(context);
}
final fingerprints = data.value;
if (fingerprints.isNotEmpty) {
nFingerprints = fingerprints.length;
children.add((_) => ListTitle(l10n.s_fingerprints));
children.addAll(
fingerprints.map((fp) => (expanded) => _registerFingerprintActions(
fp,
ref: ref,
actions: {
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) {
if (expanded) {
setState(() {
_selected = fp;
});
return null;
} else {
return showBlurDialog(
if (hasFeature(features.fingerprintsEdit))
EditIntent<Fingerprint>: CallbackAction<EditIntent<Fingerprint>>(
onInvoke: (intent) async {
final fingerprint = intent.target;
final renamed = await ref.read(withContextProvider)(
(context) => showBlurDialog<Fingerprint>(
context: context,
barrierColor: Colors.transparent,
builder: (context) => FingerprintDialog(fp),
);
}
}),
},
builder: (context) => _FingerprintListItem(
fp,
expanded: expanded,
selected: fp == selected,
builder: (context) => RenameFingerprintDialog(
widget.node.path,
fingerprint,
),
)));
));
if (_selected == fingerprint && renamed != null) {
setState(() {
_selected = renamed;
});
}
}
final hasActions = ref.watch(featureProvider)(features.actions);
if (children.isNotEmpty) {
return Actions(
actions: {
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (selected != null) {
return renamed;
}),
if (hasFeature(features.fingerprintsDelete))
DeleteIntent<Fingerprint>:
CallbackAction<DeleteIntent<Fingerprint>>(
onInvoke: (intent) async {
final fingerprint = intent.target;
final deleted = await ref.read(withContextProvider)(
(context) => showBlurDialog<bool?>(
context: context,
builder: (context) => DeleteFingerprintDialog(
widget.node.path,
fingerprint,
),
));
if (_selected == fingerprint && deleted == true) {
setState(() {
_selected = null;
});
} else {
Actions.invoke(context, intent);
}
return false;
return deleted;
}),
},
child: AppPage(
title: Text(l10n.s_webauthn),
detailViewBuilder: switch (selected) {
FidoCredential credential => (context) =>
_registerCredentialActions(credential,
ref: ref,
builder: (context) => Column(
FidoCredential credential => (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTitle(l10n.s_details),
@ -251,9 +205,7 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
children: [
Text(
credential.userName,
style: Theme.of(context)
.textTheme
.headlineSmall,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
@ -281,14 +233,11 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildCredentialActions(l10n),
actions: buildCredentialActions(credential, l10n),
),
],
)),
Fingerprint fingerprint => (context) => _registerFingerprintActions(
fingerprint,
ref: ref,
builder: (context) => Column(
),
Fingerprint fingerprint => (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTitle(l10n.s_details),
@ -300,8 +249,7 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
children: [
Text(
fingerprint.label,
style:
Theme.of(context).textTheme.headlineSmall,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
@ -314,11 +262,10 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildFingerprintActions(l10n),
actions: buildFingerprintActions(fingerprint, l10n),
),
],
),
),
_ => null
},
keyActionsBuilder: hasActions
@ -326,10 +273,31 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
context, widget.node, widget.state, nFingerprints)
: null,
keyActionsBadge: fidoShowActionsNotifier(widget.state),
builder: (context, expanded) => Column(
builder: (context, expanded) => Actions(
actions: {
if (expanded) ...{
OpenIntent<FidoCredential>:
CallbackAction<OpenIntent<FidoCredential>>(
onInvoke: (intent) {
setState(() {
_selected = intent.target;
});
return null;
}),
OpenIntent<Fingerprint>:
CallbackAction<OpenIntent<Fingerprint>>(onInvoke: (intent) {
setState(() {
_selected = intent.target;
});
return null;
}),
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children.map((f) => f(expanded)).toList()),
),
),
);
}
@ -380,6 +348,7 @@ class _CredentialListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AppListItem(
credential,
selected: selected,
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
@ -391,12 +360,13 @@ class _CredentialListItem extends StatelessWidget {
trailing: expanded
? null
: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
onPressed: Actions.handler(context, OpenIntent(credential)),
child: const Icon(Icons.more_horiz),
),
openOnSingleTap: expanded,
tapIntent: isDesktop && !expanded ? null : OpenIntent(credential),
doubleTapIntent: isDesktop && !expanded ? OpenIntent(credential) : null,
buildPopupActions: (context) =>
buildCredentialActions(AppLocalizations.of(context)!),
buildCredentialActions(credential, AppLocalizations.of(context)!),
);
}
}
@ -412,6 +382,7 @@ class _FingerprintListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AppListItem(
fingerprint,
selected: selected,
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onSecondary,
@ -422,12 +393,13 @@ class _FingerprintListItem extends StatelessWidget {
trailing: expanded
? null
: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
onPressed: Actions.handler(context, OpenIntent(fingerprint)),
child: const Icon(Icons.more_horiz),
),
openOnSingleTap: expanded,
tapIntent: isDesktop && !expanded ? null : OpenIntent(fingerprint),
doubleTapIntent: isDesktop && !expanded ? OpenIntent(fingerprint) : null,
buildPopupActions: (context) =>
buildFingerprintActions(AppLocalizations.of(context)!),
buildFingerprintActions(fingerprint, AppLocalizations.of(context)!),
);
}
}

View File

@ -32,8 +32,6 @@ import '../models.dart';
import '../state.dart';
import 'account_helper.dart';
import 'actions.dart';
import 'delete_account_dialog.dart';
import 'rename_account_dialog.dart';
class AccountDialog extends ConsumerWidget {
final OathCredential credential;
@ -55,27 +53,30 @@ class AccountDialog extends ConsumerWidget {
final subtitle = helper.subtitle;
return registerOathActions(
credential,
node.path,
ref: ref,
builder: (context) {
if (helper.code == null &&
(isDesktop || node.transport == Transport.usb)) {
Timer.run(() {
// Only call if credential hasn't been deleted/renamed
if (ref.read(credentialsProvider)?.contains(credential) == true) {
Actions.invoke(
context, RefreshIntent<OathCredential>(credential));
}
});
}
return Actions(
actions: {
if (hasFeature(features.accountsRename))
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
EditIntent<OathCredential>:
CallbackAction<EditIntent<OathCredential>>(
onInvoke: (intent) async {
final renamed =
await withContext((context) async => await showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog.forOathCredential(
ref,
node,
credential,
credentials
?.map((e) => (e.issuer, e.name))
.toList() ??
[],
)));
if (renamed != null) {
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (renamed is OathCredential) {
// Replace the dialog with the renamed credential
final withContext = ref.read(withContextProvider);
await withContext((context) async {
Navigator.of(context).pop();
await showBlurDialog(
@ -89,20 +90,12 @@ class AccountDialog extends ConsumerWidget {
return renamed;
}),
if (hasFeature(features.accountsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(
node,
credential,
),
) ??
false);
DeleteIntent:
CallbackAction<DeleteIntent>(onInvoke: (intent) async {
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
// Pop the account dialog if deleted
final withContext = ref.read(withContextProvider);
if (deleted == true) {
await withContext((context) async {
Navigator.of(context).pop();
@ -111,24 +104,16 @@ class AccountDialog extends ConsumerWidget {
return deleted;
}),
},
builder: (context) {
if (helper.code == null &&
(isDesktop || node.transport == Transport.usb)) {
Timer.run(() {
// Only call if credential hasn't been deleted/renamed
if (ref.read(credentialsProvider)?.contains(credential) == true) {
Actions.invoke(context, const RefreshIntent());
}
});
}
return FocusScope(
child: Shortcuts(
shortcuts: itemShortcuts(credential),
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 32),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 32),
child: Column(
children: [
Padding(
@ -138,7 +123,8 @@ class AccountDialog extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconTheme(
data: IconTheme.of(context).copyWith(size: 24),
data:
IconTheme.of(context).copyWith(size: 24),
child: helper.buildCodeIcon(),
),
const SizedBox(width: 8.0),
@ -161,8 +147,10 @@ class AccountDialog extends ConsumerWidget {
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style:
Theme.of(context).textTheme.bodyMedium!.copyWith(
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall!
@ -180,6 +168,8 @@ class AccountDialog extends ConsumerWidget {
],
),
),
),
),
);
},
);

View File

@ -75,7 +75,7 @@ class AccountHelper {
subtitle: l10n.l_copy_code_desc,
shortcut: Platform.isMacOS ? '\u2318 C' : 'Ctrl+C',
actionStyle: canCopy ? ActionStyle.primary : null,
intent: canCopy ? const CopyIntent() : null,
intent: canCopy ? CopyIntent(credential) : null,
),
if (manual)
ActionItem(
@ -85,7 +85,7 @@ class AccountHelper {
title: l10n.s_calculate,
subtitle: l10n.l_calculate_code_desc,
shortcut: Platform.isMacOS ? '\u2318 R' : 'Ctrl+R',
intent: ready ? const RefreshIntent() : null,
intent: ready ? RefreshIntent(credential) : null,
),
ActionItem(
key: keys.togglePinAction,
@ -95,7 +95,7 @@ class AccountHelper {
: const Icon(Icons.push_pin_outlined),
title: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
subtitle: l10n.l_pin_account_desc,
intent: const TogglePinIntent(),
intent: TogglePinIntent(credential),
),
if (data.info.version.isAtLeast(5, 3))
ActionItem(
@ -104,7 +104,7 @@ class AccountHelper {
icon: const Icon(Icons.edit_outlined),
title: l10n.s_rename_account,
subtitle: l10n.l_rename_account_desc,
intent: const EditIntent(),
intent: EditIntent(credential),
),
ActionItem(
key: keys.deleteAction,
@ -113,7 +113,7 @@ class AccountHelper {
icon: const Icon(Icons.delete_outline),
title: l10n.s_delete_account,
subtitle: l10n.l_delete_account_desc,
intent: const DeleteIntent(),
intent: DeleteIntent(credential),
),
];
},

View File

@ -25,7 +25,10 @@ import 'account_view.dart';
class AccountList extends ConsumerWidget {
final List<OathPair> accounts;
const AccountList(this.accounts, {super.key});
final bool expanded;
final OathCredential? selected;
const AccountList(this.accounts,
{super.key, required this.expanded, this.selected});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -51,12 +54,16 @@ class AccountList extends ConsumerWidget {
...pinnedCreds.map(
(entry) => AccountView(
entry.credential,
expanded: expanded,
selected: entry.credential == selected,
),
),
if (creds.isNotEmpty) ListTitle(l10n.s_accounts),
...creds.map(
(entry) => AccountView(
entry.credential,
expanded: expanded,
selected: entry.credential == selected,
),
),
],

View File

@ -16,27 +16,22 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/app_list_item.dart';
import '../../core/state.dart';
import '../features.dart' as features;
import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
import 'account_helper.dart';
import 'account_icon.dart';
import 'actions.dart';
import 'delete_account_dialog.dart';
import 'rename_account_dialog.dart';
class AccountView extends ConsumerStatefulWidget {
final OathCredential credential;
const AccountView(this.credential, {super.key});
final bool expanded;
final bool selected;
const AccountView(this.credential,
{super.key, required this.expanded, required this.selected});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AccountViewState();
@ -82,47 +77,8 @@ class _AccountViewState extends ConsumerState<AccountView> {
@override
Widget build(BuildContext context) {
final hasFeature = ref.watch(featureProvider);
return registerOathActions(
credential,
ref: ref,
actions: {
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => AccountDialog(credential),
);
return null;
}),
if (hasFeature(features.accountsRename))
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final node = ref.read(currentDeviceProvider)!;
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
return await withContext((context) async => await showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog.forOathCredential(
ref,
node,
credential,
credentials?.map((e) => (e.issuer, e.name)).toList() ?? [],
),
));
}),
if (hasFeature(features.accountsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final node = ref.read(currentDeviceProvider)!;
return await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(node, credential),
) ??
false);
}),
},
builder: (context) {
final helper = AccountHelper(context, ref, credential);
return LayoutBuilder(builder: (context, constraints) {
final showAvatar = constraints.maxWidth >= 315;
final subtitle = helper.subtitle;
@ -130,20 +86,15 @@ class _AccountViewState extends ConsumerState<AccountView> {
foregroundColor: Theme.of(context).colorScheme.background,
backgroundColor: _iconColor(400),
child: Text(
(credential.issuer ?? credential.name)
.characters
.first
.toUpperCase(),
(credential.issuer ?? credential.name).characters.first.toUpperCase(),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
),
);
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
},
child: AppListItem(
final openIntent = OpenIntent<OathCredential>(widget.credential);
return AppListItem<OathCredential>(
credential,
selected: widget.selected,
leading: showAvatar
? AccountIcon(
issuer: credential.issuer, defaultWidget: circleAvatar)
@ -156,18 +107,17 @@ class _AccountViewState extends ConsumerState<AccountView> {
? FilledButton.tonalIcon(
icon: helper.buildCodeIcon(),
label: helper.buildCodeLabel(),
onPressed: Actions.handler(context, const OpenIntent()),
onPressed: Actions.handler(context, openIntent),
)
: FilledButton.tonal(
onPressed: Actions.handler(context, const OpenIntent()),
onPressed: Actions.handler(context, openIntent),
child: helper.buildCodeIcon()),
activationIntent: hasFeature(features.accountsClipboard)
? const CopyIntent()
: const OpenIntent(),
tapIntent: isDesktop && !widget.expanded ? null : openIntent,
doubleTapIntent: hasFeature(features.accountsClipboard)
? CopyIntent<OathCredential>(credential)
: null,
buildPopupActions: (_) => helper.buildActions(),
));
});
},
);
});
}
}

View File

@ -19,6 +19,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/state.dart';
@ -26,9 +27,12 @@ import '../../exception/cancellation_exception.dart';
import '../features.dart' as features;
import '../models.dart';
import '../state.dart';
import 'delete_account_dialog.dart';
import 'rename_account_dialog.dart';
class TogglePinIntent extends Intent {
const TogglePinIntent();
final OathCredential target;
const TogglePinIntent(this.target);
}
Future<OathCode?> _calculateCode(
@ -44,7 +48,7 @@ Future<OathCode?> _calculateCode(
}
Widget registerOathActions(
OathCredential credential, {
DevicePath devicePath, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
@ -52,7 +56,9 @@ Widget registerOathActions(
final hasFeature = ref.read(featureProvider);
return Actions(
actions: {
RefreshIntent: CallbackAction<RefreshIntent>(onInvoke: (_) {
RefreshIntent<OathCredential>:
CallbackAction<RefreshIntent<OathCredential>>(onInvoke: (intent) {
final credential = intent.target;
final code = ref.read(codeProvider(credential));
if (!(credential.oathType == OathType.totp &&
code != null &&
@ -62,7 +68,9 @@ Widget registerOathActions(
return code;
}),
if (hasFeature(features.accountsClipboard))
CopyIntent: CallbackAction<CopyIntent>(onInvoke: (_) async {
CopyIntent<OathCredential>: CallbackAction<CopyIntent<OathCredential>>(
onInvoke: (intent) async {
final credential = intent.target;
var code = ref.read(codeProvider(credential));
if (code == null ||
(credential.oathType == OathType.totp &&
@ -82,10 +90,37 @@ Widget registerOathActions(
return code;
}),
if (hasFeature(features.accountsPin))
TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (_) {
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (intent) {
ref.read(favoritesProvider.notifier).toggleFavorite(intent.target.id);
return null;
}),
if (hasFeature(features.accountsRename))
EditIntent<OathCredential>:
CallbackAction<EditIntent<OathCredential>>(onInvoke: (intent) {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
return withContext((context) => showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog.forOathCredential(
ref,
devicePath,
intent.target,
credentials?.map((e) => (e.issuer, e.name)).toList() ?? [],
)));
}),
if (hasFeature(features.accountsDelete))
DeleteIntent<OathCredential>:
CallbackAction<DeleteIntent<OathCredential>>(onInvoke: (intent) {
return ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(
devicePath,
intent.target,
),
) ??
false);
}),
...actions,
},
child: Builder(builder: builder),

View File

@ -119,7 +119,7 @@ class _OathAddMultiAccountPageState
(context) async => await showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog(
device: node!,
devicePath: node!.path,
issuer: cred.issuer,
name: cred.name,
oathType: cred.oathType,

View File

@ -29,9 +29,9 @@ import '../state.dart';
import 'utils.dart';
class DeleteAccountDialog extends ConsumerWidget {
final DeviceNode device;
final DevicePath devicePath;
final OathCredential credential;
const DeleteAccountDialog(this.device, this.credential, {super.key});
const DeleteAccountDialog(this.devicePath, this.credential, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -44,7 +44,7 @@ class DeleteAccountDialog extends ConsumerWidget {
onPressed: () async {
try {
await ref
.read(credentialListProvider(device.path).notifier)
.read(credentialListProvider(devicePath).notifier)
.deleteAccount(credential);
await ref.read(withContextProvider)(
(context) async {

View File

@ -14,6 +14,7 @@
* limitations under the License.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -26,6 +27,7 @@ import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
@ -33,11 +35,15 @@ import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/file_drop_overlay.dart';
import '../../widgets/list_title.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
import 'account_helper.dart';
import 'account_list.dart';
import 'actions.dart';
import 'key_actions.dart';
import 'unlock_form.dart';
import 'utils.dart';
@ -105,6 +111,7 @@ class _UnlockedView extends ConsumerStatefulWidget {
class _UnlockedViewState extends ConsumerState<_UnlockedView> {
late FocusNode searchFocus;
late TextEditingController searchController;
OathCredential? _selected;
@override
void initState() {
@ -131,7 +138,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
// ONLY rebuild if the number of credentials changes.
final numCreds = ref.watch(credentialListProvider(widget.devicePath)
.select((value) => value?.length));
final hasActions = ref.watch(featureProvider)(features.actions);
final hasFeature = ref.watch(featureProvider);
final hasActions = hasFeature(features.actions);
Future<void> onFileDropped(File file) async {
final qrScanner = ref.read(qrScannerProvider);
@ -173,7 +181,11 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
),
);
}
return Actions(
return registerOathActions(
widget.devicePath,
ref: ref,
builder: (context) => Actions(
actions: {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection(
@ -181,6 +193,41 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
searchFocus.requestFocus();
return null;
}),
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (_selected != null) {
setState(() {
_selected = null;
});
} else {
Actions.invoke(context, intent);
}
return false;
}),
OpenIntent<OathCredential>:
CallbackAction<OpenIntent<OathCredential>>(
onInvoke: (intent) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => AccountDialog(intent.target),
);
return null;
},
),
if (hasFeature(features.accountsRename))
EditIntent<OathCredential>:
CallbackAction<EditIntent<OathCredential>>(
onInvoke: (intent) async {
final renamed =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (intent.target == _selected && renamed is OathCredential) {
setState(() {
_selected = renamed;
});
}
return renamed;
},
),
},
child: AppPage(
title: Focus(
@ -259,15 +306,112 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
),
centered: numCreds == null,
delayedContent: numCreds == null,
builder: (context, expanded) => numCreds != null
? Consumer(
detailViewBuilder: _selected != null
? (context) {
final helper = AccountHelper(context, ref, _selected!);
final subtitle = helper.subtitle;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTitle(l10n.s_details),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconTheme(
data: IconTheme.of(context)
.copyWith(size: 24),
child: helper.buildCodeIcon(),
),
const SizedBox(width: 8.0),
DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: helper.buildCodeLabel(),
),
],
),
),
Text(
helper.title,
style:
Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
if (subtitle != null)
Text(
subtitle,
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall!
.color,
),
),
],
),
),
),
ActionListSection.fromMenuActions(
context,
AppLocalizations.of(context)!.s_actions,
actions: helper.buildActions(),
),
],
);
}
: null,
builder: (context, expanded) {
// De-select if window is resized to be non-expanded.
if (!expanded) {
Timer.run(() {
setState(() {
_selected = null;
});
});
}
return numCreds != null
? Actions(
actions: {
if (expanded)
OpenIntent<OathCredential>:
CallbackAction<OpenIntent<OathCredential>>(
onInvoke: (OpenIntent<OathCredential> intent) {
setState(() {
_selected = intent.target;
});
return null;
}),
},
child: Consumer(
builder: (context, ref, _) {
return AccountList(
ref.watch(credentialListProvider(widget.devicePath)) ?? [],
ref.watch(
credentialListProvider(widget.devicePath)) ??
[],
expanded: expanded,
selected: _selected,
);
},
),
)
: const CircularProgressIndicator(),
: const CircularProgressIndicator();
},
),
),
);
}

View File

@ -38,7 +38,7 @@ import 'utils.dart';
final _log = Logger('oath.view.rename_account_dialog');
class RenameAccountDialog extends ConsumerStatefulWidget {
final DeviceNode device;
final DevicePath devicePath;
final String? issuer;
final String name;
final OathType oathType;
@ -47,7 +47,7 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
final Future<dynamic> Function(String? issuer, String name) rename;
const RenameAccountDialog({
required this.device,
required this.devicePath,
required this.issuer,
required this.name,
required this.oathType,
@ -63,11 +63,11 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
factory RenameAccountDialog.forOathCredential(
WidgetRef ref,
DeviceNode device,
DevicePath devicePath,
OathCredential credential,
List<(String? issuer, String name)> existing) {
return RenameAccountDialog(
device: device,
devicePath: devicePath,
issuer: credential.issuer,
name: credential.name,
oathType: credential.oathType,
@ -78,7 +78,7 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
try {
// Rename credentials
final renamed = await ref
.read(credentialListProvider(device.path).notifier)
.read(credentialListProvider(devicePath).notifier)
.renameAccount(credential, issuer, name);
// Update favorite

View File

@ -34,24 +34,27 @@ import 'configure_yubiotp_dialog.dart';
import 'delete_slot_dialog.dart';
class ConfigureChalRespIntent extends Intent {
const ConfigureChalRespIntent();
final OtpSlot slot;
const ConfigureChalRespIntent(this.slot);
}
class ConfigureHotpIntent extends Intent {
const ConfigureHotpIntent();
final OtpSlot slot;
const ConfigureHotpIntent(this.slot);
}
class ConfigureStaticIntent extends Intent {
const ConfigureStaticIntent();
final OtpSlot slot;
const ConfigureStaticIntent(this.slot);
}
class ConfigureYubiOtpIntent extends Intent {
const ConfigureYubiOtpIntent();
final OtpSlot slot;
const ConfigureYubiOtpIntent(this.slot);
}
Widget registerOtpActions(
DevicePath devicePath,
OtpSlot otpSlot, {
DevicePath devicePath, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
@ -68,7 +71,7 @@ Widget registerOtpActions(
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureChalrespDialog(devicePath, otpSlot));
ConfigureChalrespDialog(devicePath, intent.slot));
});
return null;
}),
@ -80,7 +83,8 @@ Widget registerOtpActions(
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureHotpDialog(devicePath, otpSlot));
builder: (context) =>
ConfigureHotpDialog(devicePath, intent.slot));
});
return null;
}),
@ -96,7 +100,7 @@ Widget registerOtpActions(
await showBlurDialog(
context: context,
builder: (context) => ConfigureStaticDialog(
devicePath, otpSlot, keyboardLayouts));
devicePath, intent.slot, keyboardLayouts));
});
return null;
}),
@ -109,19 +113,23 @@ Widget registerOtpActions(
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureYubiOtpDialog(devicePath, otpSlot));
ConfigureYubiOtpDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
DeleteIntent<OtpSlot>:
CallbackAction<DeleteIntent<OtpSlot>>(onInvoke: (intent) async {
final slot = intent.target;
if (!slot.isConfigured) {
return false;
}
final withContext = ref.read(withContextProvider);
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) =>
DeleteSlotDialog(devicePath, otpSlot)) ??
builder: (context) => DeleteSlotDialog(devicePath, slot)) ??
false);
return deleted;
}),
@ -131,7 +139,7 @@ Widget registerOtpActions(
);
}
List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
List<ActionItem> buildSlotActions(OtpSlot slot, AppLocalizations l10n) {
return [
ActionItem(
key: keys.configureYubiOtp,
@ -139,7 +147,7 @@ List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
icon: const Icon(Icons.shuffle_outlined),
title: l10n.s_yubiotp,
subtitle: l10n.l_yubiotp_desc,
intent: const ConfigureYubiOtpIntent(),
intent: ConfigureYubiOtpIntent(slot),
),
ActionItem(
key: keys.configureChalResp,
@ -147,21 +155,21 @@ List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
icon: const Icon(Icons.key_outlined),
title: l10n.s_challenge_response,
subtitle: l10n.l_challenge_response_desc,
intent: const ConfigureChalRespIntent()),
intent: ConfigureChalRespIntent(slot)),
ActionItem(
key: keys.configureStatic,
feature: features.slotsConfigureStatic,
icon: const Icon(Icons.password_outlined),
title: l10n.s_static_password,
subtitle: l10n.l_static_password_desc,
intent: const ConfigureStaticIntent()),
intent: ConfigureStaticIntent(slot)),
ActionItem(
key: keys.configureHotp,
feature: features.slotsConfigureHotp,
icon: const Icon(Icons.tag_outlined),
title: l10n.s_hotp,
subtitle: l10n.l_hotp_desc,
intent: const ConfigureHotpIntent()),
intent: ConfigureHotpIntent(slot)),
ActionItem(
key: keys.deleteAction,
feature: features.slotsDelete,
@ -169,7 +177,7 @@ List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
icon: const Icon(Icons.delete_outline),
title: l10n.s_delete_slot,
subtitle: l10n.l_delete_slot_desc,
intent: isConfigured ? const DeleteIntent() : null,
intent: slot.isConfigured ? DeleteIntent(slot) : null,
)
];
}

View File

@ -65,9 +65,12 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final selected = _selected != null
? otpState.slots.firstWhere((e) => e.slot == _selected)
: null;
return Actions(
return registerOtpActions(widget.devicePath,
ref: ref,
builder: (context) => Actions(
actions: {
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
EscapeIntent:
CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (selected != null) {
setState(() {
_selected = null;
@ -77,15 +80,21 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
}
return false;
}),
OpenIntent<OtpSlot>: CallbackAction<OpenIntent<OtpSlot>>(
onInvoke: (intent) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => SlotDialog(intent.target.slot),
);
return null;
}),
},
child: AppPage(
title: Text(l10n.s_slots),
detailViewBuilder: selected != null
? (context) => registerOtpActions(
widget.devicePath,
selected,
ref: ref,
builder: (context) => Column(
? (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTitle(l10n.s_details),
@ -119,16 +128,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
ActionListSection.fromMenuActions(
context,
l10n.s_setup,
actions:
buildSlotActions(selected.isConfigured, l10n),
actions: buildSlotActions(selected, l10n),
)
],
),
)
: null,
keyActionsBuilder: hasFeature(features.actions)
? (context) =>
otpBuildActions(context, widget.devicePath, otpState, ref)
? (context) => otpBuildActions(
context, widget.devicePath, otpState, ref)
: null,
builder: (context, expanded) {
// De-select if window is resized to be non-expanded.
@ -139,37 +146,30 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
});
});
}
return Column(children: [
ListTitle(l10n.s_slots),
...otpState.slots
.map((e) => registerOtpActions(widget.devicePath, e,
ref: ref,
return Actions(
actions: {
OpenIntent:
CallbackAction<OpenIntent>(onInvoke: (_) async {
if (expanded) {
if (expanded)
OpenIntent<OtpSlot>:
CallbackAction<OpenIntent<OtpSlot>>(
onInvoke: (intent) async {
setState(() {
_selected = e.slot;
_selected = intent.target.slot;
});
} else {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => SlotDialog(e.slot),
);
}
return null;
}),
},
builder: (context) => _SlotListItem(
child: Column(children: [
ListTitle(l10n.s_slots),
...otpState.slots.map((e) => _SlotListItem(
e,
expanded: expanded,
selected: e == selected,
)))
]);
))
]),
);
},
),
);
));
});
}
}
@ -191,6 +191,7 @@ class _SlotListItem extends ConsumerWidget {
final hasFeature = ref.watch(featureProvider);
return AppListItem(
otpSlot,
selected: selected,
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
@ -202,12 +203,13 @@ class _SlotListItem extends ConsumerWidget {
trailing: expanded
? null
: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
onPressed: Actions.handler(context, OpenIntent(otpSlot)),
child: const Icon(Icons.more_horiz),
),
openOnSingleTap: expanded,
tapIntent: isDesktop && !expanded ? null : OpenIntent(otpSlot),
doubleTapIntent: isDesktop && !expanded ? OpenIntent(otpSlot) : null,
buildPopupActions: hasFeature(features.slots)
? (context) => buildSlotActions(isConfigured, l10n)
? (context) => buildSlotActions(otpSlot, l10n)
: null,
);
}

View File

@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/fs_dialog.dart';
@ -51,9 +52,11 @@ class SlotDialog extends ConsumerWidget {
return const FsDialog(child: CircularProgressIndicator());
}
return registerOtpActions(node.path, otpSlot,
return registerOtpActions(node.path,
ref: ref,
builder: (context) => FocusScope(
builder: (context) => Shortcuts(
shortcuts: itemShortcuts(otpSlot),
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
@ -83,11 +86,12 @@ class SlotDialog extends ConsumerWidget {
ActionListSection.fromMenuActions(
context,
l10n.s_setup,
actions: buildSlotActions(otpSlot.isConfigured, l10n),
actions: buildSlotActions(otpSlot, l10n),
)
],
),
),
),
));
}
}

View File

@ -37,15 +37,18 @@ import 'import_file_dialog.dart';
import 'pin_dialog.dart';
class GenerateIntent extends Intent {
const GenerateIntent();
final PivSlot slot;
const GenerateIntent(this.slot);
}
class ImportIntent extends Intent {
const ImportIntent();
final PivSlot slot;
const ImportIntent(this.slot);
}
class ExportIntent extends Intent {
const ExportIntent();
final PivSlot slot;
const ExportIntent(this.slot);
}
Future<bool> _authenticate(
@ -72,8 +75,7 @@ Future<bool> _authIfNeeded(
Widget registerPivActions(
DevicePath devicePath,
PivState pivState,
PivSlot pivSlot, {
PivState pivState, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
@ -109,7 +111,7 @@ Widget registerPivActions(
builder: (context) => GenerateKeyDialog(
devicePath,
pivState,
pivSlot,
intent.slot,
),
);
@ -163,7 +165,7 @@ Widget registerPivActions(
builder: (context) => ImportFileDialog(
devicePath,
pivState,
pivSlot,
intent.slot,
File(picked.paths.first!),
),
) ??
@ -173,7 +175,7 @@ Widget registerPivActions(
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
final (_, cert) = await ref
.read(pivSlotsProvider(devicePath).notifier)
.read(pivSlot.slot);
.read(intent.slot.slot);
if (cert == null) {
return false;
@ -205,7 +207,8 @@ Widget registerPivActions(
return true;
}),
if (hasFeature(features.slotsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
DeleteIntent<PivSlot>:
CallbackAction<DeleteIntent<PivSlot>>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
@ -217,7 +220,7 @@ Widget registerPivActions(
context: context,
builder: (context) => DeleteCertificateDialog(
devicePath,
pivSlot,
intent.target,
),
) ??
false);
@ -229,7 +232,8 @@ Widget registerPivActions(
);
}
List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
final hasCert = slot.certInfo != null;
return [
ActionItem(
key: keys.generateAction,
@ -238,7 +242,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
actionStyle: ActionStyle.primary,
title: l10n.s_generate_key,
subtitle: l10n.l_generate_desc,
intent: const GenerateIntent(),
intent: GenerateIntent(slot),
),
ActionItem(
key: keys.importAction,
@ -246,7 +250,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
icon: const Icon(Icons.file_download_outlined),
title: l10n.l_import_file,
subtitle: l10n.l_import_desc,
intent: const ImportIntent(),
intent: ImportIntent(slot),
),
if (hasCert) ...[
ActionItem(
@ -255,7 +259,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
icon: const Icon(Icons.file_upload_outlined),
title: l10n.l_export_certificate,
subtitle: l10n.l_export_certificate_desc,
intent: const ExportIntent(),
intent: ExportIntent(slot),
),
ActionItem(
key: keys.deleteAction,
@ -264,7 +268,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
icon: const Icon(Icons.delete_outline),
title: l10n.l_delete_certificate,
subtitle: l10n.l_delete_certificate_desc,
intent: const DeleteIntent(),
intent: DeleteIntent(slot),
),
],
];

View File

@ -77,9 +77,14 @@ class _PivScreenState extends ConsumerState<PivScreen> {
final subtitleStyle = textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
);
return Actions(
return registerPivActions(
widget.devicePath,
pivState,
ref: ref,
builder: (context) => Actions(
actions: {
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
EscapeIntent:
CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (selected != null) {
setState(() {
_selected = null;
@ -89,16 +94,21 @@ class _PivScreenState extends ConsumerState<PivScreen> {
}
return false;
}),
OpenIntent<PivSlot>: CallbackAction<OpenIntent<PivSlot>>(
onInvoke: (intent) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => SlotDialog(intent.target.slot),
);
return null;
},
),
},
child: AppPage(
title: Text(l10n.s_certificates),
detailViewBuilder: selected != null
? (context) => registerPivActions(
widget.devicePath,
pivState,
selected,
ref: ref,
builder: (context) => Column(
? (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTitle(l10n.s_details),
@ -129,11 +139,9 @@ class _PivScreenState extends ConsumerState<PivScreen> {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildSlotActions(
selected.certInfo != null, l10n),
actions: buildSlotActions(selected, l10n),
),
],
),
)
: null,
keyActionsBuilder: hasFeature(features.actions)
@ -149,42 +157,34 @@ class _PivScreenState extends ConsumerState<PivScreen> {
});
});
}
return Column(
children: [
ListTitle(l10n.s_certificates),
if (pivSlots?.hasValue == true)
...pivSlots!.value.map((e) => registerPivActions(
widget.devicePath,
pivState,
e,
ref: ref,
return Actions(
actions: {
OpenIntent: CallbackAction<OpenIntent>(
onInvoke: (_) async {
if (expanded) {
if (expanded)
OpenIntent: CallbackAction<OpenIntent<PivSlot>>(
onInvoke: (intent) async {
setState(() {
_selected = e.slot;
_selected = intent.target.slot;
});
} else {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => SlotDialog(e.slot),
);
}
return null;
}),
},
builder: (context) => _CertificateListItem(
child: Column(
children: [
ListTitle(l10n.s_certificates),
if (pivSlots?.hasValue == true)
...pivSlots!.value.map(
(e) => _CertificateListItem(
e,
expanded: expanded,
selected: e == selected,
),
)),
),
],
),
);
},
),
),
);
},
);
@ -208,6 +208,7 @@ class _CertificateListItem extends ConsumerWidget {
final hasFeature = ref.watch(featureProvider);
return AppListItem(
pivSlot,
selected: selected,
key: _getAppListItemKey(slot),
leading: CircleAvatar(
@ -226,12 +227,13 @@ class _CertificateListItem extends ConsumerWidget {
? null
: OutlinedButton(
key: _getMeatballKey(slot),
onPressed: Actions.handler(context, const OpenIntent()),
onPressed: Actions.handler(context, OpenIntent(pivSlot)),
child: const Icon(Icons.more_horiz),
),
openOnSingleTap: expanded,
tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot),
doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null,
buildPopupActions: hasFeature(features.slots)
? (context) => buildSlotActions(certInfo != null, l10n)
? (context) => buildSlotActions(pivSlot, l10n)
: null,
);
}

View File

@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/fs_dialog.dart';
@ -61,9 +62,10 @@ class SlotDialog extends ConsumerWidget {
return registerPivActions(
node.path,
pivState,
slotData,
ref: ref,
builder: (context) => FocusScope(
builder: (context) => Shortcuts(
shortcuts: itemShortcuts(slotData),
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
@ -101,12 +103,13 @@ class SlotDialog extends ConsumerWidget {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildSlotActions(certInfo != null, l10n),
actions: buildSlotActions(slotData, l10n),
),
],
),
),
),
),
);
}
}