This commit is contained in:
Dennis Fokin 2022-07-12 14:44:20 +02:00
commit cb54c18c3c
No known key found for this signature in database
GPG Key ID: 870B88256690D8BC
18 changed files with 441 additions and 386 deletions

View File

@ -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

32
helper/poetry.lock generated
View File

@ -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"},
]

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 DeviceButton extends ConsumerWidget {
class _CircledDeviceAvatar extends ConsumerWidget {
final double radius;
const DeviceButton({super.key, this.radius = 16});
const _CircledDeviceAvatar(this.radius);
@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',
icon: OverflowBox(
maxHeight: 44,
maxWidth: 44,
child: CircleAvatar(
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: deviceWidget,
),
child: DeviceAvatar.currentDevice(ref, radius: radius - 1),
),
);
}
class DeviceButton extends ConsumerWidget {
final double radius;
final List<PopupMenuEntry> actions;
const DeviceButton({super.key, this.actions = const [], this.radius = 16});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
tooltip: 'More actions',
icon: OverflowBox(
maxHeight: 44,
maxWidth: 44,
child: _CircledDeviceAvatar(radius),
),
onPressed: () {
showBlurDialog(
final withContext = ref.read(withContextProvider);
showMenu(
context: context,
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'),
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

@ -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';
}
}

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,50 +62,28 @@ 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) {
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,50 +152,37 @@ 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) {
buildMenuItem(
title: const Text('Change PIN'),
leading: const Icon(Icons.pin),
action: () {
showBlurDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
MenuAction(
text: 'Reset FIDO',
icon: const Icon(Icons.delete),
action: (context) {
buildMenuItem(
title: const Text('Reset FIDO'),
leading: const Icon(Icons.delete),
action: () {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
},
),
];
}

View File

@ -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,14 +60,38 @@ abstract class OathCredentialListNotifier
Future<void> deleteAccount(OathCredential credential);
}
final credentialsProvider = Provider.autoDispose<List<OathCredential>?>((ref) {
final credentialsProvider = StateNotifierProvider.autoDispose<
_CredentialsProviderNotifier, List<OathCredential>?>((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()));
}
return null;
ref.listen<List<OathPair>?>(credentialListProvider(node.path),
(previous, next) {
provider._updatePairs(next);
});
}
return provider;
});
class _CredentialsProviderNotifier
extends StateNotifier<List<OathCredential>?> {
_CredentialsProviderNotifier() : super(null);
void _updatePairs(List<OathPair>? 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<OathCode?, OathCredential>((ref, credential) {

View File

@ -120,7 +120,7 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
return null;
}),
},
child: Focus(
child: FocusScope(
autofocus: true,
child: AlertDialog(
title: Center(

View File

@ -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<AccountList> createState() => _AccountListState();
}
class _AccountListState extends ConsumerState<AccountList> {
List<OathCredential> _credentials = [];
Map<OathCredential, FocusNode> _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<AccountList> {
final creds =
credentials.where((entry) => !favorites.contains(entry.credential.id));
_credentials =
pinnedCreds.followedBy(creds).map((e) => e.credential).toList();
_updateFocusNodes();
return Column(
return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: Column(
children: [
if (pinnedCreds.isNotEmpty) const ListTitle('Pinned'),
...pinnedCreds.map(
(entry) => AccountView(
entry.credential,
focusNode: _focusNodes[entry.credential],
),
),
if (creds.isNotEmpty) const ListTitle('Accounts'),
...creds.map(
(entry) => AccountView(
entry.credential,
focusNode: _focusNodes[entry.credential],
),
),
],
),
);
}
}

View File

@ -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

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';
@ -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<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(
return buildMenuItem(
leading: e.icon,
title: Text(e.text),
enabled: action != null,
dense: true,
contentPadding: EdgeInsets.zero,
),
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,

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,16 +52,11 @@ 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) {
keyActions: [
buildMenuItem(
title: const Text('Manage password'),
leading: const Icon(Icons.password),
action: () {
showBlurDialog(
context: context,
builder: (context) =>
@ -69,19 +64,16 @@ class _LockedView extends ConsumerWidget {
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete),
action: (context) {
buildMenuItem(
title: const Text('Reset OATH'),
leading: const Icon(Icons.delete),
action: () {
showBlurDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
},
),
]);
},
),
],
child: Column(
children: [
@ -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,8 +138,6 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
return null;
}),
},
child: Focus(
autofocus: true,
child: AppPage(
title: Focus(
canRequestFocus: false,
@ -181,20 +174,25 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
);
}),
),
actions: _buildActions(context, false),
child: AccountList(widget.devicePath, widget.oathState),
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: () {
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) => OathAddAccountPage(
@ -203,38 +201,29 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
openQrScanner: Platform.isAndroid,
),
);
},
}
: null,
),
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: Text(
widget.oathState.hasKey ? 'Manage password' : 'Set password'),
leading: const Icon(Icons.password),
action: () {
showBlurDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(widget.devicePath, widget.oathState),
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete),
action: (context) {
}),
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,
),
);