diff --git a/helper/helper/qr.py b/helper/helper/qr.py index ce84b941..9aac92fd 100644 --- a/helper/helper/qr.py +++ b/helper/helper/qr.py @@ -8,6 +8,7 @@ import subprocess import tempfile from mss.exception import ScreenShotError from PIL import Image +import numpy.core.multiarray # noqa def _capture_screen(): @@ -41,6 +42,6 @@ def scan_qr(image_data=None): img = _capture_screen() result = zxingcpp.read_barcode(img) - if result.valid: + if result and result.valid: return result.text return None diff --git a/helper/poetry.lock b/helper/poetry.lock index f7dc9362..16daa3e1 100755 --- a/helper/poetry.lock +++ b/helper/poetry.lock @@ -386,7 +386,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [[package]] name = "zxing-cpp" -version = "1.3.0" +version = "1.4.0" description = "Python bindings for the zxing-cpp barcode library" category = "main" optional = false @@ -711,19 +711,19 @@ zipp = [ {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, ] zxing-cpp = [ - {file = "zxing-cpp-1.3.0.tar.gz", hash = "sha256:5f30545afad01a278fc8c17efae11d82e36f8c2caa87c89096aec5a8d69103b2"}, - {file = "zxing_cpp-1.3.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3a6e183b6c0aae9378f674f9e7714a39482595915cf15198d10b9ba8c33b25f"}, - {file = "zxing_cpp-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88eadb723d20655caf81a6ba6ef64d74a266f57cbd782da82736c52a61a73fa5"}, - {file = "zxing_cpp-1.3.0-cp310-cp310-win32.whl", hash = "sha256:15fb165ada1730ab0d96b67eb2d9827870d9ae534686e27541f3b3add15b96d7"}, - {file = "zxing_cpp-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:8dbb17a31ee1ac2c946a96e83b170ecefbc87a52b9c35b41809d9afff77d8879"}, - {file = "zxing_cpp-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:31578db20ba0668e010cb62e4718cb86f47563ec5122e29a0746651ff1e13735"}, - {file = "zxing_cpp-1.3.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9253a3b6c8c143f3c22d172922226b10c8cc319d2554c73107fefce7e263daaa"}, - {file = "zxing_cpp-1.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:250afd201f08bd1be8fd349766e32ef184a463b616c13102b2f80a4422695957"}, - {file = "zxing_cpp-1.3.0-cp38-cp38-win32.whl", hash = "sha256:d2891dfba5c53b913867e7b01b8b430d801e15e54f53b3c05b9645dc824dfed3"}, - {file = "zxing_cpp-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:6201e60cbefbc8de90c5f18e6e25c3cb1be19be8f369bf4dad3ab910b954f29d"}, - {file = "zxing_cpp-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:44467984c1a65a332c8656926f30af1752c1ff774c6a030b95572e0a1543b23b"}, - {file = "zxing_cpp-1.3.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0dbb54f8694063376d73be6f7dbddd39f3e7907ab885403d90cff7d518c54f7f"}, - {file = "zxing_cpp-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3cff8a7fe960c2016bc8e217fcf02b9b1ac61b17fc5c0c5158f853088be4ad9"}, - {file = "zxing_cpp-1.3.0-cp39-cp39-win32.whl", hash = "sha256:f75431cf7cddcb21c267d39a5895831a3c20abfa7676426974652d25b29ae429"}, - {file = "zxing_cpp-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:de9dd0a2d01969e9828c5704d709b2559a417fea562bd2f308ebc8d4a9678b5e"}, + {file = "zxing-cpp-1.4.0.tar.gz", hash = "sha256:3d3ec36954ecbf9b0f633dab4b8cebcf0059d8a27f7a5969c4e41a308111af38"}, + {file = "zxing_cpp-1.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25b11d77cf6b9f7405af3ed6bacf4a6e0756ea74dfda7040ff53e7c58f352b05"}, + {file = "zxing_cpp-1.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1f849205237d4bda462d0a4b745e72494f825e5b6b06581e05b58d34d9869aa"}, + {file = "zxing_cpp-1.4.0-cp310-cp310-win32.whl", hash = "sha256:76e9777d943af3c51b6406b323b3f28cbf9e40cc65b53cf847fda08295f18e48"}, + {file = "zxing_cpp-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:234d672e34e607ffc8e06639e79c8e1bf2ddb7c249134a6836569e92a2f2dd64"}, + {file = "zxing_cpp-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1f66c61e43163740c59c58880c3a8c41ebd2109573c0494f255c9c96134e8c"}, + {file = "zxing_cpp-1.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9418e1bd0775820a4933b60007b7f8a177e4ddd23692c1aaed2348fafc0a8e01"}, + {file = "zxing_cpp-1.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc1e48ddfd6692d183782f091fbf54e5e1d36d0070822b1eab14cfb580b1625"}, + {file = "zxing_cpp-1.4.0-cp38-cp38-win32.whl", hash = "sha256:4f340b6907780e8eb0e6473fec43ea145c4dd3275e3c21d6f887c0e28e114f29"}, + {file = "zxing_cpp-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:71772f81c4602133b2dba6a1107339ed965725001ce9a4caaf772598110351a1"}, + {file = "zxing_cpp-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:331bec6b0ac8a9b339bc82956c52c022e7b2debfeb9102209483eb7538ed72d4"}, + {file = "zxing_cpp-1.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b0844c6ad3c944452c980a025238ba3fbd3a414fd2c36e2bec1bc5bed03b21e"}, + {file = "zxing_cpp-1.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a770aff618cd00dda3922de2f7085c1f84bbe02f2b6df114d19054ad41c52fb0"}, + {file = "zxing_cpp-1.4.0-cp39-cp39-win32.whl", hash = "sha256:ebe67de6a4d3c48a5ee52211ecf2003301ab39bd7d7b7dfa72ae80be429cfcf9"}, + {file = "zxing_cpp-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:d0e8b54b29497ed9238f31ce522ddb0189c0d6c4597787ef2eb823ca9fb42350"}, ] diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index f9f67709..06768b61 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -9,12 +9,14 @@ class AppPage extends ConsumerWidget { final Widget? title; final Widget child; final List actions; + final List 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), ), ], ), diff --git a/lib/app/views/device_avatar.dart b/lib/app/views/device_avatar.dart index f368fb34..f9146bbf 100755 --- a/lib/app/views/device_avatar.dart +++ b/lib/app/views/device_avatar.dart @@ -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; diff --git a/lib/app/views/device_button.dart b/lib/app/views/device_button.dart index 2e061326..aa481e34 100755 --- a/lib/app/views/device_button.dart +++ b/lib/app/views/device_button.dart @@ -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 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: [ + 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 _offsetAnimation = Tween( + 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), + ), + ); + } +} diff --git a/lib/app/views/device_picker_dialog.dart b/lib/app/views/device_picker_dialog.dart index 6ec53689..b7f4ca91 100755 --- a/lib/app/views/device_picker_dialog.dart +++ b/lib/app/views/device_picker_dialog.dart @@ -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>( @@ -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', ), ), diff --git a/lib/app/views/device_utils.dart b/lib/app/views/device_utils.dart new file mode 100755 index 00000000..94a201dd --- /dev/null +++ b/lib/app/views/device_utils.dart @@ -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 getDeviceMessages(DeviceNode? node, AsyncValue 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; +} diff --git a/lib/app/views/message_page.dart b/lib/app/views/message_page.dart index 3f2d766b..e325bcb9 100755 --- a/lib/app/views/message_page.dart +++ b/lib/app/views/message_page.dart @@ -8,6 +8,7 @@ class MessagePage extends StatelessWidget { final String? header; final String? message; final List actions; + final List 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( diff --git a/lib/core/models.dart b/lib/core/models.dart index 20ff46f6..4c1d575c 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -88,7 +88,7 @@ enum UsbPid { final suffix = UsbInterface.values .where((e) => e.value & usbInterfaces != 0) .map((e) => e.name.toUpperCase()) - .join(' '); + .join('+'); return '$prefix $suffix'; } } diff --git a/lib/fido/views/locked_page.dart b/lib/fido/views/locked_page.dart index 44ae723e..a5528771 100755 --- a/lib/fido/views/locked_page.dart +++ b/lib/fido/views/locked_page.dart @@ -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 _buildActions(BuildContext context) => [ + List _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), + ); }, ), ]; diff --git a/lib/fido/views/unlocked_page.dart b/lib/fido/views/unlocked_page.dart index 908aa84e..1f540d48 100755 --- a/lib/fido/views/unlocked_page.dart +++ b/lib/fido/views/unlocked_page.dart @@ -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 _buildActions(BuildContext context, - {bool fingerprintPrimary = false}) => - [ + List _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), + ); }, ), ]; diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 6966879b..217aad6c 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -59,15 +60,39 @@ abstract class OathCredentialListNotifier Future deleteAccount(OathCredential credential); } -final credentialsProvider = Provider.autoDispose?>((ref) { +final credentialsProvider = StateNotifierProvider.autoDispose< + _CredentialsProviderNotifier, List?>((ref) { + final provider = _CredentialsProviderNotifier(); final node = ref.watch(currentDeviceProvider); if (node != null) { - return ref.watch(credentialListProvider(node.path) - .select((pairs) => pairs?.map((e) => e.credential).toList())); + ref.listen?>(credentialListProvider(node.path), + (previous, next) { + provider._updatePairs(next); + }); } - return null; + return provider; }); +class _CredentialsProviderNotifier + extends StateNotifier?> { + _CredentialsProviderNotifier() : super(null); + + void _updatePairs(List? pairs) { + if (mounted) { + if (pairs == null) { + if (state != null) { + state = null; + } + } else { + final creds = pairs.map((p) => p.credential).toList(); + if (!const ListEquality().equals(creds, state)) { + state = creds; + } + } + } + } +} + final codeProvider = Provider.autoDispose.family((ref, credential) { final node = ref.watch(currentDeviceProvider); diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 96b1a243..17c96b48 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -120,7 +120,7 @@ class AccountDialog extends ConsumerWidget with AccountMixin { return null; }), }, - child: Focus( + child: FocusScope( autofocus: true, child: AlertDialog( title: Center( diff --git a/lib/oath/views/account_list.dart b/lib/oath/views/account_list.dart index a633ef08..717d4c48 100755 --- a/lib/oath/views/account_list.dart +++ b/lib/oath/views/account_list.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; @@ -8,65 +7,14 @@ import '../models.dart'; import '../state.dart'; import 'account_view.dart'; -class AccountList extends ConsumerStatefulWidget { +class AccountList extends ConsumerWidget { final DevicePath devicePath; final OathState oathState; const AccountList(this.devicePath, this.oathState, {super.key}); @override - ConsumerState createState() => _AccountListState(); -} - -class _AccountListState extends ConsumerState { - List _credentials = []; - Map _focusNodes = {}; - - @override - void dispose() { - super.dispose(); - for (var e in _focusNodes.values) { - e.dispose(); - } - _focusNodes.clear(); - } - - void _updateFocusNodes() { - _focusNodes = { - for (var cred in _credentials) - cred: _focusNodes[cred] ?? - FocusNode( - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - int index = -1; - ScrollPositionAlignmentPolicy policy = - ScrollPositionAlignmentPolicy.explicit; - if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - index = _credentials.indexOf(cred) + 1; - policy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - index = _credentials.indexOf(cred) - 1; - policy = ScrollPositionAlignmentPolicy.keepVisibleAtStart; - } - if (index >= 0 && index < _credentials.length) { - final targetNode = _focusNodes[_credentials[index]]!; - targetNode.requestFocus(); - Scrollable.ensureVisible( - targetNode.context!, - alignmentPolicy: policy, - ); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, - ) - }; - _focusNodes.removeWhere((cred, _) => !_credentials.contains(cred)); - } - - @override - Widget build(BuildContext context) { - final accounts = ref.watch(credentialListProvider(widget.devicePath)); + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(credentialListProvider(devicePath)); if (accounts == null) { return Column( mainAxisAlignment: MainAxisAlignment.center, @@ -88,27 +36,24 @@ class _AccountListState extends ConsumerState { final creds = credentials.where((entry) => !favorites.contains(entry.credential.id)); - _credentials = - pinnedCreds.followedBy(creds).map((e) => e.credential).toList(); - _updateFocusNodes(); - - return Column( - children: [ - if (pinnedCreds.isNotEmpty) const ListTitle('Pinned'), - ...pinnedCreds.map( - (entry) => AccountView( - entry.credential, - focusNode: _focusNodes[entry.credential], + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Column( + children: [ + if (pinnedCreds.isNotEmpty) const ListTitle('Pinned'), + ...pinnedCreds.map( + (entry) => AccountView( + entry.credential, + ), ), - ), - if (creds.isNotEmpty) const ListTitle('Accounts'), - ...creds.map( - (entry) => AccountView( - entry.credential, - focusNode: _focusNodes[entry.credential], + if (creds.isNotEmpty) const ListTitle('Accounts'), + ...creds.map( + (entry) => AccountView( + entry.credential, + ), ), - ), - ], + ], + ), ); } } diff --git a/lib/oath/views/account_mixin.dart b/lib/oath/views/account_mixin.dart index a74caf7e..e13f0407 100755 --- a/lib/oath/views/account_mixin.dart +++ b/lib/oath/views/account_mixin.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -104,9 +105,10 @@ mixin AccountMixin { final ready = expired || credential.oathType == OathType.hotp; final pinned = isPinned(ref); + final shortcut = Platform.isMacOS ? '\u2318 C' : 'Ctrl+C'; return [ MenuAction( - text: 'Copy to clipboard', + text: 'Copy to clipboard ($shortcut)', icon: const Icon(Icons.copy), action: code == null || expired ? null diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 287bff55..6253343d 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -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'; @@ -14,8 +13,7 @@ import 'account_mixin.dart'; class AccountView extends ConsumerWidget with AccountMixin { @override final OathCredential credential; - final FocusNode? focusNode; - AccountView(this.credential, {super.key, this.focusNode}); + AccountView(this.credential, {super.key}); Color _iconColor(int shade) { final colors = [ @@ -45,23 +43,16 @@ class AccountView extends ConsumerWidget with AccountMixin { List _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(); } @@ -117,13 +108,10 @@ class AccountView extends ConsumerWidget with AccountMixin { return ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), - focusNode: focusNode, onTap: () { showBlurDialog( context: context, - builder: (context) { - return AccountDialog(credential); - }, + builder: (context) => AccountDialog(credential), ); }, onLongPress: triggerCopy, diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index d26a344f..d00cd6db 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -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(credentialsProvider); + 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( @@ -143,98 +138,92 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { return null; }), }, - child: Focus( - autofocus: true, - 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) { - return TextFormField( - key: const Key('search_accounts'), - controller: searchController, - focusNode: searchFocus, - style: Theme.of(context).textTheme.titleSmall, - decoration: const InputDecoration( - hintText: 'Search accounts', - isDense: true, - prefixIcon: Icon(Icons.search_outlined), - prefixIconConstraints: BoxConstraints( - minHeight: 30, - minWidth: 30, - ), - border: InputBorder.none, + 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) { + return TextFormField( + key: const Key('search_accounts'), + controller: searchController, + focusNode: searchFocus, + style: Theme.of(context).textTheme.titleSmall, + decoration: const InputDecoration( + hintText: 'Search accounts', + isDense: true, + prefixIcon: Icon(Icons.search_outlined), + prefixIconConstraints: BoxConstraints( + minHeight: 30, + minWidth: 30, ), - onChanged: (value) { - ref.read(searchProvider.notifier).setFilter(value); - }, - textInputAction: TextInputAction.next, - onFieldSubmitted: (value) { - Focus.of(context).focusInDirection(TraversalDirection.down); - }, - ); - }), - ), - actions: _buildActions(context, false), - child: AccountList(widget.devicePath, widget.oathState), + border: InputBorder.none, + ), + onChanged: (value) { + ref.read(searchProvider.notifier).setFilter(value); + }, + textInputAction: TextInputAction.next, + onFieldSubmitted: (value) { + Focus.of(context).focusInDirection(TraversalDirection.down); + }, + ); + }), ), + keyActions: _buildActions( + context, + used: credentials?.length ?? 0, + capacity: widget.oathState.version.isAtLeast(4) ? 32 : null, + ), + child: AccountList(widget.devicePath, widget.oathState), ), ); } - List _buildActions(BuildContext context, bool isEmpty) { + List _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), + ); + }), ]; } } diff --git a/lib/widgets/menu_list_tile.dart b/lib/widgets/menu_list_tile.dart new file mode 100755 index 00000000..236f7504 --- /dev/null +++ b/lib/widgets/menu_list_tile.dart @@ -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, + ), + );