diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 279714655..ae2fc1c5a 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -599,7 +599,12 @@ "skip": "Skip for now", "fetching": "Fetching rekeyed accounts...", "rekeyed_account_subtitle": "Rekeyed account", - "importing_accounts": "Importing accounts..." + "importing_accounts": "Importing accounts...", + "info_sheet": { + "account_details": "Account details", + "assets": "Assets", + "can_be_signed_by": "Can be signed by" + } }, "add_account": { "title": "Add an\naccount", diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx index 3f2d673af..c78a65d91 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx @@ -22,8 +22,9 @@ import { } from '@components/core' import { WalletAccount } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' -import { useToast } from '@hooks/useToast' +import { useModalState } from '@hooks/useModalState' import { useStyles } from './styles' +import { RekeyedAccountInfoBottomSheet } from './RekeyedAccountInfoBottomSheet' type ImportRekeyedAddressesItemProps = { account: WalletAccount @@ -40,7 +41,7 @@ export const ImportRekeyedAddressesItem = ({ }: ImportRekeyedAddressesItemProps) => { const styles = useStyles() const { t } = useLanguage() - const { infoToast } = useToast() + const bottomSheetState = useModalState() return ( - infoToast( - t('common.not_implemented.title'), - t('common.not_implemented.body'), - ) - } + onPress={bottomSheetState.open} + testID={`import_rekeyed_addresses_item_info_${account.address}`} > + + {isImported && ( void + account: WalletAccount +} + +export const RekeyedAccountInfoBottomSheet = ({ + isVisible, + onClose, + account, +}: RekeyedAccountInfoBottomSheetProps) => { + const styles = useStyles() + const { t } = useLanguage() + const { + rekeyedAccountBalances, + rekeyedAccountAlgoValue, + authAddress, + authAccountAlgoValue, + } = useRekeyedAccountInfoBottomSheet({ account, isVisible }) + + return ( + + + } + center={ + + {truncateAlgorandAddress(account.address)} + + } + paddingStyle='dense' + /> + + + + {t( + 'onboarding.import_rekeyed_addresses.info_sheet.account_details', + )} + + + + + + + {account.address} + + + {t( + 'onboarding.import_rekeyed_addresses.rekeyed_account_subtitle', + )} + + + + + + + + + + + + {t('onboarding.import_rekeyed_addresses.info_sheet.assets')} + + + {rekeyedAccountBalances.map(balance => ( + + ))} + + {!!authAddress && ( + <> + + + + {t( + 'onboarding.import_rekeyed_addresses.info_sheet.can_be_signed_by', + )} + + + + + + + {authAddress} + + + + + + + + + )} + + + + + ) +} diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/__tests__/RekeyedAccountInfoBottomSheet.spec.tsx b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/__tests__/RekeyedAccountInfoBottomSheet.spec.tsx new file mode 100644 index 000000000..433403685 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/__tests__/RekeyedAccountInfoBottomSheet.spec.tsx @@ -0,0 +1,158 @@ +/* + 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, fireEvent } from '@test-utils/render' +import { describe, it, expect, vi } from 'vitest' +import { RekeyedAccountInfoBottomSheet } from '../RekeyedAccountInfoBottomSheet' +import { AccountTypes } from '@perawallet/wallet-core-accounts' +import Decimal from 'decimal.js' + +vi.mock('@perawallet/wallet-core-currencies', () => ({ + useCurrency: vi.fn(() => ({ + preferredCurrency: 'USD', + fallbackCurrency: 'USD', + usdToPreferred: (v: Decimal) => v, + })), + usePreferredCurrencyPriceQuery: vi.fn(() => ({ + data: null, + isPending: false, + })), +})) + +vi.mock('@modules/assets/components/AssetItem', () => ({ + AccountAssetItemView: ({ + accountBalance, + }: { + accountBalance: { assetId: string } + }) =>
, +})) + +const mockUseRekeyedAccountInfoBottomSheet = vi.hoisted(() => vi.fn()) + +vi.mock('../useRekeyedAccountInfoBottomSheet', () => ({ + useRekeyedAccountInfoBottomSheet: mockUseRekeyedAccountInfoBottomSheet, +})) + +const MOCK_ACCOUNT = { + id: '1', + address: 'P4ZYH3ABCDEFGHIJKLMNOPQRSTUVWXYZ1234YW4XM4', + type: AccountTypes.algo25, + rekeyAddress: 'Z6LHO4ABCDEFGHIJKLMNOPQRSTUVWXYZ1234WBYIMM', + keyPairId: 'pk', +} + +const MOCK_HOOK_RESULT = { + rekeyedAccountBalances: [ + { + assetId: '0', + amount: Decimal(1027), + algoValue: Decimal(1027), + }, + ], + rekeyedAccountAlgoValue: Decimal(1027), + authAddress: 'Z6LHO4ABCDEFGHIJKLMNOPQRSTUVWXYZ1234WBYIMM', + authAccountAlgoValue: Decimal(0), + isPending: false, +} + +describe('RekeyedAccountInfoBottomSheet', () => { + beforeEach(() => { + mockUseRekeyedAccountInfoBottomSheet.mockReturnValue(MOCK_HOOK_RESULT) + }) + + it('renders section headers when visible', () => { + render( + , + ) + + expect( + screen.getByText( + 'onboarding.import_rekeyed_addresses.info_sheet.account_details', + ), + ).toBeTruthy() + expect( + screen.getByText( + 'onboarding.import_rekeyed_addresses.info_sheet.assets', + ), + ).toBeTruthy() + expect( + screen.getByText( + 'onboarding.import_rekeyed_addresses.info_sheet.can_be_signed_by', + ), + ).toBeTruthy() + }) + + it('renders the account address in account details section', () => { + render( + , + ) + + expect( + screen.getAllByText(MOCK_ACCOUNT.address).length, + ).toBeGreaterThanOrEqual(1) + }) + + it('renders the auth address in can be signed by section', () => { + render( + , + ) + + expect(screen.getByText(MOCK_ACCOUNT.rekeyAddress)).toBeTruthy() + }) + + it('calls onClose when close button is pressed', () => { + const onClose = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('close-button')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('hides can be signed by section when no auth address', () => { + mockUseRekeyedAccountInfoBottomSheet.mockReturnValue({ + ...MOCK_HOOK_RESULT, + authAddress: undefined, + }) + + render( + , + ) + + expect( + screen.queryByText( + 'onboarding.import_rekeyed_addresses.info_sheet.can_be_signed_by', + ), + ).toBeNull() + }) +}) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/index.ts b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/index.ts new file mode 100644 index 000000000..3b50a88fb --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/index.ts @@ -0,0 +1,14 @@ +/* + 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 { RekeyedAccountInfoBottomSheet } from './RekeyedAccountInfoBottomSheet' +export type { RekeyedAccountInfoBottomSheetProps } from './RekeyedAccountInfoBottomSheet' diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/styles.ts b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/styles.ts new file mode 100644 index 000000000..1a11a50c4 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/styles.ts @@ -0,0 +1,56 @@ +/* + 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: { + paddingHorizontal: theme.spacing.lg, + }, + scrollContent: { + paddingBottom: theme.spacing.xl, + }, + sectionTitle: { + color: theme.colors.textGray, + marginTop: theme.spacing.lg, + marginBottom: theme.spacing.md, + }, + accountRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + paddingVertical: theme.spacing.md, + }, + accountTextContainer: { + flex: 1, + }, + accountSubtitle: { + color: theme.colors.textGray, + }, + balanceContainer: { + alignItems: 'flex-end', + }, + secondaryBalance: { + color: theme.colors.textGray, + }, + divider: { + height: theme.borders.sm, + backgroundColor: theme.colors.layerGrayLighter, + marginTop: theme.spacing.sm, + }, + assetItem: { + paddingVertical: theme.spacing.md, + }, + closeButton: { + marginTop: theme.spacing.xl, + }, +})) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/useRekeyedAccountInfoBottomSheet.ts b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/useRekeyedAccountInfoBottomSheet.ts new file mode 100644 index 000000000..1f8e8baa0 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/RekeyedAccountInfoBottomSheet/useRekeyedAccountInfoBottomSheet.ts @@ -0,0 +1,92 @@ +/* + 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 { useMemo } from 'react' +import { + AccountTypes, + AssetWithAccountBalance, + useAccountBalancesQuery, + WalletAccount, + WatchAccount, +} from '@perawallet/wallet-core-accounts' +import { ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' +import Decimal from 'decimal.js' + +type UseRekeyedAccountInfoBottomSheetParams = { + account: WalletAccount + isVisible: boolean +} + +export type UseRekeyedAccountInfoBottomSheetResult = { + rekeyedAccountBalances: AssetWithAccountBalance[] + rekeyedAccountAlgoValue: Decimal + authAddress: string | undefined + authAccountAlgoValue: Decimal + isPending: boolean +} + +export function useRekeyedAccountInfoBottomSheet({ + account, + isVisible, +}: UseRekeyedAccountInfoBottomSheetParams): UseRekeyedAccountInfoBottomSheetResult { + const { accountBalances: rekeyedBalances, isPending: isRekeyedPending } = + useAccountBalancesQuery([account], isVisible) + + const authAccount = useMemo(() => { + if (!account.rekeyAddress) return undefined + return { + address: account.rekeyAddress, + type: AccountTypes.watch, + } + }, [account.rekeyAddress]) + + const { accountBalances: authBalances, isPending: isAuthPending } = + useAccountBalancesQuery( + authAccount ? [authAccount] : [], + isVisible && !!authAccount, + ) + + const rekeyedAccountData = useMemo(() => { + const balanceData = rekeyedBalances.get(account.address) + if (!balanceData) { + return { + balances: [], + algoValue: Decimal(0), + } + } + + const sorted = [...balanceData.assetBalances].sort((a, b) => { + if (a.assetId === ALGO_ASSET_ID) return -1 + if (b.assetId === ALGO_ASSET_ID) return 1 + return 0 + }) + + return { + balances: sorted, + algoValue: balanceData.algoValue, + } + }, [rekeyedBalances, account.address]) + + const authAccountAlgoValue = useMemo(() => { + if (!authAccount) return Decimal(0) + const balanceData = authBalances.get(authAccount.address) + return balanceData?.algoValue ?? Decimal(0) + }, [authBalances, authAccount]) + + return { + rekeyedAccountBalances: rekeyedAccountData.balances, + rekeyedAccountAlgoValue: rekeyedAccountData.algoValue, + authAddress: account.rekeyAddress, + authAccountAlgoValue, + isPending: isRekeyedPending || isAuthPending, + } +} diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesItem.spec.tsx b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesItem.spec.tsx index fa693c3f1..9adc74f2a 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesItem.spec.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesItem.spec.tsx @@ -15,6 +15,13 @@ import { vi } from 'vitest' import { ImportRekeyedAddressesItem } from '../ImportRekeyedAddressesItem' import { AccountTypes } from '@perawallet/wallet-core-accounts' +vi.mock('../RekeyedAccountInfoBottomSheet', () => ({ + RekeyedAccountInfoBottomSheet: ({ isVisible }: { isVisible: boolean }) => + isVisible ? ( +
+ ) : null, +})) + const MOCK_ACCOUNT = { id: '1', address: 'MOCK_ADDRESS', @@ -78,6 +85,30 @@ describe('ImportRekeyedAddressesItem', () => { // but checking the chip is the main visual indicator here. }) + it('opens info bottom sheet when info icon is pressed', () => { + render( + , + ) + + expect( + screen.queryByTestId('rekeyed-account-info-bottom-sheet'), + ).toBeNull() + + const infoButton = screen.getByTestId( + `import_rekeyed_addresses_item_info_${MOCK_ACCOUNT.address}`, + ) + fireEvent.click(infoButton) + + expect( + screen.getByTestId('rekeyed-account-info-bottom-sheet'), + ).toBeTruthy() + }) + it('renders selected state correctly', () => { // Since PWCheckbox is wrapped, we might verify it via props mocking if we deeply tested, // but for now we trust the component passes the prop. diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesScreen.spec.tsx b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesScreen.spec.tsx index bf8a94e26..9b3af9bd5 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesScreen.spec.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/__tests__/ImportRekeyedAddressesScreen.spec.tsx @@ -21,6 +21,10 @@ vi.mock('../useImportRekeyedAddressesScreen', () => ({ useImportRekeyedAddressesScreen: vi.fn(), })) +vi.mock('../RekeyedAccountInfoBottomSheet', () => ({ + RekeyedAccountInfoBottomSheet: () => null, +})) + // Use global mock from vitest.setup.ts for @components/core describe('ImportRekeyedAddressesScreen', () => {