This commit is contained in:
Dain Nilsson 2022-05-24 09:55:02 +02:00
commit a806a3602f
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
62 changed files with 907 additions and 584 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
assets/graphics/no-permission.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -26,7 +26,14 @@
# POSSIBILITY OF SUCH DAMAGE. # 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 .oath import OathNode
from .fido import Ctap2Node from .fido import Ctap2Node
from .yubiotp import YubiOtpNode from .yubiotp import YubiOtpNode
@ -47,6 +54,7 @@ from yubikit.logging import LOG_LEVEL
from ykman.pcsc import list_devices, YK_READER_NAME from ykman.pcsc import list_devices, YK_READER_NAME
from smartcard.Exceptions import SmartcardException from smartcard.Exceptions import SmartcardException
from smartcard.pcsc.PCSCExceptions import EstablishContextException
from hashlib import sha256 from hashlib import sha256
from dataclasses import asdict from dataclasses import asdict
from typing import Mapping, Tuple from typing import Mapping, Tuple
@ -65,6 +73,15 @@ def _is_admin():
return os.getuid() == 0 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): class RootNode(RpcNode):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -127,9 +144,16 @@ class ReadersNode(RpcNode):
return self.list_children() return self.list_children()
def list_children(self): def list_children(self):
devices = [ try:
d for d in list_devices("") if YK_READER_NAME not in d.reader.name.lower() 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} state = {d.reader.name for d in devices}
if self._state != state: if self._state != state:
self._readers = {} self._readers = {}
@ -271,15 +295,27 @@ class UsbDeviceNode(AbstractDeviceNode):
@child(condition=lambda self: self._supports_connection(SmartCardConnection)) @child(condition=lambda self: self._supports_connection(SmartCardConnection))
def ccid(self): 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)) @child(condition=lambda self: self._supports_connection(OtpConnection))
def otp(self): 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)) @child(condition=lambda self: self._supports_connection(FidoConnection))
def fido(self): 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): class ReaderDeviceNode(AbstractDeviceNode):

View File

@ -185,19 +185,19 @@ class _QrScannerViewState extends State<QrScannerView> {
Text('Looking for a code...', Text('Looking for a code...',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.headline6 .titleLarge
?.copyWith(color: Colors.black)), ?.copyWith(color: Colors.black)),
if (_status == _ScanStatus.success) if (_status == _ScanStatus.success)
Text('Found a valid code', Text('Found a valid code',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.headline6 .titleLarge
?.copyWith(color: Colors.white)), ?.copyWith(color: Colors.white)),
if (_status == _ScanStatus.error) if (_status == _ScanStatus.error)
Text('This code is not valid, try again.', Text('This code is not valid, try again.',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.headline6 .titleLarge
?.copyWith(color: Colors.white)), ?.copyWith(color: Colors.white)),
]), ]),
Row( Row(

View 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,
);
}
}

View File

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

View File

@ -8,14 +8,15 @@ class AppPage extends ConsumerWidget {
final Key _scaffoldKey = GlobalKey(); final Key _scaffoldKey = GlobalKey();
final Widget? title; final Widget? title;
final Widget child; final Widget child;
final Widget? floatingActionButton; final List<Widget> actions;
final bool centered; final bool centered;
AppPage( AppPage({
{super.key, super.key,
this.title, this.title,
required this.child, required this.child,
this.floatingActionButton, this.actions = const [],
this.centered = false}); this.centered = false,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
@ -29,7 +30,7 @@ class AppPage extends ConsumerWidget {
body: Row( body: Row(
children: [ children: [
const SizedBox( const SizedBox(
width: 240, width: 280,
child: ListTileTheme( child: ListTileTheme(
style: ListTileStyle.drawer, style: ListTileStyle.drawer,
child: MainPageDrawer(shouldPop: false)), child: MainPageDrawer(shouldPop: false)),
@ -46,11 +47,26 @@ class AppPage extends ConsumerWidget {
Widget _buildScrollView() => SafeArea( Widget _buildScrollView() => SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
// Make sure FAB doesn't block content child: Builder(builder: (context) {
padding: floatingActionButton != null return Column(
? const EdgeInsets.only(bottom: 72) children: [
: null, child,
child: 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( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
appBar: AppBar( appBar: AppBar(
titleSpacing: 8,
title: title, title: title,
centerTitle: true, centerTitle: true,
titleTextStyle: Theme.of(context).textTheme.titleLarge,
actions: const [DeviceButton()], actions: const [DeviceButton()],
), ),
drawer: hasDrawer ? const MainPageDrawer() : null, drawer: hasDrawer ? const MainPageDrawer() : null,
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(), body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
floatingActionButton: floatingActionButton,
); );
} }
} }

View File

@ -46,7 +46,7 @@ class DeviceAvatar extends StatelessWidget {
CircleAvatar( CircleAvatar(
radius: 22, radius: 22,
backgroundColor: selected backgroundColor: selected
? Theme.of(context).colorScheme.secondary ? Theme.of(context).colorScheme.primary
: Colors.transparent, : Colors.transparent,
child: CircleAvatar( child: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.background,

View File

@ -33,6 +33,7 @@ class DeviceButton extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: IconButton( child: IconButton(
tooltip: 'Select YubiKey or device',
icon: OverflowBox( icon: OverflowBox(
maxHeight: 44, maxHeight: 44,
maxWidth: 44, maxWidth: 44,

8
lib/app/views/graphics.dart Executable file
View 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');

View File

@ -38,17 +38,20 @@ class MainPageDrawer extends ConsumerWidget {
final data = ref.watch(currentDeviceDataProvider); final data = ref.watch(currentDeviceDataProvider);
final currentApp = ref.watch(currentAppProvider); final currentApp = ref.watch(currentAppProvider);
MediaQuery? mediaQuery =
context.findAncestorWidgetOfExactType<MediaQuery>();
final width = mediaQuery?.data.size.width ?? 400;
return Drawer( 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( child: ListView(
primary: false, //Prevents conflict with the MainPage scroll view. primary: false, //Prevents conflict with the MainPage scroll view.
children: [ children: [
Padding( const SizedBox(height: 24.0),
padding: const EdgeInsets.all(12.0),
child: Text(
'Yubico Authenticator',
style: Theme.of(context).textTheme.headline6,
),
),
if (data != null) ...[ if (data != null) ...[
// Normal YubiKey Applications // Normal YubiKey Applications
...supportedApps ...supportedApps
@ -68,14 +71,6 @@ class MainPageDrawer extends ConsumerWidget {
if (supportedApps.contains(Application.management) && if (supportedApps.contains(Application.management) &&
Application.management.getAvailability(data) == Application.management.getAvailability(data) ==
Availability.enabled) ...[ Availability.enabled) ...[
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Configuration',
style: Theme.of(context).textTheme.bodyText2,
),
),
DrawerItem( DrawerItem(
titleText: 'Toggle applications', titleText: 'Toggle applications',
icon: Icon(Application.management._icon), 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 // Non-YubiKey pages
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Application',
style: Theme.of(context).textTheme.bodyText2,
),
),
DrawerItem( DrawerItem(
titleText: 'Settings', titleText: 'Settings',
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
@ -110,7 +98,7 @@ class MainPageDrawer extends ConsumerWidget {
), ),
DrawerItem( DrawerItem(
titleText: 'Help and feedback', titleText: 'Help and feedback',
icon: const Icon(Icons.help_outline), icon: const Icon(Icons.help),
onTap: () { onTap: () {
final nav = Navigator.of(context); final nav = Navigator.of(context);
if (shouldPop) nav.pop(); if (shouldPop) nav.pop();
@ -173,17 +161,22 @@ class DrawerItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(left: 12.0, right: 12.0),
child: ListTile( child: ListTile(
enabled: enabled, enabled: enabled,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)), borderRadius: BorderRadius.all(Radius.circular(30)),
), ),
dense: true, dense: true,
minLeadingWidth: 24,
minVerticalPadding: 18,
selected: selected, selected: selected,
selectedColor: Theme.of(context).backgroundColor, selectedColor: Theme.of(context).colorScheme.onPrimary,
selectedTileColor: Theme.of(context).colorScheme.secondary, selectedTileColor: Theme.of(context).colorScheme.primary,
leading: icon, leading: IconTheme.merge(
data: const IconThemeData(size: 24),
child: icon,
),
title: Text(titleText), title: Text(titleText),
onTap: onTap, onTap: onTap,
), ),

View File

@ -4,29 +4,40 @@ import 'app_page.dart';
class MessagePage extends StatelessWidget { class MessagePage extends StatelessWidget {
final Widget? title; final Widget? title;
final String header; final Widget? graphic;
final String message; final String? header;
final Widget? floatingActionButton; final String? message;
final List<Widget> actions;
const MessagePage({ const MessagePage({
super.key, super.key,
this.title, this.title,
required this.header, this.graphic,
required this.message, this.header,
this.floatingActionButton, this.message,
this.actions = const [],
}); });
@override @override
Widget build(BuildContext context) => AppPage( Widget build(BuildContext context) => AppPage(
title: title, title: title,
centered: true, centered: true,
floatingActionButton: floatingActionButton, actions: actions,
child: Column( child: Padding(
children: [ padding: const EdgeInsets.all(8.0),
Text(header, style: Theme.of(context).textTheme.headline6), child: Column(
const SizedBox(height: 12.0), children: [
Text(message, textAlign: TextAlign.center), 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),
],
],
),
), ),
); );
} }

View File

@ -5,25 +5,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import '../../desktop/state.dart'; import '../../desktop/state.dart';
import '../../theme.dart';
import '../message.dart'; import '../message.dart';
import '../models.dart'; import '../models.dart';
import 'app_page.dart';
import 'device_avatar.dart'; import 'device_avatar.dart';
import 'graphics.dart';
import 'message_page.dart';
class NoDeviceScreen extends ConsumerWidget { class NoDeviceScreen extends ConsumerWidget {
final DeviceNode? node; final DeviceNode? node;
const NoDeviceScreen(this.node, {super.key}); 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 (pid.usbInterfaces == UsbInterface.fido.value) {
if (Platform.isWindows && if (Platform.isWindows &&
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { !ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
return [ return MessagePage(
const DeviceAvatar(child: Icon(Icons.lock)), graphic: noPermission,
const Text('WebAuthn management requires elevated privileges.'), message: 'Managing this device requires elevated privileges.',
OutlinedButton.icon( actions: [
icon: const Icon(Icons.lock_open), OutlinedButton.icon(
style: AppTheme.primaryOutlinedButtonStyle(context),
label: const Text('Unlock'), label: const Text('Unlock'),
icon: const Icon(Icons.lock_open),
onPressed: () async { onPressed: () async {
final controller = showMessage( final controller = showMessage(
context, 'Elevating permissions...', context, 'Elevating permissions...',
@ -37,43 +41,31 @@ class NoDeviceScreen extends ConsumerWidget {
} finally { } finally {
controller.close(); controller.close();
} }
}), },
] ),
.map((e) => Padding( ],
padding: const EdgeInsets.symmetric(vertical: 8.0), );
child: e,
))
.toList();
} }
} }
return [ return const MessagePage(
const DeviceAvatar(child: Icon(Icons.usb_off)), graphic: DeviceAvatar(child: Icon(Icons.usb_off)),
const Text( message: 'This YubiKey cannot be accessed',
'This YubiKey cannot be accessed', );
textAlign: TextAlign.center,
),
];
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return AppPage( return node?.map(usbYubiKey: (node) {
centered: true, return _buildUsbPid(context, ref, node.pid);
child: Column( }, nfcReader: (node) {
mainAxisAlignment: MainAxisAlignment.center, return const MessagePage(
children: node?.map(usbYubiKey: (node) { graphic: DeviceAvatar(child: Icon(Icons.wifi)),
return _buildUsbPid(context, ref, node.pid); message: 'Place your YubiKey on the NFC reader',
}, nfcReader: (node) { );
return const [ }) ??
DeviceAvatar(child: Icon(Icons.wifi)), const MessagePage(
Text('Place your YubiKey on the NFC reader'), graphic: DeviceAvatar(child: Icon(Icons.usb)),
]; message: 'Insert your YubiKey',
}) ?? );
const [
DeviceAvatar(child: Icon(Icons.usb)),
Text('Insert your YubiKey'),
],
),
);
} }
} }

View File

@ -133,26 +133,27 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Step 1/2: Capture fingerprint'), const Text('Step 1/2: Capture fingerprint'),
Card( Column(
child: Column( children: [
children: [ Padding(
AnimatedBuilder( padding: const EdgeInsets.all(36.0),
child: AnimatedBuilder(
animation: _color, animation: _color,
builder: (context, _) { builder: (context, _) {
return Icon( return Icon(
_fingerprint == null ? Icons.fingerprint : Icons.check, _fingerprint == null ? Icons.fingerprint : Icons.check,
size: 200.0, size: 128.0,
color: _color.value, color: _color.value,
); );
}, },
), ),
LinearProgressIndicator(value: progress), ),
Padding( LinearProgressIndicator(value: progress),
padding: const EdgeInsets.all(8.0), Padding(
child: Text(_getMessage()), padding: const EdgeInsets.all(8.0),
), child: Text(_getMessage()),
], ),
), ],
), ),
const Text('Step 2/2: Name fingerprint'), const Text('Step 2/2: Name fingerprint'),
TextFormField( TextFormField(

View File

@ -1,16 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.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_loading_screen.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/device_avatar.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../desktop/state.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../state.dart'; import '../state.dart';
import 'locked_page.dart'; import 'locked_page.dart';
@ -51,52 +46,10 @@ class FidoScreen extends ConsumerWidget {
'WebAuthn requires the FIDO2 application to be enabled on your YubiKey', 'WebAuthn requires the FIDO2 application to be enabled on your YubiKey',
); );
} }
if (Platform.isWindows) {
if (!ref return AppFailurePage(
.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(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
centered: true, cause: error,
child: AppFailureScreen('$error'),
); );
}, },
data: (fidoState) { data: (fidoState) {

View File

@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'pin_dialog.dart'; import 'pin_dialog.dart';
@ -22,23 +24,26 @@ class FidoLockedPage extends ConsumerWidget {
if (state.bioEnroll != null) { if (state.bioEnroll != null) {
return MessagePage( return MessagePage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
graphic: noFingerprints,
header: 'No fingerprints', header: 'No fingerprints',
message: 'Set a PIN to register fingerprints', message: 'Set a PIN to register fingerprints.',
floatingActionButton: _buildFab(context), actions: _buildActions(context),
); );
} else { } else {
return MessagePage( return MessagePage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
graphic: noDiscoverable,
header: 'No discoverable accounts', header: 'No discoverable accounts',
message: message:
'Optionally set a PIN to protect access to your YubiKey\nRegister as a Security Key on websites', '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( return AppPage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
actions: _buildActions(context),
child: Column( child: Column(
children: [ children: [
const ListTile(title: Text('Unlock')), const ListTile(title: Text('Unlock')),
@ -48,41 +53,49 @@ class FidoLockedPage extends ConsumerWidget {
); );
} }
FloatingActionButton _buildFab(BuildContext context) { List<Widget> _buildActions(BuildContext context) => [
return FloatingActionButton.extended( if (!state.hasPin)
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin), OutlinedButton.icon(
label: const Text('Setup'), style: AppTheme.primaryOutlinedButtonStyle(context),
onPressed: () { label: const Text('Set PIN'),
showBottomMenu(context, [ icon: const Icon(Icons.pin),
if (state.bioEnroll != null) onPressed: () {
MenuAction(
text: 'Add fingerprint',
icon: const Icon(Icons.fingerprint),
),
MenuAction(
text: 'Set PIN',
icon: const Icon(Icons.pin_outlined),
action: (context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => FidoPinDialog(node.path, state), builder: (context) => FidoPinDialog(node.path, state),
); );
}, },
), ),
MenuAction( OutlinedButton.icon(
text: 'Reset FIDO', label: const Text('Options'),
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.tune),
action: (context) { onPressed: () {
showDialog( showBottomMenu(context, [
context: context, if (state.hasPin)
builder: (context) => ResetDialog(node), 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 { class _PinEntryForm extends ConsumerStatefulWidget {
@ -149,34 +162,6 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
onSubmitted: (_) => _submit(), 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( ListTile(
leading: leading:
noFingerprints ? const Icon(Icons.warning_amber_rounded) : null, noFingerprints ? const Icon(Icons.warning_amber_rounded) : null,

View File

@ -50,7 +50,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
if (hasPin) ...[ if (hasPin) ...[
Text( Text(
'Current PIN', 'Current PIN',
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.titleLarge,
), ),
TextFormField( TextFormField(
initialValue: _currentPin, initialValue: _currentPin,
@ -70,7 +70,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
], ],
Text( Text(
'New PIN', 'New PIN',
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.titleLarge,
), ),
TextFormField( TextFormField(
initialValue: _newPin, initialValue: _newPin,

View File

@ -94,7 +94,7 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
), ),
Center( Center(
child: Text(_getMessage(), child: Text(_getMessage(),
style: Theme.of(context).textTheme.headline6), style: Theme.of(context).textTheme.titleLarge),
), ),
] ]
.map((e) => Padding( .map((e) => Padding(

View File

@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'add_fingerprint_dialog.dart'; import 'add_fingerprint_dialog.dart';
@ -29,10 +31,23 @@ class FidoUnlockedPage extends ConsumerWidget {
? [ ? [
const ListTile(title: Text('Credentials')), const ListTile(title: Text('Credentials')),
...creds.map((cred) => ListTile( ...creds.map((cred) => ListTile(
leading: leading: CircleAvatar(
const CircleAvatar(child: Icon(Icons.link)), foregroundColor:
title: Text(cred.userName), Theme.of(context).colorScheme.onPrimary,
subtitle: Text(cred.rpId), 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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -45,7 +60,7 @@ class FidoUnlockedPage extends ConsumerWidget {
node.path, cred), 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')), const ListTile(title: Text('Fingerprints')),
...fingerprints.map((fp) => ListTile( ...fingerprints.map((fp) => ListTile(
leading: const CircleAvatar( leading: CircleAvatar(
child: Icon(Icons.fingerprint)), foregroundColor:
title: Text(fp.label), 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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -74,7 +98,7 @@ class FidoUnlockedPage extends ConsumerWidget {
node.path, fp), node.path, fp),
); );
}, },
icon: const Icon(Icons.edit)), icon: const Icon(Icons.edit_outlined)),
IconButton( IconButton(
onPressed: () { onPressed: () {
showDialog( showDialog(
@ -84,7 +108,7 @@ class FidoUnlockedPage extends ConsumerWidget {
node.path, fp), 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) { if (children.isNotEmpty) {
return AppPage( return AppPage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
floatingActionButton: _buildFab(context), actions: _buildActions(context),
child: Column( child: Column(
children: children, children: children,
), ),
); );
} }
if (state.bioEnroll == false) { if (state.bioEnroll != null) {
return MessagePage( return MessagePage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
graphic: noFingerprints,
header: 'No fingerprints', header: 'No fingerprints',
message: 'Add one or more (up to five) fingerprints', message: 'Add one or more (up to five) fingerprints',
floatingActionButton: _buildFab(context), actions: _buildActions(context, fingerprintPrimary: true),
); );
} }
return MessagePage( return MessagePage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
graphic: noDiscoverable,
header: 'No discoverable accounts', header: 'No discoverable accounts',
message: 'Register as a Security Key on websites', message: 'Register as a Security Key on websites',
floatingActionButton: _buildFab(context), actions: _buildActions(context),
); );
} }
FloatingActionButton _buildFab(BuildContext context) { List<Widget> _buildActions(BuildContext context,
return FloatingActionButton.extended( {bool fingerprintPrimary = false}) =>
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin), [
label: const Text('Setup'), if (state.bioEnroll != null)
onPressed: () { OutlinedButton.icon(
showBottomMenu(context, [ style: fingerprintPrimary
if (state.bioEnroll != null) ? AppTheme.primaryOutlinedButtonStyle(context)
MenuAction( : null,
text: 'Add fingerprint', label: const Text('Add fingerprint'),
icon: const Icon(Icons.fingerprint), icon: const Icon(Icons.fingerprint),
action: (context) { onPressed: () {
showDialog(
context: context,
builder: (context) => AddFingerprintDialog(node.path),
);
},
),
MenuAction(
text: 'Change PIN',
icon: const Icon(Icons.pin_outlined),
action: (context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => FidoPinDialog(node.path, state), builder: (context) => AddFingerprintDialog(node.path),
); );
}, },
), ),
MenuAction( OutlinedButton.icon(
text: 'Delete all data', label: const Text('Options'),
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.tune),
action: (context) { onPressed: () {
showDialog( showBottomMenu(context, [
context: context, MenuAction(
builder: (context) => ResetDialog(node), 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),
);
},
),
]);
},
),
];
} }

View File

@ -5,7 +5,6 @@ import 'package:collection/collection.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_loading_screen.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
@ -27,8 +26,8 @@ class _CapabilityForm extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return Wrap(
spacing: 4.0, spacing: 8,
runSpacing: 8.0, runSpacing: 16,
children: Capability.values children: Capability.values
.where((c) => capabilities & c.value != 0) .where((c) => capabilities & c.value != 0)
.map((c) => FilterChip( .map((c) => FilterChip(
@ -85,30 +84,41 @@ class _CapabilitiesForm extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (usbCapabilities != 0) if (usbCapabilities != 0) ...[
const ListTile( const ListTile(
leading: Icon(Icons.usb), leading: Icon(Icons.usb),
title: Text('USB applications'), title: Text('USB'),
contentPadding: EdgeInsets.only(bottom: 8),
horizontalTitleGap: 0,
), ),
_CapabilityForm( _CapabilityForm(
capabilities: usbCapabilities, capabilities: usbCapabilities,
enabled: enabled[Transport.usb] ?? 0, enabled: enabled[Transport.usb] ?? 0,
onChanged: (value) { onChanged: (value) {
onChanged({...enabled, Transport.usb: value}); onChanged({...enabled, Transport.usb: value});
}, },
), ),
if (nfcCapabilities != 0) ],
if (nfcCapabilities != 0) ...[
if (usbCapabilities != 0)
const Padding(
padding: EdgeInsets.only(top: 24, bottom: 12),
child: Divider(),
),
const ListTile( const ListTile(
leading: Icon(Icons.wifi), leading: Icon(Icons.wifi),
title: Text('NFC applications'), title: Text('NFC'),
contentPadding: EdgeInsets.only(bottom: 8),
horizontalTitleGap: 0,
), ),
_CapabilityForm( _CapabilityForm(
capabilities: nfcCapabilities, capabilities: nfcCapabilities,
enabled: enabled[Transport.nfc] ?? 0, enabled: enabled[Transport.nfc] ?? 0,
onChanged: (value) { onChanged: (value) {
onChanged({...enabled, Transport.nfc: value}); onChanged({...enabled, Transport.nfc: value});
}, },
), ),
]
], ],
); );
} }
@ -230,7 +240,18 @@ class _ManagementScreenState extends ConsumerState<ManagementScreen> {
final child = final child =
ref.watch(managementStateProvider(widget.deviceData.node.path)).when( ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
loading: () => const AppLoadingScreen(), 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) { data: (info) {
bool hasConfig = info.version.major > 4; bool hasConfig = info.version.major > 4;
if (hasConfig) { if (hasConfig) {

View File

@ -3,7 +3,9 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../widgets/dialog_frame.dart'; import '../../widgets/dialog_frame.dart';
import '../models.dart'; import '../models.dart';
@ -44,17 +46,38 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
return deleted; 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) { List<Widget> _buildActions(BuildContext context, WidgetRef ref) {
return buildActions(context, ref).map((e) { return buildActions(context, ref).map((e) {
final action = e.action; final action = e.action;
return IconButton( final colors = _getColors(context, e);
icon: e.icon, return Padding(
tooltip: e.text, padding: const EdgeInsets.symmetric(horizontal: 4.0),
onPressed: action != null child: CircleAvatar(
? () { // TODO: Hardcoded color
action(context); backgroundColor: action != null ? colors.first : Colors.grey.shade900,
} foregroundColor: colors.second,
: null, child: IconButton(
icon: e.icon,
iconSize: 22,
tooltip: e.text,
disabledColor: Colors.white70,
onPressed: action != null
? () {
action(context);
}
: null,
),
),
); );
}).toList(); }).toList();
} }
@ -69,20 +92,62 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
} }
return DialogFrame( return DialogFrame(
child: AlertDialog( child: AlertDialog(
title: Text(title), title: Center(
contentPadding: const EdgeInsets.symmetric(horizontal: 24.0), child: Text(
actionsAlignment: MainAxisAlignment.center, title,
actionsPadding: EdgeInsets.zero, overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
softWrap: false,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(subtitle ?? ''), if (subtitle != null)
const SizedBox(height: 8.0), Text(
Center(child: FittedBox(child: buildCodeView(ref, big: true))), 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)),
),
)
],
), ),
); );
} }

View File

@ -94,9 +94,11 @@ class _AccountListState extends ConsumerState<AccountList> {
return Column( return Column(
children: [ children: [
if (pinnedCreds.isNotEmpty) if (pinnedCreds.isNotEmpty)
const ListTile( ListTile(
minVerticalPadding: 16,
title: Text( title: Text(
'Pinned', 'PINNED',
style: Theme.of(context).textTheme.labelSmall,
), ),
), ),
...pinnedCreds.map( ...pinnedCreds.map(
@ -106,9 +108,11 @@ class _AccountListState extends ConsumerState<AccountList> {
), ),
), ),
if (creds.isNotEmpty) if (creds.isNotEmpty)
const ListTile( ListTile(
minVerticalPadding: 16,
title: Text( title: Text(
'Accounts', 'ACCOUNTS',
style: Theme.of(context).textTheme.labelSmall,
), ),
), ),
...creds.map( ...creds.map(

View File

@ -147,6 +147,16 @@ mixin AccountMixin {
final pinned = isPinned(ref); final pinned = isPinned(ref);
return [ 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) if (manual)
MenuAction( MenuAction(
text: 'Calculate', text: 'Calculate',
@ -157,27 +167,19 @@ mixin AccountMixin {
} }
: null, : 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( MenuAction(
text: pinned ? 'Unpin account' : 'Pin account', text: pinned ? 'Unpin account' : 'Pin account',
//TODO: Replace this with a custom icon. //TODO: Replace this with a custom icon.
icon: pinned icon: pinned
? CustomPaint( ? Builder(builder: (context) {
painter: _StrikethroughPainter( return CustomPaint(
Theme.of(context).iconTheme.color ?? Colors.black), painter: _StrikethroughPainter(
child: ClipPath( IconTheme.of(context).color ?? Colors.black),
clipper: _StrikethroughClipper(), child: ClipPath(
child: const Icon(Icons.push_pin_outlined)), clipper: _StrikethroughClipper(),
) child: const Icon(Icons.push_pin)),
);
})
: const Icon(Icons.push_pin_outlined), : const Icon(Icons.push_pin_outlined),
action: (context) { action: (context) {
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
@ -194,7 +196,7 @@ mixin AccountMixin {
), ),
MenuAction( MenuAction(
text: 'Delete account', text: 'Delete account',
icon: const Icon(Icons.delete_outlined), icon: const Icon(Icons.delete_outline),
action: (context) async { action: (context) async {
await deleteCredential(context, ref); await deleteCredential(context, ref);
}, },
@ -203,69 +205,59 @@ mixin AccountMixin {
} }
@protected @protected
Widget buildCodeView(WidgetRef ref, {bool big = false}) { Widget buildCodeView(WidgetRef ref) {
final code = getCode(ref); final code = getCode(ref);
final expired = isExpired(code, ref); final expired = isExpired(code, ref);
return DecoratedBox( return AnimatedSize(
decoration: BoxDecoration( alignment: Alignment.centerRight,
shape: BoxShape.rectangle, duration: const Duration(milliseconds: 100),
borderRadius: const BorderRadius.all(Radius.circular(30.0)), child: Builder(builder: (context) {
border: Border.all(width: 1.0, color: Colors.grey.shade500), return Row(
), mainAxisSize: MainAxisSize.min,
child: AnimatedSize( crossAxisAlignment: CrossAxisAlignment.center,
alignment: Alignment.centerRight, children: code == null
duration: const Duration(milliseconds: 100), ? [
child: Padding( Icon(
padding: big credential.oathType == OathType.hotp
? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0) ? Icons.refresh
: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), : Icons.touch_app,
child: Row( ),
mainAxisSize: MainAxisSize.min, const Text(''),
crossAxisAlignment: CrossAxisAlignment.center, ]
children: code == null : [
? [ if (credential.oathType == OathType.totp) ...[
Icon( ...expired
credential.oathType == OathType.hotp ? [
? Icons.refresh if (credential.touchRequired) ...[
: Icons.touch_app, const Icon(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,
),
),
const SizedBox(width: 8.0), const SizedBox(width: 8.0),
], ]
], ]
Opacity( : [
opacity: expired ? 0.4 : 1.0, SizedBox.square(
child: Text( dimension:
formatCode(code), (IconTheme.of(context).size ?? 18) * 0.8,
style: TextStyle( child: CircleTimer(
fontSize: big ? 32.0 : 22.0, code.validFrom * 1000,
fontFeatures: const [FontFeature.tabularFigures()], 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,
), ),
), ),
], ),
), ],
), );
), }),
); );
} }
} }

View File

@ -88,56 +88,80 @@ class AccountView extends ConsumerWidget with AccountMixin {
items: _buildPopupMenu(context, ref), items: _buildPopupMenu(context, ref),
); );
}, },
child: ListTile( child: LayoutBuilder(builder: (context, constraints) {
focusNode: focusNode, final showAvatar = constraints.maxWidth >= 315;
onTap: () { return ListTile(
showDialog( shape:
context: context, RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
builder: (context) { focusNode: focusNode,
return AccountDialog(credential); onTap: () {
}, showDialog(
); context: context,
}, builder: (context) {
onLongPress: () async { return AccountDialog(credential);
if (calculateReady) { },
await calculateCode(
context,
ref,
); );
} },
await ref.read(withContextProvider)( onLongPress: () async {
(context) async { if (calculateReady) {
copyToClipboard(context, ref); await calculateCode(
}, context,
); ref,
}, );
leading: CircleAvatar( }
foregroundColor: darkMode ? Colors.black : Colors.white, await ref.read(withContextProvider)(
backgroundColor: _iconColor(darkMode ? 300 : 400), (context) async {
child: Text( copyToClipboard(context, ref);
(credential.issuer ?? credential.name) },
.characters );
.first },
.toUpperCase(), leading: showAvatar
style: const TextStyle(fontSize: 18), ? 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,
), ),
), subtitle: subtitle != null
title: Text( ? Text(
title, subtitle!,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
maxLines: 1, style: Theme.of(context).textTheme.bodySmall,
softWrap: false, maxLines: 1,
), softWrap: false,
subtitle: subtitle != null )
? Text( : null,
subtitle!, trailing: DecoratedBox(
overflow: TextOverflow.fade, decoration: BoxDecoration(
maxLines: 1, shape: BoxShape.rectangle,
softWrap: false, color: CardTheme.of(context).color,
) borderRadius: const BorderRadius.all(Radius.circular(30.0)),
: null, ),
trailing: buildCodeView(ref), child: Padding(
), padding:
const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
child: DefaultTextStyle.merge(
style: Theme.of(context).textTheme.bodyLarge,
child: buildCodeView(ref),
),
),
),
);
}),
); );
} }
} }

View File

@ -196,7 +196,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
children: [ children: [
Text( Text(
'Account details', 'Account details',
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.titleLarge,
), ),
TextField( TextField(
key: const Key('issuer'), key: const Key('issuer'),
@ -280,7 +280,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
const Divider(), const Divider(),
Text( Text(
'Options', 'Options',
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.titleLarge,
), ),
Wrap( Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
@ -297,6 +297,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}, },
), ),
Chip( Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline( label: DropdownButtonHideUnderline(
child: DropdownButton<OathType>( child: DropdownButton<OathType>(
value: _oathType, value: _oathType,
@ -319,6 +320,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
), ),
), ),
Chip( Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline( label: DropdownButtonHideUnderline(
child: DropdownButton<HashAlgorithm>( child: DropdownButton<HashAlgorithm>(
value: _hashAlgorithm, value: _hashAlgorithm,
@ -342,6 +344,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
), ),
if (_oathType == OathType.totp) if (_oathType == OathType.totp)
Chip( Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline( label: DropdownButtonHideUnderline(
child: DropdownButton<int>( child: DropdownButton<int>(
value: int.tryParse(_periodController.text) ?? value: int.tryParse(_periodController.text) ??
@ -366,6 +369,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
), ),
), ),
Chip( Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline( label: DropdownButtonHideUnderline(
child: DropdownButton<int>( child: DropdownButton<int>(
value: _digits, value: _digits,

View File

@ -64,7 +64,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
if (widget.state.hasKey) ...[ if (widget.state.hasKey) ...[
Text( Text(
'Current password', 'Current password',
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.titleLarge,
), ),
TextField( TextField(
autofocus: true, autofocus: true,
@ -120,7 +120,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
], ],
Text( Text(
'New password', 'New password',
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.titleLarge,
), ),
TextField( TextField(
autofocus: !widget.state.hasKey, autofocus: !widget.state.hasKey,

View File

@ -6,10 +6,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.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_loading_screen.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'account_list.dart'; import 'account_list.dart';
@ -29,10 +31,9 @@ class OathScreen extends ConsumerWidget {
centered: true, centered: true,
child: const AppLoadingScreen(), child: const AppLoadingScreen(),
), ),
error: (error, _) => AppPage( error: (error, _) => AppFailurePage(
title: const Text('Authenticator'), title: const Text('Authenticator'),
centered: true, cause: error,
child: AppFailureScreen('$error'),
), ),
data: (oathState) => oathState.locked data: (oathState) => oathState.locked
? _LockedView(devicePath, oathState) ? _LockedView(devicePath, oathState)
@ -50,12 +51,42 @@ class _LockedView extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => AppPage( Widget build(BuildContext context, WidgetRef ref) => AppPage(
title: const Text('Authenticator'), 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( child: Column(
children: [ children: [
const ListTile(title: Text('Unlock')), const ListTile(title: Text('Unlock')),
_UnlockForm( _UnlockForm(
devicePath, devicePath,
oathState,
keystore: oathState.keystore, keystore: oathState.keystore,
), ),
], ],
@ -76,9 +107,9 @@ class _UnlockedView extends ConsumerWidget {
if (isEmpty) { if (isEmpty) {
return MessagePage( return MessagePage(
title: const Text('Authenticator'), title: const Text('Authenticator'),
graphic: noAccounts,
header: 'No accounts', header: 'No accounts',
message: 'Follow the instructions on a website to add an account', actions: _buildActions(context, true),
floatingActionButton: _buildFab(context),
); );
} }
@ -96,6 +127,7 @@ class _UnlockedView extends ConsumerWidget {
return TextFormField( return TextFormField(
key: const Key('search_accounts'), key: const Key('search_accounts'),
initialValue: ref.read(searchProvider), initialValue: ref.read(searchProvider),
style: Theme.of(context).textTheme.titleSmall,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Search accounts', hintText: 'Search accounts',
border: InputBorder.none, border: InputBorder.none,
@ -110,64 +142,64 @@ class _UnlockedView extends ConsumerWidget {
); );
}), }),
), ),
floatingActionButton: _buildFab(context), actions: _buildActions(context, false),
child: AccountList(devicePath, oathState), child: AccountList(devicePath, oathState),
); );
} }
FloatingActionButton _buildFab(BuildContext context) { List<Widget> _buildActions(BuildContext context, bool isEmpty) {
final fab = FloatingActionButton.extended( return [
icon: const Icon(Icons.person_add_alt_1), OutlinedButton.icon(
label: const Text('Setup'), style: isEmpty ? AppTheme.primaryOutlinedButtonStyle(context) : null,
onPressed: () { label: const Text('Add account'),
showBottomMenu(context, [ icon: const Icon(Icons.person_add_alt_1),
MenuAction( onPressed: () {
text: 'Add account', showDialog(
icon: const Icon(Icons.person_add_alt), context: context,
action: (context) { builder: (context) => OathAddAccountPage(
showDialog( devicePath,
context: context, openQrScanner: Platform.isAndroid,
builder: (context) => OathAddAccountPage( ),
devicePath, );
openQrScanner: Platform.isAndroid, },
), ),
); OutlinedButton.icon(
}, label: const Text('Options'),
), icon: const Icon(Icons.tune),
MenuAction( onPressed: () {
text: oathState.hasKey ? 'Manage password' : 'Set password', showBottomMenu(context, [
icon: const Icon(Icons.password), MenuAction(
action: (context) { text: oathState.hasKey ? 'Manage password' : 'Set password',
showDialog( icon: const Icon(Icons.password),
context: context, action: (context) {
builder: (context) => showDialog(
ManagePasswordDialog(devicePath, oathState), context: context,
); builder: (context) =>
}, ManagePasswordDialog(devicePath, oathState),
), );
MenuAction( },
text: 'Reset OATH', ),
icon: const Icon(Icons.delete_outline), MenuAction(
action: (context) { text: 'Reset OATH',
showDialog( icon: const Icon(Icons.delete),
context: context, action: (context) {
builder: (context) => ResetDialog(devicePath), showDialog(
); context: context,
}, builder: (context) => ResetDialog(devicePath),
), );
]); },
}, ),
); ]);
return fab; },
),
];
} }
} }
class _UnlockForm extends ConsumerStatefulWidget { class _UnlockForm extends ConsumerStatefulWidget {
final DevicePath _devicePath; final DevicePath _devicePath;
final OathState _oathState;
final KeystoreState keystore; final KeystoreState keystore;
const _UnlockForm(this._devicePath, this._oathState, const _UnlockForm(this._devicePath, {required this.keystore});
{required this.keystore});
@override @override
ConsumerState<_UnlockForm> createState() => _UnlockFormState(); ConsumerState<_UnlockForm> createState() => _UnlockFormState();
@ -224,33 +256,6 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> {
onChanged: (_) => setState(() {}), // Update state on change onChanged: (_) => setState(() {}), // Update state on change
onSubmitted: (_) => _submit(), 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),
);
},
),
],
),
], ],
), ),
), ),

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const primaryGreen = Color(0xffa8c86c); const primaryGreen = Color(0xffaed581);
const accentGreen = Color(0xff9aca3c); const accentGreen = Color(0xff9aca3c);
const primaryBlue = Color(0xff325f74); const primaryBlue = Color(0xff325f74);
const primaryRed = Color(0xffea4335);
class AppTheme { class AppTheme {
static ThemeData get lightTheme => ThemeData( static ThemeData get lightTheme => ThemeData(
@ -14,26 +15,71 @@ class AppTheme {
secondary: accentGreen, secondary: accentGreen,
background: Colors.grey.shade200, background: Colors.grey.shade200,
), ),
backgroundColor: Colors.white, iconTheme: IconThemeData(
color: Colors.grey.shade400,
size: 18.0,
),
//backgroundColor: Colors.white,
toggleableActiveColor: accentGreen, toggleableActiveColor: accentGreen,
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
elevation: 0.5, elevation: 0,
backgroundColor: Colors.white, toolbarHeight: 48,
//shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
backgroundColor: Colors.transparent,
foregroundColor: Colors.grey.shade800, 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( floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: primaryBlue, backgroundColor: primaryBlue,
), ),
textTheme: TextTheme( fontFamily: 'Roboto',
bodyText1: TextStyle( textTheme: const TextTheme(
color: Colors.grey.shade600, //bodySmall: TextStyle(color: Colors.grey.shade500),
), //bodyLarge: const TextStyle(color: Colors.white70),
bodyText2: TextStyle( //bodyMedium: TextStyle(color: Colors.grey.shade200),
color: Colors.grey.shade800, //labelSmall: TextStyle(color: Colors.grey.shade500),
), //labelMedium: TextStyle(color: Colors.cyan.shade200),
headline2: TextStyle( //labelLarge: TextStyle(color: Colors.cyan.shade500),
color: Colors.grey.shade800, //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:
ColorScheme.fromSwatch(brightness: Brightness.dark).copyWith( ColorScheme.fromSwatch(brightness: Brightness.dark).copyWith(
primary: primaryGreen, primary: primaryGreen,
secondary: primaryGreen, onPrimary: Colors.black,
secondary: const Color(0xff5d7d90),
),
iconTheme: const IconThemeData(
color: Colors.white70,
size: 18.0,
), ),
toggleableActiveColor: primaryGreen, 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( floatingActionButtonTheme: FloatingActionButtonThemeData(
foregroundColor: Colors.grey.shade900, foregroundColor: Colors.grey.shade900,
backgroundColor: primaryGreen, backgroundColor: primaryGreen,
), ),
fontFamily: 'Roboto',
textTheme: TextTheme( textTheme: TextTheme(
bodyText1: TextStyle( bodySmall: TextStyle(color: Colors.grey.shade500),
color: Colors.grey.shade400, bodyLarge: const TextStyle(color: Colors.white70),
), bodyMedium: TextStyle(color: Colors.grey.shade200),
bodyText2: TextStyle( labelSmall: TextStyle(color: Colors.grey.shade500),
color: Colors.grey.shade500, labelMedium: TextStyle(color: Colors.cyan.shade200),
), labelLarge: TextStyle(color: Colors.cyan.shade500),
headline2: TextStyle( titleSmall: TextStyle(color: Colors.grey.shade600),
color: Colors.grey.shade100, 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),
);
} }

View File

@ -59,6 +59,8 @@ class _CircleTimerState extends State<CircleTimer>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProgressCircle(Colors.grey, _progress.value); return ProgressCircle(
Theme.of(context).iconTheme.color ?? Colors.grey.shade600,
_progress.value);
} }
} }

View File

@ -51,7 +51,6 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
: 'Cancel'; : 'Cancel';
return DialogFrame( return DialogFrame(
child: AlertDialog( child: AlertDialog(
insetPadding: EdgeInsets.zero,
title: widget.title, title: widget.title,
scrollable: true, scrollable: true,
content: SizedBox( content: SizedBox(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -88,7 +88,7 @@ flutter:
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg
assets: assets:
- assets/product-images/ - assets/product-images/
- assets/graphics/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware. # https://flutter.dev/assets-and-images/#resolution-aware.
@ -115,3 +115,13 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages # 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