mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
Refactor Intents/Actions.
Big changes to Intents/Actions where we now have an argument for several of the intents to indicate the target. This allows for handling these actions on a higher level.
This commit is contained in:
parent
50dd72b83b
commit
5d286de0a0
@ -31,14 +31,6 @@ import 'state.dart';
|
||||
import 'views/keys.dart';
|
||||
import 'views/settings_page.dart';
|
||||
|
||||
class OpenIntent extends Intent {
|
||||
const OpenIntent();
|
||||
}
|
||||
|
||||
class CopyIntent extends Intent {
|
||||
const CopyIntent();
|
||||
}
|
||||
|
||||
class CloseIntent extends Intent {
|
||||
const CloseIntent();
|
||||
}
|
||||
@ -51,6 +43,10 @@ class SearchIntent extends Intent {
|
||||
const SearchIntent();
|
||||
}
|
||||
|
||||
class EscapeIntent extends Intent {
|
||||
const EscapeIntent();
|
||||
}
|
||||
|
||||
class NextDeviceIntent extends Intent {
|
||||
const NextDeviceIntent();
|
||||
}
|
||||
@ -63,26 +59,45 @@ class AboutIntent extends Intent {
|
||||
const AboutIntent();
|
||||
}
|
||||
|
||||
class EditIntent extends Intent {
|
||||
const EditIntent();
|
||||
class OpenIntent<T> extends Intent {
|
||||
final T target;
|
||||
const OpenIntent(this.target);
|
||||
}
|
||||
|
||||
class DeleteIntent extends Intent {
|
||||
const DeleteIntent();
|
||||
class CopyIntent<T> extends Intent {
|
||||
final T target;
|
||||
const CopyIntent(this.target);
|
||||
}
|
||||
|
||||
class RefreshIntent extends Intent {
|
||||
const RefreshIntent();
|
||||
class EditIntent<T> extends Intent {
|
||||
final T target;
|
||||
const EditIntent(this.target);
|
||||
}
|
||||
|
||||
class EscapeIntent extends Intent {
|
||||
const EscapeIntent();
|
||||
class DeleteIntent<T> extends Intent {
|
||||
final T target;
|
||||
const DeleteIntent(this.target);
|
||||
}
|
||||
|
||||
class RefreshIntent<T> extends Intent {
|
||||
final T target;
|
||||
const RefreshIntent(this.target);
|
||||
}
|
||||
|
||||
/// Use cmd on macOS, use ctrl on the other platforms
|
||||
SingleActivator ctrlOrCmd(LogicalKeyboardKey key) =>
|
||||
SingleActivator(key, meta: Platform.isMacOS, control: !Platform.isMacOS);
|
||||
|
||||
/// Common shortcuts for items
|
||||
Map<ShortcutActivator, Intent> itemShortcuts<T>(T item) => {
|
||||
ctrlOrCmd(LogicalKeyboardKey.keyR): RefreshIntent<T>(item),
|
||||
ctrlOrCmd(LogicalKeyboardKey.keyC): CopyIntent<T>(item),
|
||||
const SingleActivator(LogicalKeyboardKey.copy): CopyIntent<T>(item),
|
||||
const SingleActivator(LogicalKeyboardKey.delete): DeleteIntent<T>(item),
|
||||
const SingleActivator(LogicalKeyboardKey.enter): OpenIntent<T>(item),
|
||||
const SingleActivator(LogicalKeyboardKey.space): OpenIntent<T>(item),
|
||||
};
|
||||
|
||||
Widget registerGlobalShortcuts(
|
||||
{required WidgetRef ref, required Widget child}) =>
|
||||
Actions(
|
||||
@ -162,10 +177,7 @@ Widget registerGlobalShortcuts(
|
||||
},
|
||||
child: Shortcuts(
|
||||
shortcuts: {
|
||||
ctrlOrCmd(LogicalKeyboardKey.keyC): const CopyIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.copy): const CopyIntent(),
|
||||
ctrlOrCmd(LogicalKeyboardKey.keyF): const SearchIntent(),
|
||||
ctrlOrCmd(LogicalKeyboardKey.keyR): const RefreshIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.escape):
|
||||
const EscapeIntent(),
|
||||
if (isDesktop) ...{
|
||||
|
@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/state.dart';
|
||||
@ -23,18 +22,20 @@ import '../models.dart';
|
||||
import '../shortcuts.dart';
|
||||
import 'action_popup_menu.dart';
|
||||
|
||||
class AppListItem extends ConsumerStatefulWidget {
|
||||
class AppListItem<T> extends ConsumerStatefulWidget {
|
||||
final T item;
|
||||
final Widget? leading;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? semanticTitle;
|
||||
final Widget? trailing;
|
||||
final List<ActionItem> Function(BuildContext context)? buildPopupActions;
|
||||
final Intent? activationIntent;
|
||||
final Intent? tapIntent;
|
||||
final Intent? doubleTapIntent;
|
||||
final bool selected;
|
||||
final bool openOnSingleTap;
|
||||
|
||||
const AppListItem({
|
||||
const AppListItem(
|
||||
this.item, {
|
||||
super.key,
|
||||
this.leading,
|
||||
required this.title,
|
||||
@ -42,16 +43,16 @@ class AppListItem extends ConsumerStatefulWidget {
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.buildPopupActions,
|
||||
this.activationIntent,
|
||||
this.tapIntent,
|
||||
this.doubleTapIntent,
|
||||
this.selected = false,
|
||||
this.openOnSingleTap = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AppListItemState();
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AppListItemState<T>();
|
||||
}
|
||||
|
||||
class _AppListItemState extends ConsumerState<AppListItem> {
|
||||
class _AppListItemState<T> extends ConsumerState<AppListItem> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
int _lastTap = 0;
|
||||
|
||||
@ -65,17 +66,15 @@ class _AppListItemState extends ConsumerState<AppListItem> {
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = widget.subtitle;
|
||||
final buildPopupActions = widget.buildPopupActions;
|
||||
final activationIntent = widget.activationIntent;
|
||||
final tapIntent = widget.tapIntent;
|
||||
final doubleTapIntent = widget.doubleTapIntent;
|
||||
final trailing = widget.trailing;
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticTitle ?? widget.title,
|
||||
child: Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
|
||||
},
|
||||
shortcuts: itemShortcuts<T>(widget.item),
|
||||
child: InkWell(
|
||||
focusNode: _focusNode,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
@ -95,27 +94,28 @@ class _AppListItemState extends ConsumerState<AppListItem> {
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (isDesktop && !widget.openOnSingleTap) {
|
||||
if (tapIntent != null) {
|
||||
Actions.invoke(context, tapIntent);
|
||||
}
|
||||
if (isDesktop && doubleTapIntent != null) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastTap < 500) {
|
||||
setState(() {
|
||||
_lastTap = 0;
|
||||
});
|
||||
Actions.invoke(context, activationIntent ?? const OpenIntent());
|
||||
Actions.invoke(context, doubleTapIntent);
|
||||
} else {
|
||||
_focusNode.requestFocus();
|
||||
setState(() {
|
||||
_lastTap = now;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Actions.invoke<OpenIntent>(context, const OpenIntent());
|
||||
}
|
||||
},
|
||||
onLongPress: activationIntent == null
|
||||
onLongPress: doubleTapIntent == null
|
||||
? null
|
||||
: () {
|
||||
Actions.invoke(context, activationIntent);
|
||||
Actions.invoke(context, doubleTapIntent);
|
||||
},
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
@ -123,7 +123,7 @@ class _AppListItemState extends ConsumerState<AppListItem> {
|
||||
const SizedBox(height: 64),
|
||||
ListTile(
|
||||
mouseCursor:
|
||||
widget.openOnSingleTap ? SystemMouseCursors.click : null,
|
||||
widget.tapIntent != null ? SystemMouseCursors.click : null,
|
||||
selected: widget.selected,
|
||||
leading: widget.leading,
|
||||
title: Text(
|
||||
|
@ -21,8 +21,10 @@ import '../../app/models.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
|
||||
List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
|
||||
List<ActionItem> buildFingerprintActions(
|
||||
Fingerprint fingerprint, AppLocalizations l10n) {
|
||||
return [
|
||||
ActionItem(
|
||||
key: keys.editFingerintAction,
|
||||
@ -30,7 +32,7 @@ List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.edit),
|
||||
title: l10n.s_rename_fp,
|
||||
subtitle: l10n.l_rename_fp_desc,
|
||||
intent: const EditIntent(),
|
||||
intent: EditIntent(fingerprint),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.deleteFingerprintAction,
|
||||
@ -39,12 +41,13 @@ List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.delete),
|
||||
title: l10n.s_delete_fingerprint,
|
||||
subtitle: l10n.l_delete_fingerprint_desc,
|
||||
intent: const DeleteIntent(),
|
||||
intent: DeleteIntent(fingerprint),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
|
||||
List<ActionItem> buildCredentialActions(
|
||||
FidoCredential credential, AppLocalizations l10n) {
|
||||
return [
|
||||
ActionItem(
|
||||
key: keys.deleteCredentialAction,
|
||||
@ -53,7 +56,7 @@ List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.delete),
|
||||
title: l10n.s_delete_passkey,
|
||||
subtitle: l10n.l_delete_account_desc,
|
||||
intent: const DeleteIntent(),
|
||||
intent: DeleteIntent(credential),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
@ -33,7 +33,9 @@ class CredentialDialog extends ConsumerWidget {
|
||||
return Actions(
|
||||
actions: {
|
||||
if (hasFeature(features.credentialsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
DeleteIntent<FidoCredential>:
|
||||
CallbackAction<DeleteIntent<FidoCredential>>(
|
||||
onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
final bool? deleted =
|
||||
await ref.read(withContextProvider)((context) async =>
|
||||
@ -41,7 +43,7 @@ class CredentialDialog extends ConsumerWidget {
|
||||
context: context,
|
||||
builder: (context) => DeleteCredentialDialog(
|
||||
node.path,
|
||||
credential,
|
||||
intent.target,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
@ -55,41 +57,45 @@ class CredentialDialog extends ConsumerWidget {
|
||||
return deleted;
|
||||
}),
|
||||
},
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
credential.userName,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
credential.rpId,
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.person, size: 72),
|
||||
],
|
||||
child: Shortcuts(
|
||||
shortcuts: itemShortcuts(credential),
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
credential.userName,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
credential.rpId,
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.person, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildCredentialActions(l10n),
|
||||
),
|
||||
],
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildCredentialActions(credential, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -33,14 +33,15 @@ class FingerprintDialog extends ConsumerWidget {
|
||||
return Actions(
|
||||
actions: {
|
||||
if (hasFeature(features.fingerprintsEdit))
|
||||
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
|
||||
EditIntent<Fingerprint>:
|
||||
CallbackAction<EditIntent<Fingerprint>>(onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
final Fingerprint? renamed =
|
||||
await withContext((context) async => await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameFingerprintDialog(
|
||||
node.path,
|
||||
fingerprint,
|
||||
intent.target,
|
||||
),
|
||||
));
|
||||
if (renamed != null) {
|
||||
@ -58,7 +59,8 @@ class FingerprintDialog extends ConsumerWidget {
|
||||
return renamed;
|
||||
}),
|
||||
if (hasFeature(features.fingerprintsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
DeleteIntent<Fingerprint>: CallbackAction<DeleteIntent<Fingerprint>>(
|
||||
onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
final bool? deleted =
|
||||
await ref.read(withContextProvider)((context) async =>
|
||||
@ -66,7 +68,7 @@ class FingerprintDialog extends ConsumerWidget {
|
||||
context: context,
|
||||
builder: (context) => DeleteFingerprintDialog(
|
||||
node.path,
|
||||
fingerprint,
|
||||
intent.target,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
@ -80,32 +82,35 @@ class FingerprintDialog extends ConsumerWidget {
|
||||
return deleted;
|
||||
}),
|
||||
},
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
fingerprint.label,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.fingerprint, size: 72),
|
||||
],
|
||||
child: Shortcuts(
|
||||
shortcuts: itemShortcuts(fingerprint),
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
fingerprint.label,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.fingerprint, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildFingerprintActions(l10n),
|
||||
),
|
||||
],
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildFingerprintActions(fingerprint, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -53,88 +53,6 @@ class FidoUnlockedPage extends ConsumerStatefulWidget {
|
||||
class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
|
||||
Object? _selected;
|
||||
|
||||
Widget _registerFingerprintActions(
|
||||
Fingerprint fingerprint, {
|
||||
required WidgetRef ref,
|
||||
required Widget Function(BuildContext context) builder,
|
||||
Map<Type, Action<Intent>> actions = const {},
|
||||
}) {
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
return Actions(
|
||||
actions: {
|
||||
if (hasFeature(features.fingerprintsEdit))
|
||||
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
|
||||
final renamed = await ref.read(withContextProvider)(
|
||||
(context) => showBlurDialog<Fingerprint>(
|
||||
context: context,
|
||||
builder: (context) => RenameFingerprintDialog(
|
||||
widget.node.path,
|
||||
fingerprint,
|
||||
),
|
||||
));
|
||||
if (_selected == fingerprint && renamed != null) {
|
||||
setState(() {
|
||||
_selected = renamed;
|
||||
});
|
||||
}
|
||||
return renamed;
|
||||
}),
|
||||
if (hasFeature(features.fingerprintsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
final deleted = await ref.read(withContextProvider)(
|
||||
(context) => showBlurDialog<bool?>(
|
||||
context: context,
|
||||
builder: (context) => DeleteFingerprintDialog(
|
||||
widget.node.path,
|
||||
fingerprint,
|
||||
),
|
||||
));
|
||||
if (_selected == fingerprint && deleted == true) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}),
|
||||
...actions,
|
||||
},
|
||||
child: Builder(builder: builder),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _registerCredentialActions(
|
||||
FidoCredential credential, {
|
||||
required WidgetRef ref,
|
||||
required Widget Function(BuildContext context) builder,
|
||||
Map<Type, Action<Intent>> actions = const {},
|
||||
}) {
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
return Actions(
|
||||
actions: {
|
||||
if (hasFeature(features.credentialsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
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;
|
||||
}),
|
||||
...actions,
|
||||
},
|
||||
child: Builder(builder: builder),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@ -149,32 +67,13 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
|
||||
final creds = data.value;
|
||||
if (creds.isNotEmpty) {
|
||||
children.add((_) => ListTitle(l10n.s_passkeys));
|
||||
children.addAll(
|
||||
creds.map((cred) => (expanded) => _registerCredentialActions(
|
||||
cred,
|
||||
ref: ref,
|
||||
actions: {
|
||||
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) {
|
||||
if (expanded) {
|
||||
setState(() {
|
||||
_selected = cred;
|
||||
});
|
||||
return null;
|
||||
} else {
|
||||
return showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => CredentialDialog(cred),
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
builder: (context) => _CredentialListItem(
|
||||
cred,
|
||||
expanded: expanded,
|
||||
selected: selected == cred,
|
||||
),
|
||||
)));
|
||||
children.addAll(creds.map(
|
||||
(cred) => (expanded) => _CredentialListItem(
|
||||
cred,
|
||||
expanded: expanded,
|
||||
selected: selected == cred,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,36 +87,18 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
|
||||
if (fingerprints.isNotEmpty) {
|
||||
nFingerprints = fingerprints.length;
|
||||
children.add((_) => ListTitle(l10n.s_fingerprints));
|
||||
children.addAll(
|
||||
fingerprints.map((fp) => (expanded) => _registerFingerprintActions(
|
||||
fp,
|
||||
ref: ref,
|
||||
actions: {
|
||||
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) {
|
||||
if (expanded) {
|
||||
setState(() {
|
||||
_selected = fp;
|
||||
});
|
||||
return null;
|
||||
} else {
|
||||
return showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => FingerprintDialog(fp),
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
builder: (context) => _FingerprintListItem(
|
||||
fp,
|
||||
expanded: expanded,
|
||||
selected: fp == selected,
|
||||
),
|
||||
)));
|
||||
children.addAll(fingerprints.map(
|
||||
(fp) => (expanded) => _FingerprintListItem(
|
||||
fp,
|
||||
expanded: expanded,
|
||||
selected: fp == selected,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final hasActions = ref.watch(featureProvider)(features.actions);
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
final hasActions = hasFeature(features.actions);
|
||||
|
||||
if (children.isNotEmpty) {
|
||||
return Actions(
|
||||
@ -232,92 +113,158 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
OpenIntent<FidoCredential>:
|
||||
CallbackAction<OpenIntent<FidoCredential>>(onInvoke: (intent) {
|
||||
return showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
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(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => FingerprintDialog(intent.target),
|
||||
);
|
||||
}),
|
||||
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) {
|
||||
setState(() {
|
||||
_selected = renamed;
|
||||
});
|
||||
}
|
||||
return renamed;
|
||||
}),
|
||||
if (hasFeature(features.fingerprintsDelete))
|
||||
DeleteIntent<Fingerprint>:
|
||||
CallbackAction<DeleteIntent<Fingerprint>>(
|
||||
onInvoke: (intent) async {
|
||||
final fingerprint = intent.target;
|
||||
final deleted = await ref.read(withContextProvider)(
|
||||
(context) => showBlurDialog<bool?>(
|
||||
context: context,
|
||||
builder: (context) => DeleteFingerprintDialog(
|
||||
widget.node.path,
|
||||
fingerprint,
|
||||
),
|
||||
));
|
||||
if (_selected == fingerprint && deleted == true) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}),
|
||||
},
|
||||
child: AppPage(
|
||||
title: Text(l10n.s_webauthn),
|
||||
detailViewBuilder: switch (selected) {
|
||||
FidoCredential credential => (context) =>
|
||||
_registerCredentialActions(credential,
|
||||
ref: ref,
|
||||
builder: (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
FidoCredential credential => (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// TODO: Reuse from credential_dialog
|
||||
child: Column(
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// TODO: Reuse from credential_dialog
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
credential.userName,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
credential.rpId,
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.person, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
credential.userName,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildCredentialActions(l10n),
|
||||
Text(
|
||||
credential.rpId,
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.person, size: 72),
|
||||
],
|
||||
)),
|
||||
Fingerprint fingerprint => (context) => _registerFingerprintActions(
|
||||
fingerprint,
|
||||
ref: ref,
|
||||
builder: (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// TODO: Reuse from fingerprint_dialog
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
fingerprint.label,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.fingerprint, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildFingerprintActions(l10n),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildCredentialActions(credential, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
Fingerprint fingerprint => (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// TODO: Reuse from fingerprint_dialog
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
fingerprint.label,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.fingerprint, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildFingerprintActions(fingerprint, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => null
|
||||
},
|
||||
@ -326,9 +273,30 @@ class _FidoUnlockedPageState extends ConsumerState<FidoUnlockedPage> {
|
||||
context, widget.node, widget.state, nFingerprints)
|
||||
: null,
|
||||
keyActionsBadge: fidoShowActionsNotifier(widget.state),
|
||||
builder: (context, expanded) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children.map((f) => f(expanded)).toList()),
|
||||
builder: (context, expanded) => Actions(
|
||||
actions: {
|
||||
if (expanded) ...{
|
||||
OpenIntent<FidoCredential>:
|
||||
CallbackAction<OpenIntent<FidoCredential>>(
|
||||
onInvoke: (intent) {
|
||||
setState(() {
|
||||
_selected = intent.target;
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
OpenIntent<Fingerprint>:
|
||||
CallbackAction<OpenIntent<Fingerprint>>(onInvoke: (intent) {
|
||||
setState(() {
|
||||
_selected = intent.target;
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children.map((f) => f(expanded)).toList()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -380,6 +348,7 @@ class _CredentialListItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppListItem(
|
||||
credential,
|
||||
selected: selected,
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
@ -391,12 +360,13 @@ class _CredentialListItem extends StatelessWidget {
|
||||
trailing: expanded
|
||||
? null
|
||||
: OutlinedButton(
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
onPressed: Actions.handler(context, OpenIntent(credential)),
|
||||
child: const Icon(Icons.more_horiz),
|
||||
),
|
||||
openOnSingleTap: expanded,
|
||||
tapIntent: isDesktop && !expanded ? null : OpenIntent(credential),
|
||||
doubleTapIntent: isDesktop && !expanded ? OpenIntent(credential) : null,
|
||||
buildPopupActions: (context) =>
|
||||
buildCredentialActions(AppLocalizations.of(context)!),
|
||||
buildCredentialActions(credential, AppLocalizations.of(context)!),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -412,6 +382,7 @@ class _FingerprintListItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppListItem(
|
||||
fingerprint,
|
||||
selected: selected,
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||
@ -422,12 +393,13 @@ class _FingerprintListItem extends StatelessWidget {
|
||||
trailing: expanded
|
||||
? null
|
||||
: OutlinedButton(
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
onPressed: Actions.handler(context, OpenIntent(fingerprint)),
|
||||
child: const Icon(Icons.more_horiz),
|
||||
),
|
||||
openOnSingleTap: expanded,
|
||||
tapIntent: isDesktop && !expanded ? null : OpenIntent(fingerprint),
|
||||
doubleTapIntent: isDesktop && !expanded ? OpenIntent(fingerprint) : null,
|
||||
buildPopupActions: (context) =>
|
||||
buildFingerprintActions(AppLocalizations.of(context)!),
|
||||
buildFingerprintActions(fingerprint, AppLocalizations.of(context)!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -32,8 +32,6 @@ import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'account_helper.dart';
|
||||
import 'actions.dart';
|
||||
import 'delete_account_dialog.dart';
|
||||
import 'rename_account_dialog.dart';
|
||||
|
||||
class AccountDialog extends ConsumerWidget {
|
||||
final OathCredential credential;
|
||||
@ -55,129 +53,121 @@ class AccountDialog extends ConsumerWidget {
|
||||
final subtitle = helper.subtitle;
|
||||
|
||||
return registerOathActions(
|
||||
credential,
|
||||
node.path,
|
||||
ref: ref,
|
||||
actions: {
|
||||
if (hasFeature(features.accountsRename))
|
||||
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
final renamed =
|
||||
await withContext((context) async => await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameAccountDialog.forOathCredential(
|
||||
ref,
|
||||
node,
|
||||
credential,
|
||||
credentials
|
||||
?.map((e) => (e.issuer, e.name))
|
||||
.toList() ??
|
||||
[],
|
||||
)));
|
||||
if (renamed != null) {
|
||||
// Replace the dialog with the renamed credential
|
||||
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: (_) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
final bool? deleted =
|
||||
await ref.read(withContextProvider)((context) async =>
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteAccountDialog(
|
||||
node,
|
||||
credential,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
|
||||
// Pop the account dialog if deleted
|
||||
if (deleted == true) {
|
||||
await withContext((context) async {
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}),
|
||||
},
|
||||
builder: (context) {
|
||||
if (helper.code == null &&
|
||||
(isDesktop || node.transport == Transport.usb)) {
|
||||
Timer.run(() {
|
||||
// Only call if credential hasn't been deleted/renamed
|
||||
if (ref.read(credentialsProvider)?.contains(credential) == true) {
|
||||
Actions.invoke(context, const RefreshIntent());
|
||||
Actions.invoke(
|
||||
context, RefreshIntent<OathCredential>(credential));
|
||||
}
|
||||
});
|
||||
}
|
||||
return 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(),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
DefaultTextStyle.merge(
|
||||
style: const TextStyle(fontSize: 28),
|
||||
child: helper.buildCodeLabel(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
helper.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
// This is what ListTile uses for subtitle
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
),
|
||||
Text(
|
||||
helper.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
// This is what ListTile uses for subtitle
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
AppLocalizations.of(context)!.s_actions,
|
||||
actions: helper.buildActions(),
|
||||
),
|
||||
],
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
AppLocalizations.of(context)!.s_actions,
|
||||
actions: helper.buildActions(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -75,7 +75,7 @@ class AccountHelper {
|
||||
subtitle: l10n.l_copy_code_desc,
|
||||
shortcut: Platform.isMacOS ? '\u2318 C' : 'Ctrl+C',
|
||||
actionStyle: canCopy ? ActionStyle.primary : null,
|
||||
intent: canCopy ? const CopyIntent() : null,
|
||||
intent: canCopy ? CopyIntent(credential) : null,
|
||||
),
|
||||
if (manual)
|
||||
ActionItem(
|
||||
@ -85,7 +85,7 @@ class AccountHelper {
|
||||
title: l10n.s_calculate,
|
||||
subtitle: l10n.l_calculate_code_desc,
|
||||
shortcut: Platform.isMacOS ? '\u2318 R' : 'Ctrl+R',
|
||||
intent: ready ? const RefreshIntent() : null,
|
||||
intent: ready ? RefreshIntent(credential) : null,
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.togglePinAction,
|
||||
@ -95,7 +95,7 @@ class AccountHelper {
|
||||
: const Icon(Icons.push_pin_outlined),
|
||||
title: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
|
||||
subtitle: l10n.l_pin_account_desc,
|
||||
intent: const TogglePinIntent(),
|
||||
intent: TogglePinIntent(credential),
|
||||
),
|
||||
if (data.info.version.isAtLeast(5, 3))
|
||||
ActionItem(
|
||||
@ -104,7 +104,7 @@ class AccountHelper {
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
title: l10n.s_rename_account,
|
||||
subtitle: l10n.l_rename_account_desc,
|
||||
intent: const EditIntent(),
|
||||
intent: EditIntent(credential),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.deleteAction,
|
||||
@ -113,7 +113,7 @@ class AccountHelper {
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
title: l10n.s_delete_account,
|
||||
subtitle: l10n.l_delete_account_desc,
|
||||
intent: const DeleteIntent(),
|
||||
intent: DeleteIntent(credential),
|
||||
),
|
||||
];
|
||||
},
|
||||
|
@ -25,7 +25,10 @@ import 'account_view.dart';
|
||||
|
||||
class AccountList extends ConsumerWidget {
|
||||
final List<OathPair> accounts;
|
||||
const AccountList(this.accounts, {super.key});
|
||||
final bool expanded;
|
||||
final OathCredential? selected;
|
||||
const AccountList(this.accounts,
|
||||
{super.key, required this.expanded, this.selected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@ -51,12 +54,16 @@ class AccountList extends ConsumerWidget {
|
||||
...pinnedCreds.map(
|
||||
(entry) => AccountView(
|
||||
entry.credential,
|
||||
expanded: expanded,
|
||||
selected: entry.credential == selected,
|
||||
),
|
||||
),
|
||||
if (creds.isNotEmpty) ListTitle(l10n.s_accounts),
|
||||
...creds.map(
|
||||
(entry) => AccountView(
|
||||
entry.credential,
|
||||
expanded: expanded,
|
||||
selected: entry.credential == selected,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -16,27 +16,22 @@
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/app_list_item.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'account_dialog.dart';
|
||||
import 'account_helper.dart';
|
||||
import 'account_icon.dart';
|
||||
import 'actions.dart';
|
||||
import 'delete_account_dialog.dart';
|
||||
import 'rename_account_dialog.dart';
|
||||
|
||||
class AccountView extends ConsumerStatefulWidget {
|
||||
final OathCredential credential;
|
||||
const AccountView(this.credential, {super.key});
|
||||
final bool expanded;
|
||||
final bool selected;
|
||||
const AccountView(this.credential,
|
||||
{super.key, required this.expanded, required this.selected});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AccountViewState();
|
||||
@ -82,92 +77,47 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
final helper = AccountHelper(context, ref, credential);
|
||||
|
||||
return registerOathActions(
|
||||
credential,
|
||||
ref: ref,
|
||||
actions: {
|
||||
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => AccountDialog(credential),
|
||||
);
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.accountsRename))
|
||||
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
|
||||
final node = ref.read(currentDeviceProvider)!;
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
return await withContext((context) async => await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameAccountDialog.forOathCredential(
|
||||
ref,
|
||||
node,
|
||||
credential,
|
||||
credentials?.map((e) => (e.issuer, e.name)).toList() ?? [],
|
||||
),
|
||||
));
|
||||
}),
|
||||
if (hasFeature(features.accountsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
final node = ref.read(currentDeviceProvider)!;
|
||||
return await ref.read(withContextProvider)((context) async =>
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteAccountDialog(node, credential),
|
||||
) ??
|
||||
false);
|
||||
}),
|
||||
},
|
||||
builder: (context) {
|
||||
final helper = AccountHelper(context, ref, credential);
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final showAvatar = constraints.maxWidth >= 315;
|
||||
final subtitle = helper.subtitle;
|
||||
final circleAvatar = CircleAvatar(
|
||||
foregroundColor: Theme.of(context).colorScheme.background,
|
||||
backgroundColor: _iconColor(400),
|
||||
child: Text(
|
||||
(credential.issuer ?? credential.name)
|
||||
.characters
|
||||
.first
|
||||
.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
|
||||
),
|
||||
);
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final showAvatar = constraints.maxWidth >= 315;
|
||||
final subtitle = helper.subtitle;
|
||||
final circleAvatar = CircleAvatar(
|
||||
foregroundColor: Theme.of(context).colorScheme.background,
|
||||
backgroundColor: _iconColor(400),
|
||||
child: Text(
|
||||
(credential.issuer ?? credential.name).characters.first.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
|
||||
),
|
||||
);
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
|
||||
},
|
||||
child: AppListItem(
|
||||
leading: showAvatar
|
||||
? AccountIcon(
|
||||
issuer: credential.issuer, defaultWidget: circleAvatar)
|
||||
: null,
|
||||
title: helper.title,
|
||||
subtitle: subtitle,
|
||||
semanticTitle: _a11yCredentialLabel(
|
||||
credential.issuer, credential.name, helper.code?.value),
|
||||
trailing: helper.code != null
|
||||
? FilledButton.tonalIcon(
|
||||
icon: helper.buildCodeIcon(),
|
||||
label: helper.buildCodeLabel(),
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
child: helper.buildCodeIcon()),
|
||||
activationIntent: hasFeature(features.accountsClipboard)
|
||||
? const CopyIntent()
|
||||
: const OpenIntent(),
|
||||
buildPopupActions: (_) => helper.buildActions(),
|
||||
));
|
||||
});
|
||||
},
|
||||
);
|
||||
final openIntent = OpenIntent<OathCredential>(widget.credential);
|
||||
return AppListItem<OathCredential>(
|
||||
credential,
|
||||
selected: widget.selected,
|
||||
leading: showAvatar
|
||||
? AccountIcon(
|
||||
issuer: credential.issuer, defaultWidget: circleAvatar)
|
||||
: null,
|
||||
title: helper.title,
|
||||
subtitle: subtitle,
|
||||
semanticTitle: _a11yCredentialLabel(
|
||||
credential.issuer, credential.name, helper.code?.value),
|
||||
trailing: helper.code != null
|
||||
? FilledButton.tonalIcon(
|
||||
icon: helper.buildCodeIcon(),
|
||||
label: helper.buildCodeLabel(),
|
||||
onPressed: Actions.handler(context, openIntent),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: Actions.handler(context, openIntent),
|
||||
child: helper.buildCodeIcon()),
|
||||
tapIntent: isDesktop && !widget.expanded ? null : openIntent,
|
||||
doubleTapIntent: hasFeature(features.accountsClipboard)
|
||||
? CopyIntent<OathCredential>(credential)
|
||||
: null,
|
||||
buildPopupActions: (_) => helper.buildActions(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/state.dart';
|
||||
@ -26,9 +27,12 @@ import '../../exception/cancellation_exception.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'delete_account_dialog.dart';
|
||||
import 'rename_account_dialog.dart';
|
||||
|
||||
class TogglePinIntent extends Intent {
|
||||
const TogglePinIntent();
|
||||
final OathCredential target;
|
||||
const TogglePinIntent(this.target);
|
||||
}
|
||||
|
||||
Future<OathCode?> _calculateCode(
|
||||
@ -44,7 +48,7 @@ Future<OathCode?> _calculateCode(
|
||||
}
|
||||
|
||||
Widget registerOathActions(
|
||||
OathCredential credential, {
|
||||
DevicePath devicePath, {
|
||||
required WidgetRef ref,
|
||||
required Widget Function(BuildContext context) builder,
|
||||
Map<Type, Action<Intent>> actions = const {},
|
||||
@ -52,7 +56,9 @@ Widget registerOathActions(
|
||||
final hasFeature = ref.read(featureProvider);
|
||||
return Actions(
|
||||
actions: {
|
||||
RefreshIntent: CallbackAction<RefreshIntent>(onInvoke: (_) {
|
||||
RefreshIntent<OathCredential>:
|
||||
CallbackAction<RefreshIntent<OathCredential>>(onInvoke: (intent) {
|
||||
final credential = intent.target;
|
||||
final code = ref.read(codeProvider(credential));
|
||||
if (!(credential.oathType == OathType.totp &&
|
||||
code != null &&
|
||||
@ -62,7 +68,9 @@ Widget registerOathActions(
|
||||
return code;
|
||||
}),
|
||||
if (hasFeature(features.accountsClipboard))
|
||||
CopyIntent: CallbackAction<CopyIntent>(onInvoke: (_) async {
|
||||
CopyIntent<OathCredential>: CallbackAction<CopyIntent<OathCredential>>(
|
||||
onInvoke: (intent) async {
|
||||
final credential = intent.target;
|
||||
var code = ref.read(codeProvider(credential));
|
||||
if (code == null ||
|
||||
(credential.oathType == OathType.totp &&
|
||||
@ -82,10 +90,37 @@ Widget registerOathActions(
|
||||
return code;
|
||||
}),
|
||||
if (hasFeature(features.accountsPin))
|
||||
TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (_) {
|
||||
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
|
||||
TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (intent) {
|
||||
ref.read(favoritesProvider.notifier).toggleFavorite(intent.target.id);
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.accountsRename))
|
||||
EditIntent<OathCredential>:
|
||||
CallbackAction<EditIntent<OathCredential>>(onInvoke: (intent) {
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
return withContext((context) => showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameAccountDialog.forOathCredential(
|
||||
ref,
|
||||
devicePath,
|
||||
intent.target,
|
||||
credentials?.map((e) => (e.issuer, e.name)).toList() ?? [],
|
||||
)));
|
||||
}),
|
||||
if (hasFeature(features.accountsDelete))
|
||||
DeleteIntent<OathCredential>:
|
||||
CallbackAction<DeleteIntent<OathCredential>>(onInvoke: (intent) {
|
||||
return ref.read(withContextProvider)((context) async =>
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteAccountDialog(
|
||||
devicePath,
|
||||
intent.target,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
}),
|
||||
...actions,
|
||||
},
|
||||
child: Builder(builder: builder),
|
||||
|
@ -119,7 +119,7 @@ class _OathAddMultiAccountPageState
|
||||
(context) async => await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameAccountDialog(
|
||||
device: node!,
|
||||
devicePath: node!.path,
|
||||
issuer: cred.issuer,
|
||||
name: cred.name,
|
||||
oathType: cred.oathType,
|
||||
|
@ -29,9 +29,9 @@ import '../state.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
class DeleteAccountDialog extends ConsumerWidget {
|
||||
final DeviceNode device;
|
||||
final DevicePath devicePath;
|
||||
final OathCredential credential;
|
||||
const DeleteAccountDialog(this.device, this.credential, {super.key});
|
||||
const DeleteAccountDialog(this.devicePath, this.credential, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@ -44,7 +44,7 @@ class DeleteAccountDialog extends ConsumerWidget {
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref
|
||||
.read(credentialListProvider(device.path).notifier)
|
||||
.read(credentialListProvider(devicePath).notifier)
|
||||
.deleteAccount(credential);
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
|
@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@ -26,6 +27,7 @@ import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../app/views/app_failure_page.dart';
|
||||
import '../../app/views/app_page.dart';
|
||||
import '../../app/views/message_page.dart';
|
||||
@ -33,11 +35,15 @@ import '../../core/state.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_form_field.dart';
|
||||
import '../../widgets/file_drop_overlay.dart';
|
||||
import '../../widgets/list_title.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'account_dialog.dart';
|
||||
import 'account_helper.dart';
|
||||
import 'account_list.dart';
|
||||
import 'actions.dart';
|
||||
import 'key_actions.dart';
|
||||
import 'unlock_form.dart';
|
||||
import 'utils.dart';
|
||||
@ -105,6 +111,7 @@ class _UnlockedView extends ConsumerStatefulWidget {
|
||||
class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
late FocusNode searchFocus;
|
||||
late TextEditingController searchController;
|
||||
OathCredential? _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -131,7 +138,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
// ONLY rebuild if the number of credentials changes.
|
||||
final numCreds = ref.watch(credentialListProvider(widget.devicePath)
|
||||
.select((value) => value?.length));
|
||||
final hasActions = ref.watch(featureProvider)(features.actions);
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
final hasActions = hasFeature(features.actions);
|
||||
|
||||
Future<void> onFileDropped(File file) async {
|
||||
final qrScanner = ref.read(qrScannerProvider);
|
||||
@ -173,101 +181,237 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return Actions(
|
||||
actions: {
|
||||
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
|
||||
searchController.selection = TextSelection(
|
||||
baseOffset: 0, extentOffset: searchController.text.length);
|
||||
searchFocus.requestFocus();
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
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,
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
}),
|
||||
),
|
||||
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,
|
||||
builder: (context, expanded) => numCreds != null
|
||||
? Consumer(
|
||||
builder: (context, ref, _) {
|
||||
return AccountList(
|
||||
ref.watch(credentialListProvider(widget.devicePath)) ?? [],
|
||||
);
|
||||
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
if (_selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
} else {
|
||||
Actions.invoke(context, intent);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
OpenIntent<OathCredential>:
|
||||
CallbackAction<OpenIntent<OathCredential>>(
|
||||
onInvoke: (intent) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => AccountDialog(intent.target),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (hasFeature(features.accountsRename))
|
||||
EditIntent<OathCredential>:
|
||||
CallbackAction<EditIntent<OathCredential>>(
|
||||
onInvoke: (intent) async {
|
||||
final renamed =
|
||||
await (Actions.invoke(context, intent) as Future<dynamic>?);
|
||||
if (intent.target == _selected && renamed is OathCredential) {
|
||||
setState(() {
|
||||
_selected = renamed;
|
||||
});
|
||||
}
|
||||
return renamed;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: AppPage(
|
||||
title: Focus(
|
||||
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(() {});
|
||||
},
|
||||
)
|
||||
: const CircularProgressIndicator(),
|
||||
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(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ import 'utils.dart';
|
||||
final _log = Logger('oath.view.rename_account_dialog');
|
||||
|
||||
class RenameAccountDialog extends ConsumerStatefulWidget {
|
||||
final DeviceNode device;
|
||||
final DevicePath devicePath;
|
||||
final String? issuer;
|
||||
final String name;
|
||||
final OathType oathType;
|
||||
@ -47,7 +47,7 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
|
||||
final Future<dynamic> Function(String? issuer, String name) rename;
|
||||
|
||||
const RenameAccountDialog({
|
||||
required this.device,
|
||||
required this.devicePath,
|
||||
required this.issuer,
|
||||
required this.name,
|
||||
required this.oathType,
|
||||
@ -63,11 +63,11 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
|
||||
|
||||
factory RenameAccountDialog.forOathCredential(
|
||||
WidgetRef ref,
|
||||
DeviceNode device,
|
||||
DevicePath devicePath,
|
||||
OathCredential credential,
|
||||
List<(String? issuer, String name)> existing) {
|
||||
return RenameAccountDialog(
|
||||
device: device,
|
||||
devicePath: devicePath,
|
||||
issuer: credential.issuer,
|
||||
name: credential.name,
|
||||
oathType: credential.oathType,
|
||||
@ -78,7 +78,7 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
|
||||
try {
|
||||
// Rename credentials
|
||||
final renamed = await ref
|
||||
.read(credentialListProvider(device.path).notifier)
|
||||
.read(credentialListProvider(devicePath).notifier)
|
||||
.renameAccount(credential, issuer, name);
|
||||
|
||||
// Update favorite
|
||||
|
@ -34,24 +34,27 @@ import 'configure_yubiotp_dialog.dart';
|
||||
import 'delete_slot_dialog.dart';
|
||||
|
||||
class ConfigureChalRespIntent extends Intent {
|
||||
const ConfigureChalRespIntent();
|
||||
final OtpSlot slot;
|
||||
const ConfigureChalRespIntent(this.slot);
|
||||
}
|
||||
|
||||
class ConfigureHotpIntent extends Intent {
|
||||
const ConfigureHotpIntent();
|
||||
final OtpSlot slot;
|
||||
const ConfigureHotpIntent(this.slot);
|
||||
}
|
||||
|
||||
class ConfigureStaticIntent extends Intent {
|
||||
const ConfigureStaticIntent();
|
||||
final OtpSlot slot;
|
||||
const ConfigureStaticIntent(this.slot);
|
||||
}
|
||||
|
||||
class ConfigureYubiOtpIntent extends Intent {
|
||||
const ConfigureYubiOtpIntent();
|
||||
final OtpSlot slot;
|
||||
const ConfigureYubiOtpIntent(this.slot);
|
||||
}
|
||||
|
||||
Widget registerOtpActions(
|
||||
DevicePath devicePath,
|
||||
OtpSlot otpSlot, {
|
||||
DevicePath devicePath, {
|
||||
required WidgetRef ref,
|
||||
required Widget Function(BuildContext context) builder,
|
||||
Map<Type, Action<Intent>> actions = const {},
|
||||
@ -68,7 +71,7 @@ Widget registerOtpActions(
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ConfigureChalrespDialog(devicePath, otpSlot));
|
||||
ConfigureChalrespDialog(devicePath, intent.slot));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
@ -80,7 +83,8 @@ Widget registerOtpActions(
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfigureHotpDialog(devicePath, otpSlot));
|
||||
builder: (context) =>
|
||||
ConfigureHotpDialog(devicePath, intent.slot));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
@ -96,7 +100,7 @@ Widget registerOtpActions(
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfigureStaticDialog(
|
||||
devicePath, otpSlot, keyboardLayouts));
|
||||
devicePath, intent.slot, keyboardLayouts));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
@ -109,19 +113,23 @@ Widget registerOtpActions(
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ConfigureYubiOtpDialog(devicePath, otpSlot));
|
||||
ConfigureYubiOtpDialog(devicePath, intent.slot));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.slotsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
DeleteIntent<OtpSlot>:
|
||||
CallbackAction<DeleteIntent<OtpSlot>>(onInvoke: (intent) async {
|
||||
final slot = intent.target;
|
||||
if (!slot.isConfigured) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final withContext = ref.read(withContextProvider);
|
||||
final bool? deleted = await withContext((context) async =>
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
DeleteSlotDialog(devicePath, otpSlot)) ??
|
||||
builder: (context) => DeleteSlotDialog(devicePath, slot)) ??
|
||||
false);
|
||||
return deleted;
|
||||
}),
|
||||
@ -131,7 +139,7 @@ Widget registerOtpActions(
|
||||
);
|
||||
}
|
||||
|
||||
List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
|
||||
List<ActionItem> buildSlotActions(OtpSlot slot, AppLocalizations l10n) {
|
||||
return [
|
||||
ActionItem(
|
||||
key: keys.configureYubiOtp,
|
||||
@ -139,7 +147,7 @@ List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.shuffle_outlined),
|
||||
title: l10n.s_yubiotp,
|
||||
subtitle: l10n.l_yubiotp_desc,
|
||||
intent: const ConfigureYubiOtpIntent(),
|
||||
intent: ConfigureYubiOtpIntent(slot),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.configureChalResp,
|
||||
@ -147,21 +155,21 @@ List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.key_outlined),
|
||||
title: l10n.s_challenge_response,
|
||||
subtitle: l10n.l_challenge_response_desc,
|
||||
intent: const ConfigureChalRespIntent()),
|
||||
intent: ConfigureChalRespIntent(slot)),
|
||||
ActionItem(
|
||||
key: keys.configureStatic,
|
||||
feature: features.slotsConfigureStatic,
|
||||
icon: const Icon(Icons.password_outlined),
|
||||
title: l10n.s_static_password,
|
||||
subtitle: l10n.l_static_password_desc,
|
||||
intent: const ConfigureStaticIntent()),
|
||||
intent: ConfigureStaticIntent(slot)),
|
||||
ActionItem(
|
||||
key: keys.configureHotp,
|
||||
feature: features.slotsConfigureHotp,
|
||||
icon: const Icon(Icons.tag_outlined),
|
||||
title: l10n.s_hotp,
|
||||
subtitle: l10n.l_hotp_desc,
|
||||
intent: const ConfigureHotpIntent()),
|
||||
intent: ConfigureHotpIntent(slot)),
|
||||
ActionItem(
|
||||
key: keys.deleteAction,
|
||||
feature: features.slotsDelete,
|
||||
@ -169,7 +177,7 @@ List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
title: l10n.s_delete_slot,
|
||||
subtitle: l10n.l_delete_slot_desc,
|
||||
intent: isConfigured ? const DeleteIntent() : null,
|
||||
intent: slot.isConfigured ? DeleteIntent(slot) : null,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
@ -65,111 +65,111 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final selected = _selected != null
|
||||
? otpState.slots.firstWhere((e) => e.slot == _selected)
|
||||
: null;
|
||||
return Actions(
|
||||
actions: {
|
||||
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
} else {
|
||||
Actions.invoke(context, intent);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
},
|
||||
child: AppPage(
|
||||
title: Text(l10n.s_slots),
|
||||
detailViewBuilder: selected != null
|
||||
? (context) => registerOtpActions(
|
||||
widget.devicePath,
|
||||
selected,
|
||||
ref: ref,
|
||||
builder: (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// TODO: Reuse from fingerprint_dialog
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
selected.slot.getDisplayName(l10n),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
return registerOtpActions(widget.devicePath,
|
||||
ref: ref,
|
||||
builder: (context) => Actions(
|
||||
actions: {
|
||||
EscapeIntent:
|
||||
CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
} else {
|
||||
Actions.invoke(context, intent);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
OpenIntent<OtpSlot>: CallbackAction<OpenIntent<OtpSlot>>(
|
||||
onInvoke: (intent) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => SlotDialog(intent.target.slot),
|
||||
);
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: AppPage(
|
||||
title: Text(l10n.s_slots),
|
||||
detailViewBuilder: selected != null
|
||||
? (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// TODO: Reuse from fingerprint_dialog
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
selected.slot.getDisplayName(l10n),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Icon(
|
||||
Icons.touch_app,
|
||||
size: 100.0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(selected.isConfigured
|
||||
? l10n.l_otp_slot_configured
|
||||
: l10n.l_otp_slot_empty)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Icon(
|
||||
Icons.touch_app,
|
||||
size: 100.0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(selected.isConfigured
|
||||
? l10n.l_otp_slot_configured
|
||||
: l10n.l_otp_slot_empty)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_setup,
|
||||
actions:
|
||||
buildSlotActions(selected.isConfigured, l10n),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
keyActionsBuilder: hasFeature(features.actions)
|
||||
? (context) =>
|
||||
otpBuildActions(context, widget.devicePath, otpState, ref)
|
||||
: null,
|
||||
builder: (context, expanded) {
|
||||
// De-select if window is resized to be non-expanded.
|
||||
if (!expanded) {
|
||||
Timer.run(() {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
return Column(children: [
|
||||
ListTitle(l10n.s_slots),
|
||||
...otpState.slots
|
||||
.map((e) => registerOtpActions(widget.devicePath, e,
|
||||
ref: ref,
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_setup,
|
||||
actions: buildSlotActions(selected, l10n),
|
||||
)
|
||||
],
|
||||
)
|
||||
: null,
|
||||
keyActionsBuilder: hasFeature(features.actions)
|
||||
? (context) => otpBuildActions(
|
||||
context, widget.devicePath, otpState, ref)
|
||||
: null,
|
||||
builder: (context, expanded) {
|
||||
// De-select if window is resized to be non-expanded.
|
||||
if (!expanded) {
|
||||
Timer.run(() {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
return Actions(
|
||||
actions: {
|
||||
OpenIntent:
|
||||
CallbackAction<OpenIntent>(onInvoke: (_) async {
|
||||
if (expanded) {
|
||||
if (expanded)
|
||||
OpenIntent<OtpSlot>:
|
||||
CallbackAction<OpenIntent<OtpSlot>>(
|
||||
onInvoke: (intent) async {
|
||||
setState(() {
|
||||
_selected = e.slot;
|
||||
_selected = intent.target.slot;
|
||||
});
|
||||
} else {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => SlotDialog(e.slot),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
builder: (context) => _SlotListItem(
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
)))
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
child: Column(children: [
|
||||
ListTitle(l10n.s_slots),
|
||||
...otpState.slots.map((e) => _SlotListItem(
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
))
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -191,6 +191,7 @@ class _SlotListItem extends ConsumerWidget {
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
|
||||
return AppListItem(
|
||||
otpSlot,
|
||||
selected: selected,
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: colorScheme.onSecondary,
|
||||
@ -202,12 +203,13 @@ class _SlotListItem extends ConsumerWidget {
|
||||
trailing: expanded
|
||||
? null
|
||||
: OutlinedButton(
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
onPressed: Actions.handler(context, OpenIntent(otpSlot)),
|
||||
child: const Icon(Icons.more_horiz),
|
||||
),
|
||||
openOnSingleTap: expanded,
|
||||
tapIntent: isDesktop && !expanded ? null : OpenIntent(otpSlot),
|
||||
doubleTapIntent: isDesktop && !expanded ? OpenIntent(otpSlot) : null,
|
||||
buildPopupActions: hasFeature(features.slots)
|
||||
? (context) => buildSlotActions(isConfigured, l10n)
|
||||
? (context) => buildSlotActions(otpSlot, l10n)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../app/views/fs_dialog.dart';
|
||||
@ -51,41 +52,44 @@ class SlotDialog extends ConsumerWidget {
|
||||
return const FsDialog(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return registerOtpActions(node.path, otpSlot,
|
||||
return registerOtpActions(node.path,
|
||||
ref: ref,
|
||||
builder: (context) => FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
otpSlot.slot.getDisplayName(l10n),
|
||||
style: textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Icon(
|
||||
Icons.touch_app,
|
||||
size: 100.0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(otpSlot.isConfigured
|
||||
? l10n.l_otp_slot_configured
|
||||
: l10n.l_otp_slot_empty)
|
||||
],
|
||||
builder: (context) => Shortcuts(
|
||||
shortcuts: itemShortcuts(otpSlot),
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
otpSlot.slot.getDisplayName(l10n),
|
||||
style: textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Icon(
|
||||
Icons.touch_app,
|
||||
size: 100.0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(otpSlot.isConfigured
|
||||
? l10n.l_otp_slot_configured
|
||||
: l10n.l_otp_slot_empty)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_setup,
|
||||
actions: buildSlotActions(otpSlot.isConfigured, l10n),
|
||||
)
|
||||
],
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_setup,
|
||||
actions: buildSlotActions(otpSlot, l10n),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
@ -37,15 +37,18 @@ import 'import_file_dialog.dart';
|
||||
import 'pin_dialog.dart';
|
||||
|
||||
class GenerateIntent extends Intent {
|
||||
const GenerateIntent();
|
||||
final PivSlot slot;
|
||||
const GenerateIntent(this.slot);
|
||||
}
|
||||
|
||||
class ImportIntent extends Intent {
|
||||
const ImportIntent();
|
||||
final PivSlot slot;
|
||||
const ImportIntent(this.slot);
|
||||
}
|
||||
|
||||
class ExportIntent extends Intent {
|
||||
const ExportIntent();
|
||||
final PivSlot slot;
|
||||
const ExportIntent(this.slot);
|
||||
}
|
||||
|
||||
Future<bool> _authenticate(
|
||||
@ -72,8 +75,7 @@ Future<bool> _authIfNeeded(
|
||||
|
||||
Widget registerPivActions(
|
||||
DevicePath devicePath,
|
||||
PivState pivState,
|
||||
PivSlot pivSlot, {
|
||||
PivState pivState, {
|
||||
required WidgetRef ref,
|
||||
required Widget Function(BuildContext context) builder,
|
||||
Map<Type, Action<Intent>> actions = const {},
|
||||
@ -109,7 +111,7 @@ Widget registerPivActions(
|
||||
builder: (context) => GenerateKeyDialog(
|
||||
devicePath,
|
||||
pivState,
|
||||
pivSlot,
|
||||
intent.slot,
|
||||
),
|
||||
);
|
||||
|
||||
@ -163,7 +165,7 @@ Widget registerPivActions(
|
||||
builder: (context) => ImportFileDialog(
|
||||
devicePath,
|
||||
pivState,
|
||||
pivSlot,
|
||||
intent.slot,
|
||||
File(picked.paths.first!),
|
||||
),
|
||||
) ??
|
||||
@ -173,7 +175,7 @@ Widget registerPivActions(
|
||||
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
|
||||
final (_, cert) = await ref
|
||||
.read(pivSlotsProvider(devicePath).notifier)
|
||||
.read(pivSlot.slot);
|
||||
.read(intent.slot.slot);
|
||||
|
||||
if (cert == null) {
|
||||
return false;
|
||||
@ -205,7 +207,8 @@ Widget registerPivActions(
|
||||
return true;
|
||||
}),
|
||||
if (hasFeature(features.slotsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
DeleteIntent<PivSlot>:
|
||||
CallbackAction<DeleteIntent<PivSlot>>(onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
if (!await withContext(
|
||||
(context) => _authIfNeeded(context, devicePath, pivState))) {
|
||||
@ -217,7 +220,7 @@ Widget registerPivActions(
|
||||
context: context,
|
||||
builder: (context) => DeleteCertificateDialog(
|
||||
devicePath,
|
||||
pivSlot,
|
||||
intent.target,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
@ -229,7 +232,8 @@ Widget registerPivActions(
|
||||
);
|
||||
}
|
||||
|
||||
List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
|
||||
List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
|
||||
final hasCert = slot.certInfo != null;
|
||||
return [
|
||||
ActionItem(
|
||||
key: keys.generateAction,
|
||||
@ -238,7 +242,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
|
||||
actionStyle: ActionStyle.primary,
|
||||
title: l10n.s_generate_key,
|
||||
subtitle: l10n.l_generate_desc,
|
||||
intent: const GenerateIntent(),
|
||||
intent: GenerateIntent(slot),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.importAction,
|
||||
@ -246,7 +250,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.file_download_outlined),
|
||||
title: l10n.l_import_file,
|
||||
subtitle: l10n.l_import_desc,
|
||||
intent: const ImportIntent(),
|
||||
intent: ImportIntent(slot),
|
||||
),
|
||||
if (hasCert) ...[
|
||||
ActionItem(
|
||||
@ -255,7 +259,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.file_upload_outlined),
|
||||
title: l10n.l_export_certificate,
|
||||
subtitle: l10n.l_export_certificate_desc,
|
||||
intent: const ExportIntent(),
|
||||
intent: ExportIntent(slot),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.deleteAction,
|
||||
@ -264,7 +268,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
title: l10n.l_delete_certificate,
|
||||
subtitle: l10n.l_delete_certificate_desc,
|
||||
intent: const DeleteIntent(),
|
||||
intent: DeleteIntent(slot),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
@ -77,28 +77,38 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
final subtitleStyle = textTheme.bodyMedium!.copyWith(
|
||||
color: textTheme.bodySmall!.color,
|
||||
);
|
||||
return Actions(
|
||||
actions: {
|
||||
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
} else {
|
||||
Actions.invoke(context, intent);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
},
|
||||
child: AppPage(
|
||||
title: Text(l10n.s_certificates),
|
||||
detailViewBuilder: selected != null
|
||||
? (context) => registerPivActions(
|
||||
widget.devicePath,
|
||||
pivState,
|
||||
selected,
|
||||
ref: ref,
|
||||
builder: (context) => Column(
|
||||
return registerPivActions(
|
||||
widget.devicePath,
|
||||
pivState,
|
||||
ref: ref,
|
||||
builder: (context) => Actions(
|
||||
actions: {
|
||||
EscapeIntent:
|
||||
CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
} else {
|
||||
Actions.invoke(context, intent);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
OpenIntent<PivSlot>: CallbackAction<OpenIntent<PivSlot>>(
|
||||
onInvoke: (intent) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => SlotDialog(intent.target.slot),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: AppPage(
|
||||
title: Text(l10n.s_certificates),
|
||||
detailViewBuilder: selected != null
|
||||
? (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTitle(l10n.s_details),
|
||||
@ -129,61 +139,51 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildSlotActions(
|
||||
selected.certInfo != null, l10n),
|
||||
actions: buildSlotActions(selected, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
keyActionsBuilder: hasFeature(features.actions)
|
||||
? (context) => pivBuildActions(
|
||||
context, widget.devicePath, pivState, ref)
|
||||
: null,
|
||||
builder: (context, expanded) {
|
||||
// De-select if window is resized to be non-expanded.
|
||||
if (!expanded) {
|
||||
Timer.run(() {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
)
|
||||
: null,
|
||||
keyActionsBuilder: hasFeature(features.actions)
|
||||
? (context) => pivBuildActions(
|
||||
context, widget.devicePath, pivState, ref)
|
||||
: null,
|
||||
builder: (context, expanded) {
|
||||
// De-select if window is resized to be non-expanded.
|
||||
if (!expanded) {
|
||||
Timer.run(() {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
ListTitle(l10n.s_certificates),
|
||||
if (pivSlots?.hasValue == true)
|
||||
...pivSlots!.value.map((e) => registerPivActions(
|
||||
widget.devicePath,
|
||||
pivState,
|
||||
e,
|
||||
ref: ref,
|
||||
actions: {
|
||||
OpenIntent: CallbackAction<OpenIntent>(
|
||||
onInvoke: (_) async {
|
||||
if (expanded) {
|
||||
setState(() {
|
||||
_selected = e.slot;
|
||||
});
|
||||
} else {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => SlotDialog(e.slot),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
builder: (context) => _CertificateListItem(
|
||||
}
|
||||
return Actions(
|
||||
actions: {
|
||||
if (expanded)
|
||||
OpenIntent: CallbackAction<OpenIntent<PivSlot>>(
|
||||
onInvoke: (intent) async {
|
||||
setState(() {
|
||||
_selected = intent.target.slot;
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
ListTitle(l10n.s_certificates),
|
||||
if (pivSlots?.hasValue == true)
|
||||
...pivSlots!.value.map(
|
||||
(e) => _CertificateListItem(
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -208,6 +208,7 @@ class _CertificateListItem extends ConsumerWidget {
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
|
||||
return AppListItem(
|
||||
pivSlot,
|
||||
selected: selected,
|
||||
key: _getAppListItemKey(slot),
|
||||
leading: CircleAvatar(
|
||||
@ -226,12 +227,13 @@ class _CertificateListItem extends ConsumerWidget {
|
||||
? null
|
||||
: OutlinedButton(
|
||||
key: _getMeatballKey(slot),
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
onPressed: Actions.handler(context, OpenIntent(pivSlot)),
|
||||
child: const Icon(Icons.more_horiz),
|
||||
),
|
||||
openOnSingleTap: expanded,
|
||||
tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot),
|
||||
doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null,
|
||||
buildPopupActions: hasFeature(features.slots)
|
||||
? (context) => buildSlotActions(certInfo != null, l10n)
|
||||
? (context) => buildSlotActions(pivSlot, l10n)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../app/views/fs_dialog.dart';
|
||||
@ -61,49 +62,51 @@ class SlotDialog extends ConsumerWidget {
|
||||
return registerPivActions(
|
||||
node.path,
|
||||
pivState,
|
||||
slotData,
|
||||
ref: ref,
|
||||
builder: (context) => FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
pivSlot.getDisplayName(l10n),
|
||||
style: textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (certInfo != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: CertInfoTable(certInfo),
|
||||
builder: (context) => Shortcuts(
|
||||
shortcuts: itemShortcuts(slotData),
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
pivSlot.getDisplayName(l10n),
|
||||
style: textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text(
|
||||
l10n.l_no_certificate,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: subtitleStyle,
|
||||
if (certInfo != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: CertInfoTable(certInfo),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text(
|
||||
l10n.l_no_certificate,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildSlotActions(certInfo != null, l10n),
|
||||
),
|
||||
],
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildSlotActions(slotData, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
Loading…
Reference in New Issue
Block a user