Move Chip actions to Popup menu.

This commit is contained in:
Dain Nilsson 2022-07-07 20:20:04 +02:00
parent 5605df31fb
commit cb407691e0
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
11 changed files with 333 additions and 244 deletions

View File

@ -9,12 +9,14 @@ class AppPage extends ConsumerWidget {
final Widget? title;
final Widget child;
final List<Widget> actions;
final List<PopupMenuEntry> keyActions;
final bool centered;
AppPage({
super.key,
this.title,
required this.child,
this.actions = const [],
this.keyActions = const [],
this.centered = false,
});
@ -82,10 +84,10 @@ class AppPage extends ConsumerWidget {
title: title,
centerTitle: true,
titleTextStyle: Theme.of(context).textTheme.titleLarge,
actions: const [
actions: [
Padding(
padding: EdgeInsets.only(right: 6),
child: DeviceButton(),
padding: const EdgeInsets.only(right: 6),
child: DeviceButton(actions: keyActions),
),
],
),

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../widgets/custom_icons.dart';
import '../models.dart';
import '../state.dart';
import 'device_images.dart';
class DeviceAvatar extends StatelessWidget {
@ -38,6 +40,27 @@ class DeviceAvatar extends StatelessWidget {
),
);
factory DeviceAvatar.currentDevice(WidgetRef ref, {double? radius}) {
final deviceNode = ref.watch(currentDeviceProvider);
if (deviceNode != null) {
return ref.watch(currentDeviceDataProvider).maybeWhen(
data: (data) => DeviceAvatar.yubiKeyData(
data,
radius: radius,
),
orElse: () => DeviceAvatar.deviceNode(
deviceNode,
radius: radius,
),
);
} else {
return DeviceAvatar(
radius: radius,
child: const Icon(Icons.usb),
);
}
}
@override
Widget build(BuildContext context) {
final radius = this.radius ?? 20;

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -5,57 +7,121 @@ import '../message.dart';
import '../state.dart';
import 'device_avatar.dart';
import 'device_picker_dialog.dart';
import 'device_utils.dart';
class _CircledDeviceAvatar extends ConsumerWidget {
final double radius;
const _CircledDeviceAvatar(this.radius);
@override
Widget build(BuildContext context, WidgetRef ref) => CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.primary,
child: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: DeviceAvatar.currentDevice(ref, radius: radius - 1),
),
);
}
class DeviceButton extends ConsumerWidget {
final double radius;
const DeviceButton({super.key, this.radius = 16});
final List<PopupMenuEntry> actions;
const DeviceButton({super.key, this.actions = const [], this.radius = 16});
@override
Widget build(BuildContext context, WidgetRef ref) {
final deviceNode = ref.watch(currentDeviceProvider);
Widget deviceWidget;
if (deviceNode != null) {
deviceWidget = ref.watch(currentDeviceDataProvider).maybeWhen(
data: (data) => DeviceAvatar.yubiKeyData(
data,
radius: radius - 1,
),
orElse: () => DeviceAvatar.deviceNode(
deviceNode,
radius: radius - 1,
),
);
} else {
deviceWidget = DeviceAvatar(
radius: radius - 1,
child: const Icon(Icons.usb),
);
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
tooltip: 'Select YubiKey or device',
tooltip: 'More actions',
icon: OverflowBox(
maxHeight: 44,
maxWidth: 44,
child: CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.primary,
child: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: deviceWidget,
),
),
child: _CircledDeviceAvatar(radius),
),
onPressed: () {
showBlurDialog(
final withContext = ref.read(withContextProvider);
showMenu(
context: context,
builder: (context) => const DevicePickerDialog(),
routeSettings: const RouteSettings(name: 'device_picker'),
position: const RelativeRect.fromLTRB(100, 0, 0, 0),
items: <PopupMenuEntry>[
PopupMenuItem(
padding: const EdgeInsets.only(left: 11, right: 16),
onTap: () {
// Wait for menu to close, and use the main context to open
Timer.run(() {
withContext(
(context) async {
await showBlurDialog(
context: context,
builder: (context) => const DevicePickerDialog(),
routeSettings:
const RouteSettings(name: 'device_picker'),
);
},
);
});
},
child: _SlideInWidget(radius: radius),
),
if (actions.isNotEmpty) const PopupMenuDivider(),
...actions,
],
);
},
),
);
}
}
class _SlideInWidget extends ConsumerStatefulWidget {
final double radius;
const _SlideInWidget({required this.radius});
@override
ConsumerState<_SlideInWidget> createState() => _SlideInWidgetState();
}
class _SlideInWidgetState extends ConsumerState<_SlideInWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
)..forward();
late final Animation<Offset> _offsetAnimation = Tween<Offset>(
begin: const Offset(0.9, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final messages = getDeviceMessages(
ref.watch(currentDeviceProvider),
ref.watch(currentDeviceDataProvider),
);
return SlideTransition(
position: _offsetAnimation,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
minLeadingWidth: 0,
horizontalTitleGap: 13,
leading: _CircledDeviceAvatar(widget.radius),
title: Text(messages.removeAt(0)),
subtitle: Text(messages.first),
),
);
}
}

View File

@ -9,16 +9,7 @@ import '../../management/models.dart';
import '../models.dart';
import '../state.dart';
import 'device_avatar.dart';
String _getInfoString(DeviceInfo info) {
final serial = info.serial;
var subtitle = '';
if (serial != null) {
subtitle += 'S/N: $serial ';
}
subtitle += 'F/W: ${info.version}';
return subtitle;
}
import 'device_utils.dart';
final _hiddenDevicesProvider =
StateNotifierProvider<_HiddenDevicesNotifier, List<String>>(
@ -224,37 +215,17 @@ class _CurrentDeviceRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isNfc = node is NfcReaderNode;
final hero = data.maybeWhen(
data: (data) => DeviceAvatar.yubiKeyData(data, radius: 64),
orElse: () => DeviceAvatar.deviceNode(node, radius: 64),
);
final messages = data.whenOrNull(
data: (data) => [_getInfoString(data.info)],
error: (error, _) {
switch (error) {
case 'unknown-device':
return ['Unrecognized device'];
case 'device-inaccessible':
return ['Device inacessible'];
}
return null;
},
) ??
['No YubiKey present'];
String name =
data.asData?.value.name ?? (isNfc ? messages.removeAt(0) : node.name);
if (isNfc) {
messages.add(node.name);
}
final messages = getDeviceMessages(node, data);
return Column(
children: [
_HeroAvatar(child: hero),
ListTile(
title: Text(name, textAlign: TextAlign.center),
title: Text(messages.removeAt(0), textAlign: TextAlign.center),
isThreeLine: messages.length > 1,
subtitle: Text(messages.join('\n'), textAlign: TextAlign.center),
)
@ -280,7 +251,7 @@ class _DeviceRow extends ConsumerWidget {
subtitle: Text(
node.when(
usbYubiKey: (_, __, ___, info) =>
info == null ? 'Device inaccessible' : _getInfoString(info),
info == null ? 'Device inaccessible' : getDeviceInfoString(info),
nfcReader: (_, __) => 'Select to scan',
),
),

44
lib/app/views/device_utils.dart Executable file
View File

@ -0,0 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../management/models.dart';
import '../models.dart';
String getDeviceInfoString(DeviceInfo info) {
final serial = info.serial;
var subtitle = '';
if (serial != null) {
subtitle += 'S/N: $serial ';
}
subtitle += 'F/W: ${info.version}';
return subtitle;
}
List<String> getDeviceMessages(DeviceNode? node, AsyncValue<YubiKeyData> data) {
if (node == null) {
return ['Insert a YubiKey', 'USB'];
}
final messages = data.whenOrNull(
data: (data) => [getDeviceInfoString(data.info)],
error: (error, _) {
switch (error) {
case 'unknown-device':
return ['Unrecognized device'];
case 'device-inaccessible':
return ['Device inacessible'];
}
return null;
},
) ??
['No YubiKey present'];
final name = data.asData?.value.name;
if (name != null) {
messages.insert(0, name);
}
if (node is NfcReaderNode) {
messages.add(node.name);
}
return messages;
}

View File

@ -8,6 +8,7 @@ class MessagePage extends StatelessWidget {
final String? header;
final String? message;
final List<Widget> actions;
final List<PopupMenuEntry> keyActions;
const MessagePage({
super.key,
@ -16,6 +17,7 @@ class MessagePage extends StatelessWidget {
this.header,
this.message,
this.actions = const [],
this.keyActions = const [],
});
@override
@ -23,6 +25,7 @@ class MessagePage extends StatelessWidget {
title: title,
centered: true,
actions: actions,
keyActions: keyActions,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(

View File

@ -6,7 +6,7 @@ import '../../app/models.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../../widgets/menu_list_tile.dart';
import '../models.dart';
import '../state.dart';
import 'pin_dialog.dart';
@ -27,7 +27,7 @@ class FidoLockedPage extends ConsumerWidget {
graphic: noFingerprints,
header: 'No fingerprints',
message: 'Set a PIN to register fingerprints',
actions: _buildActions(context),
keyActions: _buildActions(context),
);
} else {
return MessagePage(
@ -36,7 +36,7 @@ class FidoLockedPage extends ConsumerWidget {
header: state.credMgmt ? 'No discoverable accounts' : 'Ready to use',
message:
'Optionally set a PIN to protect access to your YubiKey\nRegister as a Security Key on websites',
actions: _buildActions(context),
keyActions: _buildActions(context),
);
}
}
@ -47,13 +47,13 @@ class FidoLockedPage extends ConsumerWidget {
graphic: manageAccounts,
header: 'Ready to use',
message: 'Register as a Security Key on websites',
actions: _buildActions(context),
keyActions: _buildActions(context),
);
}
return AppPage(
title: const Text('WebAuthn'),
actions: _buildActions(context),
keyActions: _buildActions(context),
child: Column(
children: [
_PinEntryForm(state, node),
@ -62,48 +62,26 @@ class FidoLockedPage extends ConsumerWidget {
);
}
List<Widget> _buildActions(BuildContext context) => [
List<PopupMenuEntry> _buildActions(BuildContext context) => [
if (!state.hasPin)
OutlinedButton.icon(
style: state.bioEnroll != null
? AppTheme.primaryOutlinedButtonStyle(context)
: null,
label: const Text('Set PIN'),
icon: const Icon(Icons.pin),
onPressed: () {
buildMenuItem(
title: const Text('Set PIN'),
leading: const Icon(Icons.pin),
action: () {
showBlurDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
if (state.hasPin)
MenuAction(
text: 'Change PIN',
icon: const Icon(Icons.pin),
action: (context) {
showBlurDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
MenuAction(
text: 'Reset FIDO',
icon: const Icon(Icons.delete),
action: (context) {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
buildMenuItem(
title: const Text('Reset FIDO'),
leading: const Icon(Icons.delete),
action: () {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
];

View File

@ -6,8 +6,8 @@ import '../../app/models.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../../widgets/list_title.dart';
import '../../widgets/menu_list_tile.dart';
import '../models.dart';
import '../state.dart';
import 'add_fingerprint_dialog.dart';
@ -121,7 +121,7 @@ class FidoUnlockedPage extends ConsumerWidget {
if (children.isNotEmpty) {
return AppPage(
title: const Text('WebAuthn'),
actions: _buildActions(context),
keyActions: _buildKeyActions(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
@ -133,7 +133,7 @@ class FidoUnlockedPage extends ConsumerWidget {
graphic: noFingerprints,
header: 'No fingerprints',
message: 'Add one or more (up to five) fingerprints',
actions: _buildActions(context, fingerprintPrimary: true),
keyActions: _buildKeyActions(context),
);
}
@ -142,7 +142,7 @@ class FidoUnlockedPage extends ConsumerWidget {
graphic: manageAccounts,
header: 'No discoverable accounts',
message: 'Register as a Security Key on websites',
actions: _buildActions(context),
keyActions: _buildKeyActions(context),
);
}
@ -152,49 +152,36 @@ class FidoUnlockedPage extends ConsumerWidget {
child: const CircularProgressIndicator(),
);
List<Widget> _buildActions(BuildContext context,
{bool fingerprintPrimary = false}) =>
[
List<PopupMenuEntry> _buildKeyActions(BuildContext context) => [
if (state.bioEnroll != null)
OutlinedButton.icon(
style: fingerprintPrimary
? AppTheme.primaryOutlinedButtonStyle(context)
: null,
label: const Text('Add fingerprint'),
icon: const Icon(Icons.fingerprint),
onPressed: () {
buildMenuItem(
title: const Text('Add fingerprint'),
leading: const Icon(Icons.fingerprint),
action: () {
showBlurDialog(
context: context,
builder: (context) => AddFingerprintDialog(node.path),
);
},
),
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text: 'Change PIN',
icon: const Icon(Icons.pin),
action: (context) {
showBlurDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
MenuAction(
text: 'Reset FIDO',
icon: const Icon(Icons.delete),
action: (context) {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
buildMenuItem(
title: const Text('Change PIN'),
leading: const Icon(Icons.pin),
action: () {
showBlurDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
buildMenuItem(
title: const Text('Reset FIDO'),
leading: const Icon(Icons.delete),
action: () {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
];

View File

@ -1,11 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../widgets/menu_list_tile.dart';
import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
@ -44,23 +43,16 @@ class AccountView extends ConsumerWidget with AccountMixin {
List<PopupMenuItem> _buildPopupMenu(BuildContext context, WidgetRef ref) {
return buildActions(context, ref).map((e) {
final action = e.action;
return PopupMenuItem(
enabled: action != null,
onTap: () {
// As soon as onTap returns, the Navigator is popped,
// closing the topmost item. Since we sometimes open new dialogs in
// the action, make sure that happens *after* the pop.
Timer(Duration.zero, () {
action?.call(context);
});
},
child: ListTile(
leading: e.icon,
title: Text(e.text),
enabled: action != null,
dense: true,
contentPadding: EdgeInsets.zero,
),
return buildMenuItem(
leading: e.icon,
title: Text(e.text),
action: action != null
? () {
ref.read(withContextProvider)((context) async {
action.call(context);
});
}
: null,
);
}).toList();
}

View File

@ -12,7 +12,7 @@ import '../../app/views/app_loading_screen.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../../widgets/menu_list_tile.dart';
import '../models.dart';
import '../state.dart';
import 'account_list.dart';
@ -52,34 +52,26 @@ class _LockedView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => AppPage(
title: const Text('Authenticator'),
actions: [
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text: 'Manage password',
icon: const Icon(Icons.password),
action: (context) {
showBlurDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(devicePath, oathState),
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete),
action: (context) {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
},
),
]);
keyActions: [
buildMenuItem(
title: const Text('Manage password'),
leading: const Icon(Icons.password),
action: () {
showBlurDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(devicePath, oathState),
);
},
),
buildMenuItem(
title: const Text('Reset OATH'),
leading: const Icon(Icons.delete),
action: () {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
},
),
],
@ -124,14 +116,17 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
@override
Widget build(BuildContext context) {
final isEmpty = ref.watch(credentialListProvider(widget.devicePath)
.select((value) => value?.isEmpty == true));
if (isEmpty) {
final credentials = ref.watch(credentialListProvider(widget.devicePath));
if (credentials?.isEmpty == true) {
return MessagePage(
title: const Text('Authenticator'),
graphic: noAccounts,
header: 'No accounts',
actions: _buildActions(context, true),
keyActions: _buildActions(
context,
used: 0,
capacity: widget.oathState.version.isAtLeast(4) ? 32 : null,
),
);
}
return Actions(
@ -179,59 +174,56 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
);
}),
),
actions: _buildActions(context, false),
keyActions: _buildActions(
context,
used: credentials?.length ?? 0,
capacity: widget.oathState.version.isAtLeast(4) ? 32 : null,
),
child: AccountList(widget.devicePath, widget.oathState),
),
);
}
List<Widget> _buildActions(BuildContext context, bool isEmpty) {
List<PopupMenuEntry> _buildActions(BuildContext context,
{required int used, int? capacity}) {
return [
OutlinedButton.icon(
style: isEmpty ? AppTheme.primaryOutlinedButtonStyle(context) : null,
label: const Text('Add account'),
icon: const Icon(Icons.person_add_alt_1),
onPressed: () {
showBlurDialog(
context: context,
builder: (context) => OathAddAccountPage(
widget.devicePath,
widget.oathState,
openQrScanner: Platform.isAndroid,
),
);
},
),
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text:
widget.oathState.hasKey ? 'Manage password' : 'Set password',
icon: const Icon(Icons.password),
action: (context) {
buildMenuItem(
title: const Text('Add account'),
leading: const Icon(Icons.person_add_alt_1),
trailing: capacity != null ? '$used/$capacity' : null,
action: capacity == null || capacity > used
? () {
showBlurDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(widget.devicePath, widget.oathState),
builder: (context) => OathAddAccountPage(
widget.devicePath,
widget.oathState,
openQrScanner: Platform.isAndroid,
),
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete),
action: (context) {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(widget.devicePath),
);
},
),
]);
},
}
: null,
),
buildMenuItem(
title: Text(
widget.oathState.hasKey ? 'Manage password' : 'Set password'),
leading: const Icon(Icons.password),
action: () {
showBlurDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(widget.devicePath, widget.oathState),
);
}),
buildMenuItem(
title: const Text('Reset OATH'),
leading: const Icon(Icons.delete),
action: () {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(widget.devicePath),
);
}),
];
}
}

31
lib/widgets/menu_list_tile.dart Executable file
View File

@ -0,0 +1,31 @@
import 'dart:async';
import 'package:flutter/material.dart';
PopupMenuItem buildMenuItem({
required Widget title,
Widget? leading,
String? trailing,
void Function()? action,
}) =>
PopupMenuItem(
enabled: action != null,
onTap: () {
// Wait for popup menu to close before running action.
Timer.run(action!);
},
child: ListTile(
enabled: action != null,
dense: true,
contentPadding: EdgeInsets.zero,
minLeadingWidth: 0,
title: title,
leading: leading,
trailing: trailing != null
? Opacity(
opacity: 0.5,
child: Text(trailing, textScaleFactor: 0.7),
)
: null,
),
);