Refactor Actions to use custom Widgets more.

This commit is contained in:
Dain Nilsson 2024-01-18 14:46:15 +01:00
parent 5d286de0a0
commit e4d49ea4b4
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
16 changed files with 971 additions and 728 deletions

View File

@ -24,34 +24,30 @@ import 'logging.dart';
import 'shortcuts.dart';
import 'state.dart';
class YubicoAuthenticatorApp extends ConsumerWidget {
class YubicoAuthenticatorApp extends StatelessWidget {
final Widget page;
const YubicoAuthenticatorApp({required this.page, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return registerGlobalShortcuts(
ref: ref,
child: LogWarningOverlay(
child: Consumer(builder: (context, ref, _) {
return MaterialApp(
title: ref.watch(l10nProvider).app_name,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ref.watch(themeModeProvider),
home: page,
debugShowCheckedModeBanner: false,
locale: ref.watch(currentLocaleProvider),
supportedLocales: ref.watch(supportedLocalesProvider),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
);
}),
),
);
}
Widget build(BuildContext context) => GlobalShortcuts(
child: LogWarningOverlay(
child: Consumer(
builder: (context, ref, _) => MaterialApp(
title: ref.watch(l10nProvider).app_name,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ref.watch(themeModeProvider),
home: page,
debugShowCheckedModeBanner: false,
locale: ref.watch(currentLocaleProvider),
supportedLocales: ref.watch(supportedLocalesProvider),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
)),
),
);
}

View File

@ -89,118 +89,136 @@ 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),
};
class ItemShortcuts<T> extends StatelessWidget {
final T item;
final Widget child;
const ItemShortcuts({super.key, required this.item, required this.child});
Widget registerGlobalShortcuts(
{required WidgetRef ref, required Widget child}) =>
Actions(
actions: {
CloseIntent: CallbackAction<CloseIntent>(onInvoke: (_) {
windowManager.close();
return null;
}),
HideIntent: CallbackAction<HideIntent>(onInvoke: (_) {
if (isDesktop) {
ref.read(desktopWindowStateProvider.notifier).setWindowHidden(true);
}
return null;
}),
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (intent) {
// If the OATH view doesn't have focus, but is shown, find and select the search bar.
final searchContext = searchAccountsField.currentContext;
if (searchContext != null) {
if (!Navigator.of(searchContext).canPop()) {
return Actions.maybeInvoke(searchContext, intent);
}
}
return null;
}),
NextDeviceIntent: CallbackAction<NextDeviceIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
// Only allow switching keys if no other views are open,
// with the exception of the drawer.
if (!Navigator.of(context).canPop() ||
scaffoldGlobalKey.currentState?.isDrawerOpen == true) {
final attached = ref
.read(attachedDevicesProvider)
.whereType<UsbYubiKeyNode>()
.toList();
if (attached.length > 1) {
final current = ref.read(currentDeviceProvider);
if (current != null && current is UsbYubiKeyNode) {
final index = attached.indexOf(current);
ref.read(currentDeviceProvider.notifier).setCurrentDevice(
attached[(index + 1) % attached.length]);
}
}
}
});
return null;
}),
SettingsIntent: CallbackAction<SettingsIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
if (!Navigator.of(context).canPop()) {
await showBlurDialog(
context: context,
builder: (context) => const SettingsPage(),
routeSettings: const RouteSettings(name: 'settings'),
);
}
});
return null;
}),
AboutIntent: CallbackAction<AboutIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
if (!Navigator.of(context).canPop()) {
await showBlurDialog(
context: context,
builder: (context) => const AboutPage(),
routeSettings: const RouteSettings(name: 'about'),
);
}
});
return null;
}),
EscapeIntent: CallbackAction<EscapeIntent>(
onInvoke: (_) {
FocusManager.instance.primaryFocus?.unfocus();
return null;
},
),
},
child: Shortcuts(
@override
Widget build(BuildContext context) => Shortcuts(
shortcuts: {
ctrlOrCmd(LogicalKeyboardKey.keyF): const SearchIntent(),
const SingleActivator(LogicalKeyboardKey.escape):
const EscapeIntent(),
if (isDesktop) ...{
const SingleActivator(LogicalKeyboardKey.tab, control: true):
const NextDeviceIntent(),
},
if (Platform.isMacOS) ...{
const SingleActivator(LogicalKeyboardKey.keyW, meta: true):
const HideIntent(),
const SingleActivator(LogicalKeyboardKey.keyQ, meta: true):
const CloseIntent(),
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
const SettingsIntent(),
},
if (Platform.isWindows) ...{
const SingleActivator(LogicalKeyboardKey.keyW, control: true):
const HideIntent(),
},
if (Platform.isLinux) ...{
const SingleActivator(LogicalKeyboardKey.keyQ, control: true):
const CloseIntent(),
},
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),
},
child: child,
),
);
);
}
/// Global keyboard shortcuts
class GlobalShortcuts extends ConsumerWidget {
final Widget child;
const GlobalShortcuts({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) => Actions(
actions: {
CloseIntent: CallbackAction<CloseIntent>(onInvoke: (_) {
windowManager.close();
return null;
}),
HideIntent: CallbackAction<HideIntent>(onInvoke: (_) {
if (isDesktop) {
ref
.read(desktopWindowStateProvider.notifier)
.setWindowHidden(true);
}
return null;
}),
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (intent) {
// If the OATH view doesn't have focus, but is shown, find and select the search bar.
final searchContext = searchAccountsField.currentContext;
if (searchContext != null) {
if (!Navigator.of(searchContext).canPop()) {
return Actions.maybeInvoke(searchContext, intent);
}
}
return null;
}),
NextDeviceIntent: CallbackAction<NextDeviceIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
// Only allow switching keys if no other views are open,
// with the exception of the drawer.
if (!Navigator.of(context).canPop() ||
scaffoldGlobalKey.currentState?.isDrawerOpen == true) {
final attached = ref
.read(attachedDevicesProvider)
.whereType<UsbYubiKeyNode>()
.toList();
if (attached.length > 1) {
final current = ref.read(currentDeviceProvider);
if (current != null && current is UsbYubiKeyNode) {
final index = attached.indexOf(current);
ref.read(currentDeviceProvider.notifier).setCurrentDevice(
attached[(index + 1) % attached.length]);
}
}
}
});
return null;
}),
SettingsIntent: CallbackAction<SettingsIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
if (!Navigator.of(context).canPop()) {
await showBlurDialog(
context: context,
builder: (context) => const SettingsPage(),
routeSettings: const RouteSettings(name: 'settings'),
);
}
});
return null;
}),
AboutIntent: CallbackAction<AboutIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
if (!Navigator.of(context).canPop()) {
await showBlurDialog(
context: context,
builder: (context) => const AboutPage(),
routeSettings: const RouteSettings(name: 'about'),
);
}
});
return null;
}),
EscapeIntent: CallbackAction<EscapeIntent>(
onInvoke: (_) {
FocusManager.instance.primaryFocus?.unfocus();
return null;
},
),
},
child: Shortcuts(
shortcuts: {
ctrlOrCmd(LogicalKeyboardKey.keyF): const SearchIntent(),
const SingleActivator(LogicalKeyboardKey.escape):
const EscapeIntent(),
if (isDesktop) ...{
const SingleActivator(LogicalKeyboardKey.tab, control: true):
const NextDeviceIntent(),
},
if (Platform.isMacOS) ...{
const SingleActivator(LogicalKeyboardKey.keyW, meta: true):
const HideIntent(),
const SingleActivator(LogicalKeyboardKey.keyQ, meta: true):
const CloseIntent(),
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
const SettingsIntent(),
},
if (Platform.isWindows) ...{
const SingleActivator(LogicalKeyboardKey.keyW, control: true):
const HideIntent(),
},
if (Platform.isLinux) ...{
const SingleActivator(LogicalKeyboardKey.keyQ, control: true):
const CloseIntent(),
},
},
child: child,
),
);
}

View File

@ -73,8 +73,8 @@ class _AppListItemState<T> extends ConsumerState<AppListItem> {
return Semantics(
label: widget.semanticTitle ?? widget.title,
child: Shortcuts(
shortcuts: itemShortcuts<T>(widget.item),
child: ItemShortcuts<T>(
item: widget.item,
child: InkWell(
focusNode: _focusNode,
borderRadius: BorderRadius.circular(30),

View File

@ -16,12 +16,95 @@
import 'package:flutter/material.dart';
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';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
import 'delete_credential_dialog.dart';
import 'delete_fingerprint_dialog.dart';
import 'rename_fingerprint_dialog.dart';
class FidoActions extends ConsumerWidget {
final DevicePath devicePath;
final Map<Type, Action<Intent>> Function(BuildContext context)? actions;
final Widget Function(BuildContext context) builder;
const FidoActions(
{super.key,
required this.devicePath,
this.actions,
required this.builder});
@override
Widget build(BuildContext context, WidgetRef ref) {
final withContext = ref.read(withContextProvider);
final hasFeature = ref.read(featureProvider);
return Actions(
actions: {
if (hasFeature(features.credentialsDelete))
DeleteIntent<FidoCredential>:
CallbackAction<DeleteIntent<FidoCredential>>(
onInvoke: (intent) async {
final credential = intent.target;
final deleted = await withContext(
(context) => showBlurDialog<bool?>(
context: context,
builder: (context) => DeleteCredentialDialog(
devicePath,
credential,
),
),
);
return deleted;
}),
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,
builder: (context) => RenameFingerprintDialog(
devicePath,
fingerprint,
),
));
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(
devicePath,
fingerprint,
),
));
return deleted;
},
),
},
child: Builder(
// Builder to ensure new scope for actions, they can invoke parent actions
builder: (context) {
final child = Builder(builder: builder);
return actions != null
? Actions(actions: actions!(context), child: child)
: child;
},
),
);
}
}
List<ActionItem> buildFingerprintActions(
Fingerprint fingerprint, AppLocalizations l10n) {

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
@ -11,7 +10,6 @@ import '../../core/state.dart';
import '../features.dart' as features;
import '../models.dart';
import 'actions.dart';
import 'delete_credential_dialog.dart';
class CredentialDialog extends ConsumerWidget {
final FidoCredential credential;
@ -30,35 +28,26 @@ class CredentialDialog extends ConsumerWidget {
final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return Actions(
actions: {
return FidoActions(
devicePath: node.path,
actions: (context) => {
if (hasFeature(features.credentialsDelete))
DeleteIntent<FidoCredential>:
CallbackAction<DeleteIntent<FidoCredential>>(
onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteCredentialDialog(
node.path,
intent.target,
),
) ??
false);
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
// Pop the account dialog if deleted
if (deleted == true) {
await withContext((context) async {
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}),
},
child: Shortcuts(
shortcuts: itemShortcuts(credential),
builder: (context) => ItemShortcuts(
item: credential,
child: FocusScope(
autofocus: true,
child: FsDialog(

View File

@ -11,8 +11,6 @@ import '../../core/state.dart';
import '../features.dart' as features;
import '../models.dart';
import 'actions.dart';
import 'delete_fingerprint_dialog.dart';
import 'rename_fingerprint_dialog.dart';
class FingerprintDialog extends ConsumerWidget {
final Fingerprint fingerprint;
@ -30,29 +28,21 @@ class FingerprintDialog extends ConsumerWidget {
final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return Actions(
actions: {
return FidoActions(
devicePath: node.path,
actions: (context) => {
if (hasFeature(features.fingerprintsEdit))
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,
intent.target,
),
));
if (renamed != null) {
final renamed =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (renamed is Fingerprint) {
// Replace the dialog with the renamed credential
await withContext((context) async {
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
await showBlurDialog(
context: context,
builder: (context) {
return FingerprintDialog(renamed);
},
builder: (context) => FingerprintDialog(renamed),
);
});
}
@ -61,29 +51,19 @@ class FingerprintDialog extends ConsumerWidget {
if (hasFeature(features.fingerprintsDelete))
DeleteIntent<Fingerprint>: CallbackAction<DeleteIntent<Fingerprint>>(
onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteFingerprintDialog(
node.path,
intent.target,
),
) ??
false);
// Pop the account dialog if deleted
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
// Pop the fingerprint dialog if deleted
if (deleted == true) {
await withContext((context) async {
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}),
},
child: Shortcuts(
shortcuts: itemShortcuts(fingerprint),
builder: (context) => ItemShortcuts(
item: fingerprint,
child: FocusScope(
autofocus: true,
child: FsDialog(

View File

@ -21,7 +21,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
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_list_item.dart';
import '../../app/views/app_page.dart';
@ -33,11 +32,8 @@ import '../models.dart';
import '../state.dart';
import 'actions.dart';
import 'credential_dialog.dart';
import 'delete_credential_dialog.dart';
import 'delete_fingerprint_dialog.dart';
import 'fingerprint_dialog.dart';
import 'key_actions.dart';
import 'rename_fingerprint_dialog.dart';
class FidoUnlockedPage extends ConsumerStatefulWidget {
final DeviceNode node;
@ -56,7 +52,6 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final selected = _selected;
List<Widget Function(bool expanded)> children = [];
if (widget.state.credMgmt) {
@ -71,7 +66,7 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
(cred) => (expanded) => _CredentialListItem(
cred,
expanded: expanded,
selected: selected == cred,
selected: _selected == cred,
),
));
}
@ -91,7 +86,7 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
(fp) => (expanded) => _FingerprintListItem(
fp,
expanded: expanded,
selected: fp == selected,
selected: fp == _selected,
),
));
}
@ -101,10 +96,11 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
final hasActions = hasFeature(features.actions);
if (children.isNotEmpty) {
return Actions(
actions: {
return FidoActions(
devicePath: widget.node.path,
actions: (context) => {
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (selected != null) {
if (_selected != null) {
setState(() {
_selected = null;
});
@ -121,27 +117,6 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
builder: (context) => CredentialDialog(intent.target),
);
}),
if (hasFeature(features.credentialsDelete))
DeleteIntent<FidoCredential>:
CallbackAction<DeleteIntent<FidoCredential>>(
onInvoke: (intent) async {
final credential = intent.target;
final deleted = await ref.read(withContextProvider)(
(context) => showBlurDialog<bool?>(
context: context,
builder: (context) => DeleteCredentialDialog(
widget.node.path,
credential,
),
),
);
if (_selected == credential && deleted == true) {
setState(() {
_selected = null;
});
}
return deleted;
}),
OpenIntent<Fingerprint>:
CallbackAction<OpenIntent<Fingerprint>>(onInvoke: (intent) {
return showBlurDialog(
@ -150,19 +125,25 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
builder: (context) => FingerprintDialog(intent.target),
);
}),
if (hasFeature(features.credentialsDelete))
DeleteIntent<FidoCredential>:
CallbackAction<DeleteIntent<FidoCredential>>(
onInvoke: (intent) async {
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (deleted == true && _selected == intent.target) {
setState(() {
_selected = null;
});
}
return deleted;
}),
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,
builder: (context) => RenameFingerprintDialog(
widget.node.path,
fingerprint,
),
));
if (_selected == fingerprint && renamed != null) {
final renamed =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (_selected == intent.target && renamed is Fingerprint) {
setState(() {
_selected = renamed;
});
@ -173,16 +154,9 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
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) {
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (deleted == true && _selected == intent.target) {
setState(() {
_selected = null;
});
@ -190,9 +164,9 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
return deleted;
}),
},
child: AppPage(
builder: (context) => AppPage(
title: Text(l10n.s_webauthn),
detailViewBuilder: switch (selected) {
detailViewBuilder: switch (_selected) {
FidoCredential credential => (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [

View File

@ -52,9 +52,44 @@ class AccountDialog extends ConsumerWidget {
final helper = AccountHelper(context, ref, credential);
final subtitle = helper.subtitle;
return registerOathActions(
node.path,
ref: ref,
return OathActions(
devicePath: node.path,
actions: (context) => {
if (hasFeature(features.accountsRename))
EditIntent<OathCredential>:
CallbackAction<EditIntent<OathCredential>>(
onInvoke: (intent) async {
final renamed =
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(
context: context,
builder: (context) {
return AccountDialog(renamed);
},
);
});
}
return renamed;
}),
if (hasFeature(features.accountsDelete))
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();
});
}
return deleted;
}),
},
builder: (context) {
if (helper.code == null &&
(isDesktop || node.transport == Transport.usb)) {
@ -66,107 +101,67 @@ class AccountDialog extends ConsumerWidget {
}
});
}
return Actions(
actions: {
if (hasFeature(features.accountsRename))
EditIntent<OathCredential>:
CallbackAction<EditIntent<OathCredential>>(
onInvoke: (intent) async {
final renamed =
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(
context: context,
builder: (context) {
return AccountDialog(renamed);
},
);
});
}
return renamed;
}),
if (hasFeature(features.accountsDelete))
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();
});
}
return deleted;
}),
},
child: Shortcuts(
shortcuts: itemShortcuts(credential),
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 32),
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(),
),
],
),
return ItemShortcuts(
item: credential,
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 32),
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(
helper.title,
style: Theme.of(context).textTheme.headlineSmall,
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,
),
),
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(),
),
],
),
),
ActionListSection.fromMenuActions(
context,
AppLocalizations.of(context)!.s_actions,
actions: helper.buildActions(),
),
],
),
),
),

View File

@ -47,82 +47,103 @@ Future<OathCode?> _calculateCode(
}
}
Widget registerOathActions(
DevicePath devicePath, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) {
final hasFeature = ref.read(featureProvider);
return Actions(
actions: {
RefreshIntent<OathCredential>:
CallbackAction<RefreshIntent<OathCredential>>(onInvoke: (intent) {
final credential = intent.target;
final code = ref.read(codeProvider(credential));
if (!(credential.oathType == OathType.totp &&
code != null &&
!ref.read(expiredProvider(code.validTo)))) {
return _calculateCode(credential, ref);
}
return code;
}),
if (hasFeature(features.accountsClipboard))
CopyIntent<OathCredential>: CallbackAction<CopyIntent<OathCredential>>(
onInvoke: (intent) async {
class OathActions extends ConsumerWidget {
final DevicePath devicePath;
final Map<Type, Action<Intent>> Function(BuildContext context)? actions;
final Widget Function(BuildContext context) builder;
const OathActions(
{super.key,
required this.devicePath,
this.actions,
required this.builder});
@override
Widget build(BuildContext context, WidgetRef ref) {
final withContext = ref.read(withContextProvider);
final hasFeature = ref.read(featureProvider);
return Actions(
actions: {
RefreshIntent<OathCredential>:
CallbackAction<RefreshIntent<OathCredential>>(onInvoke: (intent) {
final credential = intent.target;
var code = ref.read(codeProvider(credential));
if (code == null ||
(credential.oathType == OathType.totp &&
ref.read(expiredProvider(code.validTo)))) {
code = await _calculateCode(credential, ref);
}
if (code != null) {
final clipboard = ref.watch(clipboardProvider);
await clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
await ref.read(withContextProvider)((context) async {
showMessage(context,
AppLocalizations.of(context)!.l_code_copied_clipboard);
});
}
final code = ref.read(codeProvider(credential));
if (!(credential.oathType == OathType.totp &&
code != null &&
!ref.read(expiredProvider(code.validTo)))) {
return _calculateCode(credential, ref);
}
return code;
}),
if (hasFeature(features.accountsPin))
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(
if (hasFeature(features.accountsClipboard))
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 &&
ref.read(expiredProvider(code.validTo)))) {
code = await _calculateCode(credential, ref);
}
if (code != null) {
final clipboard = ref.watch(clipboardProvider);
await clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
await withContext((context) async {
showMessage(context,
AppLocalizations.of(context)!.l_code_copied_clipboard);
});
}
}
return code;
}),
if (hasFeature(features.accountsPin))
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);
return withContext((context) => showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(
devicePath,
intent.target,
),
) ??
false);
}),
...actions,
},
child: Builder(builder: builder),
);
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 withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(
devicePath,
intent.target,
),
) ??
false);
},
),
},
child: Builder(
// Builder to ensure new scope for actions, they can invoke parent actions
builder: (context) {
final child = Builder(builder: builder);
return actions != null
? Actions(actions: actions!(context), child: child)
: child;
},
),
);
}
}

View File

@ -182,236 +182,240 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
);
}
return registerOathActions(
widget.devicePath,
ref: ref,
builder: (context) => Actions(
actions: {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection(
baseOffset: 0, extentOffset: searchController.text.length);
searchFocus.requestFocus();
return null;
return OathActions(
devicePath: widget.devicePath,
actions: (context) => {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection(
baseOffset: 0, extentOffset: searchController.text.length);
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 (renamed is OathCredential && _selected == intent.target) {
setState(() {
_selected = renamed;
});
}
return renamed;
}),
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (_selected != null) {
if (hasFeature(features.accountsDelete))
DeleteIntent<OathCredential>:
CallbackAction<DeleteIntent<OathCredential>>(
onInvoke: (intent) async {
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (deleted == true && _selected == intent.target) {
setState(() {
_selected = null;
});
} else {
Actions.invoke(context, intent);
}
return false;
return deleted;
}),
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(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final textTheme = Theme.of(context).textTheme;
return AppTextFormField(
key: keys.searchAccountsField,
controller: searchController,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: AppInputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
borderSide: BorderSide(
width: 0,
style: searchFocus.hasFocus
? BorderStyle.solid
: BorderStyle.none,
),
},
builder: (context) => AppPage(
title: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final textTheme = Theme.of(context).textTheme;
return AppTextFormField(
key: keys.searchAccountsField,
controller: searchController,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: AppInputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
borderSide: BorderSide(
width: 0,
style: searchFocus.hasFocus
? BorderStyle.solid
: BorderStyle.none,
),
contentPadding: const EdgeInsets.all(16),
fillColor: Theme.of(context).hoverColor,
filled: true,
hintText: l10n.s_search_accounts,
isDense: true,
prefixIcon: const Padding(
padding: EdgeInsetsDirectional.only(start: 8.0),
child: Icon(Icons.search_outlined),
),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
iconSize: 16,
onPressed: () {
searchController.clear();
ref.read(searchProvider.notifier).setFilter('');
setState(() {});
},
)
: null,
),
onChanged: (value) {
ref.read(searchProvider.notifier).setFilter(value);
setState(() {});
},
textInputAction: TextInputAction.next,
onFieldSubmitted: (value) {
Focus.of(context).focusInDirection(TraversalDirection.down);
},
);
}),
),
keyActionsBuilder: hasActions
? (context) => oathBuildActions(
context,
widget.devicePath,
widget.oathState,
ref,
used: numCreds ?? 0,
)
: null,
onFileDropped: onFileDropped,
fileDropOverlay: FileDropOverlay(
title: l10n.s_add_account,
subtitle: l10n.l_drop_qr_description,
),
centered: numCreds == null,
delayedContent: numCreds == null,
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(),
),
],
),
contentPadding: const EdgeInsets.all(16),
fillColor: Theme.of(context).hoverColor,
filled: true,
hintText: l10n.s_search_accounts,
isDense: true,
prefixIcon: const Padding(
padding: EdgeInsetsDirectional.only(start: 8.0),
child: Icon(Icons.search_outlined),
),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
iconSize: 16,
onPressed: () {
searchController.clear();
ref.read(searchProvider.notifier).setFilter('');
setState(() {});
},
)
: null,
),
onChanged: (value) {
ref.read(searchProvider.notifier).setFilter(value);
setState(() {});
},
textInputAction: TextInputAction.next,
onFieldSubmitted: (value) {
Focus.of(context).focusInDirection(TraversalDirection.down);
},
);
}),
),
keyActionsBuilder: hasActions
? (context) => oathBuildActions(
context,
widget.devicePath,
widget.oathState,
ref,
used: numCreds ?? 0,
)
: null,
onFileDropped: onFileDropped,
fileDropOverlay: FileDropOverlay(
title: l10n.s_add_account,
subtitle: l10n.l_drop_qr_description,
),
centered: numCreds == null,
delayedContent: numCreds == null,
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(
helper.title,
style:
Theme.of(context).textTheme.headlineSmall,
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,
),
),
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)) ??
[],
expanded: expanded,
selected: _selected,
);
},
),
)
: const CircularProgressIndicator();
},
),
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)) ??
[],
expanded: expanded,
selected: _selected,
);
},
),
)
: const CircularProgressIndicator();
},
),
);
}

View File

@ -53,90 +53,97 @@ class ConfigureYubiOtpIntent extends Intent {
const ConfigureYubiOtpIntent(this.slot);
}
Widget registerOtpActions(
DevicePath devicePath, {
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.slotsConfigureChalResp))
ConfigureChalRespIntent:
CallbackAction<ConfigureChalRespIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
class OtpActions extends ConsumerWidget {
final DevicePath devicePath;
final Map<Type, Action<Intent>> Function(BuildContext context)? actions;
final Widget Function(BuildContext context) builder;
const OtpActions(
{super.key,
required this.devicePath,
this.actions,
required this.builder});
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureChalrespDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsConfigureHotp))
ConfigureHotpIntent:
CallbackAction<ConfigureHotpIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
@override
Widget build(BuildContext context, WidgetRef ref) {
final withContext = ref.read(withContextProvider);
final hasFeature = ref.read(featureProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureHotpDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsConfigureStatic))
ConfigureStaticIntent:
CallbackAction<ConfigureStaticIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final keyboardLayouts = await ref
.read(otpStateProvider(devicePath).notifier)
.getKeyboardLayouts();
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureStaticDialog(
devicePath, intent.slot, keyboardLayouts));
});
return null;
}),
if (hasFeature(features.slotsConfigureYubiOtp))
ConfigureYubiOtpIntent:
CallbackAction<ConfigureYubiOtpIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureYubiOtpDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsDelete))
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 =>
return Actions(
actions: {
if (hasFeature(features.slotsConfigureChalResp))
ConfigureChalRespIntent:
CallbackAction<ConfigureChalRespIntent>(onInvoke: (intent) async {
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => DeleteSlotDialog(devicePath, slot)) ??
false);
return deleted;
}),
...actions,
},
child: Builder(builder: builder),
);
builder: (context) =>
ConfigureChalrespDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsConfigureHotp))
ConfigureHotpIntent:
CallbackAction<ConfigureHotpIntent>(onInvoke: (intent) async {
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureHotpDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsConfigureStatic))
ConfigureStaticIntent:
CallbackAction<ConfigureStaticIntent>(onInvoke: (intent) async {
final keyboardLayouts = await ref
.read(otpStateProvider(devicePath).notifier)
.getKeyboardLayouts();
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureStaticDialog(
devicePath, intent.slot, keyboardLayouts));
});
return null;
}),
if (hasFeature(features.slotsConfigureYubiOtp))
ConfigureYubiOtpIntent:
CallbackAction<ConfigureYubiOtpIntent>(onInvoke: (intent) async {
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureYubiOtpDialog(devicePath, intent.slot));
});
return null;
}),
if (hasFeature(features.slotsDelete))
DeleteIntent<OtpSlot>:
CallbackAction<DeleteIntent<OtpSlot>>(onInvoke: (intent) async {
final slot = intent.target;
if (!slot.isConfigured) {
return false;
}
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteSlotDialog(devicePath, slot)) ??
false);
return deleted;
}),
},
child: Builder(
// Builder to ensure new scope for actions, they can invoke parent actions
builder: (context) {
final child = Builder(builder: builder);
return actions != null
? Actions(actions: actions!(context), child: child)
: child;
},
),
);
}
}
List<ActionItem> buildSlotActions(OtpSlot slot, AppLocalizations l10n) {

View File

@ -65,8 +65,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final selected = _selected != null
? otpState.slots.firstWhere((e) => e.slot == _selected)
: null;
return registerOtpActions(widget.devicePath,
ref: ref,
return OtpActions(
devicePath: widget.devicePath,
builder: (context) => Actions(
actions: {
EscapeIntent:

View File

@ -52,10 +52,10 @@ class SlotDialog extends ConsumerWidget {
return const FsDialog(child: CircularProgressIndicator());
}
return registerOtpActions(node.path,
ref: ref,
builder: (context) => Shortcuts(
shortcuts: itemShortcuts(otpSlot),
return OtpActions(
devicePath: node.path,
builder: (context) => ItemShortcuts(
item: otpSlot,
child: FocusScope(
autofocus: true,
child: FsDialog(

View File

@ -73,6 +73,183 @@ Future<bool> _authIfNeeded(
return true;
}
class PivActions extends ConsumerWidget {
final DevicePath devicePath;
final PivState pivState;
final Map<Type, Action<Intent>> Function(BuildContext context)? actions;
final Widget Function(BuildContext context) builder;
const PivActions(
{super.key,
required this.devicePath,
required this.pivState,
this.actions,
required this.builder});
@override
Widget build(BuildContext context, WidgetRef ref) {
final withContext = ref.read(withContextProvider);
final hasFeature = ref.read(featureProvider);
return Actions(
actions: {
if (hasFeature(features.slotsGenerate))
GenerateIntent:
CallbackAction<GenerateIntent>(onInvoke: (intent) async {
if (!pivState.protectedKey &&
!await withContext((context) =>
_authIfNeeded(context, devicePath, pivState))) {
return false;
}
// TODO: Avoid asking for PIN if not needed?
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(devicePath))) ??
false;
if (!verified) {
return false;
}
return await withContext((context) async {
final l10n = AppLocalizations.of(context)!;
final PivGenerateResult? result = await showBlurDialog(
context: context,
builder: (context) => GenerateKeyDialog(
devicePath,
pivState,
intent.slot,
),
);
switch (result?.generateType) {
case GenerateType.csr:
final filePath = await FilePicker.platform.saveFile(
dialogTitle: l10n.l_export_csr_file,
allowedExtensions: ['csr'],
type: FileType.custom,
lockParentWindow: true,
);
if (filePath != null) {
final file = File(filePath);
await file.writeAsString(result!.result, flush: true);
}
break;
default:
break;
}
return result != null;
});
}),
if (hasFeature(features.slotsImport))
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
return false;
}
final picked = await withContext(
(context) async {
final l10n = AppLocalizations.of(context)!;
return await FilePicker.platform.pickFiles(
allowedExtensions: [
'pem',
'der',
'pfx',
'p12',
'key',
'crt'
],
type: FileType.custom,
allowMultiple: false,
lockParentWindow: true,
dialogTitle: l10n.l_select_import_file);
},
);
if (picked == null || picked.files.isEmpty) {
return false;
}
return await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => ImportFileDialog(
devicePath,
pivState,
intent.slot,
File(picked.paths.first!),
),
) ??
false);
}),
if (hasFeature(features.slotsExport))
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
final (_, cert) = await ref
.read(pivSlotsProvider(devicePath).notifier)
.read(intent.slot.slot);
if (cert == null) {
return false;
}
final filePath = await withContext((context) async {
final l10n = AppLocalizations.of(context)!;
return await FilePicker.platform.saveFile(
dialogTitle: l10n.l_export_certificate_file,
allowedExtensions: ['pem'],
type: FileType.custom,
lockParentWindow: true,
);
});
if (filePath == null) {
return false;
}
final file = File(filePath);
await file.writeAsString(cert, flush: true);
await withContext((context) async {
final l10n = AppLocalizations.of(context)!;
showMessage(context, l10n.l_certificate_exported);
});
return true;
}),
if (hasFeature(features.slotsDelete))
DeleteIntent<PivSlot>:
CallbackAction<DeleteIntent<PivSlot>>(onInvoke: (intent) async {
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
return false;
}
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteCertificateDialog(
devicePath,
intent.target,
),
) ??
false);
return deleted;
}),
},
child: Builder(
// Builder to ensure new scope for actions, they can invoke parent actions
builder: (context) {
final child = Builder(builder: builder);
return actions != null
? Actions(actions: actions!(context), child: child)
: child;
},
),
);
}
}
Widget registerPivActions(
DevicePath devicePath,
PivState pivState, {

View File

@ -77,10 +77,9 @@ class _PivScreenState extends ConsumerState<PivScreen> {
final subtitleStyle = textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
);
return registerPivActions(
widget.devicePath,
pivState,
ref: ref,
return PivActions(
devicePath: widget.devicePath,
pivState: pivState,
builder: (context) => Actions(
actions: {
EscapeIntent:
@ -160,8 +159,9 @@ class _PivScreenState extends ConsumerState<PivScreen> {
return Actions(
actions: {
if (expanded)
OpenIntent: CallbackAction<OpenIntent<PivSlot>>(
onInvoke: (intent) async {
OpenIntent<PivSlot>:
CallbackAction<OpenIntent<PivSlot>>(
onInvoke: (intent) async {
setState(() {
_selected = intent.target.slot;
});

View File

@ -59,12 +59,11 @@ class SlotDialog extends ConsumerWidget {
}
final certInfo = slotData.certInfo;
return registerPivActions(
node.path,
pivState,
ref: ref,
builder: (context) => Shortcuts(
shortcuts: itemShortcuts(slotData),
return PivActions(
devicePath: node.path,
pivState: pivState,
builder: (context) => ItemShortcuts(
item: slotData,
child: FocusScope(
autofocus: true,
child: FsDialog(