mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 02:01:36 +03:00
Introduce responsive dialogs.
This commit is contained in:
parent
3ec9216023
commit
921190ba40
@ -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...'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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());
|
||||
},
|
||||
),
|
||||
],
|
||||
|
43
lib/app/views/responsive_dialog.dart
Executable file
43
lib/app/views/responsive_dialog.dart
Executable 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user