diff --git a/apps/mobile/src/components/WealthChart/WealthChart.tsx b/apps/mobile/src/components/WealthChart/WealthChart.tsx index b55079f93..ad15528fb 100644 --- a/apps/mobile/src/components/WealthChart/WealthChart.tsx +++ b/apps/mobile/src/components/WealthChart/WealthChart.tsx @@ -24,13 +24,14 @@ import { useAllAccounts, WalletAccount, } from '@perawallet/wallet-core-accounts' -import { LoadingView } from '../LoadingView' import { EmptyView } from '../EmptyView' +import { LoadingView } from '../LoadingView' import { CHART_ANIMATION_DURATION, CHART_FOCUS_DEBOUNCE_TIME, CHART_HEIGHT, } from '@constants/ui' +import { getChartYAxisRange } from '@utils/chart' export type WealthChartProps = { account?: WalletAccount @@ -74,15 +75,10 @@ export const WealthChart = ({ [data], ) - const yAxisOffsets = useMemo(() => { - const minValue = Math.min(...dataPoints.map(p => p.value)) - const maxValue = Math.max(...dataPoints.map(p => p.value)) - if (minValue === 0 && maxValue === 0) { - return [-1, 1] - } else { - return [minValue - minValue / 10, maxValue + maxValue / 10] - } - }, [dataPoints]) + const yAxisRange = useMemo( + () => getChartYAxisRange(dataPoints), + [dataPoints], + ) const onFocus = useCallback( ({ @@ -113,18 +109,14 @@ export const WealthChart = ({ ], ) - if (isPending) { - return ( - - ) - } - return ( - {!dataPoints?.length ? ( + {isPending ? ( + + ) : !dataPoints?.length ? ( { const listRef = useRef(null) const styles = useStyles() @@ -62,6 +64,7 @@ export const AccountAssetList = ({ optOutConfirmationState, assetForOptOut, isOptingOut, + headerState, setSearchFilter, handleConfirmOptOut, handleCloseOptOut, @@ -108,33 +111,42 @@ export const AccountAssetList = ({ const listHeader = ( - - {header} - - - {t('account_details.assets.title')} - - {!isWatch && ( - - - + + {headerState.isOpen && ( + <> + {header} + + + {t('account_details.assets.title')} + + {!isWatch && ( + + + + + )} - )} - + + )} ) @@ -145,38 +157,43 @@ export const AccountAssetList = ({ behavior='padding' style={styles.keyboardAvoidingViewContainer} > - item.assetId} - estimatedItemSize={72} - recycleItems - automaticallyAdjustKeyboardInsets - keyboardDismissMode='interactive' - contentContainerStyle={styles.rootContainer} - ListHeaderComponent={listHeader} - searchPlaceholder={t( - 'account_details.assets.search_placeholder', - )} - onSearchChange={setSearchFilter} - ListEmptyComponent={ - isPending ? ( - - ) : ( - - ) - } - /> + + item.assetId} + estimatedItemSize={72} + recycleItems + automaticallyAdjustKeyboardInsets + keyboardDismissMode='interactive' + contentContainerStyle={styles.rootContainer} + ListHeaderComponent={listHeader} + searchPlaceholder={t( + 'account_details.assets.search_placeholder', + )} + onSearchChange={setSearchFilter} + ListEmptyComponent={ + isPending ? ( + + ) : ( + + ) + } + /> + { describe('backup banner integration', () => { beforeEach(() => { mockBackupReminderBannerHook.mockReset() + vi.useFakeTimers() }) + afterEach(() => { + vi.useRealTimers() + }) + + const advancePastRevealDelay = () => { + act(() => { + vi.advanceTimersByTime(BACKUP_REMINDER_BANNER_REVEAL_DELAY) + }) + } + it('renders backup reminder banner when hook reports visible', () => { mockBackupReminderBannerHook.mockReturnValue({ isVisible: true, @@ -262,6 +275,7 @@ describe('AccountAssetList', () => { }) render() + advancePastRevealDelay() expect(screen.getByTestId('backup_reminder_banner')).toBeTruthy() }) @@ -275,6 +289,7 @@ describe('AccountAssetList', () => { const { queryByTestId } = render( , ) + advancePastRevealDelay() expect(queryByTestId('backup_reminder_banner')).toBeNull() }) diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts index abd6f6f83..4b210fcb4 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts @@ -45,6 +45,7 @@ type UseAccountAssetListResult = { hideZeroBalance: boolean assetSortMode: AssetSortMode searchFilter: string + headerState: ModalState manageSheetState: ModalState sortSheetState: ModalState filterSheetState: ModalState @@ -79,6 +80,7 @@ export const useAccountAssetList = ({ account, t, }: UseAccountAssetListParams): UseAccountAssetListResult => { + const headerState = useModalState(true) const manageSheetState = useModalState(false) const sortSheetState = useModalState(false) const filterSheetState = useModalState(false) @@ -161,6 +163,7 @@ export const useAccountAssetList = ({ const goToAssetScreen = useCallback( (item: AssetWithAccountBalance) => { + headerState.open() const assetInfo = assets?.get(item.assetId) if (assetInfo && isCollectible(assetInfo)) { navigation.navigate('CollectibleDetails', { @@ -172,7 +175,7 @@ export const useAccountAssetList = ({ }) } }, - [navigation, assets], + [headerState, navigation, assets], ) const handleOptOut = useCallback( @@ -279,6 +282,7 @@ export const useAccountAssetList = ({ hideZeroBalance, assetSortMode, searchFilter, + headerState, manageSheetState, sortSheetState, filterSheetState, diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverview.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverview.tsx index c868a340c..d998ea2c1 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverview.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverview.tsx @@ -10,7 +10,6 @@ limitations under the License */ -import { useEffect, useMemo } from 'react' import { AccountOverviewHeader } from './AccountOverviewHeader' import { SendFundsBottomSheet } from '@modules/transactions/components/send-funds/SendFundsBottomSheet/SendFundsBottomSheet' import { ReceiveFundsBottomSheet } from '@modules/transactions/components/receive-funds/ReceiveFundsBottomSheet' @@ -20,10 +19,7 @@ import { useAccountOverview } from './useAccountOverview' import { PWView } from '@components/core' import { AccountAssetList } from '../AccountAssetList' import { AccountOptionsBottomSheet } from '../AccountOptionsBottomSheet' -import { - AccountOverviewModalContext, - UseAccountOverviewModalResult, -} from './AccountOverviewModalContext' +import { AccountOverviewModalContext } from './AccountOverviewModalContext' export type AccountOverviewProps = { account: WalletAccount @@ -39,38 +35,16 @@ export const AccountOverview = ({ const styles = useStyles() const { isSendFundsVisible, - openSendFunds, handleCloseSendFunds, isReceiveFundsVisible, openReceiveFunds, handleCloseReceiveFunds, isAccountOptionsVisible, - openAccountOptions, handleCloseAccountOptions, scrollingEnabled, - onScrollEnabledChange, - } = useAccountOverview(account) - - useEffect(() => { - onSwipeEnabledChange?.(scrollingEnabled) - }, [scrollingEnabled, onSwipeEnabledChange]) - - const contextValue = useMemo( - () => ({ - account, - openSendFunds, - openReceiveFunds, - openAccountOptions, - onScrollEnabledChange, - }), - [ - account, - openSendFunds, - openReceiveFunds, - openAccountOptions, - onScrollEnabledChange, - ], - ) + isLoading, + contextValue, + } = useAccountOverview({ account, onSwipeEnabledChange }) return ( @@ -78,10 +52,12 @@ export const AccountOverview = ({ } /> diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeader.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeader.tsx index d9507485f..ff5dfaac0 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeader.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeader.tsx @@ -31,15 +31,18 @@ import { ALGO_ASSET } from '@perawallet/wallet-core-assets' import { PreferredCurrencyDisplay } from '@components/PreferredCurrencyDisplay' import { ExpandablePanel } from '@components/ExpandablePanel' import { useAccountOverviewHeader } from './useAccountOverviewHeader' +import { AccountOverviewHeaderSkeleton } from './AccountOverviewHeaderSkeleton' export type AccountOverviewHeaderProps = { account: WalletAccount chartVisible: boolean + isLoading: boolean } export const AccountOverviewHeader = ({ account, chartVisible, + isLoading, }: AccountOverviewHeaderProps) => { const styles = useStyles() const { t } = useLanguage() @@ -55,81 +58,84 @@ export const AccountOverviewHeader = ({ handleChartSelectionChange, } = useAccountOverviewHeader(account) - return hasBalance || isPending ? ( + // While loading we always render the primary layout underneath so its mount + // animations (ExpandablePanel, LineChart) run hidden by the opaque skeleton + // overlay. By the time the overlay unmounts, everything below is settled. + const showPrimary = hasBalance || isLoading + + return ( - - - - - - + {showPrimary ? ( + <> + + + + + + - {!selectedPoint && ( - - )} - {selectedPoint && ( - - {formatDatetime(selectedPoint.datetime)} - - )} - - + {!selectedPoint && ( + + )} + {selectedPoint && ( + + {formatDatetime(selectedPoint.datetime)} + + )} + + - - - - - - + + + + + + - - {!canSign ? : } - - - ) : ( - - {!canSign ? ( + {!canSign ? : } + + ) : !canSign ? ( ) : ( <> @@ -150,6 +156,12 @@ export const AccountOverviewHeader = ({ )} + + {isLoading && ( + + + + )} ) } diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeaderSkeleton.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeaderSkeleton.tsx new file mode 100644 index 000000000..6a218fba1 --- /dev/null +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/AccountOverviewHeaderSkeleton.tsx @@ -0,0 +1,72 @@ +/* + 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 { PWView } from '@components/core' +import { useStyles } from './styles' + +const PERIOD_BUTTON_COUNT = 5 +const ACTION_BUTTON_COUNT = 4 + +export const AccountOverviewHeaderSkeleton = () => { + const styles = useStyles() + + return ( + + + + + + + + + + + + + + {Array.from({ length: PERIOD_BUTTON_COUNT }, (_, i) => ( + + ))} + + + + {Array.from({ length: ACTION_BUTTON_COUNT }, (_, i) => ( + + ))} + + + ) +} diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/AccountOverview.spec.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/AccountOverview.spec.tsx index bfdd9c9b6..63636c52e 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/AccountOverview.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/AccountOverview.spec.tsx @@ -65,6 +65,10 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { isRefetching: false, isError: false, })), + useAccountBalancesHistoryQuery: vi.fn(() => ({ + data: undefined, + isPending: false, + })), usePortfolioTotals: vi.fn(() => ({ portfolioUsdValue: new Decimal('200'), accountUsdValues: new Map(), diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverview.spec.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverview.spec.tsx index 1682fffd0..a7ead9c74 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverview.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverview.spec.tsx @@ -15,13 +15,26 @@ import { renderHook, act } from '@testing-library/react' import { useAccountOverview } from '../useAccountOverview' import { WalletAccount } from '@perawallet/wallet-core-accounts' -const { mockSetSelectedAccount, mockSetCanSelectAccount } = vi.hoisted(() => ({ +const { + mockSetSelectedAccount, + mockSetCanSelectAccount, + mockBalancesPending, + mockHistoryPending, +} = vi.hoisted(() => ({ mockSetSelectedAccount: vi.fn(), mockSetCanSelectAccount: vi.fn(), + mockBalancesPending: { value: false }, + mockHistoryPending: { value: false }, })) vi.mock('@perawallet/wallet-core-accounts', () => ({ useSelectedAccount: vi.fn(() => ({ address: 'selected-address' })), + useAccountBalancesQuery: vi.fn(() => ({ + isPending: mockBalancesPending.value, + })), + useAccountBalancesHistoryQuery: vi.fn(() => ({ + isPending: mockHistoryPending.value, + })), })) vi.mock('@modules/transactions/hooks', () => ({ @@ -31,15 +44,24 @@ vi.mock('@modules/transactions/hooks', () => ({ })), })) -describe('useAccountOverview', () => { - const mockAccount = { address: 'test-address' } as WalletAccount +const mockAccount = { address: 'test-address' } as WalletAccount +const renderUseAccountOverview = ( + onSwipeEnabledChange?: (enabled: boolean) => void, +) => + renderHook(() => + useAccountOverview({ account: mockAccount, onSwipeEnabledChange }), + ) + +describe('useAccountOverview', () => { beforeEach(() => { vi.clearAllMocks() + mockBalancesPending.value = false + mockHistoryPending.value = false }) it('opens send funds modal when openSendFunds is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() expect(result.current.isSendFundsVisible).toBe(false) @@ -51,7 +73,7 @@ describe('useAccountOverview', () => { }) it('closes send funds modal when handleCloseSendFunds is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() act(() => { result.current.openSendFunds() @@ -67,7 +89,7 @@ describe('useAccountOverview', () => { }) it('opens receive funds modal and sets selected account when openReceiveFunds is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() expect(result.current.isReceiveFundsVisible).toBe(false) @@ -83,7 +105,7 @@ describe('useAccountOverview', () => { }) it('closes receive funds modal when handleCloseReceiveFunds is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() act(() => { result.current.openReceiveFunds() @@ -99,7 +121,7 @@ describe('useAccountOverview', () => { }) it('opens account options when openAccountOptions is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() expect(result.current.isAccountOptionsVisible).toBe(false) @@ -111,7 +133,7 @@ describe('useAccountOverview', () => { }) it('closes account options when handleCloseAccountOptions is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() act(() => { result.current.openAccountOptions() @@ -127,13 +149,13 @@ describe('useAccountOverview', () => { }) it('starts with scrolling enabled', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() expect(result.current.scrollingEnabled).toBe(true) }) it('updates scrolling state when onScrollEnabledChange is called', () => { - const { result } = renderHook(() => useAccountOverview(mockAccount)) + const { result } = renderUseAccountOverview() act(() => { result.current.onScrollEnabledChange(false) @@ -147,4 +169,56 @@ describe('useAccountOverview', () => { expect(result.current.scrollingEnabled).toBe(true) }) + + it('forwards scrollingEnabled changes to onSwipeEnabledChange', () => { + const onSwipeEnabledChange = vi.fn() + const { result } = renderUseAccountOverview(onSwipeEnabledChange) + + expect(onSwipeEnabledChange).toHaveBeenLastCalledWith(true) + + act(() => { + result.current.onScrollEnabledChange(false) + }) + + expect(onSwipeEnabledChange).toHaveBeenLastCalledWith(false) + }) + + it('isLoading is true while balances or history are pending and stays false once both have completed', () => { + mockBalancesPending.value = true + mockHistoryPending.value = true + const { result, rerender } = renderUseAccountOverview() + + expect(result.current.isLoading).toBe(true) + + mockBalancesPending.value = false + rerender() + expect(result.current.isLoading).toBe(true) + + mockHistoryPending.value = false + rerender() + expect(result.current.isLoading).toBe(false) + + // Sticky: once cleared, isLoading does not flip back to true. + mockBalancesPending.value = true + rerender() + expect(result.current.isLoading).toBe(false) + }) + + it('exposes a contextValue containing the account and modal openers', () => { + const { result } = renderUseAccountOverview() + + expect(result.current.contextValue.account).toBe(mockAccount) + expect(typeof result.current.contextValue.openSendFunds).toBe( + 'function', + ) + expect(typeof result.current.contextValue.openReceiveFunds).toBe( + 'function', + ) + expect(typeof result.current.contextValue.openAccountOptions).toBe( + 'function', + ) + expect(typeof result.current.contextValue.onScrollEnabledChange).toBe( + 'function', + ) + }) }) diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx index 80b916024..f78da48c9 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/__tests__/useAccountOverviewHeader.spec.tsx @@ -40,6 +40,10 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { isRefetching: false, isError: false, })), + useAccountBalancesHistoryQuery: vi.fn(() => ({ + data: undefined, + isPending: false, + })), usePortfolioTotals: vi.fn(() => ({ portfolioUsdValue: new Decimal('200'), accountUsdValues: new Map(), diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/styles.ts b/apps/mobile/src/modules/accounts/components/AccountOverview/styles.ts index 875603bad..5d946c09c 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/styles.ts +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/styles.ts @@ -11,8 +11,10 @@ */ import { makeStyles } from '@rneui/themed' +import { CHART_HEIGHT } from '@constants/ui' -const HEADER_MIN_HEIGHT = 220 +const HEADER_HEIGHT = 412 +const SKELETON_BORDER_RADIUS = 8 export const useStyles = makeStyles(theme => { return { @@ -120,7 +122,66 @@ export const useStyles = makeStyles(theme => { textAlign: 'center', }, headerContainer: { - minHeight: HEADER_MIN_HEIGHT, // Fixed value to prevent "bouncing" on load + // Fixed height so the page doesn't reflow as queries resolve and + // the skeleton overlay matches the loaded layout exactly. + height: HEADER_HEIGHT, + }, + skeletonOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + // Solid background so internal mount animations (ExpandablePanel, + // LineChart, currency-display loading swap) running underneath + // are fully occluded until the overlay unmounts. + backgroundColor: theme.colors.background, + }, + skeletonRoot: { + flex: 1, + }, + skeletonBlock: { + backgroundColor: theme.colors.layerGrayLighter, + borderRadius: SKELETON_BORDER_RADIUS, + }, + skeletonPrimaryValue: { + height: theme.spacing.xxl, + width: '50%', + }, + skeletonSecondaryValue: { + height: theme.spacing.xl, + width: '35%', + }, + skeletonTrend: { + height: theme.spacing.xl, + width: '20%', + }, + skeletonChart: { + width: '100%', + height: CHART_HEIGHT, + }, + skeletonPeriodRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: theme.spacing.sm, + marginTop: theme.spacing.xs, + }, + skeletonPeriodButton: { + width: theme.spacing['3xl'], + height: theme.spacing.xl, + }, + skeletonButtonRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: theme.spacing.md, + marginTop: theme.spacing.xl, + }, + skeletonActionButton: { + width: theme.spacing['4xl'], + height: theme.spacing['4xl'], + borderRadius: theme.spacing['3xl'], }, } }) diff --git a/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverview.ts b/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverview.ts index a3d7980af..9a3c4d55d 100644 --- a/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverview.ts +++ b/apps/mobile/src/modules/accounts/components/AccountOverview/useAccountOverview.ts @@ -10,13 +10,26 @@ limitations under the License */ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { + useAccountBalancesHistoryQuery, + useAccountBalancesQuery, useSelectedAccount, WalletAccount, } from '@perawallet/wallet-core-accounts' +import { HistoryPeriod } from '@perawallet/wallet-core-shared' import { useModalState } from '@hooks/useModalState' import { useReceiveFunds } from '@modules/transactions/hooks' +import { UseAccountOverviewModalResult } from './AccountOverviewModalContext' + +// Matches the default in useChartInteraction so balances + history queries +// share keys with the header's chart and we don't double-fetch. +const INITIAL_HISTORY_PERIOD: HistoryPeriod = 'one-week' + +export type UseAccountOverviewParams = { + account: WalletAccount + onSwipeEnabledChange?: (enabled: boolean) => void +} export type UseAccountOverviewResult = { isSendFundsVisible: boolean @@ -30,11 +43,14 @@ export type UseAccountOverviewResult = { handleCloseAccountOptions: () => void scrollingEnabled: boolean onScrollEnabledChange: (enabled: boolean) => void + isLoading: boolean + contextValue: UseAccountOverviewModalResult } -export const useAccountOverview = ( - _account: WalletAccount, -): UseAccountOverviewResult => { +export const useAccountOverview = ({ + account, + onSwipeEnabledChange, +}: UseAccountOverviewParams): UseAccountOverviewResult => { const selectedAccount = useSelectedAccount() const { setSelectedAccount, setCanSelectAccount } = useReceiveFunds() @@ -71,6 +87,44 @@ export const useAccountOverview = ( const [scrollingEnabled, setScrollingEnabled] = useState(true) + useEffect(() => { + onSwipeEnabledChange?.(scrollingEnabled) + }, [scrollingEnabled, onSwipeEnabledChange]) + + // Combine balance + history pending into one sticky `isLoading` flag so the + // skeleton header reveals as a single beat and doesn't reappear when the + // user later changes period. + const { isPending: isBalancesPending } = useAccountBalancesQuery( + account ? [account] : [], + ) + const { isPending: isHistoryPending } = useAccountBalancesHistoryQuery( + account ? [account.address] : [], + INITIAL_HISTORY_PERIOD, + ) + const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = + useState(false) + useEffect(() => { + if ( + !hasCompletedInitialLoad && + !isBalancesPending && + !isHistoryPending + ) { + setHasCompletedInitialLoad(true) + } + }, [hasCompletedInitialLoad, isBalancesPending, isHistoryPending]) + const isLoading = !hasCompletedInitialLoad + + const contextValue = useMemo( + () => ({ + account, + openSendFunds, + openReceiveFunds, + openAccountOptions, + onScrollEnabledChange: setScrollingEnabled, + }), + [account, openSendFunds, openReceiveFunds, openAccountOptions], + ) + return { isSendFundsVisible, openSendFunds, @@ -83,5 +137,7 @@ export const useAccountOverview = ( handleCloseAccountOptions, scrollingEnabled, onScrollEnabledChange: setScrollingEnabled, + isLoading, + contextValue, } } diff --git a/apps/mobile/src/modules/accounts/components/BackupReminderBanner/BackupReminderBanner.tsx b/apps/mobile/src/modules/accounts/components/BackupReminderBanner/BackupReminderBanner.tsx index 7742292a4..bc538c131 100644 --- a/apps/mobile/src/modules/accounts/components/BackupReminderBanner/BackupReminderBanner.tsx +++ b/apps/mobile/src/modules/accounts/components/BackupReminderBanner/BackupReminderBanner.tsx @@ -10,52 +10,104 @@ limitations under the License */ -import { memo } from 'react' +import { memo, useEffect, useState } from 'react' +import Animated, { + withTiming, + type EntryAnimationsValues, +} from 'react-native-reanimated' import type { WalletAccount } from '@perawallet/wallet-core-accounts' import { PWIcon, PWText, PWTouchableOpacity, PWView } from '@components/core' import { useLanguage } from '@hooks/useLanguage' +import { + BACKUP_REMINDER_BANNER_REVEAL_DELAY, + BACKUP_REMINDER_BANNER_REVEAL_DURATION, +} from '@constants/ui' import { useBackupReminderBanner } from './useBackupReminderBanner' import { useStyles } from './styles' +// Entering animation: grow height from 0 to natural while fading in. Uses the +// `targetHeight` reanimated provides so the banner ends at its real height +// without us hardcoding it. +const expandAndFadeEntering = (values: EntryAnimationsValues) => { + 'worklet' + return { + initialValues: { + opacity: 0, + height: 0, + }, + animations: { + opacity: withTiming(1, { + duration: BACKUP_REMINDER_BANNER_REVEAL_DURATION, + }), + height: withTiming(values.targetHeight, { + duration: BACKUP_REMINDER_BANNER_REVEAL_DURATION, + }), + }, + } +} + type BackupReminderBannerProps = { account: WalletAccount + isLoading?: boolean } const BackupReminderBannerComponent = ({ account, + isLoading = false, }: BackupReminderBannerProps) => { const styles = useStyles() const { t } = useLanguage() const { isVisible, onPress } = useBackupReminderBanner(account) + // The reveal-delay timer starts only after the parent finishes its initial + // load, so the banner animates in as a second beat instead of riding the + // skeleton-to-content swap. + const [hasDelayed, setHasDelayed] = useState(false) + + useEffect(() => { + if (isLoading || hasDelayed) return + const timer = setTimeout( + () => setHasDelayed(true), + BACKUP_REMINDER_BANNER_REVEAL_DELAY, + ) + return () => clearTimeout(timer) + }, [isLoading, hasDelayed]) - if (!isVisible) return null + if (!isVisible || !hasDelayed) return null return ( - - - {t('backup.banner.text')} - - {t('backup.banner.cta')} - - + + {t('backup.banner.text')} + + + {t('backup.banner.cta')} + + + + ) } // Memoized: this banner lives in the asset-list header and re-renders on every -// parent render. Skipping when `account` reference is stable saves a Zustand -// selector + TanStack subscription pass per render. +// parent render. Skipping when inputs are stable saves a Zustand selector + +// TanStack subscription pass per render. export const BackupReminderBanner = memo( BackupReminderBannerComponent, - (prev, next) => prev.account === next.account, + (prev, next) => + prev.account === next.account && prev.isLoading === next.isLoading, ) diff --git a/apps/mobile/src/modules/accounts/components/BackupReminderBanner/__tests__/BackupReminderBanner.spec.tsx b/apps/mobile/src/modules/accounts/components/BackupReminderBanner/__tests__/BackupReminderBanner.spec.tsx index 5b851b04d..6a1b80440 100644 --- a/apps/mobile/src/modules/accounts/components/BackupReminderBanner/__tests__/BackupReminderBanner.spec.tsx +++ b/apps/mobile/src/modules/accounts/components/BackupReminderBanner/__tests__/BackupReminderBanner.spec.tsx @@ -10,8 +10,8 @@ limitations under the License */ -import { describe, test, expect, vi } from 'vitest' -import React from 'react' +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import React, { act } from 'react' import { render, screen, fireEvent } from '@test-utils/render' import { AccountTypes, @@ -36,6 +36,7 @@ vi.mock('@hooks/useLanguage', () => ({ })) import { BackupReminderBanner } from '../BackupReminderBanner' +import { BACKUP_REMINDER_BANNER_REVEAL_DELAY } from '@constants/ui' const account: WalletAccount = { type: AccountTypes.algo25, @@ -43,18 +44,41 @@ const account: WalletAccount = { keyPairId: 'kp', } +const advancePastRevealDelay = () => { + act(() => { + vi.advanceTimersByTime(BACKUP_REMINDER_BANNER_REVEAL_DELAY) + }) +} + describe('BackupReminderBanner', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + test('renders nothing when isVisible is false', () => { mockUseHook.mockReturnValue({ isVisible: false, onPress: vi.fn() }) const { queryByTestId } = render( , ) + advancePastRevealDelay() expect(queryByTestId('backup_reminder_banner')).toBeNull() }) - test('renders warning text and CTA when isVisible is true', () => { + test('renders nothing before the reveal delay elapses', () => { + mockUseHook.mockReturnValue({ isVisible: true, onPress: vi.fn() }) + render() + + expect(screen.queryByTestId('backup_reminder_banner')).toBeNull() + }) + + test('renders warning text and CTA after the reveal delay', () => { mockUseHook.mockReturnValue({ isVisible: true, onPress: vi.fn() }) render() + advancePastRevealDelay() expect( screen.getByText('You need to backup the passphrase'), @@ -66,6 +90,7 @@ describe('BackupReminderBanner', () => { const onPress = vi.fn() mockUseHook.mockReturnValue({ isVisible: true, onPress }) render() + advancePastRevealDelay() fireEvent.click(screen.getByTestId('backup_reminder_banner_cta')) expect(onPress).toHaveBeenCalledTimes(1) diff --git a/apps/mobile/src/modules/accounts/components/BackupReminderBanner/styles.ts b/apps/mobile/src/modules/accounts/components/BackupReminderBanner/styles.ts index ca9a0832b..4c7104d34 100644 --- a/apps/mobile/src/modules/accounts/components/BackupReminderBanner/styles.ts +++ b/apps/mobile/src/modules/accounts/components/BackupReminderBanner/styles.ts @@ -14,6 +14,11 @@ import { makeStyles } from '@rneui/themed' import { getTypography } from '@theme/typography' export const useStyles = makeStyles(theme => ({ + enterWrapper: { + // Clip while the entering animation grows height from 0; otherwise + // children overflow visibly during the animation. + overflow: 'hidden', + }, container: { flexDirection: 'row', alignItems: 'center', diff --git a/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx b/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx index dacee7e5b..eb57f50e8 100644 --- a/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx +++ b/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx @@ -30,6 +30,7 @@ import { useAssetsQuery, } from '@perawallet/wallet-core-assets' import { AssetWithAccountBalance } from '@perawallet/wallet-core-accounts' +import { useLanguage } from '@hooks/useLanguage' import { getVerificationIcon } from '@modules/assets/utils/verification' import { useStyles } from './styles' import { useMemo } from 'react' @@ -49,6 +50,7 @@ export const AccountAssetItemView = ({ ...rest }: AccountAssetItemViewProps) => { const styles = useStyles() + const { t } = useLanguage() // Use pre-fetched asset data when available to avoid N+1 queries. // Falls back to individual fetch for callers that don't populate accountBalance.asset. @@ -83,6 +85,7 @@ export const AccountAssetItemView = ({ }, [asset, isAlgo]) const isFavorited = asset?.peraMetadata?.isFavorited ?? false + const isDeleted = asset?.peraMetadata?.isDeleted === true const item = useMemo(() => { if (asset && isCollectible(asset)) { @@ -169,14 +172,23 @@ export const AccountAssetItemView = ({ /> ) : null} - + {isDeleted ? ( - {secondaryText} + {t('asset.deleted_label')} - + ) : ( + + + {secondaryText} + + + )} { color: theme.colors.error, flexShrink: 1, }, + deletedLabel: { + color: theme.colors.negative, + }, primaryUnit: { flexShrink: 1, }, diff --git a/apps/mobile/src/modules/assets/components/AssetTitle/AssetTitle.tsx b/apps/mobile/src/modules/assets/components/AssetTitle/AssetTitle.tsx index 40b7cb425..f386bc86a 100644 --- a/apps/mobile/src/modules/assets/components/AssetTitle/AssetTitle.tsx +++ b/apps/mobile/src/modules/assets/components/AssetTitle/AssetTitle.tsx @@ -13,6 +13,7 @@ import { ALGO_ASSET_ID, PeraAsset } from '@perawallet/wallet-core-assets' import { PWIcon, PWText, PWView } from '@components/core' import { CopyableText } from '@components/CopyableText' +import { useLanguage } from '@hooks/useLanguage' import { useStyles } from './styles' import { AssetIcon } from '../AssetIcon' import { useMemo } from 'react' @@ -30,6 +31,7 @@ export const AssetTitle = ({ nameVariant = 'h4', }: AssetTitleProps) => { const styles = useStyles() + const { t } = useLanguage() const isAlgo = useMemo( () => asset.assetId === ALGO_ASSET_ID, @@ -41,6 +43,8 @@ export const AssetTitle = ({ [asset.peraMetadata?.verificationTier], ) + const isDeleted = asset.peraMetadata?.isDeleted === true + return ( )} - {showId && ( - - - {asset.assetId} - - + {isDeleted ? ( + + {t('asset.deleted_label')} + + ) : ( + showId && ( + + + {asset.assetId} + + + ) )} diff --git a/apps/mobile/src/modules/assets/components/AssetTitle/styles.ts b/apps/mobile/src/modules/assets/components/AssetTitle/styles.ts index 720a8d982..6f651ddf9 100644 --- a/apps/mobile/src/modules/assets/components/AssetTitle/styles.ts +++ b/apps/mobile/src/modules/assets/components/AssetTitle/styles.ts @@ -29,6 +29,10 @@ export const useStyles = makeStyles(theme => { paddingRight: theme.spacing.xs, flexShrink: 1, } + const deletedLabel = { + color: theme.colors.negative, + lineHeight: theme.spacing.lg, + } return { container: { flexDirection: 'row', @@ -49,5 +53,6 @@ export const useStyles = makeStyles(theme => { id, name, suspiciousName, + deletedLabel, } }) diff --git a/apps/mobile/src/modules/assets/components/holdings/AssetWealthChart/AssetWealthChart.tsx b/apps/mobile/src/modules/assets/components/holdings/AssetWealthChart/AssetWealthChart.tsx index b7b9ed7e8..87b7fa301 100644 --- a/apps/mobile/src/modules/assets/components/holdings/AssetWealthChart/AssetWealthChart.tsx +++ b/apps/mobile/src/modules/assets/components/holdings/AssetWealthChart/AssetWealthChart.tsx @@ -32,6 +32,7 @@ import { CHART_FOCUS_DEBOUNCE_TIME, CHART_HEIGHT, } from '@constants/ui' +import { getChartYAxisRange } from '@utils/chart' type DataPoint = { timestamp: Date @@ -72,16 +73,10 @@ export const AssetWealthChart = ({ }) ?? []) as DataPoint[] }, [data]) - const yAxisOffsets = useMemo(() => { - if (dataPoints.length === 0) return [-1, 1] - const minValue = Math.min(...dataPoints.map(p => p.value)) - const maxValue = Math.max(...dataPoints.map(p => p.value)) - if (minValue === 0 && maxValue === 0) { - return [-1, 1] - } else { - return [minValue - minValue / 10, maxValue + maxValue / 10] - } - }, [dataPoints]) + const yAxisRange = useMemo( + () => getChartYAxisRange(dataPoints), + [dataPoints], + ) const onFocus = useCallback( ({ @@ -114,18 +109,14 @@ export const AssetWealthChart = ({ ], ) - if (isPending) { - return ( - - ) - } - return ( - {!dataPoints?.length ? ( + {isPending ? ( + + ) : !dataPoints?.length ? ( ({ container: { - flex: 1, + height: CHART_HEIGHT, backgroundColor: theme.colors.background, }, })) diff --git a/apps/mobile/src/modules/assets/components/market/AssetPriceChart/AssetPriceChart.tsx b/apps/mobile/src/modules/assets/components/market/AssetPriceChart/AssetPriceChart.tsx index 0c37a82e0..757f3c635 100644 --- a/apps/mobile/src/modules/assets/components/market/AssetPriceChart/AssetPriceChart.tsx +++ b/apps/mobile/src/modules/assets/components/market/AssetPriceChart/AssetPriceChart.tsx @@ -30,6 +30,7 @@ import { CHART_HEIGHT, } from '@constants/ui' import { useLanguage } from '@hooks/useLanguage' +import { getChartYAxisRange } from '@utils/chart' export type AssetPriceChartProps = { asset: PeraAsset @@ -61,16 +62,10 @@ export const AssetPriceChart = ({ [data], ) - const yAxisOffsets = useMemo(() => { - if (dataPoints.length === 0) return [-1, 1] - const minValue = Math.min(...dataPoints.map(p => p.value)) - const maxValue = Math.max(...dataPoints.map(p => p.value)) - if (minValue === 0 && maxValue === 0) { - return [-1, 1] - } else { - return [minValue - minValue / 10, maxValue + maxValue / 10] - } - }, [dataPoints]) + const yAxisRange = useMemo( + () => getChartYAxisRange(dataPoints), + [dataPoints], + ) const onFocus = useCallback( ({ @@ -103,18 +98,14 @@ export const AssetPriceChart = ({ ], ) - if (isPending) { - return ( - - ) - } - return ( - {!dataPoints?.length ? ( + {isPending ? ( + + ) : !dataPoints?.length ? ( ({ container: { - flex: 1, + height: CHART_HEIGHT, backgroundColor: theme.colors.background, }, })) diff --git a/apps/mobile/src/utils/__tests__/chart.spec.ts b/apps/mobile/src/utils/__tests__/chart.spec.ts new file mode 100644 index 000000000..dda584f2f --- /dev/null +++ b/apps/mobile/src/utils/__tests__/chart.spec.ts @@ -0,0 +1,88 @@ +/* + 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 { describe, it, expect } from 'vitest' +import { getChartYAxisRange } from '../chart' + +// Mirrors gifted-charts' position formula: (value - yAxisOffset) / maxValue. +const peakPosition = ( + { yAxisOffset, maxValue }: { yAxisOffset: number; maxValue: number }, + dataMax: number, +): number => (dataMax - yAxisOffset) / maxValue + +const toPoints = (values: number[]) => values.map(value => ({ value })) + +describe('getChartYAxisRange', () => { + it('returns a symmetric range for empty data', () => { + const result = getChartYAxisRange([]) + expect(result.yAxisOffset).toBe(-1) + expect(result.maxValue).toBe(2) + }) + + it('returns a symmetric range when every value is zero', () => { + const result = getChartYAxisRange(toPoints([0, 0, 0])) + expect(result.yAxisOffset).toBe(-1) + expect(result.maxValue).toBe(2) + }) + + it('places the peak at ~91.7% for a flat-baseline dataset with small variation', () => { + const result = getChartYAxisRange(toPoints([999.5, 1000, 1000.5])) + expect(peakPosition(result, 1000.5)).toBeCloseTo(11 / 12, 5) + }) + + it('places the peak at ~91.7% for a wide-variation positive dataset', () => { + const result = getChartYAxisRange(toPoints([100, 500, 1000])) + expect(peakPosition(result, 1000)).toBeCloseTo(11 / 12, 5) + }) + + it('produces a non-zero range that brackets the constant for flat positive data', () => { + const { yAxisOffset, maxValue } = getChartYAxisRange( + toPoints([100, 100, 100]), + ) + expect(yAxisOffset).toBeLessThan(100) + expect(yAxisOffset + maxValue).toBeGreaterThan(100) + expect(maxValue).toBeGreaterThan(0) + }) + + it('produces a non-zero range that brackets the constant for flat negative data', () => { + const { yAxisOffset, maxValue } = getChartYAxisRange( + toPoints([-100, -100, -100]), + ) + expect(yAxisOffset).toBeLessThan(-100) + expect(yAxisOffset + maxValue).toBeGreaterThan(-100) + expect(maxValue).toBeGreaterThan(0) + }) + + it('produces a valid range for all-negative variation', () => { + const result = getChartYAxisRange(toPoints([-1000, -750, -500])) + const { yAxisOffset, maxValue } = result + expect(maxValue).toBeGreaterThan(0) + expect(yAxisOffset).toBeLessThan(-1000) + expect(yAxisOffset + maxValue).toBeGreaterThan(-500) + expect(peakPosition(result, -500)).toBeCloseTo(11 / 12, 5) + }) + + it('produces a valid range for mixed-sign data spanning zero', () => { + const result = getChartYAxisRange(toPoints([-200, 0, 800])) + const { yAxisOffset, maxValue } = result + expect(yAxisOffset).toBeLessThan(-200) + expect(yAxisOffset + maxValue).toBeGreaterThan(800) + expect(peakPosition(result, 800)).toBeCloseTo(11 / 12, 5) + }) + + it('treats a single data point as flat and returns a valid bracketing range', () => { + const { yAxisOffset, maxValue } = getChartYAxisRange(toPoints([42])) + expect(yAxisOffset).toBeLessThan(42) + expect(yAxisOffset + maxValue).toBeGreaterThan(42) + expect(maxValue).toBeGreaterThan(0) + }) +}) diff --git a/apps/mobile/src/utils/chart.ts b/apps/mobile/src/utils/chart.ts new file mode 100644 index 000000000..466d6ad77 --- /dev/null +++ b/apps/mobile/src/utils/chart.ts @@ -0,0 +1,49 @@ +/* + 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 + */ + +const PADDING_RATIO = 0.1 + +// gifted-charts positions each point at (value - yAxisOffset) / maxValue, +// so maxValue is the chart's *range*, not its top. The chart spans +// [yAxisOffset, yAxisOffset + maxValue] in data-value space. +export type ChartYAxisRange = { + yAxisOffset: number + maxValue: number +} + +const FALLBACK: ChartYAxisRange = { yAxisOffset: -1, maxValue: 2 } + +export const getChartYAxisRange = ( + dataPoints: readonly { value: number }[], +): ChartYAxisRange => { + if (dataPoints.length === 0) return FALLBACK + + let dataMin = Infinity + let dataMax = -Infinity + for (const point of dataPoints) { + if (point.value < dataMin) dataMin = point.value + if (point.value > dataMax) dataMax = point.value + } + + const range = dataMax - dataMin + if (range > 0) { + const padding = range * PADDING_RATIO + return { yAxisOffset: dataMin - padding, maxValue: range + 2 * padding } + } + + if (dataMax !== 0) { + const padding = Math.abs(dataMax) * PADDING_RATIO + return { yAxisOffset: dataMax - padding, maxValue: 2 * padding } + } + + return FALLBACK +} diff --git a/packages/assets/src/api/assets/__tests__/endpoints.test.ts b/packages/assets/src/api/assets/__tests__/endpoints.test.ts index f6bc5110b..29eb331c5 100644 --- a/packages/assets/src/api/assets/__tests__/endpoints.test.ts +++ b/packages/assets/src/api/assets/__tests__/endpoints.test.ts @@ -59,7 +59,7 @@ describe('assets endpoints', () => { queryClientMock.mockReset() }) - test('fetchAssets calls /v1/assets/ with comma-joined asset_ids', async () => { + test('fetchAssets calls /v1/assets/ with comma-joined asset_ids and include_deleted', async () => { queryClientMock.mockResolvedValue({ data: validAssetsResponse }) await fetchAssets(['1', '2', '3'], 'mainnet') @@ -67,7 +67,7 @@ describe('assets endpoints', () => { expect(queryClientMock).toHaveBeenCalledWith( expect.objectContaining({ url: '/v1/assets/', - params: { asset_ids: '1,2,3' }, + params: { asset_ids: '1,2,3', include_deleted: true }, }), ) }) diff --git a/packages/assets/src/api/assets/endpoints.ts b/packages/assets/src/api/assets/endpoints.ts index 002ae8fed..8a980bf27 100644 --- a/packages/assets/src/api/assets/endpoints.ts +++ b/packages/assets/src/api/assets/endpoints.ts @@ -30,6 +30,7 @@ export const fetchAssets = async (assetIDs: string[], network: Network) => { url: `/v1/assets/`, params: { asset_ids: assetIDs.join(','), + include_deleted: true, }, })