diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index e532ebcb742..398722c95af 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -49,6 +49,7 @@ const getStories = () => { "./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"), "./app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx": require("../app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx"), "./app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx"), + "./app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx"), "./app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx": require("../app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx"), "./app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx"), "./app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx"), diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx new file mode 100644 index 00000000000..fdc02acf095 --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx @@ -0,0 +1,255 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { View, ScrollView } from 'react-native'; + +import { + Box, + Text, + TextVariant, + IconName, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SharedValue } from 'react-native-reanimated'; + +import HeaderWithTitleLeftScrollable from './HeaderWithTitleLeftScrollable'; +import useHeaderWithTitleLeftScrollable from './useHeaderWithTitleLeftScrollable'; +import { UseHeaderWithTitleLeftScrollableOptions } from './HeaderWithTitleLeftScrollable.types'; + +const HeaderWithTitleLeftScrollableMeta = { + title: 'Components Temp / HeaderWithTitleLeftScrollable', + component: HeaderWithTitleLeftScrollable, + argTypes: { + title: { + control: 'text', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default HeaderWithTitleLeftScrollableMeta; + +const SampleNFTImage = () => ( + + NFT + +); + +const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => ( + <> + {Array.from({ length: itemCount }).map((_, index) => ( + + Item {index + 1} + + This is sample content to demonstrate scrolling behavior. + + + ))} + +); + +interface ScrollableStoryContainerProps { + children: (props: { + scrollYValue: SharedValue; + setExpandedHeight: (h: number) => void; + }) => React.ReactNode; + hookOptions?: UseHeaderWithTitleLeftScrollableOptions; +} + +const ScrollableStoryContainer = ({ + children, + hookOptions, +}: ScrollableStoryContainerProps) => { + const tw = useTailwind(); + const { + onScroll, + scrollY: scrollYValue, + expandedHeight, + setExpandedHeight, + } = useHeaderWithTitleLeftScrollable(hookOptions); + + return ( + + {children({ scrollYValue, setExpandedHeight })} + + + + + ); +}; + +export const Default = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Back pressed')} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + }} + scrollY={scrollYValue} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; + +export const OnBack = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Back pressed')} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + endAccessory: , + }} + scrollY={scrollYValue} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; + +export const WithBottomLabel = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Back pressed')} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + bottomLabel: '0.002 ETH', + endAccessory: , + }} + scrollY={scrollYValue} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; + +export const EndButtonIconProps = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Back pressed')} + endButtonIconProps={[ + { + iconName: IconName.Close, + onPress: () => console.log('Close pressed'), + }, + ]} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + endAccessory: , + }} + scrollY={scrollYValue} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; + +export const BackButtonProps = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Custom back pressed'), + }} + titleLeftProps={{ + topLabel: 'Receive', + title: '$1,234.56', + }} + scrollY={scrollYValue} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; + +export const TitleLeft = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Back pressed')} + titleLeft={ + + Custom Title Section + + This is a completely custom title section + + + } + scrollY={scrollYValue} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; + +export const NoBackButton = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + + )} + + ), +}; + +export const FastCollapse = { + render: () => ( + + {({ scrollYValue, setExpandedHeight }) => ( + console.log('Back pressed')} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + endAccessory: , + }} + scrollY={scrollYValue} + scrollTriggerPosition={60} + onExpandedHeightChange={setExpandedHeight} + /> + )} + + ), +}; diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx new file mode 100644 index 00000000000..f1f8c7afa14 --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx @@ -0,0 +1,190 @@ +// Third party dependencies. +import React from 'react'; +import { render, renderHook } from '@testing-library/react-native'; +import { useSharedValue, SharedValue } from 'react-native-reanimated'; +import { Text } from 'react-native'; + +// Internal dependencies. +import HeaderWithTitleLeftScrollable from './HeaderWithTitleLeftScrollable'; +import useHeaderWithTitleLeftScrollable from './useHeaderWithTitleLeftScrollable'; + +// Mock react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.useSharedValue = jest.fn((initial) => ({ + value: initial, + })); + Reanimated.useAnimatedStyle = jest.fn((fn) => fn()); + Reanimated.interpolate = jest.fn( + (_value, _inputRange, outputRange) => outputRange[0], + ); + Reanimated.Extrapolation = { CLAMP: 'clamp' }; + return Reanimated; +}); + +// Test wrapper component that provides scrollY +const TestWrapper: React.FC<{ + children: (scrollYValue: SharedValue) => React.ReactNode; +}> = ({ children }) => { + const scrollYValue = useSharedValue(0); + return <>{children(scrollYValue)}; +}; + +describe('HeaderWithTitleLeftScrollable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders with title', () => { + const { getAllByText } = render( + + {(scrollYValue) => ( + + )} + , + ); + + expect(getAllByText('Test Title').length).toBeGreaterThan(0); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + + {(scrollYValue) => ( + + )} + , + ); + + expect(getByTestId('test-container')).toBeOnTheScreen(); + }); + }); + + describe('back button', () => { + it('renders back button when onBack provided', () => { + const { getByTestId } = render( + + {(scrollYValue) => ( + + )} + , + ); + + expect(getByTestId('test-back-button')).toBeOnTheScreen(); + }); + + it('renders back button when backButtonProps provided', () => { + const { getByTestId } = render( + + {(scrollYValue) => ( + + )} + , + ); + + expect(getByTestId('test-back-button')).toBeOnTheScreen(); + }); + }); + + describe('titleLeft and titleLeftProps', () => { + it('renders custom titleLeft node', () => { + const { getByText } = render( + + {(scrollYValue) => ( + Custom Content} + /> + )} + , + ); + + expect(getByText('Custom Content')).toBeOnTheScreen(); + }); + + it('titleLeft takes priority over titleLeftProps', () => { + const { getByText, queryByText } = render( + + {(scrollYValue) => ( + Custom Node} + titleLeftProps={{ title: 'Props Title' }} + /> + )} + , + ); + + expect(getByText('Custom Node')).toBeOnTheScreen(); + expect(queryByText('Props Title')).toBeNull(); + }); + }); +}); + +describe('useHeaderWithTitleLeftScrollable', () => { + it('returns onScroll handler', () => { + const { result } = renderHook(() => useHeaderWithTitleLeftScrollable()); + + expect(typeof result.current.onScroll).toBe('function'); + }); + + it('returns scrollY shared value with initial value of 0', () => { + const { result } = renderHook(() => useHeaderWithTitleLeftScrollable()); + + expect(result.current.scrollY.value).toBe(0); + }); + + it('returns expandedHeight', () => { + const { result } = renderHook(() => useHeaderWithTitleLeftScrollable()); + + expect(result.current.expandedHeight).toBe(140); + }); + + it('returns scrollTriggerPosition defaulting to expandedHeight', () => { + const { result } = renderHook(() => useHeaderWithTitleLeftScrollable()); + + expect(result.current.scrollTriggerPosition).toBe( + result.current.expandedHeight, + ); + }); + + it('uses custom scrollTriggerPosition when provided', () => { + const { result } = renderHook(() => + useHeaderWithTitleLeftScrollable({ + scrollTriggerPosition: 80, + }), + ); + + expect(result.current.scrollTriggerPosition).toBe(80); + }); + + it('uses custom expandedHeight when provided', () => { + const { result } = renderHook(() => + useHeaderWithTitleLeftScrollable({ expandedHeight: 200 }), + ); + + expect(result.current.expandedHeight).toBe(200); + }); +}); diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.tsx b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.tsx new file mode 100644 index 00000000000..e5a03696b4f --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.tsx @@ -0,0 +1,225 @@ +// Third party dependencies. +import React, { useMemo, useState, useCallback } from 'react'; +import { View, LayoutChangeEvent } from 'react-native'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + interpolate, + Extrapolation, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +// External dependencies. +import { + Box, + Text, + TextVariant, + FontWeight, + IconName, + ButtonIconProps, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +// Internal dependencies. +import HeaderBase from '../../components/HeaderBase'; +import TitleLeft from '../TitleLeft'; +import { HeaderWithTitleLeftScrollableProps } from './HeaderWithTitleLeftScrollable.types'; + +const DEFAULT_EXPANDED_HEIGHT = 140; +const DEFAULT_COLLAPSED_HEIGHT = 48; + +/** + * HeaderWithTitleLeftScrollable is a collapsing header component that transitions + * between an expanded state (with TitleLeft) and a compact sticky state (with HeaderBase) + * based on scroll position. + * + * Uses Reanimated for performant scroll-linked animations. + * + * @example + * ```tsx + * const { onScroll, scrollY, expandedHeight } = useHeaderWithTitleLeftScrollable(); + * + * return ( + * + * + * }} + * scrollY={scrollY} + * /> + * + * + * + * + * ); + * ``` + */ +const HeaderWithTitleLeftScrollable: React.FC< + HeaderWithTitleLeftScrollableProps +> = ({ + title, + onBack, + backButtonProps, + titleLeft, + titleLeftProps, + scrollTriggerPosition, + scrollY, + startButtonIconProps, + twClassName, + onExpandedHeightChange, + testID, + ...headerBaseProps +}) => { + const tw = useTailwind(); + + // Measure actual content height for dynamic sizing + const [measuredHeight, setMeasuredHeight] = useState(DEFAULT_EXPANDED_HEIGHT); + const animatedMeasuredHeight = useSharedValue(DEFAULT_EXPANDED_HEIGHT); + + const handleLayout = useCallback( + (e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + if (height > 0 && height !== measuredHeight) { + setMeasuredHeight(height); + animatedMeasuredHeight.value = height; + onExpandedHeightChange?.(height); + } + }, + [measuredHeight, animatedMeasuredHeight, onExpandedHeightChange], + ); + + // Use scrollTriggerPosition if provided, otherwise use measured height + const effectiveScrollTriggerPosition = + scrollTriggerPosition ?? measuredHeight; + + // Build startButtonIconProps with back button if onBack or backButtonProps is provided + const resolvedStartButtonIconProps = useMemo(() => { + if (startButtonIconProps) { + return startButtonIconProps; + } + + if (onBack || backButtonProps) { + const backProps: ButtonIconProps = { + iconName: IconName.ArrowLeft, + ...(backButtonProps || {}), + onPress: backButtonProps?.onPress ?? onBack, + }; + return backProps; + } + + return undefined; + }, [startButtonIconProps, onBack, backButtonProps]); + + // Animated style for the header container height (uses measured content height) + const headerAnimatedStyle = useAnimatedStyle(() => { + const height = interpolate( + scrollY.value, + [0, effectiveScrollTriggerPosition], + [animatedMeasuredHeight.value, DEFAULT_COLLAPSED_HEIGHT], + Extrapolation.CLAMP, + ); + + return { + height, + }; + }); + + // Derived value: triggers timed animation when large content is fully hidden + const compactTitleProgress = useDerivedValue(() => { + // Use effectiveScrollTriggerPosition to sync with header collapse animation + const triggerPosition = + effectiveScrollTriggerPosition - DEFAULT_COLLAPSED_HEIGHT; + const isFullyHidden = scrollY.value >= triggerPosition; + + // Animate to 1 when hidden, 0 when visible (with timing) + return withTiming(isFullyHidden ? 1 : 0, { duration: 150 }); + }); + + // Animated style for the compact title (timed fade up when large content is fully hidden) + const compactTitleAnimatedStyle = useAnimatedStyle(() => { + const progress = compactTitleProgress.value; + + return { + opacity: progress, + transform: [{ translateY: (1 - progress) * 8 }], // Fade up from 8px below + }; + }); + + // Animated style for the large header content (moves up behind header, synced 1:1 with scroll) + const largeContentAnimatedStyle = useAnimatedStyle(() => { + const largeContentHeight = + animatedMeasuredHeight.value - DEFAULT_COLLAPSED_HEIGHT; + // Move up 1:1 with scroll, clamped between 0 and -largeContentHeight + // Math.min(..., 0) prevents moving down on overscroll + // Math.max(..., -largeContentHeight) prevents moving up too far + const translateY = Math.min( + Math.max(-scrollY.value, -largeContentHeight), + 0, + ); + + return { + transform: [{ translateY }], + }; + }); + + // Render large content section + // Render large content: custom node > titleLeftProps > default title + const renderLargeContent = () => { + if (titleLeft) { + return titleLeft; + } + // Spread titleLeftProps over default title (titleLeftProps.title overrides if provided) + return ; + }; + + return ( + + {/* Header content - measured for dynamic height */} + + {/* HeaderBase with compact title */} + + {/* Compact title - fades in when collapsed */} + + + {title} + + + + + {/* Large header content - clips as it moves up behind header */} + + + {renderLargeContent()} + + + + + ); +}; + +export default HeaderWithTitleLeftScrollable; diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.types.ts b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.types.ts new file mode 100644 index 00000000000..0d469f6c8c1 --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.types.ts @@ -0,0 +1,106 @@ +// Third party dependencies. +import { ReactNode } from 'react'; +import { SharedValue } from 'react-native-reanimated'; + +// External dependencies. +import { ButtonIconProps } from '@metamask/design-system-react-native'; + +// Internal dependencies. +import { HeaderBaseProps } from '../../components/HeaderBase'; +import { TitleLeftProps } from '../TitleLeft/TitleLeft.types'; + +/** + * Props for the HeaderWithTitleLeftScrollable component. + */ +export interface HeaderWithTitleLeftScrollableProps + extends Omit { + /** + * Title text displayed in the compact header state. + * Also used as default title for TitleLeft if titleLeftProps.title is not provided. + */ + title: string; + /** + * Callback when the back button is pressed. + * If provided, a back button will be rendered as startAccessory. + */ + onBack?: () => void; + /** + * Additional props to pass to the back ButtonIcon. + * If provided, a back button will be rendered with these props spread. + */ + backButtonProps?: Omit; + /** + * Custom node to render in the large title section. + * If provided, takes priority over titleLeftProps. + */ + titleLeft?: ReactNode; + /** + * Props to pass to the TitleLeft component for the large title section. + * Only used if titleLeft is not provided. + */ + titleLeftProps?: TitleLeftProps; + /** + * Scroll position (in pixels) at which the header fully collapses. + * @default measured content height + */ + scrollTriggerPosition?: number; + /** + * Reanimated shared value tracking scroll position. + * Obtained from useHeaderWithTitleLeftScrollable hook. + */ + scrollY: SharedValue; + /** + * Callback fired when the expanded height is measured. + * Use this to update ScrollView's contentContainerStyle paddingTop. + */ + onExpandedHeightChange?: (height: number) => void; +} + +/** + * Options for the useHeaderWithTitleLeftScrollable hook. + */ +export interface UseHeaderWithTitleLeftScrollableOptions { + /** + * Height of the header in its expanded (large) state. + * @default 140 + */ + expandedHeight?: number; + /** + * Scroll position at which the header fully collapses. + * @default expandedHeight + */ + scrollTriggerPosition?: number; +} + +/** + * Return type for the useHeaderWithTitleLeftScrollable hook. + */ +export interface UseHeaderWithTitleLeftScrollableReturn { + /** + * Scroll handler to attach to ScrollView's onScroll prop. + */ + onScroll: ( + event: import('react-native').NativeSyntheticEvent< + import('react-native').NativeScrollEvent + >, + ) => void; + /** + * Shared value tracking current scroll position. + */ + scrollY: SharedValue; + /** + * Expanded header height for initial padding. + * Use this for ScrollView's contentContainerStyle paddingTop. + */ + expandedHeight: number; + /** + * Function to update the expanded height when content is measured. + * Pass this to HeaderWithTitleLeftScrollable's onExpandedHeightChange prop. + */ + setExpandedHeight: (height: number) => void; + /** + * The scroll position at which the header fully collapses. + * Defaults to expandedHeight if not provided. + */ + scrollTriggerPosition: number; +} diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/index.ts b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/index.ts new file mode 100644 index 00000000000..3277b02bcb7 --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/index.ts @@ -0,0 +1,7 @@ +export { default } from './HeaderWithTitleLeftScrollable'; +export { default as useHeaderWithTitleLeftScrollable } from './useHeaderWithTitleLeftScrollable'; +export type { + HeaderWithTitleLeftScrollableProps, + UseHeaderWithTitleLeftScrollableOptions, + UseHeaderWithTitleLeftScrollableReturn, +} from './HeaderWithTitleLeftScrollable.types'; diff --git a/app/component-library/components-temp/HeaderWithTitleLeftScrollable/useHeaderWithTitleLeftScrollable.ts b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/useHeaderWithTitleLeftScrollable.ts new file mode 100644 index 00000000000..c6d502674e6 --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeftScrollable/useHeaderWithTitleLeftScrollable.ts @@ -0,0 +1,72 @@ +// Third party dependencies. +import { useCallback, useState } from 'react'; +import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; +import { useSharedValue } from 'react-native-reanimated'; + +// Internal dependencies. +import { + UseHeaderWithTitleLeftScrollableOptions, + UseHeaderWithTitleLeftScrollableReturn, +} from './HeaderWithTitleLeftScrollable.types'; + +/** + * Hook for managing HeaderWithTitleLeftScrollable scroll-linked animations. + * + * @param options - Configuration options for the hook. + * @returns Object containing scroll handler, scrollY value, and header heights. + * + * @example + * ```tsx + * const { onScroll, scrollY, expandedHeight, setExpandedHeight } = useHeaderWithTitleLeftScrollable(); + * + * return ( + * + * + * + * + * + * + * ); + * ``` + */ +const useHeaderWithTitleLeftScrollable = ( + options: UseHeaderWithTitleLeftScrollableOptions = {}, +): UseHeaderWithTitleLeftScrollableReturn => { + const { expandedHeight: initialExpandedHeight = 140, scrollTriggerPosition } = + options; + + // Track expanded height - can be updated by component via onExpandedHeightChange + const [expandedHeight, setExpandedHeight] = useState(initialExpandedHeight); + + // Default scrollTriggerPosition to expandedHeight if not provided + const effectiveScrollTriggerPosition = + scrollTriggerPosition ?? expandedHeight; + + const scrollYValue = useSharedValue(0); + + const onScroll = useCallback( + (scrollEvent: NativeSyntheticEvent) => { + scrollYValue.value = scrollEvent.nativeEvent.contentOffset.y; + }, + [scrollYValue], + ); + + return { + onScroll, + scrollY: scrollYValue, + expandedHeight, + setExpandedHeight, + scrollTriggerPosition: effectiveScrollTriggerPosition, + }; +}; + +export default useHeaderWithTitleLeftScrollable; diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 65032822930..a5d6ae97764 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -96,7 +96,10 @@ import { selectTokenMarketData } from '../../../selectors/tokenRatesController'; import { getTokenExchangeRate } from '../Bridge/utils/exchange-rates'; import { isNonEvmChainId } from '../../../core/Multichain/utils'; ///: BEGIN:ONLY_INCLUDE_IF(tron) -import { selectTronResourcesBySelectedAccountGroup } from '../../../selectors/assets/assets-list'; +import { + selectTronResourcesBySelectedAccountGroup, + selectAsset, +} from '../../../selectors/assets/assets-list'; import { createStakedTrxAsset } from './utils/createStakedTrxAsset'; ///: END:ONLY_INCLUDE_IF import { getDetectedGeolocation } from '../../../reducers/fiatOrders'; @@ -168,6 +171,18 @@ const AssetOverview: React.FC = ({ const strxBandwidth = tronResources.find( (a) => a.symbol.toLowerCase() === 'strx-bandwidth', ); + + // Use selector to get live Tron asset balance (not static navigation params) + const isTronChain = String(asset.chainId).startsWith('tron:'); + const liveAsset = useSelector((state: RootState) => + isTronChain && asset.address && asset.chainId + ? selectAsset(state, { + address: asset.address, + chainId: asset.chainId, + isStaked: false, + }) + : undefined, + ); ///: END:ONLY_INCLUDE_IF const currentAddress = asset.address as Hex; @@ -497,13 +512,21 @@ const AssetOverview: React.FC = ({ : undefined; ///: END:ONLY_INCLUDE_IF - if (isMultichainAccountsState2Enabled && asset.balance != null) { + // Determine the balance source - prefer live data for Tron, otherwise use asset prop + let balanceSource = asset.balance; + ///: BEGIN:ONLY_INCLUDE_IF(tron) + if (isTronChain && liveAsset?.balance != null) { + balanceSource = liveAsset.balance; + } + ///: END:ONLY_INCLUDE_IF + + if (isMultichainAccountsState2Enabled && balanceSource != null) { // When state2 is enabled and asset has balance, use it directly - balance = asset.balance; + balance = balanceSource; } else if (isMultichainAsset) { - balance = asset.balance + balance = balanceSource ? formatWithThreshold( - parseFloat(asset.balance), + parseFloat(balanceSource), minimumDisplayThreshold, I18n.locale, { minimumFractionDigits: 0, maximumFractionDigits: 5 }, @@ -568,7 +591,13 @@ const AssetOverview: React.FC = ({ } // Calculate fiat balance if not provided in asset (e.g., when coming from trending view) - let mainBalance = asset.balanceFiat || ''; + let balanceFiatSource = asset.balanceFiat; + ///: BEGIN:ONLY_INCLUDE_IF(tron) + if (isTronChain && liveAsset?.balanceFiat != null) { + balanceFiatSource = liveAsset.balanceFiat; + } + ///: END:ONLY_INCLUDE_IF + let mainBalance = balanceFiatSource || ''; if (!mainBalance && balance != null) { // Convert balance to number for calculations const balanceNumber = diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx index 0a9d9ecda40..d8cdb3cb981 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx @@ -713,6 +713,55 @@ describe('EarnInputView', () => { expect(queryByText('Max')).toBeNull(); expect(getByText(strings('onboarding_success.done'))).toBeOnTheScreen(); }); + + it('does not show MaxInputModal when Max button is pressed for TRX', async () => { + (selectTrxStakingEnabled as unknown as jest.Mock).mockReturnValue(true); + + const TRX_TOKEN = { + name: 'TRON', + symbol: 'TRX', + ticker: 'TRX', + chainId: 'tron:728126428', + isNative: true, + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + balance: '100', + balanceFiat: '$100', + decimals: 6, + isETH: false, + } as unknown as typeof MOCK_ETH_MAINNET_ASSET; + + (useEarnTokens as jest.Mock).mockReturnValue({ + getEarnToken: jest.fn(() => ({ + ...TRX_TOKEN, + balanceMinimalUnit: '100000000', + balanceFormatted: '100 TRX', + balanceFiatNumber: 100, + tokenUsdExchangeRate: 1, + experiences: [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }], + experience: { type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }, + })), + getOutputToken: jest.fn(() => undefined), + }); + + const { getByText } = render(EarnInputView, { + params: { + token: TRX_TOKEN, + }, + key: Routes.STAKING.STAKE, + name: 'params', + }); + + await act(async () => { + fireEvent.press(getByText('Max')); + }); + + expect(mockNavigate).not.toHaveBeenCalledWith( + 'StakeModals', + expect.objectContaining({ + screen: Routes.STAKING.MODALS.MAX_INPUT, + }), + ); + }); }); describe('when values are entered in the keypad', () => { diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index 24a5cba784d..1a9ffe86908 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -133,6 +133,7 @@ const EarnInputView = () => { preview: tronPreview, validateStakeAmount: tronValidateStakeAmount, confirmStake: tronConfirmStake, + tronAccountId, } = useTronStake({ token }); const { apyPercent: tronApyPercent } = useTronStakeApy(); ///: END:ONLY_INCLUDE_IF @@ -625,7 +626,12 @@ const EarnInputView = () => { ///: BEGIN:ONLY_INCLUDE_IF(tron) if (isTronEnabled) { const result = await tronConfirmStake?.(amountToken); - handleTronStakingNavigationResult(navigation, result, 'stake'); + handleTronStakingNavigationResult( + navigation, + result, + 'stake', + tronAccountId, + ); return; } ///: END:ONLY_INCLUDE_IF @@ -649,6 +655,7 @@ const EarnInputView = () => { isTronEnabled, navigation, tronConfirmStake, + tronAccountId, ///: END:ONLY_INCLUDE_IF handlePooledStakingFlow, handleLendingFlow, @@ -713,8 +720,15 @@ const EarnInputView = () => { // Right action press: act as "Done" in TRON editing with non-zero amount; otherwise behave as Max const onRightActionPress = React.useCallback(() => { - if (isTronEnabled && isTronNative && isNonZeroAmount && !isPreviewVisible) { - setIsPreviewVisible(true); + // For TRON: if we have a non-zero amount, show preview; otherwise just set max directly (skip modal) + if (isTronEnabled && isTronNative) { + if (isNonZeroAmount && !isPreviewVisible) { + setIsPreviewVisible(true); + } else { + // Directly call handleMax for Tron - the MaxInputModal is EVM-specific + lastQuickAmountButtonPressed.current = 'MAX'; + handleMax(); + } return; } handleMaxPressWithTracking(); @@ -724,6 +738,7 @@ const EarnInputView = () => { isNonZeroAmount, isPreviewVisible, handleMaxPressWithTracking, + handleMax, ]); const handleCurrencySwitchWithTracking = useCallback(() => { diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap index 0d93008e77c..5576f5cd616 100644 --- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap @@ -1879,7 +1879,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "flexGrow": 1, } } - handlerTag={12} + handlerTag={14} handlerType="NativeViewGestureHandler" onGestureHandlerEvent={[Function]} onGestureHandlerStateChange={[Function]} diff --git a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx index 929531fda42..e2b2bdbea00 100644 --- a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx +++ b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx @@ -92,6 +92,7 @@ const EarnWithdrawInputView = () => { preview: tronPreview, validateUnstakeAmount: tronValidateUnstakeAmount, confirmUnstake: tronConfirmUnstake, + tronAccountId, } = useTronUnstake({ token }); const { apyPercent: tronApyPercent } = useTronStakeApy(); ///: END:ONLY_INCLUDE_IF @@ -579,7 +580,12 @@ const EarnWithdrawInputView = () => { ///: BEGIN:ONLY_INCLUDE_IF(tron) if (isTronEnabled) { const result = await tronConfirmUnstake?.(amountToken); - handleTronStakingNavigationResult(navigation, result, 'unstake'); + handleTronStakingNavigationResult( + navigation, + result, + 'unstake', + tronAccountId, + ); return; } ///: END:ONLY_INCLUDE_IF @@ -598,6 +604,7 @@ const EarnWithdrawInputView = () => { tronConfirmUnstake, amountToken, navigation, + tronAccountId, ///: END:ONLY_INCLUDE_IF withdrawalToken?.experience?.type, handleLendingWithdrawalFlow, diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx index 9fc6dac0a61..0340ae8ec9a 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx @@ -84,7 +84,7 @@ describe('TronStakingButtons', () => { , ); - expect(getByText('stake.stake')).toBeTruthy(); + expect(getByText('stake.stake_your_trx_cta.earn_button')).toBeOnTheScreen(); fireEvent.press(getByTestId('stake-more-button')); diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx index e15c71beb98..ccddf717370 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx @@ -93,7 +93,7 @@ const TronStakingButtons = ({ label={ hasStakedPositions ? strings('stake.stake_more') - : strings('stake.stake') + : strings('stake.stake_your_trx_cta.earn_button') } onPress={onStakePress} /> diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.styles.ts new file mode 100644 index 00000000000..2aef53ade15 --- /dev/null +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.styles.ts @@ -0,0 +1,19 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + alignItems: 'center', + marginTop: 16, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + justifyContent: 'center', + marginTop: 4, + marginBottom: 8, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx index c4d9dc3bc42..c938331e2f8 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.test.tsx @@ -7,26 +7,26 @@ import TronStakingCta from './TronStakingCta'; jest.mock('../../../../../../../locales/i18n', () => ({ strings: (key: string) => { const map: Record = { - 'stake.earn': 'Earn', - 'stake.stake_your_trx_cta.base': 'Stake your TRX and earn', - 'stake.stake_your_trx_cta.annually': 'annually', - 'stake.stake_your_trx_cta.learn_more_with_period': 'Learn more.', + 'stake.stake_your_trx_cta.title': 'Lend Tron and earn', + 'stake.stake_your_trx_cta.description_start': 'Stake your Tron and earn ', + 'stake.stake_your_trx_cta.description_end': ' annually.', + 'stake.stake_your_trx_cta.learn_more': 'Learn more.', }; return map[key] ?? key; }, })); describe('TronStakingCta', () => { - it('renders title and base description without APR or Learn More', () => { + it('renders title and description without APR or learn more button', () => { const { getByText, queryByText } = renderWithProvider(); - expect(getByText('Earn')).toBeOnTheScreen(); - expect(getByText('Stake your TRX and earn')).toBeOnTheScreen(); - expect(queryByText('annually')).toBeNull(); + expect(getByText('Lend Tron and earn')).toBeOnTheScreen(); + expect(getByText('Stake your Tron and earn ')).toBeOnTheScreen(); + expect(getByText(' annually.')).toBeOnTheScreen(); expect(queryByText('Learn more.')).toBeNull(); }); - it('renders APR and annually when aprText is provided', () => { + it('renders APR when aprText is provided', () => { const aprText = '4.5%'; const { getByText } = renderWithProvider( @@ -34,18 +34,18 @@ describe('TronStakingCta', () => { ); expect(getByText(aprText)).toBeOnTheScreen(); - expect(getByText('annually')).toBeOnTheScreen(); + expect(getByText(' annually.')).toBeOnTheScreen(); }); - it('calls onLearnMore when Learn more is pressed', () => { - const onLearnMore = jest.fn(); + it('calls onEarn when learn more button is pressed', () => { + const onEarn = jest.fn(); const { getByText } = renderWithProvider( - , + , ); fireEvent.press(getByText('Learn more.')); - expect(onLearnMore).toHaveBeenCalledTimes(1); + expect(onEarn).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx index fff1b12dce9..a8e8be862ae 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingCta.tsx @@ -7,41 +7,37 @@ import Text, { import Button, { ButtonVariants, } from '../../../../../../component-library/components/Buttons/Button'; +import { useStyles } from '../../../../../../component-library/hooks'; import { strings } from '../../../../../../../locales/i18n'; - -const styles = { - row: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' }, -} as const; +import styleSheet from './TronStakingCta.styles'; interface TronStakingCtaProps extends Pick { aprText?: string; - onLearnMore?: () => void; + onEarn?: () => void; } -const TronStakingCta = ({ - style, - aprText, - onLearnMore, -}: TronStakingCtaProps) => ( - - {strings('stake.earn')} - - {strings('stake.stake_your_trx_cta.base')} - {aprText ? ( - <> - {aprText} - {` ${strings('stake.stake_your_trx_cta.annually')} `} - - ) : null} - {onLearnMore ? ( +const TronStakingCta = ({ style, aprText, onEarn }: TronStakingCtaProps) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + {strings('stake.stake_your_trx_cta.title')} + + + {strings('stake.stake_your_trx_cta.description_start')} + {aprText ? {aprText} : null} + {strings('stake.stake_your_trx_cta.description_end')} + + {onEarn ? (