diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index 825192413a85..209b8bbc5c94 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -1195,6 +1195,16 @@ export const en = { description: 'We currently support staking as view-only in Trezor Suite Lite.', }, }, + moduleTrading: { + selectCoin: { + buttonTitle: 'Select coin', + }, + networksSheet: { + title: 'Tokens', + popularTitle: 'Popular', + listTitle: 'Tokens', + }, + }, }; export type Translations = typeof en; diff --git a/suite-native/module-trading/package.json b/suite-native/module-trading/package.json index 25182f5e1e3e..b13b41622917 100644 --- a/suite-native/module-trading/package.json +++ b/suite-native/module-trading/package.json @@ -15,6 +15,7 @@ "@reduxjs/toolkit": "1.9.5", "@suite-native/navigation": "workspace:*", "@suite-native/test-utils": "workspace:*", + "expo-linear-gradient": "^14.0.1", "react": "18.2.0", "react-native": "0.76.1" } diff --git a/suite-native/module-trading/src/components/buy/AmountCard.tsx b/suite-native/module-trading/src/components/buy/AmountCard.tsx new file mode 100644 index 000000000000..f33495a871f2 --- /dev/null +++ b/suite-native/module-trading/src/components/buy/AmountCard.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { Card, HStack } from '@suite-native/atoms'; + +import { SelectTradeableAssetButton } from '../general/SelectTradeableAssetButton'; +import { TradeableAssetsSheet } from '../general/TradeableAssetsSheet'; +import { useTradeableAssetsSheetControls } from '../../hooks/useTradeableAssetsSheetControls'; + +export const AmountCard = () => { + const { + isTradeableAssetsSheetVisible, + showTradeableAssetsSheet, + hideTradeableAssetsSheet, + selectedTradeableAsset, + setSelectedTradeableAsset, + } = useTradeableAssetsSheetControls(); + + return ( + + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/buy/__tests__/AmountCard.comp.test.tsx b/suite-native/module-trading/src/components/buy/__tests__/AmountCard.comp.test.tsx new file mode 100644 index 000000000000..bdce10c0c928 --- /dev/null +++ b/suite-native/module-trading/src/components/buy/__tests__/AmountCard.comp.test.tsx @@ -0,0 +1,30 @@ +import { render, fireEvent } from '@suite-native/test-utils'; + +import { AmountCard } from '../AmountCard'; + +describe('AmountCard', () => { + it('should display Select coin button', () => { + const { getByText, queryByText } = render(); + + expect(getByText('Select coin')).toBeDefined(); + expect(queryByText('Tokens')).toBeNull(); + }); + + it('should display AssetsSheet after button click', () => { + const { getByText } = render(); + + fireEvent.press(getByText('Select coin')); + + expect(getByText('Tokens')).toBeDefined(); + }); + + it('should display selected network from AssetsSheet', () => { + const { getByText, queryByText } = render(); + + fireEvent.press(getByText('Select coin')); + fireEvent.press(getByText('BTC')); + + expect(queryByText('Tokens')).toBeNull(); + expect(getByText('BTC')).toBeDefined(); + }); +}); diff --git a/suite-native/module-trading/src/components/general/PopularTradeableNetworks.tsx b/suite-native/module-trading/src/components/general/PopularTradeableNetworks.tsx new file mode 100644 index 000000000000..2e9be2e5407c --- /dev/null +++ b/suite-native/module-trading/src/components/general/PopularTradeableNetworks.tsx @@ -0,0 +1,38 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { HStack, Text, VStack } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; + +import { TradeableNetworkButton } from './TradeableNetworkButton'; + +export type PopularTradeableNetworksProps = { + symbols: NetworkSymbol[]; + onNetworkSelect: (symbol: NetworkSymbol) => void; + maxNetworkSymbols?: number; +}; + +const DEFAULT_MAX_NETWORK_SYMBOLS = 4; + +export const PopularTradeableNetworks = ({ + symbols, + onNetworkSelect, + maxNetworkSymbols = DEFAULT_MAX_NETWORK_SYMBOLS, +}: PopularTradeableNetworksProps) => { + const limitedSymbols = symbols.slice(0, maxNetworkSymbols); + + return ( + + + + + + {limitedSymbols.map(symbol => ( + onNetworkSelect(symbol)} + /> + ))} + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/SelectTradeableAssetButton.tsx b/suite-native/module-trading/src/components/general/SelectTradeableAssetButton.tsx new file mode 100644 index 000000000000..4fb85935a7ec --- /dev/null +++ b/suite-native/module-trading/src/components/general/SelectTradeableAssetButton.tsx @@ -0,0 +1,32 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { Button, buttonSchemeToColorsMap } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; +import { Icon } from '@suite-native/icons'; + +import { TradeableNetworkButton } from './TradeableNetworkButton'; + +export type SelectTradeableAssetButtonProps = { + onPress: () => void; + selectedAsset: NetworkSymbol | undefined; +}; + +export const SelectTradeableAssetButton = ({ + onPress, + selectedAsset, +}: SelectTradeableAssetButtonProps) => { + const { iconColor } = buttonSchemeToColorsMap.primary; + + if (selectedAsset) { + return ; + } + + return ( + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetButton.tsx b/suite-native/module-trading/src/components/general/TradeableAssetButton.tsx new file mode 100644 index 000000000000..d5af09acdd28 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetButton.tsx @@ -0,0 +1,63 @@ +import { ReactNode } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; + +import { LinearGradient } from 'expo-linear-gradient'; + +import { hexToRgba } from '@suite-common/suite-utils'; +import { Text } from '@suite-native/atoms'; +import { Icon } from '@suite-native/icons'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { nativeSpacings } from '@trezor/theme'; + +export type TradeableAssetButtonProps = { + icon: ReactNode; + children: ReactNode; + bgBaseColor: string; + caret?: boolean; + onPress: () => void; +}; + +const styles = StyleSheet.create({ + button: { + height: 36, + padding: nativeSpacings.sp4, + paddingRight: nativeSpacings.sp12, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: nativeSpacings.sp8, + }, +}); + +const gradientBackgroundStyle = prepareNativeStyle(({ borders }) => ({ + borderRadius: borders.radii.round, + borderWidth: borders.widths.small, + borderColor: 'rgba(0, 0, 0, 0.06)', +})); + +export const TradeableAssetButton = ({ + bgBaseColor, + caret, + icon, + children, + onPress, +}: TradeableAssetButtonProps) => { + const { applyStyle } = useNativeStyles(); + + return ( + + + {icon} + + {children} + + {caret && } + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetListItem.tsx b/suite-native/module-trading/src/components/general/TradeableAssetListItem.tsx new file mode 100644 index 000000000000..4fa46fb02d7f --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetListItem.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react'; +import { Pressable } from 'react-native'; + +import { HStack, VStack, Text, Badge } from '@suite-native/atoms'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { nativeSpacings } from '@trezor/theme'; + +export type AssetListItemProps = { + title: ReactNode; + subtitle: ReactNode; + icon: ReactNode; + badge?: ReactNode; + onPress: () => void; +}; + +const vStackStyle = prepareNativeStyle(({ borders, colors }) => ({ + height: 68, + paddingVertical: nativeSpacings.sp8, + borderBottomWidth: borders.widths.small, + borderBottomColor: colors.borderElevation1, + flex: 1, + spacing: 0, +})); + +export const TradeableAssetListItem = ({ + title, + icon, + subtitle, + badge, + onPress, +}: AssetListItemProps) => { + const { applyStyle } = useNativeStyles(); + + return ( + + + {icon} + + + {title} + + + + {subtitle} + + {!!badge && } + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet.tsx new file mode 100644 index 000000000000..2ae3a23ab69a --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet.tsx @@ -0,0 +1,37 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { BottomSheet, VStack } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; + +import { PickerCloseButton } from './PickerCloseButton'; +import { PickerHeader } from './PickerHeader'; +import { PopularTradeableNetworks } from './PopularTradeableNetworks'; + +export type TradeableAssetsSheetProps = { + isVisible: boolean; + onClose: () => void; + onAssetSelect: (symbol: NetworkSymbol) => void; +}; + +export const TradeableAssetsSheet = ({ + isVisible, + onClose, + onAssetSelect, +}: TradeableAssetsSheetProps) => { + const onAssetSelectCallback = (symbol: NetworkSymbol) => { + onAssetSelect(symbol); + onClose(); + }; + + return ( + + + } /> + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableNetworkButton.tsx b/suite-native/module-trading/src/components/general/TradeableNetworkButton.tsx new file mode 100644 index 000000000000..f95142e812e7 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableNetworkButton.tsx @@ -0,0 +1,29 @@ +import { useFormatters } from '@suite-common/formatters'; +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { CryptoIcon } from '@suite-native/icons'; +import { useNativeStyles } from '@trezor/styles'; + +import { TradeableAssetButton } from './TradeableAssetButton'; + +export type TradeableNetworkButtonProps = { + symbol: NetworkSymbol; + onPress: () => void; + caret?: boolean; +}; + +export const TradeableNetworkButton = ({ symbol, onPress, caret }: TradeableNetworkButtonProps) => { + const { DisplaySymbolFormatter } = useFormatters(); + const { utils } = useNativeStyles(); + const baseSymbolColor = utils.coinsColors[symbol]; + + return ( + } + > + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableNetworkListItem.tsx b/suite-native/module-trading/src/components/general/TradeableNetworkListItem.tsx new file mode 100644 index 000000000000..49f6afd075e2 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableNetworkListItem.tsx @@ -0,0 +1,24 @@ +import { useFormatters } from '@suite-common/formatters'; +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { CryptoIcon } from '@suite-native/icons'; + +import { TradeableAssetListItem } from './TradeableAssetListItem'; + +export type TradeableNetworkListItemProps = { + symbol: NetworkSymbol; + onPress: () => void; +}; + +export const TradeableNetworkListItem = ({ symbol, onPress }: TradeableNetworkListItemProps) => { + const { DisplaySymbolFormatter, NetworkNameFormatter } = useFormatters(); + + return ( + } + subtitle={} + badge={} + icon={} + onPress={onPress} + /> + ); +}; diff --git a/suite-native/module-trading/src/components/general/__tests__/PopularNetworks.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/PopularNetworks.comp.test.tsx new file mode 100644 index 000000000000..feb8581fcc03 --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/PopularNetworks.comp.test.tsx @@ -0,0 +1,48 @@ +import { render, fireEvent } from '@suite-native/test-utils'; + +import { PopularTradeableNetworks } from '../PopularTradeableNetworks'; + +describe('PopularNetworks', () => { + it('should render up to 4 networks by default', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('BTC')).toBeDefined(); + expect(queryByText('ETH')).toBeDefined(); + expect(queryByText('ADA')).toBeDefined(); + expect(queryByText('SOL')).toBeDefined(); + expect(queryByText('DOGE')).toBeNull(); + }); + + it('should render up to maxNetworkSymbols networks', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('BTC')).toBeDefined(); + expect(queryByText('ETH')).toBeDefined(); + expect(queryByText('ADA')).toBeDefined(); + expect(queryByText('SOL')).toBeNull(); + expect(queryByText('DOGE')).toBeNull(); + }); + + it('should call onNetworkSelect callback', () => { + const selectSpy = jest.fn(); + const { getByText } = render( + , + ); + + const button = getByText('BTC'); + fireEvent.press(button); + + expect(selectSpy).toHaveBeenCalledWith('btc'); + }); +}); diff --git a/suite-native/module-trading/src/components/general/__tests__/SelectTradeableAssetButton.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/SelectTradeableAssetButton.comp.test.tsx new file mode 100644 index 000000000000..d9c1787f5561 --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/SelectTradeableAssetButton.comp.test.tsx @@ -0,0 +1,22 @@ +import { render } from '@suite-native/test-utils'; + +import { SelectTradeableAssetButton } from '../SelectTradeableAssetButton'; + +describe('SelectTradeableAssetButton', () => { + it('should render "Select coin" when no network is selected', () => { + const { getByText } = render( + , + ); + + expect(getByText('Select coin')).toBeDefined(); + }); + + it('should render TradeableAssetButton when network is selected', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Select coin')).toBeNull(); + expect(queryByText('ADA')).toBeDefined(); + }); +}); diff --git a/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkButton.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkButton.comp.test.tsx new file mode 100644 index 000000000000..18b4b21f34a6 --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkButton.comp.test.tsx @@ -0,0 +1,21 @@ +import { render, fireEvent } from '@suite-native/test-utils'; + +import { TradeableNetworkButton } from '../TradeableNetworkButton'; + +describe('TradeableNetworkButton', () => { + it('should render display name of given symbol', () => { + const { getByText } = render(); + + expect(getByText('BTC')).toBeDefined(); + }); + + it('should call onPress callback', () => { + const pressSpy = jest.fn(); + const { getByText } = render(); + + const button = getByText('BTC'); + fireEvent.press(button); + + expect(pressSpy).toHaveBeenCalledWith(); + }); +}); diff --git a/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkListItem.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkListItem.comp.test.tsx new file mode 100644 index 000000000000..51d596c7ae4f --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkListItem.comp.test.tsx @@ -0,0 +1,24 @@ +import { render, fireEvent } from '@suite-native/test-utils'; + +import { TradeableNetworkListItem } from '../TradeableNetworkListItem'; + +describe('TradeableNetworkListItem', () => { + it('should render with correct labels', () => { + const { getByText, getAllByText } = render( + , + ); + + // title and badge + expect(getAllByText('Bitcoin').length).toBe(2); + expect(getByText('BTC')).toBeDefined(); + }); + + it('should call onPress callback when clicked', () => { + const onPress = jest.fn(); + const { getByText } = render(); + + fireEvent.press(getByText('BTC')); + + expect(onPress).toHaveBeenCalledWith(); + }); +}); diff --git a/suite-native/module-trading/src/hooks/__tests__/useAssetsSheetControls.test.ts b/suite-native/module-trading/src/hooks/__tests__/useAssetsSheetControls.test.ts new file mode 100644 index 000000000000..b3959e53ea5b --- /dev/null +++ b/suite-native/module-trading/src/hooks/__tests__/useAssetsSheetControls.test.ts @@ -0,0 +1,52 @@ +import { renderHook, act } from '@suite-native/test-utils'; + +import { useTradeableAssetsSheetControls } from '../useTradeableAssetsSheetControls'; + +describe('useTradeableAssetsSheetControls', () => { + describe('isTokensSheetVisible', () => { + it('should be false by default', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + expect(result.current.isTradeableAssetsSheetVisible).toBe(false); + }); + + it('should be true after showTokensSheet call', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + act(() => { + result.current.showTradeableAssetsSheet(); + }); + + expect(result.current.isTradeableAssetsSheetVisible).toBe(true); + }); + + it('should be false after hideTokensSheet call', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + act(() => { + result.current.showTradeableAssetsSheet(); + result.current.hideTradeableAssetsSheet(); + }); + + expect(result.current.isTradeableAssetsSheetVisible).toBe(false); + }); + }); + + describe('selectedAsset', () => { + it('should be undefined by default', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + expect(result.current.selectedTradeableAsset).toBeUndefined(); + }); + + it('should be set after setSelectedNetwork call', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + act(() => { + result.current.setSelectedTradeableAsset('btc'); + }); + + expect(result.current.selectedTradeableAsset).toBe('btc'); + }); + }); +}); diff --git a/suite-native/module-trading/src/hooks/useTradeableAssetsSheetControls.ts b/suite-native/module-trading/src/hooks/useTradeableAssetsSheetControls.ts new file mode 100644 index 000000000000..be659d5b326b --- /dev/null +++ b/suite-native/module-trading/src/hooks/useTradeableAssetsSheetControls.ts @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +import { NetworkSymbol } from '@suite-common/wallet-config'; + +export const useTradeableAssetsSheetControls = () => { + const [isTradeableAssetsSheetVisible, setIsTradeableAssetsSheetVisible] = useState(false); + const [selectedTradeableAsset, setSelectedTradeableAsset] = useState< + undefined | NetworkSymbol + >(); + + const showTradeableAssetsSheet = () => { + setIsTradeableAssetsSheetVisible(true); + }; + + const hideTradeableAssetsSheet = () => { + setIsTradeableAssetsSheetVisible(false); + }; + + return { + isTradeableAssetsSheetVisible, + showTradeableAssetsSheet, + hideTradeableAssetsSheet, + selectedTradeableAsset, + setSelectedTradeableAsset, + }; +}; diff --git a/suite-native/module-trading/src/screens/TradingScreen.tsx b/suite-native/module-trading/src/screens/TradingScreen.tsx index 7c6c8996fecb..de0b73a79144 100644 --- a/suite-native/module-trading/src/screens/TradingScreen.tsx +++ b/suite-native/module-trading/src/screens/TradingScreen.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { Screen } from '@suite-native/navigation'; -import { Card, Text } from '@suite-native/atoms'; +import { Text } from '@suite-native/atoms'; import { DeviceManagerScreenHeader } from '@suite-native/device-manager'; +import { AmountCard } from '../components/buy/AmountCard'; + export const TradingScreen = () => ( }> - - Trading placeholder - + Trading placeholder + ); diff --git a/suite-native/test-utils/src/BasicProvider.tsx b/suite-native/test-utils/src/BasicProvider.tsx index 981f6dd96430..c664608fa5ca 100644 --- a/suite-native/test-utils/src/BasicProvider.tsx +++ b/suite-native/test-utils/src/BasicProvider.tsx @@ -1,8 +1,9 @@ -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { NavigationContainer } from '@react-navigation/native'; +import { FormatterProvider } from '@suite-common/formatters'; import { createRenderer, StylesProvider } from '@trezor/styles'; import { prepareNativeTheme } from '@trezor/theme'; import { IntlProvider } from '@suite-native/intl'; @@ -14,12 +15,26 @@ type ProviderProps = { const renderer = createRenderer(); const theme = prepareNativeTheme({ colorVariant: 'standard' }); -export const BasicProvider = ({ children }: ProviderProps) => ( - - - - {children} - - - -); +export const BasicProvider = ({ children }: ProviderProps) => { + const formattersConfig = useMemo( + () => ({ + locale: 'en' as const, + fiatCurrency: 'usd' as const, + bitcoinAmountUnit: 0, + is24HourFormat: true, + }), + [], + ); + + return ( + + + + + {children} + + + + + ); +}; diff --git a/yarn.lock b/yarn.lock index f25cccbb642e..f5b89a71c695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10941,8 +10941,10 @@ __metadata: "@reduxjs/toolkit": "npm:1.9.5" "@suite-native/navigation": "workspace:*" "@suite-native/test-utils": "workspace:*" + expo-linear-gradient: "npm:^14.0.1" react: "npm:18.2.0" react-native: "npm:0.76.1" + react-native-reanimated: "npm:^3.16.7" languageName: unknown linkType: soft