Skip to content

Commit

Permalink
feat: add import select screen (#4800)
Browse files Browse the repository at this point in the history
### Description

Adds an import select screen when the Statsig feature gate
`show_cloud_account_backup_restore` is set to true. By default, when the
flag is false, the old restore flow should be served.


<p align="left">
<img
src="https://github.com/valora-inc/wallet/assets/26950305/ec44f6c2-ab49-4ec2-8ec3-ba460a3bf8d2"
width="45%" height="auto" />
</p>

### Test plan

- [x] Tested Locally on iOS and Android (feature flags were tested by
manually hardcoding - no address to target)
- [x] Unit tests added

### Related issues

- Fixes #ACT-874

### Backwards compatibility

Yes
  • Loading branch information
MuckT authored Feb 5, 2024
1 parent a757a89 commit ea1d2e1
Show file tree
Hide file tree
Showing 15 changed files with 332 additions and 12 deletions.
12 changes: 12 additions & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,18 @@
"title": "Balance\n<0></0>"
}
},
"importSelect": {
"title": "Restore Your Wallet",
"description": "If you set up a backup to protect your wallet, choose an option below.",
"emailAndPhone": {
"title": "From email & phone backup",
"description": "Restore your wallet with the phone number & email you set up previously"
},
"recoveryPhrase": {
"title": "From recovery phrase",
"description": "Enter your 12 word recovery phrase to regain access to Valora"
}
},
"verificationInput": {
"title": "Confirm",
"body": "We sent you three messages (SMS). Please copy and paste them below.",
Expand Down
19 changes: 19 additions & 0 deletions src/icons/CloudCheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react'
import Svg, { Path } from 'react-native-svg'
import colors from 'src/styles/colors'

interface Props {
color?: string
width?: number
height?: number
}

const CloudCheck = ({ color = colors.primary, width = 20, height = 14 }: Props) => (
<Svg width={width} height={height} fill="none" viewBox="0 0 20 14">
<Path
fill={color}
d="m8.625 11.167 4.708-4.708-1.208-1.209-3.52 3.521-1.75-1.75-1.188 1.188 2.958 2.958Zm-3.208 2.5c-1.264 0-2.344-.438-3.24-1.313C1.282 11.48.834 10.41.834 9.146c0-1.083.327-2.049.98-2.896a4.331 4.331 0 0 1 2.562-1.625 5.656 5.656 0 0 1 2.083-3.104A5.702 5.702 0 0 1 10 .333c1.625 0 3.003.566 4.135 1.698 1.132 1.132 1.698 2.51 1.698 4.136a3.64 3.64 0 0 1 2.386 1.239c.632.716.948 1.553.948 2.51 0 1.043-.365 1.928-1.094 2.657-.73.73-1.615 1.094-2.656 1.094h-10Zm0-1.667h10c.583 0 1.076-.201 1.479-.604.403-.403.604-.896.604-1.48 0-.582-.201-1.076-.604-1.478a2.012 2.012 0 0 0-1.48-.605h-1.25V6.168c0-1.153-.405-2.136-1.218-2.948C12.136 2.405 11.153 2 10 2s-2.135.406-2.947 1.219c-.813.812-1.22 1.795-1.22 2.948h-.416a2.81 2.81 0 0 0-2.063.854A2.81 2.81 0 0 0 2.5 9.084c0 .805.285 1.493.854 2.062A2.81 2.81 0 0 0 5.417 12Z"
/>
</Svg>
)
export default CloudCheck
19 changes: 19 additions & 0 deletions src/icons/Lock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react'
import Svg, { Path } from 'react-native-svg'
import colors from 'src/styles/colors'

interface Props {
color?: string
width?: number
height?: number
}

const Lock = ({ color = colors.primary, width = 20, height = 20 }: Props) => (
<Svg width={width} height={height} fill="none" viewBox="0 0 20 20">
<Path
fill={color}
d="M5 18.334c-.458 0-.85-.164-1.177-.49a1.605 1.605 0 0 1-.49-1.177V8.333c0-.458.164-.85.49-1.177.326-.326.719-.49 1.177-.49h.833V5c0-1.153.407-2.135 1.22-2.948C7.865 1.24 8.847.833 10 .833s2.136.407 2.948 1.219c.813.813 1.219 1.795 1.219 2.948v1.667H15c.458 0 .85.163 1.177.489.327.327.49.72.49 1.178v8.333c0 .458-.163.85-.49 1.177-.326.326-.719.49-1.177.49H5Zm0-1.667h10V8.333H5v8.334Zm5-2.5c.458 0 .85-.163 1.178-.49.326-.326.489-.718.489-1.177 0-.458-.163-.85-.49-1.177a1.607 1.607 0 0 0-1.177-.49c-.458 0-.85.164-1.177.49-.326.326-.49.719-.49 1.177 0 .459.164.851.49 1.178.326.326.719.489 1.177.489Zm-2.5-7.5h5V5a2.41 2.41 0 0 0-.73-1.77A2.411 2.411 0 0 0 10 2.5a2.41 2.41 0 0 0-1.77.73A2.41 2.41 0 0 0 7.5 5v1.667Z"
/>
</Svg>
)
export default Lock
8 changes: 6 additions & 2 deletions src/import/ImportWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import KeyboardSpacer from 'src/components/KeyboardSpacer'
import RecoveryPhraseInput, { RecoveryPhraseInputStatus } from 'src/components/RecoveryPhraseInput'
import { importBackupPhrase } from 'src/import/actions'
import { HeaderTitleWithSubtitle, nuxNavigationOptions } from 'src/navigator/Headers'
import { navigateClearingStack } from 'src/navigator/NavigationService'
import { navigate, navigateClearingStack } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { StackParamList } from 'src/navigator/types'
import TopBarTextButtonOnboarding from 'src/onboarding/TopBarTextButtonOnboarding'
import { isAppConnected } from 'src/redux/selectors'
import useTypedSelector from 'src/redux/useSelector'
import { getFeatureGate } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import colors from 'src/styles/colors'
import fontStyles from 'src/styles/fonts'
import Logger from 'src/utils/Logger'
Expand Down Expand Up @@ -70,7 +72,9 @@ function ImportWallet({ navigation, route }: Props) {
const handleNavigateBack = () => {
dispatch(cancelCreateOrRestoreAccount())
ValoraAnalytics.track(OnboardingEvents.restore_account_cancel)
navigateClearingStack(Screens.Welcome)
getFeatureGate(StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE)
? navigate(Screens.ImportSelect)
: navigateClearingStack(Screens.Welcome)
}

useBackHandler(() => {
Expand Down
53 changes: 53 additions & 0 deletions src/importSelect/ImportSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fireEvent, render } from '@testing-library/react-native'
import * as React from 'react'
import 'react-native'
import { Provider } from 'react-redux'
import ImportSelect from 'src/importSelect/ImportSelect'
import { KeylessBackupFlow } from 'src/keylessBackup/types'
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { createMockStore, getMockStackScreenProps } from 'test/utils'

jest.mock('src/analytics/ValoraAnalytics')
const mockScreenProps = getMockStackScreenProps(Screens.ImportSelect)

describe('ImportSelect', () => {
it('renders correctly', () => {
const { getByText } = render(
<Provider store={createMockStore()}>
<ImportSelect {...mockScreenProps} />
</Provider>
)

expect(getByText('importSelect.title')).toBeTruthy()
expect(getByText('importSelect.description')).toBeTruthy()
expect(getByText('importSelect.emailAndPhone.title')).toBeTruthy()
expect(getByText('importSelect.emailAndPhone.description')).toBeTruthy()
expect(getByText('importSelect.recoveryPhrase.title')).toBeTruthy()
expect(getByText('importSelect.recoveryPhrase.description')).toBeTruthy()
})

it('should be able to navigate to cloud restore', () => {
const { getByTestId } = render(
<Provider store={createMockStore()}>
<ImportSelect {...mockScreenProps} />
</Provider>
)

fireEvent.press(getByTestId('ImportSelect/CloudBackup'))
expect(navigate).toHaveBeenCalledWith(Screens.SignInWithEmail, {
keylessBackupFlow: KeylessBackupFlow.Restore,
})
})

it('should be able to navigate to mnemonic restore', () => {
const { getByTestId } = render(
<Provider store={createMockStore()}>
<ImportSelect {...mockScreenProps} />
</Provider>
)

fireEvent.press(getByTestId('ImportSelect/Mnemonic'))
expect(navigate).toHaveBeenCalledWith(Screens.ImportWallet, { clean: true })
})
})
171 changes: 171 additions & 0 deletions src/importSelect/ImportSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { useHeaderHeight } from '@react-navigation/elements'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import React, { useLayoutEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useDispatch } from 'react-redux'
import { cancelCreateOrRestoreAccount } from 'src/account/actions'
import { OnboardingEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import Card from 'src/components/Card'
import Touchable from 'src/components/Touchable'
import CloudCheck from 'src/icons/CloudCheck'
import Lock from 'src/icons/Lock'
import { KeylessBackupFlow } from 'src/keylessBackup/types'
import { nuxNavigationOptions } from 'src/navigator/Headers'
import { navigate, navigateClearingStack } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { StackParamList } from 'src/navigator/types'
import TopBarTextButtonOnboarding from 'src/onboarding/TopBarTextButtonOnboarding'
import colors from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
import { Shadow, Spacing } from 'src/styles/styles'

type Props = NativeStackScreenProps<StackParamList, Screens.ImportSelect>

function ActionCard({
title,
description,
icon,
onPress,
testID,
}: {
title: string
description: string
icon: React.ReactNode
onPress?: () => void
testID?: string
}) {
return (
<Card style={styles.card} rounded={true} shadow={Shadow.SoftLight} testID={testID}>
<Touchable borderRadius={8} style={styles.touchable} onPress={onPress}>
<View style={styles.cardContent}>
<View style={styles.topLine}>
{icon}
<Text style={styles.cardTitle}>{title}</Text>
</View>
<Text style={styles.cardDescription}>{description}</Text>
</View>
</Touchable>
</Card>
)
}

export default function ImportSelect({ navigation }: Props) {
const dispatch = useDispatch()
const headerHeight = useHeaderHeight()
const { t } = useTranslation()

const handleNavigateBack = () => {
dispatch(cancelCreateOrRestoreAccount())
ValoraAnalytics.track(OnboardingEvents.restore_account_cancel)
navigateClearingStack(Screens.Welcome)
}

useLayoutEffect(() => {
navigation.setOptions({
headerLeft: () => (
<TopBarTextButtonOnboarding
title={t('cancel')}
onPress={handleNavigateBack}
titleStyle={{ color: colors.gray5 }}
/>
),
headerStyle: {
backgroundColor: 'transparent',
},
})
}, [navigation])

return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scrollContainer}
style={[headerHeight ? { marginTop: headerHeight } : undefined]}
>
<View style={styles.viewContainer}>
<View style={styles.screenTextContainer}>
<Text style={styles.screenTitle}>{t('importSelect.title')}</Text>
<Text style={styles.screenDescription}>{t('importSelect.description')}</Text>
</View>
<ActionCard
title={t('importSelect.emailAndPhone.title')}
description={t('importSelect.emailAndPhone.description')}
icon={<CloudCheck />}
onPress={() =>
navigate(Screens.SignInWithEmail, { keylessBackupFlow: KeylessBackupFlow.Restore })
}
testID="ImportSelect/CloudBackup"
/>
<ActionCard
title={t('importSelect.recoveryPhrase.title')}
description={t('importSelect.recoveryPhrase.description')}
icon={<Lock />}
onPress={() => navigate(Screens.ImportWallet, { clean: true })}
testID="ImportSelect/Mnemonic"
/>
</View>
</ScrollView>
</SafeAreaView>
)
}

ImportSelect.navigationOptions = {
...nuxNavigationOptions,
// Prevent swipe back on iOS, users have to explicitly press cancel
gestureEnabled: false,
}

const styles = StyleSheet.create({
card: {
padding: 0,
width: '100%',
},
cardContent: {
padding: Spacing.Regular16,
},
cardDescription: {
...typeScale.bodySmall,
marginLeft: 28,
},
cardTitle: {
...typeScale.labelMedium,
color: colors.primary,
},
safeArea: {
alignItems: 'center',
backgroundColor: colors.gray1,
flexGrow: 1,
justifyContent: 'space-between',
},
scrollContainer: {
flexGrow: 1,
},
screenDescription: {
...typeScale.bodyMedium,
textAlign: 'center',
},
screenTitle: {
...typeScale.titleSmall,
marginTop: Spacing.Thick24,
textAlign: 'center',
},
screenTextContainer: {
gap: Spacing.Regular16,
},
topLine: {
alignItems: 'center',
flexDirection: 'row',
gap: Spacing.Smallest8,
},
touchable: {
overflow: 'hidden',
},
viewContainer: {
alignItems: 'center',
flex: 1,
gap: Spacing.Thick24,
paddingHorizontal: Spacing.Thick24,
},
})
2 changes: 1 addition & 1 deletion src/keylessBackup/KeylessBackupCancelButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('KeylessBackupCancelButton', () => {
fireEvent.press(getByTestId('CancelButton'))

expect(navigate).toHaveBeenCalledTimes(1)
expect(navigate).toHaveBeenCalledWith(Screens.ImportWallet)
expect(navigate).toHaveBeenCalledWith(Screens.ImportSelect)
expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)
expect(ValoraAnalytics.track).toHaveBeenCalledWith(
KeylessBackupEvents.cab_sign_in_with_email_screen_cancel,
Expand Down
2 changes: 1 addition & 1 deletion src/keylessBackup/KeylessBackupCancelButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function KeylessBackupCancelButton({
eventName={eventName}
eventProperties={{ keylessBackupFlow: flow }}
onCancel={() => {
flow === KeylessBackupFlow.Setup ? navigateHome() : navigate(Screens.ImportWallet) // TODO(any): use the new restore landing screen once built
flow === KeylessBackupFlow.Setup ? navigateHome() : navigate(Screens.ImportSelect)
}}
/>
)
Expand Down
6 changes: 6 additions & 0 deletions src/navigator/Navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import KycPending from 'src/fiatconnect/kyc/KycPending'
import NotificationCenter from 'src/home/NotificationCenter'
import { currentLanguageSelector } from 'src/i18n/selectors'
import ImportWallet from 'src/import/ImportWallet'
import ImportSelect from 'src/importSelect/ImportSelect'
import KeylessBackupPhoneCodeInput from 'src/keylessBackup/KeylessBackupPhoneCodeInput'
import KeylessBackupPhoneInput from 'src/keylessBackup/KeylessBackupPhoneInput'
import KeylessBackupProgress from 'src/keylessBackup/KeylessBackupProgress'
Expand Down Expand Up @@ -200,6 +201,11 @@ const nuxScreens = (Navigator: typeof Stack) => (
component={PincodeSet}
options={PincodeSet.navigationOptions}
/>
<Navigator.Screen
name={Screens.ImportSelect}
component={ImportSelect}
options={ImportSelect.navigationOptions}
/>
<Navigator.Screen
name={Screens.ImportWallet}
component={ImportWallet}
Expand Down
1 change: 1 addition & 0 deletions src/navigator/Screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export enum Screens {
FiatConnectRefetchQuote = 'FiatConnectRefetchQuote',
FiatConnectTransferStatus = 'FiatConnectTransferStatus',
GoldEducation = 'GoldEducation',
ImportSelect = 'ImportSelect',
ImportWallet = 'ImportWallet',
Invite = 'Invite',
KeylessBackupPhoneInput = 'KeylessBackupPhoneInput',
Expand Down
6 changes: 5 additions & 1 deletion src/navigator/initialRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export function getInitialRoute({
// User didn't go far enough in onboarding, start again from education
return Screens.Welcome
} else if (!account) {
return choseToRestoreAccount ? Screens.ImportWallet : Screens.Welcome
return choseToRestoreAccount
? getFeatureGate(StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE)
? Screens.ImportSelect
: Screens.ImportWallet
: Screens.Welcome
} else if (recoveryPhraseInOnboardingStatus === RecoveryPhraseInOnboardingStatus.InProgress) {
return Screens.ProtectWallet
} else if (!hasSeenVerificationNux) {
Expand Down
1 change: 1 addition & 0 deletions src/navigator/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export type StackParamList = {
tokenId: string
}
[Screens.GoldEducation]: undefined
[Screens.ImportSelect]: undefined
[Screens.ImportWallet]:
| {
clean: boolean
Expand Down
Loading

0 comments on commit ea1d2e1

Please sign in to comment.