mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-25 23:14:18 +03:00
Refactor Actions to use custom Widgets more.
This commit is contained in:
parent
5d286de0a0
commit
e4d49ea4b4
@ -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,
|
||||
],
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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) {
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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: [
|
||||
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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:
|
||||
|
@ -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(
|
||||
|
@ -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, {
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user