From 5ef9cffec3e968a20853a3f3e6b9b5ebb8ef4842 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 17 May 2024 10:01:55 +0200 Subject: [PATCH] Remove intermediate OATH dialog and add QR animation --- lib/oath/views/add_account_dialog.dart | 134 ----------- lib/oath/views/add_account_page.dart | 300 +++++++++++++++++-------- lib/oath/views/utils.dart | 6 +- 3 files changed, 215 insertions(+), 225 deletions(-) delete mode 100644 lib/oath/views/add_account_dialog.dart diff --git a/lib/oath/views/add_account_dialog.dart b/lib/oath/views/add_account_dialog.dart deleted file mode 100644 index d376491bc..000000000 --- a/lib/oath/views/add_account_dialog.dart +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (C) 2023 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:material_symbols_icons/symbols.dart'; - -import '../../app/message.dart'; -import '../../app/models.dart'; -import '../../app/state.dart'; -import '../../widgets/file_drop_overlay.dart'; -import '../../widgets/file_drop_target.dart'; -import '../../widgets/responsive_dialog.dart'; -import '../keys.dart'; -import '../models.dart'; -import '../state.dart'; -import 'add_account_page.dart'; -import 'utils.dart'; - -class AddAccountDialog extends ConsumerStatefulWidget { - final DevicePath? devicePath; - final OathState? state; - - const AddAccountDialog(this.devicePath, this.state, {super.key}); - - @override - ConsumerState createState() => - _AddAccountDialogState(); -} - -class _AddAccountDialogState extends ConsumerState { - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final credentials = ref.read(credentialsProvider); - final withContext = ref.read(withContextProvider); - - final qrScanner = ref.watch(qrScannerProvider); - return FileDropTarget( - onFileDropped: (file) async { - Navigator.of(context).pop(); - if (qrScanner != null) { - final qrData = - await handleQrFile(file, context, withContext, qrScanner); - if (qrData != null) { - await withContext((context) async { - await handleUri(context, credentials, qrData, widget.devicePath, - widget.state, l10n); - }); - } - } - }, - overlay: FileDropOverlay( - title: l10n.s_add_account, - subtitle: l10n.l_drop_qr_description, - ), - child: ResponsiveDialog( - title: Text(l10n.s_add_account), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.p_add_description), - const SizedBox(height: 4), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - runSpacing: 8.0, - children: [ - ActionChip( - avatar: const Icon(Symbols.qr_code_scanner), - label: Text(l10n.s_qr_scan), - onPressed: () async { - if (qrScanner != null) { - final qrData = await qrScanner.scanQr(); - await withContext( - (context) async { - if (qrData != null) { - Navigator.of(context).pop(); - await handleUri(context, credentials, qrData, - widget.devicePath, widget.state, l10n); - } else { - showMessage(context, l10n.l_qr_not_found); - } - }, - ); - } - }, - ), - ActionChip( - key: addAccountManuallyButton, - avatar: const Icon(Symbols.edit), - label: Text(l10n.s_add_manually), - onPressed: () async { - Navigator.of(context).pop(); - await withContext((context) async { - await showBlurDialog( - context: context, - builder: (context) => OathAddAccountPage( - widget.devicePath, - widget.state, - credentials: credentials, - ), - ); - }); - }), - ]) - ] - .map((e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: e, - )) - .toList(), - ), - ), - ), - ); - } -} diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 6504d000d..84bef8b7f 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -69,7 +69,8 @@ class OathAddAccountPage extends ConsumerStatefulWidget { _OathAddAccountPageState(); } -class _OathAddAccountPageState extends ConsumerState { +class _OathAddAccountPageState extends ConsumerState + with TickerProviderStateMixin { final _issuerController = TextEditingController(); final _accountController = TextEditingController(); final _secretController = TextEditingController(); @@ -77,6 +78,7 @@ class _OathAddAccountPageState extends ConsumerState { final _accountFocus = FocusNode(); final _secretFocus = FocusNode(); final _periodController = TextEditingController(text: '$defaultPeriod'); + late AnimationController _animationController; UserInteractionController? _promptController; Uri? _otpauthUri; bool _touch = false; @@ -91,6 +93,7 @@ class _OathAddAccountPageState extends ConsumerState { List _digitsValues = [6, 8]; List? _credentials; bool _submitting = false; + bool _qrScanError = false; @override void dispose() { @@ -101,12 +104,15 @@ class _OathAddAccountPageState extends ConsumerState { _issuerFocus.dispose(); _accountFocus.dispose(); _secretFocus.dispose(); + _animationController.dispose(); super.dispose(); } @override void initState() { super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 900), vsync: this); final cred = widget.credentialData; if (cred != null) { _loadCredentialData(cred); @@ -326,10 +332,15 @@ class _OathAddAccountPageState extends ConsumerState { } } + final qrScanner = ref.read(qrScannerProvider); + final withContext = ref.read(withContextProvider); + + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + return FileDropTarget( onFileDropped: (file) async { - final qrScanner = ref.read(qrScannerProvider); - final withContext = ref.read(withContextProvider); if (qrScanner != null) { final qrData = await handleQrFile(file, context, withContext, qrScanner); @@ -374,8 +385,100 @@ class _OathAddAccountPageState extends ConsumerState { : Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + if (widget.credentialData == null) + Column( + children: [ + _animationController.isAnimating + ? ScaleTransition( + scale: Tween(begin: 1.2, end: 0.8).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.elasticOut, + ), + ), + child: SizedBox( + width: 64, + height: 64, + child: CircleAvatar( + backgroundColor: _qrScanError + ? colorScheme.error + : colorScheme.primary, + child: Icon( + _qrScanError + ? Symbols.close + : Symbols.check, + fill: 1, + size: 48, + color: _qrScanError + ? colorScheme.onError + : colorScheme.onPrimary, + ), + ), + ), + ) + : Icon( + Symbols.qr_code_scanner, + size: 64, + color: colorScheme.onSurface, + ), + const SizedBox(height: 4.0), + TextButton( + style: TextButton.styleFrom( + textStyle: textTheme.bodySmall), + onPressed: () async { + if (qrScanner != null) { + final qrData = await qrScanner.scanQr(); + await withContext( + (context) async { + if (qrData != null) { + try { + final creds = CredentialData.fromUri( + Uri.parse(qrData)); + if (creds.isEmpty) { + setState(() { + _qrScanError = true; + }); + } + if (creds.length == 1) { + _loadCredentialData(creds[0]); + } else { + Navigator.of(context).pop(); + await handleUri( + context, + widget.credentials, + qrData, + widget.devicePath, + widget.state, + l10n, + ); + return; + } + } catch (_) { + setState(() { + _qrScanError = true; + }); + } + } else { + setState(() { + _qrScanError = true; + }); + } + await _animationController.forward(); + _animationController.reset(); + setState(() { + _qrScanError = false; + }); + }, + ); + } + }, + child: const Text('Scan QR code on screen'), + ), + const SizedBox(height: 8.0) + ], + ), AppTextField( key: keys.issuerField, controller: _issuerController, @@ -395,7 +498,7 @@ class _OathAddAccountPageState extends ConsumerState { : issuerNoColon ? null : l10n.l_invalid_character_issuer, - prefixIcon: const Icon(Symbols.business), + icon: const Icon(Symbols.business), ), textInputAction: TextInputAction.next, focusNode: _issuerFocus, @@ -426,7 +529,7 @@ class _OathAddAccountPageState extends ConsumerState { : isUnique ? null : l10n.l_name_already_exists, - prefixIcon: const Icon(Symbols.person), + icon: const Icon(Symbols.person), ), textInputAction: TextInputAction.next, focusNode: _accountFocus, @@ -456,7 +559,7 @@ class _OathAddAccountPageState extends ConsumerState { ? l10n.l_invalid_format_allowed_chars( Format.base32.allowedCharacters) : null, - prefixIcon: const Icon(Symbols.key), + icon: const Icon(Symbols.key), suffixIcon: IconButton( icon: Icon(_isObscure ? Symbols.visibility @@ -483,94 +586,113 @@ class _OathAddAccountPageState extends ConsumerState { }, ).init(), const SizedBox(height: 8), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - runSpacing: 8.0, + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (oathState?.version.isAtLeast(4, 2) ?? true) - FilterChip( - key: keys.requireTouchFilterChip, - label: Text(l10n.s_require_touch), - selected: _touch, - onSelected: (value) { - setState(() { - _touch = value; - }); - }, + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Icon( + Symbols.tune, + color: colorScheme.onSurfaceVariant, ), - ChoiceFilterChip( - key: keys.oathTypeFilterChip, - items: OathType.values, - value: _oathType, - selected: _oathType != defaultOathType, - itemBuilder: (value) => Text( - value.getDisplayName(l10n), - key: value == OathType.totp - ? keys.oathTypeTotpFilterValue - : keys.oathTypeHotpFilterValue), - onChanged: !_dataLoaded - ? (value) { - setState(() { - _oathType = value; - }); - } - : null, - ), - ChoiceFilterChip( - key: keys.hashAlgorithmFilterChip, - items: hashAlgorithms, - value: _hashAlgorithm, - selected: _hashAlgorithm != defaultHashAlgorithm, - itemBuilder: (value) => Text(value.displayName, - key: value == HashAlgorithm.sha1 - ? keys.hashAlgorithmSha1FilterValue - : value == HashAlgorithm.sha256 - ? keys.hashAlgorithmSha256FilterValue - : keys.hashAlgorithmSha512FilterValue), - onChanged: !_dataLoaded - ? (value) { - setState(() { - _hashAlgorithm = value; - }); - } - : null, ), - if (_oathType == OathType.totp) - ChoiceFilterChip( - key: keys.periodFilterChip, - items: _periodValues, - value: int.tryParse(_periodController.text) ?? - defaultPeriod, - selected: int.tryParse(_periodController.text) != - defaultPeriod, - itemBuilder: ((value) => - Text(l10n.s_num_sec(value))), - onChanged: !_dataLoaded - ? (period) { + const SizedBox(width: 16.0), + Flexible( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 4.0, + runSpacing: 8.0, + children: [ + if (oathState?.version.isAtLeast(4, 2) ?? true) + FilterChip( + key: keys.requireTouchFilterChip, + label: Text(l10n.s_require_touch), + selected: _touch, + onSelected: (value) { setState(() { - _periodController.text = '$period'; + _touch = value; }); - } - : null, + }, + ), + ChoiceFilterChip( + key: keys.oathTypeFilterChip, + items: OathType.values, + value: _oathType, + selected: _oathType != defaultOathType, + itemBuilder: (value) => Text( + value.getDisplayName(l10n), + key: value == OathType.totp + ? keys.oathTypeTotpFilterValue + : keys.oathTypeHotpFilterValue), + onChanged: !_dataLoaded + ? (value) { + setState(() { + _oathType = value; + }); + } + : null, + ), + ChoiceFilterChip( + key: keys.hashAlgorithmFilterChip, + items: hashAlgorithms, + value: _hashAlgorithm, + selected: + _hashAlgorithm != defaultHashAlgorithm, + itemBuilder: (value) => Text(value.displayName, + key: value == HashAlgorithm.sha1 + ? keys.hashAlgorithmSha1FilterValue + : value == HashAlgorithm.sha256 + ? keys + .hashAlgorithmSha256FilterValue + : keys + .hashAlgorithmSha512FilterValue), + onChanged: !_dataLoaded + ? (value) { + setState(() { + _hashAlgorithm = value; + }); + } + : null, + ), + if (_oathType == OathType.totp) + ChoiceFilterChip( + key: keys.periodFilterChip, + items: _periodValues, + value: int.tryParse(_periodController.text) ?? + defaultPeriod, + selected: + int.tryParse(_periodController.text) != + defaultPeriod, + itemBuilder: ((value) => + Text(l10n.s_num_sec(value))), + onChanged: !_dataLoaded + ? (period) { + setState(() { + _periodController.text = '$period'; + }); + } + : null, + ), + ChoiceFilterChip( + key: keys.digitsFilterChip, + items: _digitsValues, + value: _digits, + selected: _digits != defaultDigits, + itemBuilder: (value) => + Text(l10n.s_num_digits(value)), + // TODO: need to figure out how to add values for + // digits6FilterValue + // digits8FilterValue + onChanged: !_dataLoaded + ? (digits) { + setState(() { + _digits = digits; + }); + } + : null, + ), + ], ), - ChoiceFilterChip( - key: keys.digitsFilterChip, - items: _digitsValues, - value: _digits, - selected: _digits != defaultDigits, - itemBuilder: (value) => - Text(l10n.s_num_digits(value)), - // TODO: need to figure out how to add values for - // digits6FilterValue - // digits8FilterValue - onChanged: !_dataLoaded - ? (digits) { - setState(() { - _digits = digits; - }); - } - : null, ), ], ), diff --git a/lib/oath/views/utils.dart b/lib/oath/views/utils.dart index 83072e08e..f43a35d03 100755 --- a/lib/oath/views/utils.dart +++ b/lib/oath/views/utils.dart @@ -32,7 +32,7 @@ import '../../exception/cancellation_exception.dart'; import '../../widgets/utf8_utils.dart'; import '../keys.dart'; import '../models.dart'; -import 'add_account_dialog.dart'; +import '../state.dart'; import 'add_account_page.dart'; import 'add_multi_account_page.dart'; import 'manage_password_dialog.dart'; @@ -173,9 +173,11 @@ Future addOathAccount(BuildContext context, WidgetRef ref, await AndroidQrScanner.showAccountManualEntryDialog(withContext, l10n); } } else { + final credentials = ref.read(credentialsProvider); await showBlurDialog( context: context, - builder: (context) => AddAccountDialog(devicePath, oathState), + builder: (context) => + OathAddAccountPage(devicePath, oathState, credentials: credentials), ); } }