Introduce responsive dialogs.

This commit is contained in:
Dain Nilsson 2022-03-15 18:04:26 +01:00
parent 3ec9216023
commit 921190ba40
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
9 changed files with 644 additions and 653 deletions

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'app/views/responsive_dialog.dart';
import 'core/state.dart';
import 'desktop/state.dart';
@ -14,44 +15,36 @@ class AboutPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('About Yubico Authenticator'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TODO: Store the version number elsewhere
const Text('Yubico Authenticator version: 6.0.0-alpha.1'),
if (isDesktop)
Text('ykman version: ${ref.watch(rpcStateProvider).version}'),
Text('Dart version: ${Platform.version}'),
const SizedBox(height: 8.0),
const Divider(),
if (isDesktop)
TextButton(
onPressed: () async {
_log.info('Running diagnostics...');
final response =
await ref.read(rpcProvider).command('diagnose', []);
_log.info('Response', response['diagnostics']);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Diagnostics done. See log for results...'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Run diagnostics...'),
),
],
),
),
return ResponsiveDialog(
title: const Text('About Yubico Authenticator'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TODO: Store the version number elsewhere
const Text('Yubico Authenticator version: 6.0.0-alpha.1'),
if (isDesktop)
Text('ykman version: ${ref.watch(rpcStateProvider).version}'),
Text('Dart version: ${Platform.version}'),
const SizedBox(height: 8.0),
const Divider(),
if (isDesktop)
TextButton(
onPressed: () async {
_log.info('Running diagnostics...');
final response =
await ref.read(rpcProvider).command('diagnose', []);
_log.info('Response', response['diagnostics']);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: 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_riverpod/flutter_riverpod.dart';
import '../../management/views/management_screen.dart';
import '../../about_page.dart';
import '../../settings_page.dart';
import '../models.dart';
@ -78,12 +79,12 @@ class MainPageDrawer extends ConsumerWidget {
DrawerItem(
titleText: 'Toggle applications',
icon: Icon(Application.management._icon),
selected: Application.management == currentApp,
onTap: () {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(Application.management);
if (shouldPop) Navigator.of(context).pop();
showDialog(
context: context,
builder: (context) => ManagementScreen(data),
);
},
),
const Divider(),
@ -103,9 +104,8 @@ class MainPageDrawer extends ConsumerWidget {
onTap: () {
final nav = Navigator.of(context);
if (shouldPop) nav.pop();
nav.push(
MaterialPageRoute(builder: (context) => const SettingsPage()),
);
showDialog(
context: context, builder: (context) => const SettingsPage());
},
),
DrawerItem(
@ -114,9 +114,8 @@ class MainPageDrawer extends ConsumerWidget {
onTap: () {
final nav = Navigator.of(context);
if (shouldPop) nav.pop();
nav.push(
MaterialPageRoute(builder: (context) => const AboutPage()),
);
showDialog(
context: context, builder: (context) => const AboutPage());
},
),
],

View File

@ -0,0 +1,43 @@
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})
: super(key: key);
@override
Widget build(BuildContext context) {
final query = MediaQuery.of(context);
if (query.size.width < 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: actions,
);
}
}
}

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:collection/collection.dart';
import 'package:yubico_authenticator/app/views/app_loading_screen.dart';
import '../../app/models.dart';
import '../../app/state.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 '../state.dart';
@ -92,37 +94,22 @@ class _ModeFormState extends State<_ModeForm> {
}
}
class _CapabilitiesForm extends StatefulWidget {
final DeviceInfo info;
final Function(Map<Transport, int>) onSubmit;
class _CapabilitiesForm extends StatelessWidget {
final Map<Transport, int> supported;
final Map<Transport, int> enabled;
final Function(Map<Transport, int> enabled) onChanged;
const _CapabilitiesForm(this.info, {required this.onSubmit, Key? key})
: super(key: key);
@override
State<StatefulWidget> createState() => _CapabilitiesFormState();
}
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};
}
const _CapabilitiesForm({
required this.onChanged,
required this.supported,
required this.enabled,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final usbCapabilities =
widget.info.supportedCapabilities[Transport.usb] ?? 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;
final usbCapabilities = supported[Transport.usb] ?? 0;
final nfcCapabilities = supported[Transport.nfc] ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -134,11 +121,9 @@ class _CapabilitiesFormState extends State<_CapabilitiesForm> {
),
_CapabilityForm(
capabilities: usbCapabilities,
enabled: _enabled[Transport.usb] ?? 0,
onChanged: (enabled) {
setState(() {
_enabled[Transport.usb] = enabled;
});
enabled: enabled[Transport.usb] ?? 0,
onChanged: (value) {
onChanged({...enabled, Transport.usb: value});
},
),
if (nfcCapabilities != 0)
@ -148,74 +133,87 @@ class _CapabilitiesFormState extends State<_CapabilitiesForm> {
),
_CapabilityForm(
capabilities: nfcCapabilities,
enabled: _enabled[Transport.nfc] ?? 0,
onChanged: (enabled) {
setState(() {
_enabled[Transport.nfc] = enabled;
});
enabled: enabled[Transport.nfc] ?? 0,
onChanged: (value) {
onChanged({...enabled, Transport.nfc: value});
},
),
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;
const ManagementScreen(this.deviceData, {Key? key}) : super(key: key);
Widget _buildCapabilitiesForm(
BuildContext context, WidgetRef ref, DeviceInfo info) =>
_CapabilitiesForm(info, onSubmit: (enabled) async {
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;
}
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ManagementScreenState();
}
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(deviceData.node.path).notifier)
.writeConfig(
info.config.copyWith(enabledCapabilities: enabled),
reboot: reboot,
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Configuration updated'),
duration: Duration(seconds: 2),
));
} finally {
close?.call();
}
});
class _ManagementScreenState extends ConsumerState<ManagementScreen> {
late Map<Transport, int> _enabled;
@override
void initState() {
super.initState();
_enabled = widget.deviceData.info.config.enabledCapabilities;
}
Widget _buildCapabilitiesForm(
BuildContext context, WidgetRef ref, DeviceInfo info) {
return _CapabilitiesForm(
supported: widget.deviceData.info.supportedCapabilities,
enabled: _enabled,
onChanged: (enabled) {
setState(() {
_enabled = enabled;
});
},
);
}
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) =>
_ModeForm(
@ -229,15 +227,41 @@ class ManagementScreen extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) =>
ref.watch(managementStateProvider(deviceData.node.path)).when(
none: () => const AppLoadingScreen(),
failure: (reason) => AppFailureScreen(reason),
success: (info) => ListView(
children: [
info.version.major > 4
? _buildCapabilitiesForm(context, ref, info)
: _buildModeForm(context, ref, info),
],
));
Widget build(BuildContext context) {
ref.listen<DeviceNode?>(currentDeviceProvider, (_, __) {
//TODO: This can probably be checked better to make sure it's the main page.
Navigator.of(context).popUntil((route) => route.isFirst);
});
bool changed = false;
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',
icon: const Icon(Icons.add),
action: (context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
OathAddAccountPage(device: device),
),
showDialog(
context: context,
builder: (context) =>
OathAddAccountPage(device: device),
);
},
),

View File

@ -3,10 +3,11 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/oath/models.dart';
import '../../app/state.dart';
import '../../app/models.dart';
import '../../app/views/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import 'utils.dart';
@ -15,21 +16,21 @@ final _secretFormatterPattern =
enum _QrScanState { none, scanning, success, failed }
class AddAccountForm extends ConsumerStatefulWidget {
final Function(CredentialData, bool) onSubmit;
const AddAccountForm({Key? key, required this.onSubmit}) : super(key: key);
class OathAddAccountPage extends ConsumerStatefulWidget {
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
final DeviceNode device;
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AddAccountFormState();
ConsumerState<ConsumerStatefulWidget> createState() =>
_OathAddAccountPageState();
}
class _AddAccountFormState extends ConsumerState<AddAccountForm> {
class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final _issuerController = TextEditingController();
final _accountController = TextEditingController();
final _secretController = TextEditingController();
final _periodController = TextEditingController(text: '$defaultPeriod');
bool _touch = false;
bool _advanced = false;
OathType _oathType = defaultOathType;
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
int _digits = defaultDigits;
@ -83,6 +84,12 @@ class _AddAccountFormState extends ConsumerState<AddAccountForm> {
@override
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 remaining = getRemainingKeySpace(
oathType: _oathType,
@ -100,277 +107,237 @@ class _AddAccountFormState extends ConsumerState<AddAccountForm> {
final qrScanner = ref.watch(qrScannerProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _issuerController,
autofocus: true,
enabled: issuerRemaining > 0,
maxLength: max(issuerRemaining, 1),
decoration: const InputDecoration(
labelText: 'Issuer (optional)',
helperText:
'', // Prevents dialog resizing when enabled = false
),
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
),
TextField(
controller: _accountController,
maxLength: nameRemaining,
decoration: const InputDecoration(
labelText: 'Account name',
helperText:
'', // Prevents dialog resizing when enabled = false
),
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
),
TextField(
controller: _secretController,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(_secretFormatterPattern)
return ResponsiveDialog(
title: const Text('Add account'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Account details',
style: Theme.of(context).textTheme.headline6,
),
TextField(
controller: _issuerController,
autofocus: true,
enabled: issuerRemaining > 0,
maxLength: max(issuerRemaining, 1),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Issuer (optional)',
helperText: '', // Prevents dialog resizing when enabled = false
),
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
),
TextField(
controller: _accountController,
maxLength: nameRemaining,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Account name',
helperText: '', // Prevents dialog resizing when enabled = false
),
onChanged: (value) {
setState(() {
// Update maxlengths
});
},
),
TextField(
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
? 'Invalid length'
: null),
enabled: _qrState != _QrScanState.success,
onChanged: (value) {
),
),
const Divider(),
Text(
'Options',
style: Theme.of(context).textTheme.headline6,
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
FilterChip(
label: const Text('Require touch'),
selected: _touch,
onSelected: (value) {
setState(() {
_validateSecretLength = false;
_touch = value;
});
},
),
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(),
],
Chip(
label: DropdownButtonHideUnderline(
child: DropdownButton<OathType>(
value: _oathType,
isDense: true,
underline: null,
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,
),
),
),
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(
title: const Text('Require touch'),
controlAffinity: ListTileControlAffinity.leading,
value: _touch,
onChanged: (value) {
setState(() {
_touch = value ?? false;
});
},
),
CheckboxListTile(
title: const Text('Show advanced settings'),
controlAffinity: ListTileControlAffinity.leading,
value: _advanced,
onChanged: (value) {
setState(() {
_advanced = value ?? false;
});
},
),
if (_advanced)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<OathType>(
decoration: const InputDecoration(labelText: 'Type'),
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,
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
actions: [
TextButton(
onPressed: isValid
? () {
if (secretLengthValid) {
final issuer = _issuerController.text;
final cred = CredentialData(
issuer: issuer.isEmpty ? null : issuer,
name: _accountController.text,
secret: secret,
oathType: _oathType,
hashAlgorithm: _hashAlgorithm,
digits: _digits,
period: period,
);
ref
.read(
credentialListProvider(widget.device.path).notifier)
.addAccount(cred.toUri(), requireTouch: _touch);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account added'),
duration: Duration(seconds: 2),
),
),
const SizedBox(
width: 8.0,
),
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;
});
}
);
} 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,20 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/views/responsive_dialog.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../models.dart';
import '../state.dart';
class ManagePasswordDialog extends ConsumerStatefulWidget {
final DeviceNode device;
const ManagePasswordDialog(this.device, {Key? key}) : super(key: key);
class _ManagePasswordForm extends ConsumerStatefulWidget {
final DevicePath path;
final OathState state;
final Function(bool)? onValid;
const _ManagePasswordForm(this.path, this.state, {this.onValid, Key? key})
: super(key: key);
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ManagePasswordDialogState();
_ManagePasswordFormState();
}
class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
class _ManagePasswordFormState extends ConsumerState<_ManagePasswordForm> {
String _currentPassword = '';
String _newPassword = '';
String _confirmPassword = '';
@ -22,185 +27,42 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
@override
Widget build(BuildContext context) {
// 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(widget.device.path)).maybeWhen(
success: (state) => AlertDialog(
title: const Text('Manage password'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.hasKey)
Column(
children: [
if (state.remembered)
// TODO: This is temporary, to be able to forget a password.
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: const [
Text(
'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: [
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.state.hasKey) ...[
Text(
'Current password',
style: Theme.of(context).textTheme.headline6,
),
TextField(
autofocus: true,
obscureText: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Current password',
errorText: _currentIsWrong ? 'Wrong password' : null),
onChanged: (value) {
setState(() {
_currentPassword = value;
});
},
),
Wrap(
spacing: 8.0,
children: [
OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _newPassword.isNotEmpty &&
_newPassword == _confirmPassword &&
(!state.hasKey || _currentPassword.isNotEmpty)
child: const Text('Remove password'),
onPressed: _currentPassword.isNotEmpty
? () async {
final result = await ref
.read(
oathStateProvider(widget.device.path).notifier)
.setPassword(_currentPassword, _newPassword);
.read(oathStateProvider(widget.path).notifier)
.unsetPassword(_currentPassword);
if (result) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password set'),
content: Text('Password removed'),
duration: Duration(seconds: 2),
),
);
@ -211,10 +73,117 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
}
}
: 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: () {
throw Exception(
'Attempted to show password dialog without an OathState');

View File

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

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
final _log = Logger('settings');
@ -12,48 +13,41 @@ class SettingsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<ThemeMode>(
decoration: const InputDecoration(labelText: 'Theme'),
value: ref.watch(themeModeProvider),
items: [ThemeMode.system, ThemeMode.dark, ThemeMode.light]
.map((e) => DropdownMenuItem(
value: e,
child: Text(
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');
},
),
],
return ResponsiveDialog(
title: const Text('Settings'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<ThemeMode>(
decoration: const InputDecoration(labelText: 'Theme'),
value: ref.watch(themeModeProvider),
items: [ThemeMode.system, ThemeMode.dark, ThemeMode.light]
.map((e) => DropdownMenuItem(
value: e,
child:
Text(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');
},
),
],
),
);
}