Skip to content
Draft
4 changes: 4 additions & 0 deletions apps/mobile/assets/icons/phone.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/mobile/src/components/core/PWIcon/PWIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import PlusIcon from '@assets/icons/plus.svg'
import QuestionMarkIcon from '@assets/icons/question-mark.svg'
import RekeyIcon from '@assets/icons/rekey.svg'
import ReloadIcon from '@assets/icons/reload.svg'
import PhoneIcon from '@assets/icons/phone.svg'
import ShareIcon from '@assets/icons/share.svg'
import ShieldCheckIcon from '@assets/icons/shield-check.svg'
import SlidersIcon from '@assets/icons/sliders.svg'
Expand Down Expand Up @@ -152,6 +153,7 @@ const ICON_LIBRARY = {
'question-mark': QuestionMarkIcon,
rekey: RekeyIcon,
reload: ReloadIcon,
phone: PhoneIcon,
share: ShareIcon,
'shield-check': ShieldCheckIcon,
sliders: SlidersIcon,
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/hooks/useShowOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import {
useHasNoAccounts,
useSelectedAccountAddress,
} from '@perawallet/wallet-core-accounts'
import { useIsOnboarding } from '@modules/onboarding/hooks'

export const useShowOnboarding = () => {
const noAccounts = useHasNoAccounts()
const { selectedAccountAddress } = useSelectedAccountAddress()
const { isOnboarding } = useIsOnboarding()

return noAccounts || !selectedAccountAddress
return noAccounts || !selectedAccountAddress || isOnboarding
}
17 changes: 16 additions & 1 deletion apps/mobile/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,14 @@
"importing": "Importing wallet...",
"button": "Import Wallet",
"failed_title": "Import failed",
"failed_body": "There was an error trying to import your wallet"
"failed_body": "There was an error trying to import your wallet",
"invalid_mnemonic_title": "Invalid Passphrase",
"invalid_mnemonic_body": "The passphrase you pasted contains too many words.",
"insufficient_slots_title": "Invalid Passphrase",
"insufficient_slots_body": "The passphrase you pasted does not fit in the remaining slots."
},
"searching_accounts": {
"title": "Searching your accounts"
},
"create_account": {
"processing": "Setting up your wallet...",
Expand All @@ -319,6 +326,14 @@
"wallet_label": "Wallet #{{count}}",
"input_label": "Account name",
"finish_button": "Finish Account Creation"
},
"import_select_addresses": {
"title": "Select addresses to add",
"description": "We found that there are {{count}} addresses registered to this wallet",
"addresses_count": "{{count}} addresses",
"select_all": "Select all",
"already_imported": "already imported",
"continue": "Continue"
}
},
"portfolio": {
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/modules/onboarding/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@
limitations under the License
*/

export { useOnboardingStore, useShouldPlayConfetti } from './useOnboardingStore'
export {
useOnboardingStore,
useShouldPlayConfetti,
useIsOnboarding,
} from './useOnboardingStore'
15 changes: 15 additions & 0 deletions apps/mobile/src/modules/onboarding/hooks/useOnboardingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,27 @@ import { create } from 'zustand'

type OnboardingState = {
shouldPlayConfetti: boolean
isOnboarding: boolean
}

type OnboardingActions = {
setShouldPlayConfetti: (value: boolean) => void
setIsOnboarding: (value: boolean) => void
reset: () => void
}

type OnboardingStore = OnboardingState & OnboardingActions

const initialState: OnboardingState = {
shouldPlayConfetti: false,
isOnboarding: false,
}

export const useOnboardingStore = create<OnboardingStore>()(set => ({
...initialState,
setShouldPlayConfetti: (value: boolean) =>
set({ shouldPlayConfetti: value }),
setIsOnboarding: (value: boolean) => set({ isOnboarding: value }),
reset: () => set(initialState),
}))

Expand All @@ -50,3 +54,14 @@ export const useShouldPlayConfetti = (): UseShouldPlayConfettiResult => {
)
return { shouldPlayConfetti, setShouldPlayConfetti }
}

type UseIsOnboardingResult = {
isOnboarding: boolean
setIsOnboarding: (value: boolean) => void
}

export const useIsOnboarding = (): UseIsOnboardingResult => {
const isOnboarding = useOnboardingStore(state => state.isOnboarding)
const setIsOnboarding = useOnboardingStore(state => state.setIsOnboarding)
return { isOnboarding, setIsOnboarding }
}
23 changes: 23 additions & 0 deletions apps/mobile/src/modules/onboarding/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ import { OnboardingScreen } from '@modules/onboarding/screens/OnboardingScreen'
import { NameAccountScreen } from '@modules/onboarding/screens/NameAccountScreen'
import { ImportAccountScreen } from '@modules/onboarding/screens/ImportAccountScreen'
import { ImportInfoScreen } from '@modules/onboarding/screens/ImportInfoScreen'
import { ImportSelectAddressesScreen } from '@modules/onboarding/screens/ImportSelectAddressesScreen'
import { AccountErrorBoundary } from '@modules/accounts/components/AccountErrorBoundary/AccountErrorBoundary'
import { useLanguage } from '@hooks/useLanguage'
import { screenListeners } from '@routes/listeners'
import { fullScreenLayout, safeAreaLayout } from '@layouts/index'
import type React from 'react'

import { SearchAccountsScreen } from '@modules/onboarding/screens/SearchAccountsScreen'

// Wrap screens with AccountErrorBoundary to catch account-related errors
const withAccountErrorBoundary = <P extends object>(
WrappedComponent: React.ComponentType<P>,
Expand All @@ -48,6 +51,10 @@ const ImportAccountScreenWithErrorBoundary =
withAccountErrorBoundary(ImportAccountScreen)
const ImportInfoScreenWithErrorBoundary =
withAccountErrorBoundary(ImportInfoScreen)
const SearchAccountsScreenWithErrorBoundary =
withAccountErrorBoundary(SearchAccountsScreen)
const ImportSelectAddressesScreenWithErrorBoundary =
withAccountErrorBoundary(ImportSelectAddressesScreen)

import { OnboardingStackParamList } from './types'
export type { OnboardingStackParamList } from './types'
Expand Down Expand Up @@ -97,6 +104,22 @@ export const OnboardingStackNavigator = () => {
layout={safeAreaLayout}
component={ImportAccountScreenWithErrorBoundary}
/>
<OnboardingStack.Screen
name='SearchAccounts'
options={{
headerShown: false,
}}
layout={fullScreenLayout}
component={SearchAccountsScreenWithErrorBoundary}
/>
<OnboardingStack.Screen
name='ImportSelectAddresses'
options={{
headerShown: false,
}}
layout={safeAreaLayout}
component={ImportSelectAddressesScreenWithErrorBoundary}
/>
</OnboardingStack.Navigator>
)
}
17 changes: 13 additions & 4 deletions apps/mobile/src/modules/onboarding/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,29 @@

import {
WalletAccount,
HDWalletAccount,
ImportAccountType,
} from '@perawallet/wallet-core-accounts'

export type OnboardingStackParamList = {
OnboardingHome: undefined
NameAccount:
| {
account?: WalletAccount
}
| undefined
| {
account?: WalletAccount
}
| undefined
ImportSelectAddresses: {
accounts: HDWalletAccount[]
}

ImportInfo: {
accountType: ImportAccountType
}
ImportAccount: {
accountType: ImportAccountType
}
SearchAccounts: {
account: HDWalletAccount
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
Copyright 2022-2025 Pera Wallet, LDA
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 { renderHook, act } from '@testing-library/react'
import { useImportAccountScreen } from '../useImportAccountScreen'
import { vi, describe, it, expect, beforeEach } from 'vitest'

const mockShowToast = vi.fn()
const mockReplace = vi.fn()
const mockGoBack = vi.fn()

vi.mock('react-native', () => ({
Keyboard: {
addListener: vi.fn(() => ({
remove: vi.fn(),
})),
},
Platform: {
OS: 'ios',
},
}))

vi.mock('@react-navigation/native', () => ({
useRoute: vi.fn(() => ({
params: { accountType: 'hdWallet' },
})),
}))

vi.mock('@hooks/useAppNavigation', () => ({
useAppNavigation: vi.fn(() => ({
replace: mockReplace,
goBack: mockGoBack,
})),
}))

vi.mock('@perawallet/wallet-core-accounts', () => ({
useImportAccount: vi.fn(),
}))

vi.mock('@hooks/useToast', () => ({
useToast: vi.fn(() => ({
showToast: mockShowToast,
})),
}))

vi.mock('@hooks/useLanguage', () => ({
useLanguage: vi.fn(() => ({
t: (key: string) => key,
})),
}))

describe('useImportAccountScreen', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('initializes with empty words', () => {
const { result } = renderHook(() => useImportAccountScreen())
expect(result.current.words).toHaveLength(24)
expect(result.current.words.every(w => w === '')).toBe(true)
})

it('updates a single word at a specific index', () => {
const { result } = renderHook(() => useImportAccountScreen())

act(() => {
result.current.updateWord('apple', 5)
})

expect(result.current.words[5]).toBe('apple')
})

it('fills all slots when a full mnemonic is pasted into any slot', () => {
const { result } = renderHook(() => useImportAccountScreen())
const mnemonic = new Array(24).fill('word').join(' ')

act(() => {
result.current.updateWord(mnemonic, 5)
})

expect(result.current.words.every(w => w === 'word')).toBe(true)
})

it('fills sequential slots when a partial mnemonic is pasted into any slot and fits', () => {
const { result } = renderHook(() => useImportAccountScreen())
const mnemonic = 'word1 word2 word3'

act(() => {
result.current.updateWord(mnemonic, 20)
})

expect(result.current.words[20]).toBe('word1')
expect(result.current.words[21]).toBe('word2')
expect(result.current.words[22]).toBe('word3')
expect(result.current.words[23]).toBe('')
})

it('shows insufficient slots toast if partial mnemonic does not fit in remaining slots', () => {
const { result } = renderHook(() => useImportAccountScreen())
const mnemonic = 'word1 word2 word3'

act(() => {
result.current.updateWord(mnemonic, 22)
})

expect(mockShowToast).toHaveBeenCalledWith({
title: 'onboarding.import_account.insufficient_slots_title',
body: 'onboarding.import_account.insufficient_slots_body',
type: 'error',
})
expect(result.current.words[22]).toBe('')
})

it('shows a toast and does not update words if too many words are pasted', () => {
const { result } = renderHook(() => useImportAccountScreen())
const mnemonic = new Array(25).fill('word').join(' ')

act(() => {
result.current.updateWord(mnemonic, 0)
})

expect(mockShowToast).toHaveBeenCalledWith({
title: 'onboarding.import_account.invalid_mnemonic_title',
body: 'onboarding.import_account.invalid_mnemonic_body',
type: 'error',
})
expect(result.current.words[0]).toBe('')
})

it('treats spaces at the end of a single word as a single word update', () => {
const { result } = renderHook(() => useImportAccountScreen())

act(() => {
result.current.updateWord('apple ', 0)
})

expect(result.current.words[0]).toBe('apple')
expect(result.current.words[1]).toBe('')
})
})
Loading
Loading