Merge PR #132.
BIN
assets/fonts/Roboto-Light.ttf
Normal file
BIN
assets/fonts/Roboto-Regular.ttf
Normal file
BIN
assets/fonts/Roboto-Thin.ttf
Normal file
BIN
assets/graphics/no-accounts.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
assets/graphics/no-discoverable.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
assets/graphics/no-fingerprints.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
assets/graphics/no-permission.png
Executable file
After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 695 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 770 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 629 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 720 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 796 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 497 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 774 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 13 KiB |
@ -26,7 +26,14 @@
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
from .base import RpcNode, child, action, NoSuchNodeException, ChildResetException
|
||||
from .base import (
|
||||
RpcNode,
|
||||
child,
|
||||
action,
|
||||
RpcException,
|
||||
NoSuchNodeException,
|
||||
ChildResetException,
|
||||
)
|
||||
from .oath import OathNode
|
||||
from .fido import Ctap2Node
|
||||
from .yubiotp import YubiOtpNode
|
||||
@ -47,6 +54,7 @@ from yubikit.logging import LOG_LEVEL
|
||||
|
||||
from ykman.pcsc import list_devices, YK_READER_NAME
|
||||
from smartcard.Exceptions import SmartcardException
|
||||
from smartcard.pcsc.PCSCExceptions import EstablishContextException
|
||||
from hashlib import sha256
|
||||
from dataclasses import asdict
|
||||
from typing import Mapping, Tuple
|
||||
@ -65,6 +73,15 @@ def _is_admin():
|
||||
return os.getuid() == 0
|
||||
|
||||
|
||||
class ConnectionException(RpcException):
|
||||
def __init__(self, connection, exc_type):
|
||||
super().__init__(
|
||||
"connection-error",
|
||||
f"Error connecting to {connection} interface",
|
||||
dict(connection=connection, exc_type=type(exc_type).__name__),
|
||||
)
|
||||
|
||||
|
||||
class RootNode(RpcNode):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@ -127,9 +144,16 @@ class ReadersNode(RpcNode):
|
||||
return self.list_children()
|
||||
|
||||
def list_children(self):
|
||||
devices = [
|
||||
d for d in list_devices("") if YK_READER_NAME not in d.reader.name.lower()
|
||||
]
|
||||
try:
|
||||
devices = [
|
||||
d
|
||||
for d in list_devices("")
|
||||
if YK_READER_NAME not in d.reader.name.lower()
|
||||
]
|
||||
except EstablishContextException:
|
||||
logger.warning("Unable to list readers", exc_info=True)
|
||||
return {}
|
||||
|
||||
state = {d.reader.name for d in devices}
|
||||
if self._state != state:
|
||||
self._readers = {}
|
||||
@ -271,15 +295,27 @@ class UsbDeviceNode(AbstractDeviceNode):
|
||||
|
||||
@child(condition=lambda self: self._supports_connection(SmartCardConnection))
|
||||
def ccid(self):
|
||||
return self._create_connection(SmartCardConnection)
|
||||
try:
|
||||
return self._create_connection(SmartCardConnection)
|
||||
except (ValueError, SmartcardException) as e:
|
||||
logger.warning("Error opening connection", exc_info=True)
|
||||
raise ConnectionException("ccid", e)
|
||||
|
||||
@child(condition=lambda self: self._supports_connection(OtpConnection))
|
||||
def otp(self):
|
||||
return self._create_connection(OtpConnection)
|
||||
try:
|
||||
return self._create_connection(OtpConnection)
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning("Error opening connection", exc_info=True)
|
||||
raise ConnectionException("otp", e)
|
||||
|
||||
@child(condition=lambda self: self._supports_connection(FidoConnection))
|
||||
def fido(self):
|
||||
return self._create_connection(FidoConnection)
|
||||
try:
|
||||
return self._create_connection(FidoConnection)
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning("Error opening connection", exc_info=True)
|
||||
raise ConnectionException("fido", e)
|
||||
|
||||
|
||||
class ReaderDeviceNode(AbstractDeviceNode):
|
||||
|
@ -185,19 +185,19 @@ class _QrScannerViewState extends State<QrScannerView> {
|
||||
Text('Looking for a code...',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headline6
|
||||
.titleLarge
|
||||
?.copyWith(color: Colors.black)),
|
||||
if (_status == _ScanStatus.success)
|
||||
Text('Found a valid code',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headline6
|
||||
.titleLarge
|
||||
?.copyWith(color: Colors.white)),
|
||||
if (_status == _ScanStatus.error)
|
||||
Text('This code is not valid, try again.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headline6
|
||||
.titleLarge
|
||||
?.copyWith(color: Colors.white)),
|
||||
]),
|
||||
Row(
|
||||
|
83
lib/app/views/app_failure_page.dart
Executable file
@ -0,0 +1,83 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../desktop/models.dart';
|
||||
import '../../desktop/state.dart';
|
||||
import '../../theme.dart';
|
||||
import '../message.dart';
|
||||
import 'graphics.dart';
|
||||
import 'message_page.dart';
|
||||
|
||||
class AppFailurePage extends ConsumerWidget {
|
||||
final Widget? title;
|
||||
final Object cause;
|
||||
const AppFailurePage({this.title, required this.cause, super.key}) : super();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final reason = cause;
|
||||
|
||||
Widget? graphic = const Icon(Icons.error);
|
||||
String? header = 'An error has occured';
|
||||
String? message = reason.toString();
|
||||
List<Widget> actions = [];
|
||||
|
||||
if (reason is RpcError) {
|
||||
if (reason.status == 'connection-error') {
|
||||
switch (reason.body['connection']) {
|
||||
case 'ccid':
|
||||
header = 'Failed to open smart card connection';
|
||||
if (Platform.isMacOS) {
|
||||
message = 'Try to remove and re-insert your YubiKey.';
|
||||
} else if (Platform.isLinux) {
|
||||
message = 'Make sure pcscd is running.';
|
||||
} else {
|
||||
message = 'Make sure your smart card service is functioning.';
|
||||
}
|
||||
break;
|
||||
case 'fido':
|
||||
if (Platform.isWindows &&
|
||||
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
|
||||
graphic = noPermission;
|
||||
header = null;
|
||||
message = 'WebAuthn management requires elevated privileges.';
|
||||
actions = [
|
||||
OutlinedButton.icon(
|
||||
label: const Text('Unlock'),
|
||||
icon: const Icon(Icons.lock_open),
|
||||
style: AppTheme.primaryOutlinedButtonStyle(context),
|
||||
onPressed: () async {
|
||||
final controller = showMessage(
|
||||
context, 'Elevating permissions...',
|
||||
duration: const Duration(seconds: 30));
|
||||
try {
|
||||
if (await ref.read(rpcProvider).elevate()) {
|
||||
ref.refresh(rpcProvider);
|
||||
} else {
|
||||
showMessage(context, 'Permission denied');
|
||||
}
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
header = 'Failed to open connection';
|
||||
message = 'Try to remove and re-insert your YubiKey.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MessagePage(
|
||||
title: title,
|
||||
graphic: graphic,
|
||||
header: header,
|
||||
message: message,
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppFailureScreen extends StatelessWidget {
|
||||
final String reason;
|
||||
const AppFailureScreen(this.reason, {super.key}) : super();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
reason,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -8,14 +8,15 @@ class AppPage extends ConsumerWidget {
|
||||
final Key _scaffoldKey = GlobalKey();
|
||||
final Widget? title;
|
||||
final Widget child;
|
||||
final Widget? floatingActionButton;
|
||||
final List<Widget> actions;
|
||||
final bool centered;
|
||||
AppPage(
|
||||
{super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.floatingActionButton,
|
||||
this.centered = false});
|
||||
AppPage({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.actions = const [],
|
||||
this.centered = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||
@ -29,7 +30,7 @@ class AppPage extends ConsumerWidget {
|
||||
body: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 240,
|
||||
width: 280,
|
||||
child: ListTileTheme(
|
||||
style: ListTileStyle.drawer,
|
||||
child: MainPageDrawer(shouldPop: false)),
|
||||
@ -46,11 +47,26 @@ class AppPage extends ConsumerWidget {
|
||||
|
||||
Widget _buildScrollView() => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
// Make sure FAB doesn't block content
|
||||
padding: floatingActionButton != null
|
||||
? const EdgeInsets.only(bottom: 72)
|
||||
: null,
|
||||
child: child,
|
||||
child: Builder(builder: (context) {
|
||||
return Column(
|
||||
children: [
|
||||
child,
|
||||
if (actions.isNotEmpty)
|
||||
Align(
|
||||
alignment:
|
||||
centered ? Alignment.center : Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: actions,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@ -58,13 +74,14 @@ class AppPage extends ConsumerWidget {
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
titleSpacing: 8,
|
||||
title: title,
|
||||
centerTitle: true,
|
||||
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
||||
actions: const [DeviceButton()],
|
||||
),
|
||||
drawer: hasDrawer ? const MainPageDrawer() : null,
|
||||
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
|
||||
floatingActionButton: floatingActionButton,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ class DeviceAvatar extends StatelessWidget {
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: selected
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
|
@ -33,6 +33,7 @@ class DeviceButton extends ConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: IconButton(
|
||||
tooltip: 'Select YubiKey or device',
|
||||
icon: OverflowBox(
|
||||
maxHeight: 44,
|
||||
maxWidth: 44,
|
||||
|
8
lib/app/views/graphics.dart
Executable file
@ -0,0 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final Image noAccounts = _graphic('no-accounts');
|
||||
final Image noDiscoverable = _graphic('no-discoverable');
|
||||
final Image noFingerprints = _graphic('no-fingerprints');
|
||||
final Image noPermission = _graphic('no-permission');
|
||||
|
||||
Image _graphic(String name) => Image.asset('assets/graphics/$name.png');
|
@ -38,17 +38,20 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
final data = ref.watch(currentDeviceDataProvider);
|
||||
final currentApp = ref.watch(currentAppProvider);
|
||||
|
||||
MediaQuery? mediaQuery =
|
||||
context.findAncestorWidgetOfExactType<MediaQuery>();
|
||||
final width = mediaQuery?.data.size.width ?? 400;
|
||||
|
||||
return Drawer(
|
||||
width: width < 357 ? 0.85 * width : null,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(20.0),
|
||||
bottomRight: Radius.circular(20.0))),
|
||||
child: ListView(
|
||||
primary: false, //Prevents conflict with the MainPage scroll view.
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
'Yubico Authenticator',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24.0),
|
||||
if (data != null) ...[
|
||||
// Normal YubiKey Applications
|
||||
...supportedApps
|
||||
@ -68,14 +71,6 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
if (supportedApps.contains(Application.management) &&
|
||||
Application.management.getAvailability(data) ==
|
||||
Availability.enabled) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Configuration',
|
||||
style: Theme.of(context).textTheme.bodyText2,
|
||||
),
|
||||
),
|
||||
DrawerItem(
|
||||
titleText: 'Toggle applications',
|
||||
icon: Icon(Application.management._icon),
|
||||
@ -87,17 +82,10 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
const Divider(indent: 16.0, endIndent: 28.0),
|
||||
],
|
||||
// Non-YubiKey pages
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Application',
|
||||
style: Theme.of(context).textTheme.bodyText2,
|
||||
),
|
||||
),
|
||||
DrawerItem(
|
||||
titleText: 'Settings',
|
||||
icon: const Icon(Icons.settings),
|
||||
@ -110,7 +98,7 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
),
|
||||
DrawerItem(
|
||||
titleText: 'Help and feedback',
|
||||
icon: const Icon(Icons.help_outline),
|
||||
icon: const Icon(Icons.help),
|
||||
onTap: () {
|
||||
final nav = Navigator.of(context);
|
||||
if (shouldPop) nav.pop();
|
||||
@ -173,17 +161,22 @@ class DrawerItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.only(left: 12.0, right: 12.0),
|
||||
child: ListTile(
|
||||
enabled: enabled,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)),
|
||||
borderRadius: BorderRadius.all(Radius.circular(30)),
|
||||
),
|
||||
dense: true,
|
||||
minLeadingWidth: 24,
|
||||
minVerticalPadding: 18,
|
||||
selected: selected,
|
||||
selectedColor: Theme.of(context).backgroundColor,
|
||||
selectedTileColor: Theme.of(context).colorScheme.secondary,
|
||||
leading: icon,
|
||||
selectedColor: Theme.of(context).colorScheme.onPrimary,
|
||||
selectedTileColor: Theme.of(context).colorScheme.primary,
|
||||
leading: IconTheme.merge(
|
||||
data: const IconThemeData(size: 24),
|
||||
child: icon,
|
||||
),
|
||||
title: Text(titleText),
|
||||
onTap: onTap,
|
||||
),
|
||||
|
@ -4,29 +4,40 @@ import 'app_page.dart';
|
||||
|
||||
class MessagePage extends StatelessWidget {
|
||||
final Widget? title;
|
||||
final String header;
|
||||
final String message;
|
||||
final Widget? floatingActionButton;
|
||||
final Widget? graphic;
|
||||
final String? header;
|
||||
final String? message;
|
||||
final List<Widget> actions;
|
||||
|
||||
const MessagePage({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.header,
|
||||
required this.message,
|
||||
this.floatingActionButton,
|
||||
this.graphic,
|
||||
this.header,
|
||||
this.message,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
title: title,
|
||||
centered: true,
|
||||
floatingActionButton: floatingActionButton,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(header, style: Theme.of(context).textTheme.headline6),
|
||||
const SizedBox(height: 12.0),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
],
|
||||
actions: actions,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (graphic != null) graphic!,
|
||||
if (header != null)
|
||||
Text(header!,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12.0),
|
||||
if (message != null) ...[
|
||||
Text(message!, textAlign: TextAlign.center),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -5,25 +5,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/models.dart';
|
||||
import '../../desktop/state.dart';
|
||||
import '../../theme.dart';
|
||||
import '../message.dart';
|
||||
import '../models.dart';
|
||||
import 'app_page.dart';
|
||||
import 'device_avatar.dart';
|
||||
import 'graphics.dart';
|
||||
import 'message_page.dart';
|
||||
|
||||
class NoDeviceScreen extends ConsumerWidget {
|
||||
final DeviceNode? node;
|
||||
const NoDeviceScreen(this.node, {super.key});
|
||||
|
||||
List<Widget> _buildUsbPid(BuildContext context, WidgetRef ref, UsbPid pid) {
|
||||
Widget _buildUsbPid(BuildContext context, WidgetRef ref, UsbPid pid) {
|
||||
if (pid.usbInterfaces == UsbInterface.fido.value) {
|
||||
if (Platform.isWindows &&
|
||||
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
|
||||
return [
|
||||
const DeviceAvatar(child: Icon(Icons.lock)),
|
||||
const Text('WebAuthn management requires elevated privileges.'),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.lock_open),
|
||||
return MessagePage(
|
||||
graphic: noPermission,
|
||||
message: 'Managing this device requires elevated privileges.',
|
||||
actions: [
|
||||
OutlinedButton.icon(
|
||||
style: AppTheme.primaryOutlinedButtonStyle(context),
|
||||
label: const Text('Unlock'),
|
||||
icon: const Icon(Icons.lock_open),
|
||||
onPressed: () async {
|
||||
final controller = showMessage(
|
||||
context, 'Elevating permissions...',
|
||||
@ -37,43 +41,31 @@ class NoDeviceScreen extends ConsumerWidget {
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
return [
|
||||
const DeviceAvatar(child: Icon(Icons.usb_off)),
|
||||
const Text(
|
||||
'This YubiKey cannot be accessed',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
];
|
||||
return const MessagePage(
|
||||
graphic: DeviceAvatar(child: Icon(Icons.usb_off)),
|
||||
message: 'This YubiKey cannot be accessed',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppPage(
|
||||
centered: true,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: node?.map(usbYubiKey: (node) {
|
||||
return _buildUsbPid(context, ref, node.pid);
|
||||
}, nfcReader: (node) {
|
||||
return const [
|
||||
DeviceAvatar(child: Icon(Icons.wifi)),
|
||||
Text('Place your YubiKey on the NFC reader'),
|
||||
];
|
||||
}) ??
|
||||
const [
|
||||
DeviceAvatar(child: Icon(Icons.usb)),
|
||||
Text('Insert your YubiKey'),
|
||||
],
|
||||
),
|
||||
);
|
||||
return node?.map(usbYubiKey: (node) {
|
||||
return _buildUsbPid(context, ref, node.pid);
|
||||
}, nfcReader: (node) {
|
||||
return const MessagePage(
|
||||
graphic: DeviceAvatar(child: Icon(Icons.wifi)),
|
||||
message: 'Place your YubiKey on the NFC reader',
|
||||
);
|
||||
}) ??
|
||||
const MessagePage(
|
||||
graphic: DeviceAvatar(child: Icon(Icons.usb)),
|
||||
message: 'Insert your YubiKey',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -133,26 +133,27 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Step 1/2: Capture fingerprint'),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(36.0),
|
||||
child: AnimatedBuilder(
|
||||
animation: _color,
|
||||
builder: (context, _) {
|
||||
return Icon(
|
||||
_fingerprint == null ? Icons.fingerprint : Icons.check,
|
||||
size: 200.0,
|
||||
size: 128.0,
|
||||
color: _color.value,
|
||||
);
|
||||
},
|
||||
),
|
||||
LinearProgressIndicator(value: progress),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(_getMessage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
LinearProgressIndicator(value: progress),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(_getMessage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text('Step 2/2: Name fingerprint'),
|
||||
TextFormField(
|
||||
|
@ -1,16 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/views/app_failure_screen.dart';
|
||||
import '../../app/views/app_failure_page.dart';
|
||||
import '../../app/views/app_loading_screen.dart';
|
||||
import '../../app/views/app_page.dart';
|
||||
import '../../app/views/device_avatar.dart';
|
||||
import '../../app/views/message_page.dart';
|
||||
import '../../desktop/state.dart';
|
||||
import '../../management/models.dart';
|
||||
import '../state.dart';
|
||||
import 'locked_page.dart';
|
||||
@ -51,52 +46,10 @@ class FidoScreen extends ConsumerWidget {
|
||||
'WebAuthn requires the FIDO2 application to be enabled on your YubiKey',
|
||||
);
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
if (!ref
|
||||
.watch(rpcStateProvider.select((state) => state.isAdmin))) {
|
||||
return AppPage(
|
||||
title: const Text('WebAuthn'),
|
||||
centered: true,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const DeviceAvatar(child: Icon(Icons.lock)),
|
||||
const Text(
|
||||
'WebAuthn management requires elevated privileges.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.lock_open),
|
||||
label: const Text('Unlock'),
|
||||
onPressed: () async {
|
||||
final controller = showMessage(
|
||||
context, 'Elevating permissions...',
|
||||
duration: const Duration(seconds: 30));
|
||||
try {
|
||||
if (await ref.read(rpcProvider).elevate()) {
|
||||
ref.refresh(rpcProvider);
|
||||
} else {
|
||||
showMessage(context, 'Permission denied');
|
||||
}
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return AppPage(
|
||||
|
||||
return AppFailurePage(
|
||||
title: const Text('WebAuthn'),
|
||||
centered: true,
|
||||
child: AppFailureScreen('$error'),
|
||||
cause: error,
|
||||
);
|
||||
},
|
||||
data: (fidoState) {
|
||||
|
@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../app/message.dart';
|
||||
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 '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'pin_dialog.dart';
|
||||
@ -22,23 +24,26 @@ class FidoLockedPage extends ConsumerWidget {
|
||||
if (state.bioEnroll != null) {
|
||||
return MessagePage(
|
||||
title: const Text('WebAuthn'),
|
||||
graphic: noFingerprints,
|
||||
header: 'No fingerprints',
|
||||
message: 'Set a PIN to register fingerprints',
|
||||
floatingActionButton: _buildFab(context),
|
||||
message: 'Set a PIN to register fingerprints.',
|
||||
actions: _buildActions(context),
|
||||
);
|
||||
} else {
|
||||
return MessagePage(
|
||||
title: const Text('WebAuthn'),
|
||||
graphic: noDiscoverable,
|
||||
header: 'No discoverable accounts',
|
||||
message:
|
||||
'Optionally set a PIN to protect access to your YubiKey\nRegister as a Security Key on websites',
|
||||
floatingActionButton: _buildFab(context),
|
||||
actions: _buildActions(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AppPage(
|
||||
title: const Text('WebAuthn'),
|
||||
actions: _buildActions(context),
|
||||
child: Column(
|
||||
children: [
|
||||
const ListTile(title: Text('Unlock')),
|
||||
@ -48,41 +53,49 @@ class FidoLockedPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
FloatingActionButton _buildFab(BuildContext context) {
|
||||
return FloatingActionButton.extended(
|
||||
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin),
|
||||
label: const Text('Setup'),
|
||||
onPressed: () {
|
||||
showBottomMenu(context, [
|
||||
if (state.bioEnroll != null)
|
||||
MenuAction(
|
||||
text: 'Add fingerprint',
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Set PIN',
|
||||
icon: const Icon(Icons.pin_outlined),
|
||||
action: (context) {
|
||||
List<Widget> _buildActions(BuildContext context) => [
|
||||
if (!state.hasPin)
|
||||
OutlinedButton.icon(
|
||||
style: AppTheme.primaryOutlinedButtonStyle(context),
|
||||
label: const Text('Set PIN'),
|
||||
icon: const Icon(Icons.pin),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FidoPinDialog(node.path, state),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Reset FIDO',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
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) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FidoPinDialog(node.path, state),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Reset FIDO',
|
||||
icon: const Icon(Icons.delete),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class _PinEntryForm extends ConsumerStatefulWidget {
|
||||
@ -149,34 +162,6 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.pin_outlined),
|
||||
label: const Text('Change PIN'),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
FidoPinDialog(widget._deviceNode.path, widget._state),
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
label: const Text('Reset FIDO'),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(widget._deviceNode),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
ListTile(
|
||||
leading:
|
||||
noFingerprints ? const Icon(Icons.warning_amber_rounded) : null,
|
||||
|
@ -50,7 +50,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
|
||||
if (hasPin) ...[
|
||||
Text(
|
||||
'Current PIN',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
TextFormField(
|
||||
initialValue: _currentPin,
|
||||
@ -70,7 +70,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
|
||||
],
|
||||
Text(
|
||||
'New PIN',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
TextFormField(
|
||||
initialValue: _newPin,
|
||||
|
@ -94,7 +94,7 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
|
||||
),
|
||||
Center(
|
||||
child: Text(_getMessage(),
|
||||
style: Theme.of(context).textTheme.headline6),
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
|
@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../app/message.dart';
|
||||
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 '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'add_fingerprint_dialog.dart';
|
||||
@ -29,10 +31,23 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
? [
|
||||
const ListTile(title: Text('Credentials')),
|
||||
...creds.map((cred) => ListTile(
|
||||
leading:
|
||||
const CircleAvatar(child: Icon(Icons.link)),
|
||||
title: Text(cred.userName),
|
||||
subtitle: Text(cred.rpId),
|
||||
leading: CircleAvatar(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(Icons.person),
|
||||
),
|
||||
title: Text(
|
||||
cred.userName,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
subtitle: Text(
|
||||
cred.rpId,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -45,7 +60,7 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
node.path, cred),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outlined)),
|
||||
icon: const Icon(Icons.delete_outline)),
|
||||
],
|
||||
),
|
||||
)),
|
||||
@ -59,9 +74,18 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
? [
|
||||
const ListTile(title: Text('Fingerprints')),
|
||||
...fingerprints.map((fp) => ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.fingerprint)),
|
||||
title: Text(fp.label),
|
||||
leading: CircleAvatar(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSecondary,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
child: const Icon(Icons.fingerprint),
|
||||
),
|
||||
title: Text(
|
||||
fp.label,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -74,7 +98,7 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
node.path, fp),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit)),
|
||||
icon: const Icon(Icons.edit_outlined)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
@ -84,7 +108,7 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
node.path, fp),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outlined)),
|
||||
icon: const Icon(Icons.delete_outline)),
|
||||
],
|
||||
),
|
||||
))
|
||||
@ -97,69 +121,76 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
if (children.isNotEmpty) {
|
||||
return AppPage(
|
||||
title: const Text('WebAuthn'),
|
||||
floatingActionButton: _buildFab(context),
|
||||
actions: _buildActions(context),
|
||||
child: Column(
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.bioEnroll == false) {
|
||||
if (state.bioEnroll != null) {
|
||||
return MessagePage(
|
||||
title: const Text('WebAuthn'),
|
||||
graphic: noFingerprints,
|
||||
header: 'No fingerprints',
|
||||
message: 'Add one or more (up to five) fingerprints',
|
||||
floatingActionButton: _buildFab(context),
|
||||
actions: _buildActions(context, fingerprintPrimary: true),
|
||||
);
|
||||
}
|
||||
|
||||
return MessagePage(
|
||||
title: const Text('WebAuthn'),
|
||||
graphic: noDiscoverable,
|
||||
header: 'No discoverable accounts',
|
||||
message: 'Register as a Security Key on websites',
|
||||
floatingActionButton: _buildFab(context),
|
||||
actions: _buildActions(context),
|
||||
);
|
||||
}
|
||||
|
||||
FloatingActionButton _buildFab(BuildContext context) {
|
||||
return FloatingActionButton.extended(
|
||||
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin),
|
||||
label: const Text('Setup'),
|
||||
onPressed: () {
|
||||
showBottomMenu(context, [
|
||||
if (state.bioEnroll != null)
|
||||
MenuAction(
|
||||
text: 'Add fingerprint',
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddFingerprintDialog(node.path),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Change PIN',
|
||||
icon: const Icon(Icons.pin_outlined),
|
||||
action: (context) {
|
||||
List<Widget> _buildActions(BuildContext context,
|
||||
{bool fingerprintPrimary = false}) =>
|
||||
[
|
||||
if (state.bioEnroll != null)
|
||||
OutlinedButton.icon(
|
||||
style: fingerprintPrimary
|
||||
? AppTheme.primaryOutlinedButtonStyle(context)
|
||||
: null,
|
||||
label: const Text('Add fingerprint'),
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FidoPinDialog(node.path, state),
|
||||
builder: (context) => AddFingerprintDialog(node.path),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Delete all data',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
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) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FidoPinDialog(node.path, state),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Reset FIDO',
|
||||
icon: const Icon(Icons.delete),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import 'package:collection/collection.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/app_failure_screen.dart';
|
||||
import '../../app/views/app_loading_screen.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
@ -27,8 +26,8 @@ class _CapabilityForm extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
spacing: 8,
|
||||
runSpacing: 16,
|
||||
children: Capability.values
|
||||
.where((c) => capabilities & c.value != 0)
|
||||
.map((c) => FilterChip(
|
||||
@ -85,30 +84,41 @@ class _CapabilitiesForm extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (usbCapabilities != 0)
|
||||
if (usbCapabilities != 0) ...[
|
||||
const ListTile(
|
||||
leading: Icon(Icons.usb),
|
||||
title: Text('USB applications'),
|
||||
title: Text('USB'),
|
||||
contentPadding: EdgeInsets.only(bottom: 8),
|
||||
horizontalTitleGap: 0,
|
||||
),
|
||||
_CapabilityForm(
|
||||
capabilities: usbCapabilities,
|
||||
enabled: enabled[Transport.usb] ?? 0,
|
||||
onChanged: (value) {
|
||||
onChanged({...enabled, Transport.usb: value});
|
||||
},
|
||||
),
|
||||
if (nfcCapabilities != 0)
|
||||
_CapabilityForm(
|
||||
capabilities: usbCapabilities,
|
||||
enabled: enabled[Transport.usb] ?? 0,
|
||||
onChanged: (value) {
|
||||
onChanged({...enabled, Transport.usb: value});
|
||||
},
|
||||
),
|
||||
],
|
||||
if (nfcCapabilities != 0) ...[
|
||||
if (usbCapabilities != 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 24, bottom: 12),
|
||||
child: Divider(),
|
||||
),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.wifi),
|
||||
title: Text('NFC applications'),
|
||||
title: Text('NFC'),
|
||||
contentPadding: EdgeInsets.only(bottom: 8),
|
||||
horizontalTitleGap: 0,
|
||||
),
|
||||
_CapabilityForm(
|
||||
capabilities: nfcCapabilities,
|
||||
enabled: enabled[Transport.nfc] ?? 0,
|
||||
onChanged: (value) {
|
||||
onChanged({...enabled, Transport.nfc: value});
|
||||
},
|
||||
),
|
||||
_CapabilityForm(
|
||||
capabilities: nfcCapabilities,
|
||||
enabled: enabled[Transport.nfc] ?? 0,
|
||||
onChanged: (value) {
|
||||
onChanged({...enabled, Transport.nfc: value});
|
||||
},
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -230,7 +240,18 @@ class _ManagementScreenState extends ConsumerState<ManagementScreen> {
|
||||
final child =
|
||||
ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
|
||||
loading: () => const AppLoadingScreen(),
|
||||
error: (error, _) => AppFailureScreen('$error'),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (info) {
|
||||
bool hasConfig = info.version.major > 4;
|
||||
if (hasConfig) {
|
||||
|
@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/dialog_frame.dart';
|
||||
import '../models.dart';
|
||||
@ -44,17 +46,38 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
Pair<Color?, Color?> _getColors(BuildContext context, MenuAction action) {
|
||||
final theme =
|
||||
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
|
||||
return action.text.startsWith('Copy')
|
||||
? Pair(theme.primary, theme.onPrimary)
|
||||
: (action.text.startsWith('Delete')
|
||||
? Pair(theme.error, theme.onError)
|
||||
: Pair(theme.secondary, theme.onSecondary));
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(BuildContext context, WidgetRef ref) {
|
||||
return buildActions(context, ref).map((e) {
|
||||
final action = e.action;
|
||||
return IconButton(
|
||||
icon: e.icon,
|
||||
tooltip: e.text,
|
||||
onPressed: action != null
|
||||
? () {
|
||||
action(context);
|
||||
}
|
||||
: null,
|
||||
final colors = _getColors(context, e);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: CircleAvatar(
|
||||
// TODO: Hardcoded color
|
||||
backgroundColor: action != null ? colors.first : Colors.grey.shade900,
|
||||
foregroundColor: colors.second,
|
||||
child: IconButton(
|
||||
icon: e.icon,
|
||||
iconSize: 22,
|
||||
tooltip: e.text,
|
||||
disabledColor: Colors.white70,
|
||||
onPressed: action != null
|
||||
? () {
|
||||
action(context);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@ -69,20 +92,62 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
|
||||
}
|
||||
return DialogFrame(
|
||||
child: AlertDialog(
|
||||
title: Text(title),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actionsPadding: EdgeInsets.zero,
|
||||
title: Center(
|
||||
child: Text(
|
||||
title,
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle ?? ''),
|
||||
const SizedBox(height: 8.0),
|
||||
Center(child: FittedBox(child: buildCodeView(ref, big: true))),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
const SizedBox(height: 12.0),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
color: CardTheme.of(context).color,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
|
||||
),
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
child: DefaultTextStyle.merge(
|
||||
style: const TextStyle(fontSize: 28),
|
||||
child: IconTheme(
|
||||
data: IconTheme.of(context).copyWith(size: 24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0, vertical: 8.0),
|
||||
child: buildCodeView(ref),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [FittedBox(child: Row(children: _buildActions(context, ref)))],
|
||||
actionsPadding: const EdgeInsets.only(top: 10.0, right: -16.0),
|
||||
actions: [
|
||||
Center(
|
||||
child: FittedBox(
|
||||
alignment: Alignment.center,
|
||||
child: Row(children: _buildActions(context, ref)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -94,9 +94,11 @@ class _AccountListState extends ConsumerState<AccountList> {
|
||||
return Column(
|
||||
children: [
|
||||
if (pinnedCreds.isNotEmpty)
|
||||
const ListTile(
|
||||
ListTile(
|
||||
minVerticalPadding: 16,
|
||||
title: Text(
|
||||
'Pinned',
|
||||
'PINNED',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
...pinnedCreds.map(
|
||||
@ -106,9 +108,11 @@ class _AccountListState extends ConsumerState<AccountList> {
|
||||
),
|
||||
),
|
||||
if (creds.isNotEmpty)
|
||||
const ListTile(
|
||||
ListTile(
|
||||
minVerticalPadding: 16,
|
||||
title: Text(
|
||||
'Accounts',
|
||||
'ACCOUNTS',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
...creds.map(
|
||||
|
@ -147,6 +147,16 @@ mixin AccountMixin {
|
||||
final pinned = isPinned(ref);
|
||||
|
||||
return [
|
||||
MenuAction(
|
||||
text: 'Copy to clipboard',
|
||||
icon: const Icon(Icons.copy),
|
||||
action: code == null || expired
|
||||
? null
|
||||
: (context) {
|
||||
Clipboard.setData(ClipboardData(text: code.value));
|
||||
showMessage(context, 'Code copied to clipboard');
|
||||
},
|
||||
),
|
||||
if (manual)
|
||||
MenuAction(
|
||||
text: 'Calculate',
|
||||
@ -157,27 +167,19 @@ mixin AccountMixin {
|
||||
}
|
||||
: null,
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Copy to clipboard',
|
||||
icon: const Icon(Icons.copy),
|
||||
action: code == null || expired
|
||||
? null
|
||||
: (context) {
|
||||
Clipboard.setData(ClipboardData(text: code.value));
|
||||
showMessage(context, 'Code copied to clipboard');
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: pinned ? 'Unpin account' : 'Pin account',
|
||||
//TODO: Replace this with a custom icon.
|
||||
icon: pinned
|
||||
? CustomPaint(
|
||||
painter: _StrikethroughPainter(
|
||||
Theme.of(context).iconTheme.color ?? Colors.black),
|
||||
child: ClipPath(
|
||||
clipper: _StrikethroughClipper(),
|
||||
child: const Icon(Icons.push_pin_outlined)),
|
||||
)
|
||||
? Builder(builder: (context) {
|
||||
return CustomPaint(
|
||||
painter: _StrikethroughPainter(
|
||||
IconTheme.of(context).color ?? Colors.black),
|
||||
child: ClipPath(
|
||||
clipper: _StrikethroughClipper(),
|
||||
child: const Icon(Icons.push_pin)),
|
||||
);
|
||||
})
|
||||
: const Icon(Icons.push_pin_outlined),
|
||||
action: (context) {
|
||||
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
|
||||
@ -194,7 +196,7 @@ mixin AccountMixin {
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Delete account',
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
action: (context) async {
|
||||
await deleteCredential(context, ref);
|
||||
},
|
||||
@ -203,69 +205,59 @@ mixin AccountMixin {
|
||||
}
|
||||
|
||||
@protected
|
||||
Widget buildCodeView(WidgetRef ref, {bool big = false}) {
|
||||
Widget buildCodeView(WidgetRef ref) {
|
||||
final code = getCode(ref);
|
||||
final expired = isExpired(code, ref);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
|
||||
border: Border.all(width: 1.0, color: Colors.grey.shade500),
|
||||
),
|
||||
child: AnimatedSize(
|
||||
alignment: Alignment.centerRight,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Padding(
|
||||
padding: big
|
||||
? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: code == null
|
||||
? [
|
||||
Icon(
|
||||
credential.oathType == OathType.hotp
|
||||
? Icons.refresh
|
||||
: Icons.touch_app,
|
||||
size: big ? 36 : 18,
|
||||
),
|
||||
Text('', style: TextStyle(fontSize: big ? 32.0 : 22.0)),
|
||||
]
|
||||
: [
|
||||
if (credential.oathType == OathType.totp) ...[
|
||||
...expired
|
||||
? [
|
||||
if (credential.touchRequired) ...[
|
||||
const Icon(Icons.touch_app),
|
||||
const SizedBox(width: 8.0),
|
||||
]
|
||||
]
|
||||
: [
|
||||
SizedBox.square(
|
||||
dimension: big ? 32 : 16,
|
||||
child: CircleTimer(
|
||||
code.validFrom * 1000,
|
||||
code.validTo * 1000,
|
||||
),
|
||||
),
|
||||
return AnimatedSize(
|
||||
alignment: Alignment.centerRight,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Builder(builder: (context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: code == null
|
||||
? [
|
||||
Icon(
|
||||
credential.oathType == OathType.hotp
|
||||
? Icons.refresh
|
||||
: Icons.touch_app,
|
||||
),
|
||||
const Text(''),
|
||||
]
|
||||
: [
|
||||
if (credential.oathType == OathType.totp) ...[
|
||||
...expired
|
||||
? [
|
||||
if (credential.touchRequired) ...[
|
||||
const Icon(Icons.touch_app),
|
||||
const SizedBox(width: 8.0),
|
||||
],
|
||||
],
|
||||
Opacity(
|
||||
opacity: expired ? 0.4 : 1.0,
|
||||
child: Text(
|
||||
formatCode(code),
|
||||
style: TextStyle(
|
||||
fontSize: big ? 32.0 : 22.0,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
]
|
||||
]
|
||||
: [
|
||||
SizedBox.square(
|
||||
dimension:
|
||||
(IconTheme.of(context).size ?? 18) * 0.8,
|
||||
child: CircleTimer(
|
||||
code.validFrom * 1000,
|
||||
code.validTo * 1000,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
],
|
||||
],
|
||||
Opacity(
|
||||
opacity: expired ? 0.4 : 1.0,
|
||||
child: Text(
|
||||
formatCode(code),
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
//fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -88,56 +88,80 @@ class AccountView extends ConsumerWidget with AccountMixin {
|
||||
items: _buildPopupMenu(context, ref),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
focusNode: focusNode,
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AccountDialog(credential);
|
||||
},
|
||||
);
|
||||
},
|
||||
onLongPress: () async {
|
||||
if (calculateReady) {
|
||||
await calculateCode(
|
||||
context,
|
||||
ref,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
final showAvatar = constraints.maxWidth >= 315;
|
||||
return ListTile(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||
focusNode: focusNode,
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AccountDialog(credential);
|
||||
},
|
||||
);
|
||||
}
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
copyToClipboard(context, ref);
|
||||
},
|
||||
);
|
||||
},
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: darkMode ? Colors.black : Colors.white,
|
||||
backgroundColor: _iconColor(darkMode ? 300 : 400),
|
||||
child: Text(
|
||||
(credential.issuer ?? credential.name)
|
||||
.characters
|
||||
.first
|
||||
.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 18),
|
||||
},
|
||||
onLongPress: () async {
|
||||
if (calculateReady) {
|
||||
await calculateCode(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
}
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
copyToClipboard(context, ref);
|
||||
},
|
||||
);
|
||||
},
|
||||
leading: showAvatar
|
||||
? CircleAvatar(
|
||||
foregroundColor: darkMode ? Colors.black : Colors.white,
|
||||
backgroundColor: _iconColor(darkMode ? 300 : 400),
|
||||
child: Text(
|
||||
(credential.issuer ?? credential.name)
|
||||
.characters
|
||||
.first
|
||||
.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w300),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: Text(
|
||||
title,
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle!,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: null,
|
||||
trailing: buildCodeView(ref),
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle!,
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: null,
|
||||
trailing: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
color: CardTheme.of(context).color,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
child: buildCodeView(ref),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -196,7 +196,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
children: [
|
||||
Text(
|
||||
'Account details',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
TextField(
|
||||
key: const Key('issuer'),
|
||||
@ -280,7 +280,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
const Divider(),
|
||||
Text(
|
||||
'Options',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
@ -297,6 +297,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
},
|
||||
),
|
||||
Chip(
|
||||
backgroundColor: ChipTheme.of(context).selectedColor,
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OathType>(
|
||||
value: _oathType,
|
||||
@ -319,6 +320,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
backgroundColor: ChipTheme.of(context).selectedColor,
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<HashAlgorithm>(
|
||||
value: _hashAlgorithm,
|
||||
@ -342,6 +344,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
Chip(
|
||||
backgroundColor: ChipTheme.of(context).selectedColor,
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: int.tryParse(_periodController.text) ??
|
||||
@ -366,6 +369,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
backgroundColor: ChipTheme.of(context).selectedColor,
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _digits,
|
||||
|
@ -64,7 +64,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
|
||||
if (widget.state.hasKey) ...[
|
||||
Text(
|
||||
'Current password',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
@ -120,7 +120,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
|
||||
],
|
||||
Text(
|
||||
'New password',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
TextField(
|
||||
autofocus: !widget.state.hasKey,
|
||||
|
@ -6,10 +6,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/views/app_failure_screen.dart';
|
||||
import '../../app/views/app_failure_page.dart';
|
||||
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 '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'account_list.dart';
|
||||
@ -29,10 +31,9 @@ class OathScreen extends ConsumerWidget {
|
||||
centered: true,
|
||||
child: const AppLoadingScreen(),
|
||||
),
|
||||
error: (error, _) => AppPage(
|
||||
error: (error, _) => AppFailurePage(
|
||||
title: const Text('Authenticator'),
|
||||
centered: true,
|
||||
child: AppFailureScreen('$error'),
|
||||
cause: error,
|
||||
),
|
||||
data: (oathState) => oathState.locked
|
||||
? _LockedView(devicePath, oathState)
|
||||
@ -50,12 +51,42 @@ 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) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ManagePasswordDialog(devicePath, oathState),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Reset OATH',
|
||||
icon: const Icon(Icons.delete),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(devicePath),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
const ListTile(title: Text('Unlock')),
|
||||
_UnlockForm(
|
||||
devicePath,
|
||||
oathState,
|
||||
keystore: oathState.keystore,
|
||||
),
|
||||
],
|
||||
@ -76,9 +107,9 @@ class _UnlockedView extends ConsumerWidget {
|
||||
if (isEmpty) {
|
||||
return MessagePage(
|
||||
title: const Text('Authenticator'),
|
||||
graphic: noAccounts,
|
||||
header: 'No accounts',
|
||||
message: 'Follow the instructions on a website to add an account',
|
||||
floatingActionButton: _buildFab(context),
|
||||
actions: _buildActions(context, true),
|
||||
);
|
||||
}
|
||||
|
||||
@ -96,6 +127,7 @@ class _UnlockedView extends ConsumerWidget {
|
||||
return TextFormField(
|
||||
key: const Key('search_accounts'),
|
||||
initialValue: ref.read(searchProvider),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search accounts',
|
||||
border: InputBorder.none,
|
||||
@ -110,64 +142,64 @@ class _UnlockedView extends ConsumerWidget {
|
||||
);
|
||||
}),
|
||||
),
|
||||
floatingActionButton: _buildFab(context),
|
||||
actions: _buildActions(context, false),
|
||||
child: AccountList(devicePath, oathState),
|
||||
);
|
||||
}
|
||||
|
||||
FloatingActionButton _buildFab(BuildContext context) {
|
||||
final fab = FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
label: const Text('Setup'),
|
||||
onPressed: () {
|
||||
showBottomMenu(context, [
|
||||
MenuAction(
|
||||
text: 'Add account',
|
||||
icon: const Icon(Icons.person_add_alt),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
devicePath,
|
||||
openQrScanner: Platform.isAndroid,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: oathState.hasKey ? 'Manage password' : 'Set password',
|
||||
icon: const Icon(Icons.password),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ManagePasswordDialog(devicePath, oathState),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Reset OATH',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(devicePath),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
return fab;
|
||||
List<Widget> _buildActions(BuildContext context, bool isEmpty) {
|
||||
return [
|
||||
OutlinedButton.icon(
|
||||
style: isEmpty ? AppTheme.primaryOutlinedButtonStyle(context) : null,
|
||||
label: const Text('Add account'),
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
devicePath,
|
||||
openQrScanner: Platform.isAndroid,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
label: const Text('Options'),
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: () {
|
||||
showBottomMenu(context, [
|
||||
MenuAction(
|
||||
text: oathState.hasKey ? 'Manage password' : 'Set password',
|
||||
icon: const Icon(Icons.password),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ManagePasswordDialog(devicePath, oathState),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Reset OATH',
|
||||
icon: const Icon(Icons.delete),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(devicePath),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _UnlockForm extends ConsumerStatefulWidget {
|
||||
final DevicePath _devicePath;
|
||||
final OathState _oathState;
|
||||
final KeystoreState keystore;
|
||||
const _UnlockForm(this._devicePath, this._oathState,
|
||||
{required this.keystore});
|
||||
const _UnlockForm(this._devicePath, {required this.keystore});
|
||||
|
||||
@override
|
||||
ConsumerState<_UnlockForm> createState() => _UnlockFormState();
|
||||
@ -224,33 +256,6 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> {
|
||||
onChanged: (_) => setState(() {}), // Update state on change
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.password),
|
||||
label: const Text('Manage password'),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ManagePasswordDialog(
|
||||
widget._devicePath, widget._oathState),
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
label: const Text('Reset OATH'),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(widget._devicePath),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
156
lib/theme.dart
@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const primaryGreen = Color(0xffa8c86c);
|
||||
const primaryGreen = Color(0xffaed581);
|
||||
const accentGreen = Color(0xff9aca3c);
|
||||
const primaryBlue = Color(0xff325f74);
|
||||
const primaryRed = Color(0xffea4335);
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
@ -14,26 +15,71 @@ class AppTheme {
|
||||
secondary: accentGreen,
|
||||
background: Colors.grey.shade200,
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
iconTheme: IconThemeData(
|
||||
color: Colors.grey.shade400,
|
||||
size: 18.0,
|
||||
),
|
||||
//backgroundColor: Colors.white,
|
||||
toggleableActiveColor: accentGreen,
|
||||
appBarTheme: AppBarTheme(
|
||||
elevation: 0.5,
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
toolbarHeight: 48,
|
||||
//shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.grey.shade800,
|
||||
),
|
||||
// Mainly used for the OATH dialog view at the moment
|
||||
buttonTheme: ButtonThemeData(
|
||||
colorScheme: ColorScheme.light(
|
||||
secondary: Colors.grey.shade300,
|
||||
onSecondary: Colors.grey.shade900,
|
||||
primary: primaryGreen,
|
||||
onPrimary: Colors.grey.shade900,
|
||||
error: primaryRed,
|
||||
onError: Colors.grey.shade100,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
primary: Colors.grey.shade800,
|
||||
side: BorderSide(width: 1, color: Colors.grey.shade400),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
|
||||
)),
|
||||
cardTheme: CardTheme(
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedColor: const Color(0xffd2dbdf),
|
||||
side: BorderSide(width: 1, color: Colors.grey.shade400),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: primaryBlue,
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
bodyText1: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
bodyText2: TextStyle(
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
headline2: TextStyle(
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
textTheme: const TextTheme(
|
||||
//bodySmall: TextStyle(color: Colors.grey.shade500),
|
||||
//bodyLarge: const TextStyle(color: Colors.white70),
|
||||
//bodyMedium: TextStyle(color: Colors.grey.shade200),
|
||||
//labelSmall: TextStyle(color: Colors.grey.shade500),
|
||||
//labelMedium: TextStyle(color: Colors.cyan.shade200),
|
||||
//labelLarge: TextStyle(color: Colors.cyan.shade500),
|
||||
//titleSmall: TextStyle(color: Colors.grey.shade600),
|
||||
//titleMedium: const TextStyle(),
|
||||
titleLarge: TextStyle(
|
||||
//color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 18),
|
||||
headlineSmall: TextStyle(
|
||||
//color: Colors.grey.shade200,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 16),
|
||||
),
|
||||
);
|
||||
|
||||
@ -43,23 +89,85 @@ class AppTheme {
|
||||
colorScheme:
|
||||
ColorScheme.fromSwatch(brightness: Brightness.dark).copyWith(
|
||||
primary: primaryGreen,
|
||||
secondary: primaryGreen,
|
||||
onPrimary: Colors.black,
|
||||
secondary: const Color(0xff5d7d90),
|
||||
),
|
||||
iconTheme: const IconThemeData(
|
||||
color: Colors.white70,
|
||||
size: 18.0,
|
||||
),
|
||||
toggleableActiveColor: primaryGreen,
|
||||
appBarTheme: AppBarTheme(
|
||||
elevation: 0,
|
||||
toolbarHeight: 48,
|
||||
//shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.grey.shade400,
|
||||
),
|
||||
// Mainly used for the OATH dialog view at the moment
|
||||
buttonTheme: ButtonThemeData(
|
||||
colorScheme: ColorScheme.dark(
|
||||
secondary: Colors.grey.shade800,
|
||||
onSecondary: Colors.white70,
|
||||
primary: primaryGreen,
|
||||
onPrimary: Colors.grey.shade900,
|
||||
error: primaryRed,
|
||||
onError: Colors.grey.shade100,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
primary: Colors.white70,
|
||||
side: const BorderSide(width: 1, color: Colors.white12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
|
||||
)),
|
||||
cardTheme: CardTheme(
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedColor: Colors.white12,
|
||||
side: const BorderSide(width: 1, color: Colors.white12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
dialogTheme: const DialogTheme(
|
||||
backgroundColor: Color(0xff323232),
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
foregroundColor: Colors.grey.shade900,
|
||||
backgroundColor: primaryGreen,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
textTheme: TextTheme(
|
||||
bodyText1: TextStyle(
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
bodyText2: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
headline2: TextStyle(
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
bodySmall: TextStyle(color: Colors.grey.shade500),
|
||||
bodyLarge: const TextStyle(color: Colors.white70),
|
||||
bodyMedium: TextStyle(color: Colors.grey.shade200),
|
||||
labelSmall: TextStyle(color: Colors.grey.shade500),
|
||||
labelMedium: TextStyle(color: Colors.cyan.shade200),
|
||||
labelLarge: TextStyle(color: Colors.cyan.shade500),
|
||||
titleSmall: TextStyle(color: Colors.grey.shade600),
|
||||
titleMedium: const TextStyle(),
|
||||
titleLarge: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 18),
|
||||
headlineSmall: TextStyle(
|
||||
color: Colors.grey.shade200,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 16),
|
||||
),
|
||||
);
|
||||
|
||||
static ButtonStyle primaryOutlinedButtonStyle(BuildContext context) =>
|
||||
OutlinedButton.styleFrom(
|
||||
primary: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
side:
|
||||
BorderSide(width: 1, color: Theme.of(context).colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
@ -59,6 +59,8 @@ class _CircleTimerState extends State<CircleTimer>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProgressCircle(Colors.grey, _progress.value);
|
||||
return ProgressCircle(
|
||||
Theme.of(context).iconTheme.color ?? Colors.grey.shade600,
|
||||
_progress.value);
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,6 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
||||
: 'Cancel';
|
||||
return DialogFrame(
|
||||
child: AlertDialog(
|
||||
insetPadding: EdgeInsets.zero,
|
||||
title: widget.title,
|
||||
scrollable: true,
|
||||
content: SizedBox(
|
||||
|
BIN
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
Executable file → Normal file
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 5.9 KiB |
BIN
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png
Executable file → Normal file
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 719 B |
BIN
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png
Executable file → Normal file
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
BIN
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png
Executable file → Normal file
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png
Executable file → Normal file
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
BIN
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png
Executable file → Normal file
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.6 KiB |
12
pubspec.yaml
@ -88,7 +88,7 @@ flutter:
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/product-images/
|
||||
|
||||
- assets/graphics/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||
@ -115,3 +115,13 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/custom-fonts/#from-packages
|
||||
|
||||
fonts:
|
||||
- family: Roboto
|
||||
fonts:
|
||||
- asset: assets/fonts/Roboto-Regular.ttf
|
||||
weight: 400
|
||||
- asset: assets/fonts/Roboto-Light.ttf
|
||||
weight: 300
|
||||
- asset: assets/fonts/Roboto-Thin.ttf
|
||||
weight: 100
|
||||
|