CW 818: Improve Passphrase Discoverability When Restoring (#2224)

* feat(passphrase for restore): improve passphrase discoverability for restore flow.

Makes the option for adding a passphrase more visible for the user to see while restoring a wallet with seeds.

This change:
- Adds a checkbox on the restore from seeds page that asks if the wallet has a passphrase
- Switches the main CTA for the page to "Add Passphrase" when the checkbox is checked
- Pops up a bottomsheet with input fields for the passphrase when the "Add Passphrase" button is pressed
- Completes the flow and restores the wallet with the passphrase when the "Restore" button on the bottomsheet is pressed
- Hides the passphrase input fields in AdvancedSettings page when its a restore flow.

* feat(passphrase for restore): improve passphrase discoverability for restore flow. (Cont.)

This change:
- Obscures the passpgrase input fields by default
- Changes the color for the main CTA button

* feat(passphrase for restore): improve passphrase discoverability for restore flow. (Cont.)

This change:
- Adjusts the colors for the buttons on both the seed page and the bottomsheet

* feat(passphrase for restore): improve passphrase discoverability for restore flow. (Cont.)

This change:
- Combines the control for the visibility icons of the passphrase input fields - one tap to rule them all :)
This commit is contained in:
David Adegoke
2025-05-02 02:56:06 +03:00
committed by GitHub
parent cb257e1391
commit dd8413bae2
34 changed files with 491 additions and 57 deletions
@@ -204,7 +204,7 @@ class _AdvancedPrivacySettingsBodyState extends State<_AdvancedPrivacySettingsBo
);
return Container();
}),
if (widget.privacySettingsViewModel.hasPassphraseOption)
if (widget.privacySettingsViewModel.hasPassphraseOption && !widget.isFromRestore)
Padding(
padding: EdgeInsets.all(24),
child: Form(
@@ -5,10 +5,14 @@ import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/restore/wallet_restore_from_keys_form.dart';
import 'package:cake_wallet/src/screens/restore/wallet_restore_from_seed_form.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/add_passphrase_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/keyboard_done_button.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/src/widgets/standard_checkbox.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart';
import 'package:cake_wallet/themes/extensions/keyboard_theme.dart';
import 'package:cake_wallet/themes/extensions/wallet_list_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/restore/restore_mode.dart';
@@ -52,7 +56,6 @@ class WalletRestorePage extends BasePage {
// String? derivationPath = null;
DerivationInfo? derivationInfo;
@override
Function(BuildContext)? get popWidget => (context) => seedSettingsViewModel.setPassphrase(null);
@@ -102,37 +105,81 @@ class WalletRestorePage extends BasePage {
padding: EdgeInsets.only(top: 20, bottom: 24, left: 24, right: 24),
child: Column(
children: [
Observer(
builder: (context) {
return walletRestoreViewModel.mode == WalletRestoreMode.seed
? StandardCheckbox(
value: walletRestoreViewModel.hasPassphrase,
caption: S.of(context).wallet_has_passphrase,
onChanged: (value) {
walletRestoreViewModel.hasPassphrase = value;
},
)
: SizedBox.shrink();
},
),
SizedBox(height: 16),
PrimaryButton(
key: ValueKey('wallet_restore_advanced_settings_button_key'),
onPressed: () {
Navigator.of(context).pushNamed(
Routes.advancedPrivacySettings,
arguments: {
'isFromRestore': true,
'type': walletRestoreViewModel.type,
'useTestnet': walletRestoreViewModel.useTestnet,
'toggleTestnet': walletRestoreViewModel.toggleUseTestnet
},
);
},
text: S.of(context).advanced_settings,
color: Theme.of(context).cardColor,
textColor: Theme.of(context).extension<CakeTextTheme>()!.buttonTextColor,
),
SizedBox(height: 8),
Observer(
builder: (context) {
return LoadingPrimaryButton(
key: ValueKey('wallet_restore_seed_or_key_restore_button_key'),
onPressed: () async => await _confirmForm(context),
text: S.of(context).restore_recover,
color: Theme.of(context)
.extension<WalletListTheme>()!
.createNewWalletButtonBackgroundColor,
textColor: Theme.of(context)
.extension<WalletListTheme>()!
.restoreWalletButtonTextColor,
onPressed: () async {
if (walletRestoreViewModel.hasPassphrase) {
await showModalBottomSheet<void>(
context: context,
isDismissible: false,
isScrollControlled: true,
builder: (BuildContext bottomSheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(bottomSheetContext).viewInsets.bottom,
),
child: AddPassphraseBottomSheet(
currentTheme: currentTheme,
titleText: S.of(context).add_passphrase,
onRestoreButtonPressed: (passphrase) async {
await _onPassphraseBottomSheetRestoreButtonPressed(
passphrase,
context,
);
},
),
);
},
);
} else {
await _confirmForm(context);
}
},
text: walletRestoreViewModel.hasPassphrase
? S.of(context).add_passphrase
: S.of(context).restore_recover,
color: Theme.of(context).primaryColor,
textColor: Colors.white,
isLoading: walletRestoreViewModel.state is IsExecutingState,
isDisabled: !walletRestoreViewModel.isButtonEnabled,
);
},
),
const SizedBox(height: 25),
GestureDetector(
key: ValueKey('wallet_restore_advanced_settings_button_key'),
onTap: () {
Navigator.of(context)
.pushNamed(Routes.advancedPrivacySettings, arguments: {
'isFromRestore': true,
'type': walletRestoreViewModel.type,
'useTestnet': walletRestoreViewModel.useTestnet,
'toggleTestnet': walletRestoreViewModel.toggleUseTestnet
});
},
child: Text(S.of(context).advanced_settings),
),
const SizedBox(height: 24),
],
),
)
@@ -144,6 +191,14 @@ class WalletRestorePage extends BasePage {
);
}
Future<void> _onPassphraseBottomSheetRestoreButtonPressed(
String passphrase,
BuildContext context,
) async {
walletRestoreViewModel.seedSettingsViewModel.setPassphrase(passphrase);
await _confirmForm(context);
}
Map<String, dynamic> _credentials() {
final credentials = <String, dynamic>{};
@@ -172,9 +227,10 @@ class WalletRestorePage extends BasePage {
walletRestoreFromKeysFormKey.currentState!.nameTextEditingController.text;
credentials['viewKey'] = walletRestoreFromKeysFormKey.currentState!.viewKeyController.text;
if (walletRestoreViewModel.type != WalletType.decred) {
credentials['address'] = walletRestoreFromKeysFormKey.currentState!.addressController.text;
credentials['address'] =
walletRestoreFromKeysFormKey.currentState!.addressController.text;
credentials['spendKey'] =
walletRestoreFromKeysFormKey.currentState!.spendKeyController.text;
walletRestoreFromKeysFormKey.currentState!.spendKeyController.text;
credentials['height'] =
walletRestoreFromKeysFormKey.currentState!.blockchainHeightKey.currentState!.height;
}
@@ -335,8 +391,8 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
final initialIndex = walletRestoreViewModel.mode == WalletRestoreMode.seed
? 0
: _hasKeysTab
? 1
: 0;
? 1
: 0;
_tabController = TabController(length: tabCount, vsync: this, initialIndex: initialIndex);
@@ -429,7 +485,7 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
padding: EdgeInsets.zero,
tabs: [
Tab(text: S.of(context).widgets_seed),
if (_hasKeysTab) Tab(text: S.of(context).keys),
if (_hasKeysTab) Tab(text: S.of(context).keys),
],
),
),
@@ -527,42 +583,30 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
}
bool _isValidSeed() {
final seedPhrase = walletRestoreFromSeedFormKey
.currentState!.seedWidgetStateKey.currentState!.text;
final seedPhrase =
walletRestoreFromSeedFormKey.currentState!.seedWidgetStateKey.currentState!.text;
if (walletRestoreViewModel.isPolyseed(seedPhrase)) return true;
final seedWords = seedPhrase.split(' ');
if (seedWords.length == 14 &&
walletRestoreViewModel.type == WalletType.wownero) return true;
if (seedWords.length == 26 &&
walletRestoreViewModel.type == WalletType.zano) return true;
if (seedWords.length == 14 && walletRestoreViewModel.type == WalletType.wownero) return true;
if (seedWords.length == 26 && walletRestoreViewModel.type == WalletType.zano) return true;
if (seedWords.length == 12 &&
walletRestoreViewModel.type == WalletType.monero) {
return walletRestoreFromSeedFormKey
.currentState
?.blockchainHeightKey
.currentState
?.restoreHeightController
.text
.isNotEmpty == true;
if (seedWords.length == 12 && walletRestoreViewModel.type == WalletType.monero) {
return walletRestoreFromSeedFormKey.currentState?.blockchainHeightKey.currentState
?.restoreHeightController.text.isNotEmpty ==
true;
}
if ([WalletType.monero, WalletType.wownero, WalletType.haven]
.contains(walletRestoreViewModel.type) &&
seedWords.length ==
WalletRestoreViewModelBase.moneroSeedMnemonicLength) {
seedWords.length == WalletRestoreViewModelBase.moneroSeedMnemonicLength) {
return true;
}
// bip39:
final validBip39SeedLengths = [12, 18, 24];
final nonBip39WalletTypes = [
WalletType.wownero,
WalletType.haven,
WalletType.decred
];
final nonBip39WalletTypes = [WalletType.wownero, WalletType.haven, WalletType.decred];
// if it's a bip39 wallet and the length is not valid return false
if (!nonBip39WalletTypes.contains(walletRestoreViewModel.type) &&
!(validBip39SeedLengths.contains(seedWords.length))) {
@@ -570,14 +614,12 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
}
if ((walletRestoreViewModel.type == WalletType.decred) &&
seedWords.length !=
WalletRestoreViewModelBase.decredSeedMnemonicLength) {
seedWords.length != WalletRestoreViewModelBase.decredSeedMnemonicLength) {
return false;
}
final words = walletRestoreFromSeedFormKey
.currentState!.seedWidgetStateKey.currentState!.words
.toSet();
final words =
walletRestoreFromSeedFormKey.currentState!.seedWidgetStateKey.currentState!.words.toSet();
return seedWords.toSet().difference(words).toSet().isEmpty;
}
@@ -15,6 +15,8 @@ class BaseTextFormField extends StatelessWidget {
this.textColor,
this.hintColor,
this.borderColor,
this.fillColor,
this.filled,
this.prefix,
this.prefixIcon,
this.suffix,
@@ -44,6 +46,8 @@ class BaseTextFormField extends StatelessWidget {
final Color? textColor;
final Color? hintColor;
final Color? borderColor;
final Color? fillColor;
bool? filled;
final Widget? prefix;
final Widget? prefixIcon;
final Widget? suffix;
@@ -89,6 +93,8 @@ class BaseTextFormField extends StatelessWidget {
prefixIcon: prefixIcon,
suffix: suffix,
suffixIcon: suffixIcon,
fillColor: fillColor,
filled: filled,
hintStyle: placeholderTextStyle ??
TextStyle(
color: hintColor ?? Theme.of(context).hintColor,
@@ -0,0 +1,220 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/palette.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class AddPassphraseBottomSheet extends StatefulWidget {
AddPassphraseBottomSheet({
required String titleText,
required this.currentTheme,
required this.onRestoreButtonPressed,
});
final void Function(String) onRestoreButtonPressed;
final ThemeBase currentTheme;
@override
State<AddPassphraseBottomSheet> createState() => _AddPassphraseBottomSheetState();
}
class _AddPassphraseBottomSheetState extends State<AddPassphraseBottomSheet> {
late final TextEditingController passphraseController;
late final TextEditingController confirmPassphraseController;
@override
void initState() {
super.initState();
passphraseController = TextEditingController();
confirmPassphraseController = TextEditingController();
}
@override
void dispose() {
passphraseController.dispose();
confirmPassphraseController.dispose();
super.dispose();
}
bool obscurePassphrase = true;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(30.0)),
color: Theme.of(context).dialogBackgroundColor,
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
children: [
Row(
children: [
const Spacer(flex: 4),
Expanded(
flex: 2,
child: Container(
height: 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color:
Theme.of(context).extension<CakeTextTheme>()!.titleColor.withOpacity(0.6),
),
),
),
const Spacer(flex: 4),
],
),
SizedBox(height: 16),
Text(
S.of(context).add_passphrase,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
),
),
SvgPicture.asset('assets/images/passphrase_key.svg'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${S.of(context).warning.toUpperCase()}: ',
style: TextStyle(
fontSize: 16,
fontFamily: 'Lato',
fontWeight: FontWeight.w700,
color: Palette.red,
decoration: TextDecoration.none,
),
),
TextSpan(
text: S.of(context).add_passphrase_warning_text,
),
],
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontFamily: 'Lato',
fontWeight: FontWeight.w500,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor.withOpacity(0.7),
decoration: TextDecoration.none,
),
),
),
SizedBox(height: 24),
TextFormField(
key: ValueKey('add_passphrase_bottom_sheet_widget_passphrase_textfield_key'),
controller: passphraseController,
obscureText: obscurePassphrase,
decoration: InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 12),
filled: true,
fillColor: Theme.of(context).cardColor,
hintText: S.of(context).required_passphrase,
suffixIcon: GestureDetector(
onTap: () {
setState(() {
obscurePassphrase = !obscurePassphrase;
});
},
child: Icon(
obscurePassphrase ? Icons.visibility_off : Icons.visibility,
size: 24,
color: Theme.of(context).textTheme.bodyLarge?.color?.withOpacity(0.6),
),
),
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(16),
),
),
),
SizedBox(height: 8),
TextFormField(
key: ValueKey('add_passphrase_bottom_sheet_widget_confirm_passphrase_textfield_key'),
controller: confirmPassphraseController,
obscureText: obscurePassphrase,
decoration: InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 12),
filled: true,
fillColor: Theme.of(context).cardColor,
hintText: S.of(context).confirm_passphrase,
suffixIcon: GestureDetector(
onTap: () {
setState(() {
obscurePassphrase = !obscurePassphrase;
});
},
child: Icon(
obscurePassphrase ? Icons.visibility_off : Icons.visibility,
size: 24,
color: Theme.of(context).textTheme.bodyLarge?.color?.withOpacity(0.6),
),
),
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(16),
),
),
validator: (text) {
if (text == passphraseController.text) {
return null;
}
return S.of(context).passphrases_doesnt_match;
},
),
SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.only(right: 8.0, top: 8.0),
child: PrimaryButton(
key: ValueKey('add_passphrase_bottom_sheet_widget_cancel_button_key'),
onPressed: () {
Navigator.pop(context);
},
text: S.of(context).cancel,
color: Theme.of(context).cardColor,
textColor: Theme.of(context).extension<CakeTextTheme>()!.buttonTextColor,
),
),
),
Flexible(
child: Container(
padding: const EdgeInsets.only(left: 8.0, top: 8.0),
child: PrimaryButton(
key: ValueKey('add_passphrase_bottom_sheet_widget_restore_button_key'),
onPressed: () {
Navigator.pop(context);
widget.onRestoreButtonPressed(passphraseController.text);
},
text: S.of(context).restore,
color: Theme.of(context).primaryColor,
textColor: Colors.white,
),
),
),
],
),
),
SizedBox(height: 24),
],
),
),
);
}
}