diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 5e4a3d76a..0b7fcc37b 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -502,6 +502,9 @@ class _RestoreOptionsViewState extends ConsumerState { ), ), ), + const SizedBox( + height: 16, + ), ], ), ), diff --git a/lib/pages/pinpad_views/pinpad_dialog.dart b/lib/pages/pinpad_views/pinpad_dialog.dart new file mode 100644 index 000000000..35d60aa32 --- /dev/null +++ b/lib/pages/pinpad_views/pinpad_dialog.dart @@ -0,0 +1,292 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/prefs_provider.dart'; +import '../../providers/global/secure_store_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/biometrics.dart'; +import '../../utilities/flutter_secure_storage_interface.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/custom_pin_put/custom_pin_put.dart'; +import '../../widgets/shake/shake.dart'; +import '../../widgets/stack_dialog.dart'; + +class PinpadDialog extends ConsumerStatefulWidget { + const PinpadDialog({ + super.key, + required this.biometricsAuthenticationTitle, + required this.biometricsLocalizedReason, + required this.biometricsCancelButtonString, + this.biometrics = const Biometrics(), + this.customKeyLabel = "Button", + }); + + final String biometricsAuthenticationTitle; + final String biometricsLocalizedReason; + final String biometricsCancelButtonString; + final Biometrics biometrics; + final String customKeyLabel; + + @override + ConsumerState createState() => _PinpadDialogState(); +} + +class _PinpadDialogState extends ConsumerState { + late final ShakeController _shakeController; + + late int _attempts; + bool _attemptLock = false; + late Duration _timeout; + static const maxAttemptsBeforeThrottling = 3; + Timer? _timer; + + final FocusNode _pinFocusNode = FocusNode(); + + late SecureStorageInterface _secureStore; + late Biometrics biometrics; + int pinCount = 1; + + final _pinTextController = TextEditingController(); + + BoxDecoration get _pinPutDecoration { + return BoxDecoration( + color: Theme.of(context).extension()!.infoItemIcons, + border: Border.all( + width: 1, + color: Theme.of(context).extension()!.infoItemIcons, + ), + borderRadius: BorderRadius.circular(6), + ); + } + + Future _onPinChanged() async { + final enteredPin = _pinTextController.text; + final storedPin = await _secureStore.read(key: 'stack_pin'); + final autoPin = ref.read(prefsChangeNotifierProvider).autoPin; + + if (enteredPin.length >= 4 && autoPin && enteredPin == storedPin) { + await Future.delayed( + const Duration(milliseconds: 200), + ); + unawaited(_onUnlock()); + } + } + + Future _onUnlock() async { + final now = DateTime.now().toUtc(); + ref.read(prefsChangeNotifierProvider).lastUnlocked = + now.millisecondsSinceEpoch ~/ 1000; + + Navigator.of(context).pop("verified success"); + } + + Future _checkUseBiometrics() async { + if (!ref.read(prefsChangeNotifierProvider).isInitialized) { + await ref.read(prefsChangeNotifierProvider).init(); + } + + final bool useBiometrics = + ref.read(prefsChangeNotifierProvider).useBiometrics; + + final title = widget.biometricsAuthenticationTitle; + final localizedReason = widget.biometricsLocalizedReason; + final cancelButtonText = widget.biometricsCancelButtonString; + + if (useBiometrics) { + if (await biometrics.authenticate( + title: title, + localizedReason: localizedReason, + cancelButtonText: cancelButtonText, + )) { + unawaited(_onUnlock()); + } + // leave this commented to enable pin fall back should biometrics not work properly + // else { + // Navigator.pop(context); + // } + } + } + + Future _onSubmit(String pin) async { + _attempts++; + + if (_attempts > maxAttemptsBeforeThrottling) { + _attemptLock = true; + switch (_attempts) { + case 4: + _timeout = const Duration(seconds: 30); + break; + + case 5: + _timeout = const Duration(seconds: 60); + break; + + case 6: + _timeout = const Duration(minutes: 5); + break; + + case 7: + _timeout = const Duration(minutes: 10); + break; + + case 8: + _timeout = const Duration(minutes: 20); + break; + + case 9: + _timeout = const Duration(minutes: 30); + break; + + default: + _timeout = const Duration(minutes: 60); + } + + _timer?.cancel(); + _timer = Timer(_timeout, () { + _attemptLock = false; + _attempts = 0; + }); + } + + if (_attemptLock) { + String prettyTime = ""; + if (_timeout.inSeconds >= 60) { + prettyTime += "${_timeout.inMinutes} minutes"; + } else { + prettyTime += "${_timeout.inSeconds} seconds"; + } + + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Incorrect PIN entered too many times. Please wait $prettyTime", + context: context, + iconAsset: Assets.svg.alertCircle, + ), + ); + + await Future.delayed( + const Duration(milliseconds: 100), + ); + + _pinTextController.text = ''; + + return; + } + + final storedPin = await _secureStore.read(key: 'stack_pin'); + + if (mounted) { + if (storedPin == pin) { + await Future.delayed( + const Duration(milliseconds: 200), + ); + unawaited(_onUnlock()); + } else { + unawaited(_shakeController.shake()); + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Incorrect PIN. Please try again", + context: context, + iconAsset: Assets.svg.alertCircle, + ), + ); + + await Future.delayed( + const Duration(milliseconds: 100), + ); + + _pinTextController.text = ''; + } + } + } + + @override + void initState() { + _shakeController = ShakeController(); + + _secureStore = ref.read(secureStoreProvider); + biometrics = widget.biometrics; + _attempts = 0; + _timeout = Duration.zero; + + _checkUseBiometrics(); + _pinTextController.addListener(_onPinChanged); + super.initState(); + } + + @override + dispose() { + // _shakeController.dispose(); + _pinTextController.removeListener(_onPinChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StackDialogBase( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Shake( + animationDuration: const Duration(milliseconds: 700), + animationRange: 12, + controller: _shakeController, + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Text( + "Enter PIN", + style: STextStyles.pageTitleH1(context), + ), + ), + const SizedBox( + height: 40, + ), + CustomPinPut( + fieldsCount: pinCount, + eachFieldHeight: 12, + eachFieldWidth: 12, + textStyle: STextStyles.label(context).copyWith( + fontSize: 1, + ), + focusNode: _pinFocusNode, + controller: _pinTextController, + useNativeKeyboard: false, + obscureText: "", + inputDecoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + fillColor: + Theme.of(context).extension()!.popupBG, + counterText: "", + ), + submittedFieldDecoration: _pinPutDecoration, + isRandom: + ref.read(prefsChangeNotifierProvider).randomizePIN, + onSubmit: _onSubmit, + ), + const SizedBox( + height: 32, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/password/request_desktop_auth_dialog.dart b/lib/pages_desktop_specific/password/request_desktop_auth_dialog.dart new file mode 100644 index 000000000..c991513cb --- /dev/null +++ b/lib/pages_desktop_specific/password/request_desktop_auth_dialog.dart @@ -0,0 +1,264 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/desktop/storage_crypto_handler_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/stack_text_field.dart'; + +class RequestDesktopAuthDialog extends ConsumerStatefulWidget { + const RequestDesktopAuthDialog({ + super.key, + this.title, + }); + + final String? title; + + @override + ConsumerState createState() => + _RequestDesktopAuthDialogState(); +} + +class _RequestDesktopAuthDialogState + extends ConsumerState { + late final TextEditingController passwordController; + late final FocusNode passwordFocusNode; + + bool continueEnabled = false; + bool hidePassword = true; + + bool _lock = false; + Future _auth() async { + if (_lock) { + return; + } + _lock = true; + + try { + final verified = await showLoading( + whileFuture: ref + .read(storageCryptoHandlerProvider) + .verifyPassphrase(passwordController.text), + context: context, + message: "Checking...", + rootNavigator: true, + delay: const Duration(milliseconds: 1000), + ); + + if (verified == true) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop("verified success"); + } + } else { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + + await Future.delayed(const Duration(milliseconds: 300)); + + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase!", + context: context, + ), + ); + } + } + } + } finally { + _lock = false; + } + } + + @override + void initState() { + passwordController = TextEditingController(); + passwordFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + passwordController.dispose(); + passwordFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DesktopDialog( + maxWidth: 579, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, + ), + ], + ), + const SizedBox( + height: 12, + ), + SvgPicture.asset( + Assets.svg.keys, + width: 100, + height: 58, + ), + const SizedBox( + height: 55, + ), + if (widget.title != null) + Text( + widget.title!, + style: STextStyles.desktopH2(context), + ), + if (widget.title != null) + const SizedBox( + height: 16, + ), + Text( + "Enter your password", + style: STextStyles.desktopTextMedium(context).copyWith( + color: Theme.of(context).extension()!.textDark3, + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("enterPasswordUnlockWalletKeysDesktopFieldKey"), + focusNode: passwordFocusNode, + controller: passwordController, + style: STextStyles.desktopTextMedium(context).copyWith( + height: 2, + ), + obscureText: hidePassword, + enableSuggestions: false, + autocorrect: false, + autofocus: true, + onSubmitted: (_) { + if (continueEnabled) { + _auth(); + } + }, + decoration: standardInputDecoration( + "Enter password", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: SizedBox( + height: 70, + child: Row( + children: [ + GestureDetector( + key: const Key( + "enterUnlockWalletKeysDesktopFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(1000), + ), + height: 32, + width: 32, + child: Center( + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of(context) + .extension()! + .textDark3, + width: 24, + height: 19, + ), + ), + ), + ), + const SizedBox( + width: 10, + ), + ], + ), + ), + ), + ), + onChanged: (newValue) { + setState(() { + continueEnabled = newValue.isNotEmpty; + }); + }, + ), + ), + ), + const SizedBox( + height: 55, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of( + context, + rootNavigator: true, + ).pop, + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: PrimaryButton( + label: "Continue", + enabled: continueEnabled, + onPressed: continueEnabled ? _auth : null, + ), + ), + ], + ), + ), + const SizedBox( + height: 32, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/address_private_key.dart b/lib/widgets/address_private_key.dart index 8bcbd404d..324af585e 100644 --- a/lib/widgets/address_private_key.dart +++ b/lib/widgets/address_private_key.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/isar/models/isar_models.dart'; +import '../pages/pinpad_views/pinpad_dialog.dart'; +import '../pages_desktop_specific/password/request_desktop_auth_dialog.dart'; import '../providers/global/wallets_provider.dart'; import '../utilities/show_loading.dart'; import '../utilities/text_styles.dart'; @@ -41,21 +43,36 @@ class _AddressPrivateKeyState extends ConsumerState { _lock = true; try { - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as Bip39HDWallet; - - _private = await showLoading( - whileFuture: wallet.getPrivateKeyWIF(widget.address), + final verified = await showDialog( context: context, - message: "Loading...", - delay: const Duration(milliseconds: 800), - rootNavigator: Util.isDesktop, + builder: (context) => Util.isDesktop + ? const RequestDesktopAuthDialog(title: "Show WIF private key") + : const PinpadDialog( + biometricsAuthenticationTitle: "Show WIF private key", + biometricsLocalizedReason: + "Authenticate to show WIF private key", + biometricsCancelButtonString: "CANCEL", + ), + barrierDismissible: !Util.isDesktop, ); - if (context.mounted) { - setState(() {}); - } else { - _private == null; + if (verified == "verified success" && mounted) { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as Bip39HDWallet; + + _private = await showLoading( + whileFuture: wallet.getPrivateKeyWIF(widget.address), + context: context, + message: "Loading...", + delay: const Duration(milliseconds: 800), + rootNavigator: Util.isDesktop, + ); + + if (context.mounted) { + setState(() {}); + } else { + _private == null; + } } } finally { _lock = false; @@ -71,7 +88,7 @@ class _AddressPrivateKeyState extends ConsumerState { enabled: _private == null, ), title: Text( - "Private key", + "Private key (WIF)", style: STextStyles.itemSubtitle(context), ), detail: SelectableText(