mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-26 10:33:15 +03:00
Merge PR #165
This commit is contained in:
commit
cb54c18c3c
@ -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
32
helper/poetry.lock
generated
@ -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"},
|
||||
]
|
||||
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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;
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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
44
lib/app/views/device_utils.dart
Executable 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;
|
||||
}
|
@ -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(
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -120,7 +120,7 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: Focus(
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: AlertDialog(
|
||||
title: Center(
|
||||
|
@ -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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
31
lib/widgets/menu_list_tile.dart
Executable 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,
|
||||
),
|
||||
);
|
Loading…
Reference in New Issue
Block a user