diff --git a/apps/mobile/assets/icons/phone.svg b/apps/mobile/assets/icons/phone.svg new file mode 100644 index 00000000..0e1d1589 --- /dev/null +++ b/apps/mobile/assets/icons/phone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/components/core/PWIcon/PWIcon.tsx b/apps/mobile/src/components/core/PWIcon/PWIcon.tsx index ce88b80b..3afea31b 100644 --- a/apps/mobile/src/components/core/PWIcon/PWIcon.tsx +++ b/apps/mobile/src/components/core/PWIcon/PWIcon.tsx @@ -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' @@ -152,6 +153,7 @@ const ICON_LIBRARY = { 'question-mark': QuestionMarkIcon, rekey: RekeyIcon, reload: ReloadIcon, + phone: PhoneIcon, share: ShareIcon, 'shield-check': ShieldCheckIcon, sliders: SlidersIcon, diff --git a/apps/mobile/src/hooks/useShowOnboarding.ts b/apps/mobile/src/hooks/useShowOnboarding.ts index 48a6645b..e4d2049c 100644 --- a/apps/mobile/src/hooks/useShowOnboarding.ts +++ b/apps/mobile/src/hooks/useShowOnboarding.ts @@ -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 } diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index cd5b3142..5253692e 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -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...", @@ -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": { diff --git a/apps/mobile/src/modules/onboarding/hooks/index.ts b/apps/mobile/src/modules/onboarding/hooks/index.ts index d9fd74cf..250b8198 100644 --- a/apps/mobile/src/modules/onboarding/hooks/index.ts +++ b/apps/mobile/src/modules/onboarding/hooks/index.ts @@ -10,4 +10,8 @@ limitations under the License */ -export { useOnboardingStore, useShouldPlayConfetti } from './useOnboardingStore' +export { + useOnboardingStore, + useShouldPlayConfetti, + useIsOnboarding, +} from './useOnboardingStore' diff --git a/apps/mobile/src/modules/onboarding/hooks/useOnboardingStore.ts b/apps/mobile/src/modules/onboarding/hooks/useOnboardingStore.ts index c74e6321..68c2669a 100644 --- a/apps/mobile/src/modules/onboarding/hooks/useOnboardingStore.ts +++ b/apps/mobile/src/modules/onboarding/hooks/useOnboardingStore.ts @@ -14,10 +14,12 @@ import { create } from 'zustand' type OnboardingState = { shouldPlayConfetti: boolean + isOnboarding: boolean } type OnboardingActions = { setShouldPlayConfetti: (value: boolean) => void + setIsOnboarding: (value: boolean) => void reset: () => void } @@ -25,12 +27,14 @@ type OnboardingStore = OnboardingState & OnboardingActions const initialState: OnboardingState = { shouldPlayConfetti: false, + isOnboarding: false, } export const useOnboardingStore = create()(set => ({ ...initialState, setShouldPlayConfetti: (value: boolean) => set({ shouldPlayConfetti: value }), + setIsOnboarding: (value: boolean) => set({ isOnboarding: value }), reset: () => set(initialState), })) @@ -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 } +} diff --git a/apps/mobile/src/modules/onboarding/routes/index.tsx b/apps/mobile/src/modules/onboarding/routes/index.tsx index 7d1cc066..81141bb7 100644 --- a/apps/mobile/src/modules/onboarding/routes/index.tsx +++ b/apps/mobile/src/modules/onboarding/routes/index.tsx @@ -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 =

( WrappedComponent: React.ComponentType

, @@ -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' @@ -97,6 +104,22 @@ export const OnboardingStackNavigator = () => { layout={safeAreaLayout} component={ImportAccountScreenWithErrorBoundary} /> + + ) } diff --git a/apps/mobile/src/modules/onboarding/routes/types.ts b/apps/mobile/src/modules/onboarding/routes/types.ts index 7776b333..a2199d5d 100644 --- a/apps/mobile/src/modules/onboarding/routes/types.ts +++ b/apps/mobile/src/modules/onboarding/routes/types.ts @@ -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 + } } + diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/__tests__/useImportAccountScreen.spec.ts b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/__tests__/useImportAccountScreen.spec.ts new file mode 100644 index 00000000..86255233 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/__tests__/useImportAccountScreen.spec.ts @@ -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('') + }) +}) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts index 068f230a..11c6303c 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.ts @@ -17,7 +17,9 @@ import { OnboardingStackParamList } from '../../routes/types' import { useImportAccount, ImportAccountType, + AccountTypes, } from '@perawallet/wallet-core-accounts' + import { useToast } from '@hooks/useToast' import { useLanguage } from '@hooks/useLanguage' import { useAppNavigation } from '@hooks/useAppNavigation' @@ -86,36 +88,81 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { const updateWord = useCallback( (word: string, index: number) => { - const splitWords = word.split('\n') - - if (splitWords.length === mnemonicLength) { - setWords(splitWords) - } else { - setWords(prev => { - const next = [...prev] - next[index] = word.trim() - return next - }) + const trimmedValue = word.trim() + const splitWords = trimmedValue.split(/\s+/).filter(Boolean) + + if (splitWords.length > 1) { + // Case: Pasted content is a full mnemonic of the expected length + if (splitWords.length === mnemonicLength) { + setWords(splitWords) + return + } + + // Case: Pasted content is larger than the total expected mnemonic length + if (splitWords.length > mnemonicLength) { + showToast({ + title: t( + 'onboarding.import_account.invalid_mnemonic_title', + ), + body: t( + 'onboarding.import_account.invalid_mnemonic_body', + ), + type: 'error', + }) + return + } + + // Case: Pasted content is smaller than the total expected length + const remainingSlots = mnemonicLength - index + + if (splitWords.length <= remainingSlots) { + setWords(prev => { + const next = [...prev] + + splitWords.forEach((w, i) => { + next[index + i] = w + }) + return next + }) + } else { + showToast({ + title: t( + 'onboarding.import_account.insufficient_slots_title', + ), + body: t( + 'onboarding.import_account.insufficient_slots_body', + ), + type: 'error', + }) + } + return } + + setWords(prev => { + const next = [...prev] + + next[index] = word.trim() + return next + }) }, - [mnemonicLength], + [mnemonicLength, showToast, t], ) - const goToHome = useCallback(() => { - navigation.replace('TabBar', { - screen: 'Home', - }) - }, [navigation]) - const handleImportAccount = useCallback(() => { setProcessing(true) setTimeout(async () => { + const mnemonic = words.join(' ') + try { - await importAccount({ - mnemonic: words.join(' '), + const importedAccount = await importAccount({ + mnemonic, type: accountType, }) - goToHome() + + if (importedAccount.type === AccountTypes.hdWallet) { + navigation.push('SearchAccounts', { account: importedAccount }) + } + // TODO: Algo25 will come later } catch { showToast({ title: t('onboarding.import_account.failed_title'), @@ -126,7 +173,16 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { setProcessing(false) } }, 0) - }, [importAccount, words, goToHome, showToast, t]) + }, [ + importAccount, + words, + accountType, + navigation, + showToast, + t, + ]) + + return { words, diff --git a/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/ImportSelectAddressesScreen.tsx b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/ImportSelectAddressesScreen.tsx new file mode 100644 index 00000000..bdfae32b --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/ImportSelectAddressesScreen.tsx @@ -0,0 +1,144 @@ +/* + 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 React from 'react' +import { FlatList } from 'react-native' +import { + PWView, + PWText, + PWToolbar, + PWIcon, + PWTouchableOpacity, + PWButton, + PWCheckbox, + PWChip, +} from '@components/core' + +import { useStyles } from './styles' +import { useImportSelectAddressesScreen } from './useImportSelectAddressesScreen' +import { useAppNavigation } from '@hooks/useAppNavigation' +import { + getAccountDisplayName, + HDWalletAccount, +} from '@perawallet/wallet-core-accounts' + +export const ImportSelectAddressesScreen = () => { + const styles = useStyles() + const { + accounts, + selectedAddresses, + isAllSelected, + alreadyImportedAddresses, + toggleSelection, + toggleSelectAll, + handleContinue, + t, + } = useImportSelectAddressesScreen() + const navigation = useAppNavigation() + + const renderItem = ({ item }: { item: HDWalletAccount }) => { + const isImported = alreadyImportedAddresses.has(item.address) + const isSelected = selectedAddresses.has(item.address) + const displayName = getAccountDisplayName(item) + + return ( + toggleSelection(item.address)} + disabled={isImported} + > + + + + {displayName} + + + + + {isImported ? ( + + ) : ( + toggleSelection(item.address)} + containerStyle={styles.checkboxContainer} + /> + )} + + ) + } + + return ( + + + + + } + /> + + + + {t('onboarding.import_select_addresses.title')} + + + {t('onboarding.import_select_addresses.description', { + count: accounts.length, + })} + + + + + {t('onboarding.import_select_addresses.addresses_count', { + count: accounts.length, + })} + + + + + {t('onboarding.import_select_addresses.select_all')} + + + + + + item.address} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + /> + + + + + + + ) +} diff --git a/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/__tests__/ImportSelectAddressesScreen.spec.tsx b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/__tests__/ImportSelectAddressesScreen.spec.tsx new file mode 100644 index 00000000..2da7516b --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/__tests__/ImportSelectAddressesScreen.spec.tsx @@ -0,0 +1,48 @@ +/* + 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 { render, screen } from '@test-utils/render' +import { vi } from 'vitest' +import { useRoute } from '@react-navigation/native' +import { ImportSelectAddressesScreen } from '../ImportSelectAddressesScreen' +import { AccountTypes } from '@perawallet/wallet-core-accounts' + +describe('ImportSelectAddressesScreen', () => { + beforeEach(() => { + vi.mocked(useRoute).mockReturnValue({ + params: { + accounts: [ + { + id: '1', + address: 'MOCK_ADDRESS', + type: AccountTypes.hdWallet, + canSign: true, + hdWalletDetails: { + walletId: '1', + account: 0, + change: 0, + keyIndex: 0, + derivationType: 9, + }, + }, + ], + }, + } as any) + }) + + it('renders the select addresses title', () => { + render() + expect( + screen.getByText('onboarding.import_select_addresses.title'), + ).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/index.ts b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/index.ts new file mode 100644 index 00000000..52f50bdb --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/index.ts @@ -0,0 +1,13 @@ +/* + 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 + */ + +export { ImportSelectAddressesScreen } from './ImportSelectAddressesScreen' diff --git a/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/styles.ts b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/styles.ts new file mode 100644 index 00000000..3d3b073a --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/styles.ts @@ -0,0 +1,89 @@ +/* + 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 { makeStyles } from '@rneui/themed' + +export const useStyles = makeStyles(theme => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + content: { + flex: 1, + paddingHorizontal: theme.spacing.xl, + }, + title: { + marginBottom: theme.spacing.sm, + marginTop: theme.spacing.sm, + }, + description: { + marginBottom: theme.spacing.xl, + color: theme.colors.textGray, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: theme.spacing.md, + borderBottomWidth: 1, + borderBottomColor: theme.colors.layerGrayLighter, + }, + headerCount: { + color: theme.colors.textGray, + fontWeight: '600', + }, + selectAllContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + selectAllText: { + color: theme.colors.linkPrimary, + marginRight: theme.spacing.xs, + }, + checkboxContainer: { + padding: 0, + margin: 0, + marginLeft: 0, + marginRight: 0, + backgroundColor: 'transparent', + }, + listContent: { + paddingBottom: theme.spacing.xl, + }, + itemContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: theme.spacing.md, + borderBottomWidth: 1, + borderBottomColor: theme.colors.layerGrayLighter, + }, + itemTextContainer: { + flex: 1, + paddingRight: theme.spacing.md, + }, + itemTitle: { + color: theme.colors.textMain, + marginBottom: 2, + }, + itemSubtitle: { + color: theme.colors.textGray, + fontSize: 12, + }, + footer: { + padding: theme.spacing.xl, + borderTopWidth: 1, + borderTopColor: theme.colors.layerGrayLighter, + backgroundColor: theme.colors.background, + paddingBottom: theme.spacing.xl + 20, // Extra padding for safe area logic usually handled by SafeAreaView but good to have buffer + }, +})) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/useImportSelectAddressesScreen.ts b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/useImportSelectAddressesScreen.ts new file mode 100644 index 00000000..c00cdc8c --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportSelectAddressesScreen/useImportSelectAddressesScreen.ts @@ -0,0 +1,109 @@ +/* + 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 { useState, useMemo, useCallback } from 'react' +import { RouteProp, useRoute } from '@react-navigation/native' +import { OnboardingStackParamList } from '../../routes/types' +import { + useAllAccounts, + useAccountsStore, + HDWalletAccount, +} from '@perawallet/wallet-core-accounts' +import { useLanguage } from '@hooks/useLanguage' +import { useIsOnboarding } from '@modules/onboarding/hooks' + + +type ImportSelectAddressesRouteProp = RouteProp< + OnboardingStackParamList, + 'ImportSelectAddresses' +> + +export type UseImportSelectAddressesScreenResult = { + accounts: HDWalletAccount[] + selectedAddresses: Set + isAllSelected: boolean + alreadyImportedAddresses: Set + toggleSelection: (address: string) => void + toggleSelectAll: () => void + handleContinue: () => void + t: (key: string, options?: any) => string +} + + +export function useImportSelectAddressesScreen(): UseImportSelectAddressesScreenResult { + const { + params: { accounts }, + } = useRoute() + const { t } = useLanguage() + const allAccounts = useAllAccounts() + + const { setIsOnboarding } = useIsOnboarding() + + const alreadyImportedAddresses = useMemo(() => { + return new Set(allAccounts.map(acc => acc.address)) + }, [allAccounts]) + + const selectableAccounts = useMemo(() => { + return accounts.filter(acc => !alreadyImportedAddresses.has(acc.address)) + }, [accounts, alreadyImportedAddresses]) + + const [selectedAddresses, setSelectedAddresses] = useState>( + new Set(selectableAccounts.map(acc => acc.address)), + ) + + const isAllSelected = selectableAccounts.length > 0 && selectedAddresses.size === selectableAccounts.length + + const toggleSelection = useCallback((address: string) => { + if (alreadyImportedAddresses.has(address)) return + + setSelectedAddresses(prev => { + const next = new Set(prev) + if (next.has(address)) { + next.delete(address) + } else { + next.add(address) + } + return next + }) + }, [alreadyImportedAddresses]) + + const toggleSelectAll = useCallback(() => { + if (isAllSelected) { + setSelectedAddresses(new Set()) + } else { + setSelectedAddresses(new Set(selectableAccounts.map(acc => acc.address))) + } + }, [isAllSelected, selectableAccounts]) + + const handleContinue = useCallback(() => { + const accountsToAdd = accounts.filter(acc => selectedAddresses.has(acc.address)) + + if (accountsToAdd.length > 0) { + const { setAccounts } = useAccountsStore.getState() + setAccounts([...allAccounts, ...accountsToAdd]) + } + + setIsOnboarding(false) + }, [accounts, selectedAddresses, allAccounts, setIsOnboarding]) + + return { + accounts, + selectedAddresses, + isAllSelected, + alreadyImportedAddresses, + toggleSelection, + toggleSelectAll, + handleContinue, + t, + } +} + diff --git a/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/useNameAccountScreen.ts b/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/useNameAccountScreen.ts index e3dd55e0..15a9d3ac 100644 --- a/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/useNameAccountScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/NameAccountScreen/useNameAccountScreen.ts @@ -24,7 +24,10 @@ import { useLanguage } from '@hooks/useLanguage' import { useToast } from '@hooks/useToast' import { useRoute, RouteProp } from '@react-navigation/native' import { OnboardingStackParamList } from '../../routes' -import { useShouldPlayConfetti } from '@modules/onboarding/hooks' +import { + useShouldPlayConfetti, + useIsOnboarding, +} from '@modules/onboarding/hooks' type NameAccountScreenRouteProp = RouteProp< OnboardingStackParamList, @@ -41,6 +44,7 @@ export const useNameAccountScreen = () => { const { t } = useLanguage() const { showToast } = useToast() const { setShouldPlayConfetti } = useShouldPlayConfetti() + const { setIsOnboarding } = useIsOnboarding() const routeAccount = route.params?.account @@ -96,6 +100,8 @@ export const useNameAccountScreen = () => { // Set confetti state - AccountScreen will read this and play the animation setShouldPlayConfetti(true) + + setIsOnboarding(false) } catch (error) { showToast({ title: t('onboarding.create_account.error_title'), diff --git a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts index ffbfb2fc..7159a1e3 100644 --- a/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/OnboardingScreen/useOnboardingScreen.ts @@ -15,6 +15,7 @@ import { useAppNavigation } from '@hooks/useAppNavigation' import { useWebView } from '@modules/webview' import { config } from '@perawallet/wallet-core-config' import { useModalState } from '@hooks/useModalState' +import { useIsOnboarding } from '@modules/onboarding/hooks' export const useOnboardingScreen = () => { const navigation = useAppNavigation() @@ -24,6 +25,7 @@ export const useOnboardingScreen = () => { open: openImportOptions, close: closeImportOptions, } = useModalState() + const { setIsOnboarding } = useIsOnboarding() const handleTermsPress = useCallback(() => { pushWebView({ @@ -45,13 +47,15 @@ export const useOnboardingScreen = () => { const handleHDWalletPress = useCallback(() => { closeImportOptions() + setIsOnboarding(true) navigation.push('ImportInfo', { accountType: 'hdWallet' }) - }, [closeImportOptions, navigation]) + }, [closeImportOptions, navigation, setIsOnboarding]) const handleAlgo25Press = useCallback(() => { closeImportOptions() + setIsOnboarding(true) navigation.push('ImportInfo', { accountType: 'algo25' }) - }, [closeImportOptions, navigation]) + }, [closeImportOptions, navigation, setIsOnboarding]) return { isImportOptionsVisible, diff --git a/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/SearchAccountsScreen.tsx b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/SearchAccountsScreen.tsx new file mode 100644 index 00000000..b9ee762b --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/SearchAccountsScreen.tsx @@ -0,0 +1,76 @@ +/* + 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 { Animated } from 'react-native' +import { PWText, PWView } from '@components/core' +import { RoundButton } from '@components/RoundButton/RoundButton' +import { useStyles } from './styles' +import { useSearchAccountsScreen } from './useSearchAccountsScreen' + +export const SearchAccountsScreen = () => { + const { t, dotOpacities } = useSearchAccountsScreen() + const styles = useStyles() + + return ( + + + + + + + + + + + + + + + + {t('onboarding.searching_accounts.title')} + + + ) +} diff --git a/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/__tests__/SearchAccountsScreen.spec.tsx b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/__tests__/SearchAccountsScreen.spec.tsx new file mode 100644 index 00000000..8c1b688a --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/__tests__/SearchAccountsScreen.spec.tsx @@ -0,0 +1,83 @@ +/* + 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 { render, screen } from '@test-utils/render' +import { vi } from 'vitest' +import { useRoute } from '@react-navigation/native' +import { SearchAccountsScreen } from '../SearchAccountsScreen' +import { AccountTypes } from '@perawallet/wallet-core-accounts' + +// Mock the hooks to avoid actual blockchain/KMS calls during tests +vi.mock('@perawallet/wallet-core-accounts', async (importOriginal) => ({ + ...(await importOriginal()), + useHDWallet: () => ({ + deriveAccountAddress: vi.fn(), + }), + useCreateAccount: () => vi.fn(), +})) + +vi.mock('@perawallet/wallet-core-kms', () => ({ + useKMS: () => ({ + getPrivateData: vi.fn(), + }), +})) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useAlgorandClient: () => ({ + client: { + algod: { + accountInformation: vi.fn(() => ({})), + + }, + }, + }), + encodeAlgorandAddress: vi.fn(() => 'MOCK_ADDRESS'), +})) + +describe('SearchAccountsScreen', () => { + beforeEach(() => { + vi.mocked(useRoute).mockReturnValue({ + params: { + account: { + id: '1', + address: 'MOCK_ADDRESS', + type: AccountTypes.hdWallet, + canSign: true, + hdWalletDetails: { + walletId: '1', + account: 0, + change: 0, + keyIndex: 0, + derivationType: 9, + }, + }, + }, + } as any) + }) + + it('renders searching accounts title', () => { + render() + expect( + screen.getByText('onboarding.searching_accounts.title'), + ).toBeTruthy() + }) + + it('renders globe and phone icons', () => { + render() + // Since RoundButtons are disabled, they might be harder to find by role, + // but we can check if they are rendered. + // PWIcon rendered globe and phone icons + expect(screen.getByTestId('icon-globe')).toBeTruthy() + expect(screen.getByTestId('icon-phone')).toBeTruthy() + }) +}) + diff --git a/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/index.ts b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/index.ts new file mode 100644 index 00000000..89d21bab --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/index.ts @@ -0,0 +1,13 @@ +/* + 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 + */ + +export { SearchAccountsScreen } from './SearchAccountsScreen' diff --git a/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/styles.ts b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/styles.ts new file mode 100644 index 00000000..dedb754c --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/styles.ts @@ -0,0 +1,55 @@ +/* + 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 { makeStyles } from '@rneui/themed' + +export const useStyles = makeStyles(theme => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + justifyContent: 'center', + alignItems: 'center', + padding: theme.spacing.xl, + gap: theme.spacing.xl, + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: theme.spacing.xl, + }, + dotsContainer: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: theme.spacing.xl, + gap: theme.spacing.md, + }, + dot: { + width: theme.spacing.xs, + height: theme.spacing.xs, + borderRadius: theme.spacing.xs, + }, + dot1: { + backgroundColor: theme.colors.grey2, // Gray 200 + }, + dot2: { + backgroundColor: theme.colors.grey4, // Gray 400 + }, + dot3: { + backgroundColor: theme.colors.grey5, // Gray 600 + }, + dot4: { + backgroundColor: theme.colors.black, // Gray 800 + }, + title: { + textAlign: 'center', + }, +})) diff --git a/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/useSearchAccountsScreen.ts b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/useSearchAccountsScreen.ts new file mode 100644 index 00000000..9bd195c9 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/SearchAccountsScreen/useSearchAccountsScreen.ts @@ -0,0 +1,212 @@ +/* + 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 { useCallback, useEffect, useRef } from 'react' +import { Animated } from 'react-native' +import { useLanguage } from '@hooks/useLanguage' +import { useAppNavigation } from '@hooks/useAppNavigation' +import { RouteProp, useRoute } from '@react-navigation/native' +import { v7 as uuidv7 } from 'uuid' +import { + useHDWallet, + getSeedFromMasterKey, + AccountTypes, + type WalletAccount, + type HDWalletAccount, +} from '@perawallet/wallet-core-accounts' +import { useKMS } from '@perawallet/wallet-core-kms' +import { + useAlgorandClient, + encodeAlgorandAddress, +} from '@perawallet/wallet-core-blockchain' +import { OnboardingStackParamList } from '../../routes/types' + +export type UseSearchAccountsScreenResult = { + t: (key: string) => string + dotOpacities: Animated.Value[] +} + +const DOT_COUNT = 4 +const ANIMATION_DURATION = 400 +const STEP_DURATION = 500 +const TRANSPARENT_OPACITY = 0.3 +const FULL_OPACITY = 1 + +const MAX_ACCOUNT_GAP = 5 +const MAX_KEY_INDEX_GAP = 5 +const MAX_SEARCH_DEPTH = 20 + +export function useSearchAccountsScreen(): UseSearchAccountsScreenResult { + const { + params: { account }, + } = useRoute>() + const { t } = useLanguage() + const navigation = useAppNavigation() + const { getPrivateData } = useKMS() + const { deriveAccountAddress } = useHDWallet() + const algorandClient = useAlgorandClient() + + const onboardingWalletId = account.hdWalletDetails.walletId; + + const dotOpacities = useRef( + Array.from({ length: DOT_COUNT }, () => new Animated.Value(FULL_OPACITY)), + ).current + + useEffect(() => { + let currentIndex = 0 + + dotOpacities[0].setValue(TRANSPARENT_OPACITY) + + const interval = setInterval(() => { + const prevIndex = currentIndex + currentIndex = (currentIndex + 1) % DOT_COUNT + + Animated.parallel([ + Animated.timing(dotOpacities[prevIndex], { + toValue: FULL_OPACITY, + duration: ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.timing(dotOpacities[currentIndex], { + toValue: TRANSPARENT_OPACITY, + duration: ANIMATION_DURATION, + useNativeDriver: true, + }), + ]).start() + }, STEP_DURATION) + + return () => clearInterval(interval) + }, [dotOpacities]) + + const searchAccounts = useCallback(async () => { + if (!onboardingWalletId) { + return + } + + try { + const privateData = await getPrivateData(onboardingWalletId) + + if (!privateData) { + return + } + + const seed = getSeedFromMasterKey(privateData) + const derivationType = account.hdWalletDetails.derivationType + const foundAccounts: HDWalletAccount[] = [account] + + let accountGap = 0 + + for ( + let accountIndex = 0; + accountIndex < MAX_SEARCH_DEPTH; + accountIndex++ + ) { + if (accountGap >= MAX_ACCOUNT_GAP) { + break + } + + let keyIndexGap = 0 + let accountHasActivity = false + + for ( + let keyIndex = 0; + keyIndex < MAX_SEARCH_DEPTH; + keyIndex++ + ) { + if (keyIndexGap >= MAX_KEY_INDEX_GAP) { + break + } + + // Skip 0/0 as it was already imported in the previous step + if (accountIndex === 0 && keyIndex === 0) { + keyIndexGap = 0 + continue + } + + const { address } = await deriveAccountAddress({ + seed, + account: accountIndex, + keyIndex, + derivationType, + }) + + const encodedAddress = encodeAlgorandAddress(address) + const accountInfo = + await algorandClient.client.algod.accountInformation(encodedAddress) + + const hasActivity = + accountInfo.amount > 0 || + (accountInfo.assets && accountInfo.assets.length > 0) || + (accountInfo.appsLocalState && + accountInfo.appsLocalState.length > 0) || + (accountInfo.appsTotalSchema && + ((accountInfo.appsTotalSchema.numUints ?? 0) > 0 || + (accountInfo.appsTotalSchema.numByteSlices ?? 0) > 0)) + + if (hasActivity) { + accountHasActivity = true + keyIndexGap = 0 + + const newAccount: WalletAccount = { + id: uuidv7(), + address: encodedAddress, + type: AccountTypes.hdWallet, + canSign: true, + hdWalletDetails: { + walletId: onboardingWalletId!, + account: accountIndex, + change: 0, + keyIndex, + derivationType, + }, + } + + + foundAccounts.push(newAccount) + } else { + keyIndexGap++ + } + } + + if (accountHasActivity) { + accountGap = 0 + } else { + // We only count an account as "empty" if even its 0th key index is empty + // This matches the typical discovery logic + accountGap++ + } + } + + navigation.replace('ImportSelectAddresses', { accounts: foundAccounts }) + } catch { + // Error handling could be added here (e.g. show toast) + // For now we just stop searching + } + }, [ + onboardingWalletId, + getPrivateData, + deriveAccountAddress, + algorandClient, + navigation, + account, + ]) + + useEffect(() => { + searchAccounts() + }, [searchAccounts]) + + return { + t, + dotOpacities, + } +} + diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index 82e9bfee..1f5eff02 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -22,7 +22,7 @@ vi.mock('react-native-reanimated', () => { const React = require('react') const Reanimated = { default: { - call: () => {}, + call: () => { }, createAnimatedComponent: (component: any) => component, View: (props: any) => React.createElement('div', props, props.children), @@ -31,16 +31,16 @@ vi.mock('react-native-reanimated', () => { Image: (props: any) => React.createElement('img', props), ScrollView: (props: any) => React.createElement('div', props, props.children), - addWhitelistedNativeProps: () => {}, - addWhitelistedUIProps: () => {}, + addWhitelistedNativeProps: () => { }, + addWhitelistedUIProps: () => { }, }, useSharedValue: (v: any) => ({ value: v }), useDerivedValue: (a: any) => ({ value: a() }), useAnimatedStyle: () => ({}), useAnimatedProps: () => ({}), - useAnimatedGestureHandler: () => {}, - useAnimatedScrollHandler: () => {}, - useAnimatedReaction: () => {}, + useAnimatedGestureHandler: () => { }, + useAnimatedScrollHandler: () => { }, + useAnimatedReaction: () => { }, withTiming: (toValue: any) => toValue, withSpring: (toValue: any) => toValue, withDecay: () => 0, @@ -50,7 +50,7 @@ vi.mock('react-native-reanimated', () => { runOnJS: (fn: any) => fn, runOnUI: (fn: any) => fn, makeMutable: (v: any) => ({ value: v }), - cancelAnimation: () => {}, + cancelAnimation: () => { }, interpolate: () => 0, Extrapolate: { CLAMP: 'clamp' }, Layout: { @@ -284,11 +284,22 @@ vi.mock('react-native', () => { .mockImplementation(props => require('react').createElement('div', props, props.children), ), + FlatList: vi.fn().mockImplementation(({ data, renderItem, ...props }) => { + const React = require('react') + return React.createElement( + 'div', + { ...props, 'data-testid': 'FlatList' }, + data?.map((item: any, index: number) => + renderItem({ item, index }), + ), + ) + }), TextInput: vi .fn() .mockImplementation(props => require('react').createElement('input', props, props.children), ), + Modal: vi .fn() .mockImplementation(props => @@ -344,17 +355,36 @@ vi.mock('react-native', () => { quad: vi.fn(), }, Animated: { - timing: vi.fn(() => ({ start: vi.fn() })), - spring: vi.fn(() => ({ start: vi.fn() })), - event: vi.fn(), - Value: vi.fn(() => ({ - setValue: vi.fn(), - interpolate: vi.fn(() => '0px'), + timing: vi.fn(() => ({ start: vi.fn(cb => cb?.()) })), + spring: vi.fn(() => ({ start: vi.fn(cb => cb?.()) })), + parallel: vi.fn(animations => ({ + start: vi.fn(cb => { + animations.forEach((a: any) => a.start()) + cb?.() + }), })), + event: vi.fn(), + Value: vi.fn(function (this: any) { + this.setValue = vi.fn() + this.interpolate = vi.fn(() => '0px') + }), createAnimatedComponent: vi.fn(c => c), - View: vi.fn(({ children }) => children), - Text: vi.fn(({ children }) => children), + View: vi.fn(({ children, style, ...props }) => + require('react').createElement( + 'div', + { ...props, style }, + children, + ), + ), + Text: vi.fn(({ children, style, ...props }) => + require('react').createElement( + 'span', + { ...props, style }, + children, + ), + ), }, + PixelRatio: { get: vi.fn(() => 1), getFontScale: vi.fn(() => 1), @@ -399,7 +429,7 @@ vi.mock('react-native-quick-crypto', () => ({ // Basic NativeEventEmitter dependency to avoid errors when no native module is provided vi.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => { - return class NativeEventEmitter {} + return class NativeEventEmitter { } }) // Mock React Navigation @@ -410,9 +440,9 @@ vi.mock('@react-navigation/native', () => ({ reset: vi.fn(), setOptions: vi.fn(), }), - useRoute: () => ({ + useRoute: vi.fn(() => ({ params: {}, - }), + })), useFocusEffect: vi.fn(), NavigationContainer: ({ children }: any) => children, DefaultTheme: { @@ -464,7 +494,7 @@ vi.mock('@react-native-firebase/messaging', () => ({ vi.mock('@react-native-firebase/remote-config', () => ({ getRemoteConfig: () => ({ - setDefaults: vi.fn(async () => {}), + setDefaults: vi.fn(async () => { }), fetchAndActivate: vi.fn(async () => true), setConfigSettings: vi.fn(), getValue: vi.fn(() => ({ @@ -672,10 +702,10 @@ vi.mock('@rneui/themed', () => { ({ isVisible, children, ...props }: any) => isVisible ? React.createElement( - MockView, - { ...props, 'data-testid': 'Dialog' }, - children, - ) + MockView, + { ...props, 'data-testid': 'Dialog' }, + children, + ) : null, { Title: DialogTitle, @@ -749,18 +779,18 @@ vi.mock('@rneui/themed', () => { BottomSheet: ({ isVisible, children, ...props }: any) => isVisible ? React.createElement( - MockView, - { ...props, 'data-testid': 'RNEBottomSheet' }, - children, - ) + MockView, + { ...props, 'data-testid': 'RNEBottomSheet' }, + children, + ) : null, Overlay: ({ isVisible, children, ...props }: any) => isVisible ? React.createElement( - MockView, - { ...props, 'data-testid': 'RNEOverlay' }, - children, - ) + MockView, + { ...props, 'data-testid': 'RNEOverlay' }, + children, + ) : null, Icon: (props: any) => React.createElement(MockView, props), Tab, @@ -969,7 +999,15 @@ vi.mock('@perawallet/wallet-core-accounts', () => { isPending: false, })), ALGO_ASSET_ID: '0', + AccountTypes: { + algo25: 'algo25', + hdWallet: 'hdWallet', + hardware: 'hardware', + multisig: 'multisig', + watch: 'watch', + }, } + }) // Mock @perawallet/wallet-core-contacts diff --git a/packages/accounts/src/index.ts b/packages/accounts/src/index.ts index 4db03579..f5caf802 100644 --- a/packages/accounts/src/index.ts +++ b/packages/accounts/src/index.ts @@ -17,4 +17,5 @@ export * from './hooks' export * from './errors' export * from './utils' -export { initAccountsStore } from './store' +export { initAccountsStore, useAccountsStore } from './store' +