Merge branch 'main' into feature/android-native

This commit is contained in:
Adam Velebil 2022-03-16 11:46:18 +01:00
commit a9f7a2eef6
No known key found for this signature in database
GPG Key ID: AC6D6B9D715FC084
13 changed files with 701 additions and 703 deletions

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'app/views/responsive_dialog.dart';
import 'core/state.dart'; import 'core/state.dart';
import 'desktop/state.dart'; import 'desktop/state.dart';
@ -14,44 +15,36 @@ class AboutPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return ResponsiveDialog(
appBar: AppBar( title: const Text('About Yubico Authenticator'),
title: const Text('About Yubico Authenticator'), child: Column(
), mainAxisSize: MainAxisSize.min,
body: Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.all(8.0), // TODO: Store the version number elsewhere
child: Column( const Text('Yubico Authenticator version: 6.0.0-alpha.1'),
mainAxisSize: MainAxisSize.min, if (isDesktop)
crossAxisAlignment: CrossAxisAlignment.start, Text('ykman version: ${ref.watch(rpcStateProvider).version}'),
children: [ Text('Dart version: ${Platform.version}'),
// TODO: Store the version number elsewhere const SizedBox(height: 8.0),
const Text('Yubico Authenticator version: 6.0.0-alpha.1'), const Divider(),
if (isDesktop) if (isDesktop)
Text('ykman version: ${ref.watch(rpcStateProvider).version}'), TextButton(
Text('Dart version: ${Platform.version}'), onPressed: () async {
const SizedBox(height: 8.0), _log.info('Running diagnostics...');
const Divider(), final response =
if (isDesktop) await ref.read(rpcProvider).command('diagnose', []);
TextButton( _log.info('Response', response['diagnostics']);
onPressed: () async { ScaffoldMessenger.of(context).showSnackBar(
_log.info('Running diagnostics...'); const SnackBar(
final response = content: Text('Diagnostics done. See log for results...'),
await ref.read(rpcProvider).command('diagnose', []); duration: Duration(seconds: 2),
_log.info('Response', response['diagnostics']); ),
ScaffoldMessenger.of(context).showSnackBar( );
const SnackBar( },
content: child: const Text('Run diagnostics...'),
Text('Diagnostics done. See log for results...'), ),
duration: Duration(seconds: 2), ],
),
);
},
child: const Text('Run diagnostics...'),
),
],
),
),
), ),
); );
} }

View File

@ -1,6 +1,7 @@
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 '../../management/views/management_screen.dart';
import '../../about_page.dart'; import '../../about_page.dart';
import '../../settings_page.dart'; import '../../settings_page.dart';
import '../models.dart'; import '../models.dart';
@ -78,12 +79,12 @@ class MainPageDrawer extends ConsumerWidget {
DrawerItem( DrawerItem(
titleText: 'Toggle applications', titleText: 'Toggle applications',
icon: Icon(Application.management._icon), icon: Icon(Application.management._icon),
selected: Application.management == currentApp,
onTap: () { onTap: () {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(Application.management);
if (shouldPop) Navigator.of(context).pop(); if (shouldPop) Navigator.of(context).pop();
showDialog(
context: context,
builder: (context) => ManagementScreen(data),
);
}, },
), ),
const Divider(), const Divider(),
@ -103,9 +104,8 @@ class MainPageDrawer extends ConsumerWidget {
onTap: () { onTap: () {
final nav = Navigator.of(context); final nav = Navigator.of(context);
if (shouldPop) nav.pop(); if (shouldPop) nav.pop();
nav.push( showDialog(
MaterialPageRoute(builder: (context) => const SettingsPage()), context: context, builder: (context) => const SettingsPage());
);
}, },
), ),
DrawerItem( DrawerItem(
@ -114,9 +114,8 @@ class MainPageDrawer extends ConsumerWidget {
onTap: () { onTap: () {
final nav = Navigator.of(context); final nav = Navigator.of(context);
if (shouldPop) nav.pop(); if (shouldPop) nav.pop();
nav.push( showDialog(
MaterialPageRoute(builder: (context) => const AboutPage()), context: context, builder: (context) => const AboutPage());
);
}, },
), ),
], ],

View File

@ -36,26 +36,27 @@ class MainPage extends ConsumerWidget {
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
final query = MediaQuery.of(context); builder: (context, constraints) {
if (query.size.width < 540) { if (constraints.maxWidth < 540) {
// Single column layout // Single column layout
return _buildScaffold(context, ref, true); return _buildScaffold(context, ref, true);
} else { } else {
// Two-column layout // Two-column layout
return Row( return Row(
children: [ children: [
const SizedBox( const SizedBox(
width: 240, width: 240,
child: MainPageDrawer(shouldPop: false), child: MainPageDrawer(shouldPop: false),
), ),
Expanded( Expanded(
child: _buildScaffold(context, ref, false), child: _buildScaffold(context, ref, false),
), ),
], ],
);
}
},
); );
}
}
Scaffold _buildScaffold(BuildContext context, WidgetRef ref, bool hasDrawer) { Scaffold _buildScaffold(BuildContext context, WidgetRef ref, bool hasDrawer) {
final deviceNode = ref.watch(currentDeviceProvider); final deviceNode = ref.watch(currentDeviceProvider);

View File

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class ResponsiveDialog extends StatelessWidget {
final Widget? title;
final Widget child;
final List<Widget> actions;
const ResponsiveDialog(
{Key? key, required this.child, this.title, this.actions = const []})
: super(key: key);
@override
Widget build(BuildContext context) =>
LayoutBuilder(builder: ((context, constraints) {
if (constraints.maxWidth < 540) {
// Fullscreen
return Dialog(
insetPadding: const EdgeInsets.all(0),
child: Scaffold(
appBar: AppBar(
title: title,
actions: actions,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(18.0),
child: child,
),
));
} else {
// Dialog
return AlertDialog(
insetPadding: EdgeInsets.zero,
title: title,
scrollable: true,
content: SizedBox(
width: 380,
child: child,
),
actions: [
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
...actions
],
);
}
}));
}

View File

@ -1,10 +1,12 @@
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 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:yubico_authenticator/app/views/app_loading_screen.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart';
import '../../app/views/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
@ -92,37 +94,22 @@ class _ModeFormState extends State<_ModeForm> {
} }
} }
class _CapabilitiesForm extends StatefulWidget { class _CapabilitiesForm extends StatelessWidget {
final DeviceInfo info; final Map<Transport, int> supported;
final Function(Map<Transport, int>) onSubmit; final Map<Transport, int> enabled;
final Function(Map<Transport, int> enabled) onChanged;
const _CapabilitiesForm(this.info, {required this.onSubmit, Key? key}) const _CapabilitiesForm({
: super(key: key); required this.onChanged,
required this.supported,
@override required this.enabled,
State<StatefulWidget> createState() => _CapabilitiesFormState(); Key? key,
} }) : super(key: key);
class _CapabilitiesFormState extends State<_CapabilitiesForm> {
late Map<Transport, int> _enabled;
@override
void initState() {
super.initState();
// Make sure to copy enabledCapabilites, not mutate the original.
_enabled = {...widget.info.config.enabledCapabilities};
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final usbCapabilities = final usbCapabilities = supported[Transport.usb] ?? 0;
widget.info.supportedCapabilities[Transport.usb] ?? 0; final nfcCapabilities = supported[Transport.nfc] ?? 0;
final nfcCapabilities =
widget.info.supportedCapabilities[Transport.nfc] ?? 0;
final changed =
!_mapEquals(widget.info.config.enabledCapabilities, _enabled);
final valid = changed && (_enabled[Transport.usb] ?? 0) > 0;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -134,11 +121,9 @@ class _CapabilitiesFormState extends State<_CapabilitiesForm> {
), ),
_CapabilityForm( _CapabilityForm(
capabilities: usbCapabilities, capabilities: usbCapabilities,
enabled: _enabled[Transport.usb] ?? 0, enabled: enabled[Transport.usb] ?? 0,
onChanged: (enabled) { onChanged: (value) {
setState(() { onChanged({...enabled, Transport.usb: value});
_enabled[Transport.usb] = enabled;
});
}, },
), ),
if (nfcCapabilities != 0) if (nfcCapabilities != 0)
@ -148,74 +133,87 @@ class _CapabilitiesFormState extends State<_CapabilitiesForm> {
), ),
_CapabilityForm( _CapabilityForm(
capabilities: nfcCapabilities, capabilities: nfcCapabilities,
enabled: _enabled[Transport.nfc] ?? 0, enabled: enabled[Transport.nfc] ?? 0,
onChanged: (enabled) { onChanged: (value) {
setState(() { onChanged({...enabled, Transport.nfc: value});
_enabled[Transport.nfc] = enabled;
});
}, },
), ),
Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.centerRight,
child: ElevatedButton(
onPressed: valid
? () {
widget.onSubmit(_enabled);
}
: null,
child: const Text('Apply changes'),
),
)
], ],
); );
} }
} }
class ManagementScreen extends ConsumerWidget { class ManagementScreen extends ConsumerStatefulWidget {
final YubiKeyData deviceData; final YubiKeyData deviceData;
const ManagementScreen(this.deviceData, {Key? key}) : super(key: key); const ManagementScreen(this.deviceData, {Key? key}) : super(key: key);
Widget _buildCapabilitiesForm( @override
BuildContext context, WidgetRef ref, DeviceInfo info) => ConsumerState<ConsumerStatefulWidget> createState() =>
_CapabilitiesForm(info, onSubmit: (enabled) async { _ManagementScreenState();
final bool reboot; }
if (deviceData.node is UsbYubiKeyNode) {
// Reboot if USB device descriptor is changed.
final oldInterfaces = UsbInterfaces.forCapabilites(
info.config.enabledCapabilities[Transport.usb] ?? 0);
final newInterfaces =
UsbInterfaces.forCapabilites(enabled[Transport.usb] ?? 0);
reboot = oldInterfaces != newInterfaces;
} else {
reboot = false;
}
Function()? close; class _ManagementScreenState extends ConsumerState<ManagementScreen> {
try { late Map<Transport, int> _enabled;
if (reboot) {
// This will take longer, show a message @override
close = ScaffoldMessenger.of(context) void initState() {
.showSnackBar(const SnackBar( super.initState();
content: Text('Reconfiguring YubiKey...'), _enabled = widget.deviceData.info.config.enabledCapabilities;
duration: Duration(seconds: 8), }
))
.close; Widget _buildCapabilitiesForm(
} BuildContext context, WidgetRef ref, DeviceInfo info) {
await ref return _CapabilitiesForm(
.read(managementStateProvider(deviceData.node.path).notifier) supported: widget.deviceData.info.supportedCapabilities,
.writeConfig( enabled: _enabled,
info.config.copyWith(enabledCapabilities: enabled), onChanged: (enabled) {
reboot: reboot, setState(() {
); _enabled = enabled;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( });
content: Text('Configuration updated'), },
duration: Duration(seconds: 2), );
)); }
} finally {
close?.call(); void _submitCapabilitiesForm() async {
} final bool reboot;
}); if (widget.deviceData.node is UsbYubiKeyNode) {
// Reboot if USB device descriptor is changed.
final oldInterfaces = UsbInterfaces.forCapabilites(
widget.deviceData.info.config.enabledCapabilities[Transport.usb] ??
0);
final newInterfaces =
UsbInterfaces.forCapabilites(_enabled[Transport.usb] ?? 0);
reboot = oldInterfaces != newInterfaces;
} else {
reboot = false;
}
Function()? close;
try {
if (reboot) {
// This will take longer, show a message
close = ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text('Reconfiguring YubiKey...'),
duration: Duration(seconds: 8),
))
.close;
}
await ref
.read(managementStateProvider(widget.deviceData.node.path).notifier)
.writeConfig(
widget.deviceData.info.config
.copyWith(enabledCapabilities: _enabled),
reboot: reboot,
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Configuration updated'),
duration: Duration(seconds: 2),
));
} finally {
close?.call();
}
}
Widget _buildModeForm(BuildContext context, WidgetRef ref, DeviceInfo info) => Widget _buildModeForm(BuildContext context, WidgetRef ref, DeviceInfo info) =>
_ModeForm( _ModeForm(
@ -229,15 +227,41 @@ class ManagementScreen extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) => Widget build(BuildContext context) {
ref.watch(managementStateProvider(deviceData.node.path)).when( ref.listen<DeviceNode?>(currentDeviceProvider, (_, __) {
none: () => const AppLoadingScreen(), //TODO: This can probably be checked better to make sure it's the main page.
failure: (reason) => AppFailureScreen(reason), Navigator.of(context).popUntil((route) => route.isFirst);
success: (info) => ListView( });
children: [
info.version.major > 4 bool changed = false;
? _buildCapabilitiesForm(context, ref, info)
: _buildModeForm(context, ref, info), return ResponsiveDialog(
], title: const Text('Toggle applications'),
)); child:
ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
none: () => const AppLoadingScreen(),
failure: (reason) => AppFailureScreen(reason),
success: (info) {
// TODO: Check mode for < YK5 intead
changed = !_mapEquals(
_enabled,
info.config.enabledCapabilities,
);
return Column(
children: [
info.version.major > 4
? _buildCapabilitiesForm(context, ref, info)
: _buildModeForm(context, ref, info),
],
);
},
),
actions: [
TextButton(
onPressed: changed ? _submitCapabilitiesForm : null,
child: const Text('Save'),
),
],
);
}
} }

View File

@ -19,11 +19,10 @@ List<MenuAction> buildOathMenuActions(AutoDisposeProviderRef ref) {
text: 'Add credential', text: 'Add credential',
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
action: (context) { action: (context) {
Navigator.of(context).push( showDialog(
MaterialPageRoute( context: context,
builder: (context) => builder: (context) =>
OathAddAccountPage(device: device), OathAddAccountPage(device: device),
),
); );
}, },
), ),

View File

@ -152,9 +152,10 @@ mixin AccountMixin {
Future<bool> deleteCredential(BuildContext context, WidgetRef ref) async { Future<bool> deleteCredential(BuildContext context, WidgetRef ref) async {
final node = ref.read(currentDeviceProvider)!; final node = ref.read(currentDeviceProvider)!;
return await showDialog( return await showDialog(
context: context, context: context,
builder: (context) => DeleteAccountDialog(node, credential), builder: (context) => DeleteAccountDialog(node, credential),
); ) ??
false;
} }
@protected @protected

View File

@ -3,10 +3,11 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/oath/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/responsive_dialog.dart';
import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'utils.dart'; import 'utils.dart';
@ -15,21 +16,21 @@ final _secretFormatterPattern =
enum _QrScanState { none, scanning, success, failed } enum _QrScanState { none, scanning, success, failed }
class AddAccountForm extends ConsumerStatefulWidget { class OathAddAccountPage extends ConsumerStatefulWidget {
final Function(CredentialData, bool) onSubmit; const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
const AddAccountForm({Key? key, required this.onSubmit}) : super(key: key); final DeviceNode device;
@override @override
ConsumerState<ConsumerStatefulWidget> createState() => _AddAccountFormState(); ConsumerState<ConsumerStatefulWidget> createState() =>
_OathAddAccountPageState();
} }
class _AddAccountFormState extends ConsumerState<AddAccountForm> { class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final _issuerController = TextEditingController(); final _issuerController = TextEditingController();
final _accountController = TextEditingController(); final _accountController = TextEditingController();
final _secretController = TextEditingController(); final _secretController = TextEditingController();
final _periodController = TextEditingController(text: '$defaultPeriod'); final _periodController = TextEditingController(text: '$defaultPeriod');
bool _touch = false; bool _touch = false;
bool _advanced = false;
OathType _oathType = defaultOathType; OathType _oathType = defaultOathType;
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm; HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
int _digits = defaultDigits; int _digits = defaultDigits;
@ -83,6 +84,12 @@ class _AddAccountFormState extends ConsumerState<AddAccountForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
//TODO: This can probably be checked better to make sure it's the main page.
Navigator.of(context).popUntil((route) => route.isFirst);
});
final period = int.tryParse(_periodController.text) ?? -1; final period = int.tryParse(_periodController.text) ?? -1;
final remaining = getRemainingKeySpace( final remaining = getRemainingKeySpace(
oathType: _oathType, oathType: _oathType,
@ -100,277 +107,237 @@ class _AddAccountFormState extends ConsumerState<AddAccountForm> {
final qrScanner = ref.watch(qrScannerProvider); final qrScanner = ref.watch(qrScannerProvider);
return Column( return ResponsiveDialog(
children: [ title: const Text('Add account'),
Padding( child: Column(
padding: const EdgeInsets.all(16.0), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ 'Account details',
TextField( style: Theme.of(context).textTheme.headline6,
controller: _issuerController, ),
autofocus: true, TextField(
enabled: issuerRemaining > 0, controller: _issuerController,
maxLength: max(issuerRemaining, 1), autofocus: true,
decoration: const InputDecoration( enabled: issuerRemaining > 0,
labelText: 'Issuer (optional)', maxLength: max(issuerRemaining, 1),
helperText: decoration: const InputDecoration(
'', // Prevents dialog resizing when enabled = false border: OutlineInputBorder(),
), labelText: 'Issuer (optional)',
onChanged: (value) { helperText: '', // Prevents dialog resizing when enabled = false
setState(() { ),
// Update maxlengths onChanged: (value) {
}); setState(() {
}, // Update maxlengths
), });
TextField( },
controller: _accountController, ),
maxLength: nameRemaining, TextField(
decoration: const InputDecoration( controller: _accountController,
labelText: 'Account name', maxLength: nameRemaining,
helperText: decoration: const InputDecoration(
'', // Prevents dialog resizing when enabled = false border: OutlineInputBorder(),
), labelText: 'Account name',
onChanged: (value) { helperText: '', // Prevents dialog resizing when enabled = false
setState(() { ),
// Update maxlengths onChanged: (value) {
}); setState(() {
}, // Update maxlengths
), });
TextField( },
controller: _secretController, ),
inputFormatters: <TextInputFormatter>[ TextField(
FilteringTextInputFormatter.allow(_secretFormatterPattern) controller: _secretController,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(_secretFormatterPattern)
],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Secret key',
errorText: _validateSecretLength && !secretLengthValid
? 'Invalid length'
: null),
enabled: _qrState != _QrScanState.success,
onChanged: (value) {
setState(() {
_validateSecretLength = false;
});
},
),
if (qrScanner != null)
Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Row(
children: [
OutlinedButton.icon(
onPressed: () {
_scanQrCode(qrScanner);
},
icon: const Icon(Icons.qr_code),
label: const Text('Scan QR code'),
),
const SizedBox(width: 8.0),
..._buildQrStatus(),
], ],
decoration: InputDecoration( ),
labelText: 'Secret key', ),
errorText: _validateSecretLength && !secretLengthValid const Divider(),
? 'Invalid length' Text(
: null), 'Options',
enabled: _qrState != _QrScanState.success, style: Theme.of(context).textTheme.headline6,
onChanged: (value) { ),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
FilterChip(
label: const Text('Require touch'),
selected: _touch,
onSelected: (value) {
setState(() { setState(() {
_validateSecretLength = false; _touch = value;
}); });
}, },
), ),
if (qrScanner != null) Chip(
Padding( label: DropdownButtonHideUnderline(
padding: const EdgeInsets.only(top: 24.0), child: DropdownButton<OathType>(
child: Row( value: _oathType,
children: [ isDense: true,
OutlinedButton.icon( underline: null,
onPressed: () { items: OathType.values
_scanQrCode(qrScanner); .map((e) => DropdownMenuItem(
}, value: e,
icon: const Icon(Icons.qr_code), child: Text(e.name.toUpperCase()),
label: const Text('Scan QR code'), ))
), .toList(),
const SizedBox(width: 8.0), onChanged: _qrState != _QrScanState.success
..._buildQrStatus(), ? (type) {
], setState(() {
_oathType = type ?? OathType.totp;
});
}
: null,
), ),
), ),
),
Chip(
label: DropdownButtonHideUnderline(
child: DropdownButton<HashAlgorithm>(
value: _hashAlgorithm,
isDense: true,
underline: null,
items: HashAlgorithm.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: _qrState != _QrScanState.success
? (type) {
setState(() {
_hashAlgorithm = type ?? HashAlgorithm.sha1;
});
}
: null,
),
),
),
if (_oathType == OathType.totp)
Chip(
label: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value:
int.tryParse(_periodController.text) ?? defaultPeriod,
isDense: true,
underline: null,
items: [20, 30, 45, 60]
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e sec'),
))
.toList(),
onChanged: _qrState != _QrScanState.success
? (period) {
setState(() {
_periodController.text =
'${period ?? defaultPeriod}';
});
}
: null,
),
),
),
Chip(
label: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _digits,
isDense: true,
underline: null,
items: [6, 7, 8]
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e digits'),
))
.toList(),
onChanged: _qrState != _QrScanState.success
? (digits) {
setState(() {
_digits = digits ?? defaultDigits;
});
}
: null,
),
),
),
], ],
), ),
), ]
CheckboxListTile( .map((e) => Padding(
title: const Text('Require touch'), padding: const EdgeInsets.symmetric(vertical: 8.0),
controlAffinity: ListTileControlAffinity.leading, child: e,
value: _touch, ))
onChanged: (value) { .toList(),
setState(() { ),
_touch = value ?? false; actions: [
}); TextButton(
}, onPressed: isValid
), ? () {
CheckboxListTile( if (secretLengthValid) {
title: const Text('Show advanced settings'), final issuer = _issuerController.text;
controlAffinity: ListTileControlAffinity.leading,
value: _advanced, final cred = CredentialData(
onChanged: (value) { issuer: issuer.isEmpty ? null : issuer,
setState(() { name: _accountController.text,
_advanced = value ?? false; secret: secret,
}); oathType: _oathType,
}, hashAlgorithm: _hashAlgorithm,
), digits: _digits,
if (_advanced) period: period,
Padding( );
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( ref
children: [ .read(
Row( credentialListProvider(widget.device.path).notifier)
mainAxisSize: MainAxisSize.max, .addAccount(cred.toUri(), requireTouch: _touch);
crossAxisAlignment: CrossAxisAlignment.start, Navigator.of(context).pop();
children: [ ScaffoldMessenger.of(context).showSnackBar(
Expanded( const SnackBar(
child: DropdownButtonFormField<OathType>( content: Text('Account added'),
decoration: const InputDecoration(labelText: 'Type'), duration: Duration(seconds: 2),
value: _oathType,
items: OathType.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: _qrState != _QrScanState.success
? (type) {
setState(() {
_oathType = type ?? OathType.totp;
});
}
: null,
), ),
), );
const SizedBox( } else {
width: 8.0, setState(() {
), _validateSecretLength = true;
Expanded( });
child: DropdownButtonFormField<HashAlgorithm>(
decoration:
const InputDecoration(labelText: 'Algorithm'),
value: _hashAlgorithm,
items: HashAlgorithm.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: _qrState != _QrScanState.success
? (type) {
setState(() {
_hashAlgorithm = type ?? HashAlgorithm.sha1;
});
}
: null,
),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_oathType == OathType.totp)
Expanded(
child: TextFormField(
controller: _periodController,
enabled: _qrState != _QrScanState.success,
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
contentPadding:
// Manual alignment to match digits-dropdown.
const EdgeInsets.fromLTRB(0, 12, 0, 15),
labelText: 'Period',
errorText:
period > 0 ? null : 'Must be a positive number',
),
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
),
),
if (_oathType == OathType.totp)
const SizedBox(
width: 8.0,
),
Expanded(
child: DropdownButtonFormField<int>(
decoration: const InputDecoration(labelText: 'Digits'),
value: _digits,
items: [6, 7, 8]
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.toString()),
))
.toList(),
onChanged: _qrState != _QrScanState.success
? (value) {
setState(() {
_digits = value ?? defaultDigits;
});
}
: null,
),
),
],
),
],
),
),
Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.centerRight,
child: ElevatedButton(
onPressed: isValid
? () {
if (secretLengthValid) {
final issuer = _issuerController.text;
widget.onSubmit(
CredentialData(
issuer: issuer.isEmpty ? null : issuer,
name: _accountController.text,
secret: secret,
oathType: _oathType,
hashAlgorithm: _hashAlgorithm,
digits: _digits,
period: period,
),
_touch,
);
} else {
setState(() {
_validateSecretLength = true;
});
}
} }
: null, }
child: const Text('Add account'), : null,
), child: const Text('Save'),
), ),
], ],
); );
} }
} }
class OathAddAccountPage extends ConsumerWidget {
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
final DeviceNode device;
@override
Widget build(BuildContext context, WidgetRef ref) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
//TODO: This can probably be checked better to make sure it's the main page.
Navigator.of(context).popUntil((route) => route.isFirst);
});
return Scaffold(
appBar: AppBar(
title: const Text('Add account'),
),
body: ListView(
children: [
AddAccountForm(
onSubmit: (cred, requireTouch) {
ref
.read(credentialListProvider(device.path).notifier)
.addAccount(cred.toUri(), requireTouch: requireTouch);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account added'),
duration: Duration(seconds: 2),
),
);
},
)
],
));
}
}

View File

@ -1,6 +1,7 @@
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/views/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
@ -23,28 +24,27 @@ class DeleteAccountDialog extends ConsumerWidget {
? '${credential.issuer} (${credential.name})' ? '${credential.issuer} (${credential.name})'
: credential.name; : credential.name;
return AlertDialog( return ResponsiveDialog(
title: Text('Delete $label?'), title: const Text('Delete account'),
content: Column( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'Warning! This action will delete the account from your YubiKey.'), 'Warning! This action will delete the account from your YubiKey.'),
const Text(''),
Text( Text(
'You will no longer be able to generate OTPs for this account. Make sure to first disable this credential from the website to avoid being locked out of your account.', 'You will no longer be able to generate OTPs for this account. Make sure to first disable this credential from the website to avoid being locked out of your account.',
style: Theme.of(context).textTheme.bodyText1, style: Theme.of(context).textTheme.bodyText1,
), ),
], Text('Account: $label'),
]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
), ),
actions: [ actions: [
OutlinedButton( TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async { onPressed: () async {
await ref await ref
.read(credentialListProvider(device.path).notifier) .read(credentialListProvider(device.path).notifier)
@ -57,7 +57,7 @@ class DeleteAccountDialog extends ConsumerWidget {
), ),
); );
}, },
child: const Text('Delete account'), child: const Text('Delete'),
), ),
], ],
); );

View File

@ -1,20 +1,24 @@
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/views/responsive_dialog.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../models.dart';
import '../state.dart'; import '../state.dart';
class ManagePasswordDialog extends ConsumerStatefulWidget { class _ManagePasswordForm extends ConsumerStatefulWidget {
final DeviceNode device; final DevicePath path;
const ManagePasswordDialog(this.device, {Key? key}) : super(key: key); final OathState state;
const _ManagePasswordForm(this.path, this.state, {Key? key})
: super(key: key);
@override @override
ConsumerState<ConsumerStatefulWidget> createState() => ConsumerState<ConsumerStatefulWidget> createState() =>
_ManagePasswordDialogState(); _ManagePasswordFormState();
} }
class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> { class _ManagePasswordFormState extends ConsumerState<_ManagePasswordForm> {
String _currentPassword = ''; String _currentPassword = '';
String _newPassword = ''; String _newPassword = '';
String _confirmPassword = ''; String _confirmPassword = '';
@ -22,185 +26,42 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// If current device changes, we need to pop back to the main Page. return Column(
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) { crossAxisAlignment: CrossAxisAlignment.start,
Navigator.of(context).pop(); children: [
}); if (widget.state.hasKey) ...[
Text(
return ref.watch(oathStateProvider(widget.device.path)).maybeWhen( 'Current password',
success: (state) => AlertDialog( style: Theme.of(context).textTheme.headline6,
title: const Text('Manage password'), ),
content: Column( TextField(
mainAxisSize: MainAxisSize.min, autofocus: true,
children: [ obscureText: true,
if (state.hasKey) decoration: InputDecoration(
Column( border: const OutlineInputBorder(),
children: [ labelText: 'Current password',
if (state.remembered) errorText: _currentIsWrong ? 'Wrong password' : null),
// TODO: This is temporary, to be able to forget a password. onChanged: (value) {
Padding( setState(() {
padding: const EdgeInsets.only(bottom: 16.0), _currentPassword = value;
child: Column( });
children: [ },
Column( ),
crossAxisAlignment: CrossAxisAlignment.end, Wrap(
children: const [ spacing: 8.0,
Text( children: [
'Your password is remembered by the app.',
style:
TextStyle(fontWeight: FontWeight.bold),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () async {
await ref
.read(oathStateProvider(
widget.device.path)
.notifier)
.forgetPassword();
Navigator.of(context).pop();
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text('Password forgotten'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Forget'),
),
],
),
],
),
),
const Text(
'Enter your current password to change it. If you don\'t know your password, you\'ll need to reset the YubiKey, then create a new password.'),
Row(
children: [
Expanded(
child: TextField(
autofocus: true,
obscureText: true,
decoration: InputDecoration(
labelText: 'Current',
errorText: _currentIsWrong
? 'Wrong password'
: null),
onChanged: (value) {
setState(() {
_currentPassword = value;
});
},
),
),
const SizedBox(
width: 8.0,
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
child: const Text('Remove'),
onPressed: _currentPassword.isNotEmpty
? () async {
final result = await ref
.read(oathStateProvider(
widget.device.path)
.notifier)
.unsetPassword(_currentPassword);
if (result) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content:
Text('Password removed'),
duration: Duration(seconds: 2),
),
);
} else {
setState(() {
_currentIsWrong = true;
});
}
}
: null,
),
],
),
),
],
),
const SizedBox(
height: 16.0,
),
],
),
const Text(
'Enter your new password. A password may contain letters, numbers and other characters.'),
Row(
children: [
Expanded(
child: TextField(
autofocus: !state.hasKey,
obscureText: true,
decoration:
const InputDecoration(labelText: 'Password'),
onChanged: (value) {
setState(() {
_newPassword = value;
});
},
),
),
const SizedBox(
width: 8,
),
Expanded(
child: TextField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Confirm',
),
onChanged: (value) {
setState(() {
_confirmPassword = value;
});
},
),
),
],
),
],
),
actions: [
OutlinedButton( OutlinedButton(
onPressed: () { child: const Text('Remove password'),
Navigator.of(context).pop(); onPressed: _currentPassword.isNotEmpty
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _newPassword.isNotEmpty &&
_newPassword == _confirmPassword &&
(!state.hasKey || _currentPassword.isNotEmpty)
? () async { ? () async {
final result = await ref final result = await ref
.read( .read(oathStateProvider(widget.path).notifier)
oathStateProvider(widget.device.path).notifier) .unsetPassword(_currentPassword);
.setPassword(_currentPassword, _newPassword);
if (result) { if (result) {
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Password set'), content: Text('Password removed'),
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
@ -211,10 +72,117 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
} }
} }
: null, : null,
child: const Text('Save'), ),
) if (widget.state.remembered)
OutlinedButton(
child: const Text('Clear saved password'),
onPressed: () async {
await ref
.read(oathStateProvider(widget.path).notifier)
.forgetPassword();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password forgotten'),
duration: Duration(seconds: 2),
),
);
},
),
], ],
), ),
const Divider(),
],
Text(
'New password',
style: Theme.of(context).textTheme.headline6,
),
TextField(
autofocus: !widget.state.hasKey,
obscureText: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'New password',
enabled: !widget.state.hasKey || _currentPassword.isNotEmpty,
),
onChanged: (value) {
setState(() {
_newPassword = value;
});
},
),
TextField(
obscureText: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Confirm password',
enabled: _newPassword.isNotEmpty,
),
onChanged: (value) {
setState(() {
_confirmPassword = value;
});
},
),
Align(
alignment: Alignment.bottomRight,
child: ElevatedButton(
onPressed: _newPassword.isNotEmpty &&
_newPassword == _confirmPassword &&
(!widget.state.hasKey || _currentPassword.isNotEmpty)
? () async {
final result = await ref
.read(oathStateProvider(widget.path).notifier)
.setPassword(_currentPassword, _newPassword);
if (result) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password set'),
duration: Duration(seconds: 2),
),
);
} else {
setState(() {
_currentIsWrong = true;
});
}
}
: null,
child: const Text('Save'),
),
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
);
}
}
class ManagePasswordDialog extends ConsumerWidget {
final DeviceNode device;
const ManagePasswordDialog(this.device, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
Navigator.of(context).pop();
});
return ref.watch(oathStateProvider(device.path)).maybeWhen(
success: (state) => ResponsiveDialog(
title: const Text('Manage password'),
child: _ManagePasswordForm(
device.path,
state,
// Prevents from losing state on responsive change.
key: GlobalKey(),
),
),
orElse: () { orElse: () {
throw Exception( throw Exception(
'Attempted to show password dialog without an OathState'); 'Attempted to show password dialog without an OathState');

View File

@ -1,6 +1,7 @@
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/views/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
@ -53,11 +54,12 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
final nameRemaining = remaining.second; final nameRemaining = remaining.second;
final isValid = _account.isNotEmpty; final isValid = _account.isNotEmpty;
return AlertDialog( return ResponsiveDialog(
title: Text('Rename $label?'), title: const Text('Rename account'),
content: Column( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Rename $label?'),
const Text( const Text(
'This will change how the account is displayed in the list.'), 'This will change how the account is displayed in the list.'),
TextFormField( TextFormField(
@ -65,6 +67,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
enabled: issuerRemaining > 0, enabled: issuerRemaining > 0,
maxLength: issuerRemaining > 0 ? issuerRemaining : null, maxLength: issuerRemaining > 0 ? issuerRemaining : null,
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Issuer (optional)', labelText: 'Issuer (optional)',
helperText: '', // Prevents dialog resizing when enabled = false helperText: '', // Prevents dialog resizing when enabled = false
), ),
@ -78,6 +81,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
initialValue: _account, initialValue: _account,
maxLength: nameRemaining, maxLength: nameRemaining,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Account name', labelText: 'Account name',
helperText: '', // Prevents dialog resizing when enabled = false helperText: '', // Prevents dialog resizing when enabled = false
errorText: isValid ? null : 'Your account must have a name', errorText: isValid ? null : 'Your account must have a name',
@ -88,16 +92,15 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
}); });
}, },
), ),
], ]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
), ),
actions: [ actions: [
OutlinedButton( TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: isValid onPressed: isValid
? () async { ? () async {
final renamed = await ref final renamed = await ref
@ -113,7 +116,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
); );
} }
: null, : null,
child: const Text('Rename account'), child: const Text('Save'),
), ),
], ],
); );

View File

@ -1,6 +1,7 @@
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/views/responsive_dialog.dart';
import '../state.dart'; import '../state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
@ -16,28 +17,25 @@ class ResetDialog extends ConsumerWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
}); });
return AlertDialog( return ResponsiveDialog(
title: const Text('Reset to defaults?'), title: const Text('Factory reset'),
content: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text( const Text(
'Warning! This will irrevocably delete all OATH TOTP/HOTP accounts from your YubiKey.'), 'Warning! This will irrevocably delete all OATH TOTP/HOTP accounts from your YubiKey.'),
const Text(''),
Text( Text(
'Your OATH credentials, as well as any password set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.', 'Your OATH credentials, as well as any password set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.',
style: Theme.of(context).textTheme.bodyText1, style: Theme.of(context).textTheme.bodyText1,
), ),
], ]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
), ),
actions: [ actions: [
OutlinedButton( TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async { onPressed: () async {
await ref.read(oathStateProvider(device.path).notifier).reset(); await ref.read(oathStateProvider(device.path).notifier).reset();
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -48,7 +46,7 @@ class ResetDialog extends ConsumerWidget {
), ),
); );
}, },
child: const Text('Reset YubiKey'), child: const Text('Reset'),
), ),
], ],
); );

View File

@ -1,8 +1,9 @@
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 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:yubico_authenticator/app/state.dart';
import 'app/state.dart';
import 'app/views/responsive_dialog.dart';
import 'core/state.dart'; import 'core/state.dart';
final _log = Logger('settings'); final _log = Logger('settings');
@ -12,48 +13,41 @@ class SettingsPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return ResponsiveDialog(
appBar: AppBar( title: const Text('Settings'),
title: const Text('Settings'), child: Column(
), mainAxisSize: MainAxisSize.min,
body: Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.all(8.0), DropdownButtonFormField<ThemeMode>(
child: Column( decoration: const InputDecoration(labelText: 'Theme'),
mainAxisSize: MainAxisSize.min, value: ref.watch(themeModeProvider),
crossAxisAlignment: CrossAxisAlignment.start, items: [ThemeMode.system, ThemeMode.dark, ThemeMode.light]
children: [ .map((e) => DropdownMenuItem(
DropdownButtonFormField<ThemeMode>( value: e,
decoration: const InputDecoration(labelText: 'Theme'), child:
value: ref.watch(themeModeProvider), Text(e.name[0].toUpperCase() + e.name.substring(1)),
items: [ThemeMode.system, ThemeMode.dark, ThemeMode.light] ))
.map((e) => DropdownMenuItem( .toList(),
value: e, onChanged: (mode) {
child: Text( ref.read(themeModeProvider.notifier).setThemeMode(mode!);
e.name[0].toUpperCase() + e.name.substring(1)), },
))
.toList(),
onChanged: (mode) {
ref.read(themeModeProvider.notifier).setThemeMode(mode!);
},
),
DropdownButtonFormField<Level>(
decoration: const InputDecoration(labelText: 'Logging'),
value: ref.watch(logLevelProvider),
items: [Level.INFO, Level.CONFIG, Level.FINE]
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: (level) {
ref.read(logLevelProvider.notifier).setLogLevel(level!);
_log.config('Log level set to $level');
},
),
],
), ),
), DropdownButtonFormField<Level>(
decoration: const InputDecoration(labelText: 'Logging'),
value: ref.watch(logLevelProvider),
items: [Level.INFO, Level.CONFIG, Level.FINE]
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name.toUpperCase()),
))
.toList(),
onChanged: (level) {
ref.read(logLevelProvider.notifier).setLogLevel(level!);
_log.config('Log level set to $level');
},
),
],
), ),
); );
} }