diff --git a/suite-common/icons/generateIconFont.ts b/suite-common/icons/generateIconFont.ts index da79eab2b9d8..613062fac5fd 100644 --- a/suite-common/icons/generateIconFont.ts +++ b/suite-common/icons/generateIconFont.ts @@ -93,6 +93,8 @@ const usedIcons = [ 'shieldWarning', 'shuffle', 'stack', + 'star', + 'starFilled', 'swap', 'trashSimple', 'treeStructure', diff --git a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json index b4b2da1b6169..ae44b085364b 100644 --- a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json +++ b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json @@ -20,84 +20,86 @@ "treeStructure": 61715, "trashSimple": 61716, "swap": 61717, - "stack": 61718, - "shuffle": 61719, - "shieldWarning": 61720, - "shieldCheck": 61721, - "shareNetwork": 61722, - "question": 61723, - "qrCode": 61724, - "prohibit": 61725, - "plusCircle": 61726, - "plus": 61727, - "plugs": 61728, - "piggyBankFilled": 61729, - "piggyBank": 61730, - "pictureFrame": 61731, - "pencilSimpleLine": 61732, - "pencilSimple": 61733, - "pencil": 61734, - "password": 61735, - "palette": 61736, - "magnifyingGlass": 61737, - "lock": 61738, - "link": 61739, - "lightbulb": 61740, - "lifebuoy": 61741, - "info": 61742, - "image": 61743, - "houseFilled": 61744, - "house": 61745, - "handPalm": 61746, - "githubLogo": 61747, - "gearFilled": 61748, - "gear": 61749, - "flagCheckered": 61750, - "flag": 61751, - "fingerprintSimple": 61752, - "fingerprint": 61753, - "filePdf": 61754, - "facebookLogo": 61755, - "eyeSlash": 61756, - "eye": 61757, - "discoverFilled": 61758, - "discover": 61759, - "detective": 61760, - "database": 61761, - "cpu": 61762, - "copy": 61763, - "coins": 61764, - "coinVerticalCheck": 61765, - "code": 61766, - "clockClockwise": 61767, - "circleDashed": 61768, - "checks": 61769, - "checkCircleFilled": 61770, - "checkCircle": 61771, - "check": 61772, - "chatCircle": 61773, - "change": 61774, - "caretUpFilled": 61775, - "caretUpDown": 61776, - "caretUp": 61777, - "caretRight": 61778, - "caretLeft": 61779, - "caretDownFilled": 61780, - "caretDown": 61781, - "caretCircleRight": 61782, - "calendar": 61783, - "bugBeetle": 61784, - "bookmarkSimple": 61785, - "backspace": 61786, - "arrowsLeftRight": 61787, - "arrowsCounterClockwise": 61788, - "arrowUpRight": 61789, - "arrowUp": 61790, - "arrowURightDown": 61791, - "arrowSquareOut": 61792, - "arrowRight": 61793, - "arrowLineUpRight": 61794, - "arrowLineUp": 61795, - "arrowLineDown": 61796, - "arrowDown": 61797 + "starFilled": 61718, + "star": 61719, + "stack": 61720, + "shuffle": 61721, + "shieldWarning": 61722, + "shieldCheck": 61723, + "shareNetwork": 61724, + "question": 61725, + "qrCode": 61726, + "prohibit": 61727, + "plusCircle": 61728, + "plus": 61729, + "plugs": 61730, + "piggyBankFilled": 61731, + "piggyBank": 61732, + "pictureFrame": 61733, + "pencilSimpleLine": 61734, + "pencilSimple": 61735, + "pencil": 61736, + "password": 61737, + "palette": 61738, + "magnifyingGlass": 61739, + "lock": 61740, + "link": 61741, + "lightbulb": 61742, + "lifebuoy": 61743, + "info": 61744, + "image": 61745, + "houseFilled": 61746, + "house": 61747, + "handPalm": 61748, + "githubLogo": 61749, + "gearFilled": 61750, + "gear": 61751, + "flagCheckered": 61752, + "flag": 61753, + "fingerprintSimple": 61754, + "fingerprint": 61755, + "filePdf": 61756, + "facebookLogo": 61757, + "eyeSlash": 61758, + "eye": 61759, + "discoverFilled": 61760, + "discover": 61761, + "detective": 61762, + "database": 61763, + "cpu": 61764, + "copy": 61765, + "coins": 61766, + "coinVerticalCheck": 61767, + "code": 61768, + "clockClockwise": 61769, + "circleDashed": 61770, + "checks": 61771, + "checkCircleFilled": 61772, + "checkCircle": 61773, + "check": 61774, + "chatCircle": 61775, + "change": 61776, + "caretUpFilled": 61777, + "caretUpDown": 61778, + "caretUp": 61779, + "caretRight": 61780, + "caretLeft": 61781, + "caretDownFilled": 61782, + "caretDown": 61783, + "caretCircleRight": 61784, + "calendar": 61785, + "bugBeetle": 61786, + "bookmarkSimple": 61787, + "backspace": 61788, + "arrowsLeftRight": 61789, + "arrowsCounterClockwise": 61790, + "arrowUpRight": 61791, + "arrowUp": 61792, + "arrowURightDown": 61793, + "arrowSquareOut": 61794, + "arrowRight": 61795, + "arrowLineUpRight": 61796, + "arrowLineUp": 61797, + "arrowLineDown": 61798, + "arrowDown": 61799 } diff --git a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf index a5518161b6d8..1de8a6ca8bd5 100644 Binary files a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf and b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf differ diff --git a/suite-native/atoms/src/Button/Button.tsx b/suite-native/atoms/src/Button/Button.tsx index 4607e827428e..9a9ee14bb8af 100644 --- a/suite-native/atoms/src/Button/Button.tsx +++ b/suite-native/atoms/src/Button/Button.tsx @@ -33,7 +33,7 @@ export type ButtonColorScheme = | 'blueBold' | 'blueElevation0' | 'blueElevation1' - | 'invisible'; + | 'backgroundSurfaceElevation0'; export type ButtonProps = Omit & { children: ReactNode; @@ -182,7 +182,7 @@ export const buttonSchemeToColorsMap = { iconColor: 'iconAlertBlue', disabledColors: baseDisabledScheme, }, - invisible: { + backgroundSurfaceElevation0: { backgroundColor: 'backgroundSurfaceElevation0', onPressColor: 'backgroundTertiaryPressedOnElevation0', textColor: 'textSubdued', diff --git a/suite-native/atoms/src/Input/SearchInput.tsx b/suite-native/atoms/src/Input/SearchInput.tsx index 5c2b4ae7dc90..f35e6e57a5f7 100644 --- a/suite-native/atoms/src/Input/SearchInput.tsx +++ b/suite-native/atoms/src/Input/SearchInput.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; import { Pressable, TextInput, TouchableOpacity } from 'react-native'; import { Icon } from '@suite-native/icons'; @@ -7,12 +7,15 @@ import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { Box } from '../Box'; import { SurfaceElevation } from '../types'; -type InputProps = { +export type SearchInputProps = { onChange: (value: string) => void; placeholder?: string; isDisabled?: boolean; maxLength?: number; elevation?: SurfaceElevation; + onFocus?: () => void; + onBlur?: () => void; + value?: string; }; const inputStyle = prepareNativeStyle(utils => ({ @@ -27,6 +30,7 @@ type InputStyleProps = { isFocused: boolean; elevation: SurfaceElevation; }; + const inputWrapperStyle = prepareNativeStyle( (utils, { isFocused, elevation }) => ({ flexDirection: 'row', @@ -57,53 +61,73 @@ const inputWrapperStyle = prepareNativeStyle( }), ); -export const SearchInput = ({ - onChange, - placeholder, - maxLength, - isDisabled = false, - elevation = '0', -}: InputProps) => { - const { applyStyle, utils } = useNativeStyles(); - const [isFocused, setIsFocused] = useState(false); - const [isClearButtonVisible, setIsClearButtonVisible] = useState(false); - const searchInputRef = useRef(null); - const handleClear = () => { - setIsClearButtonVisible(false); - searchInputRef.current?.clear(); - onChange(''); - }; +const noOp = () => {}; - const handleInputFocus = () => { - searchInputRef?.current?.focus(); - }; +export const SearchInput = forwardRef( + ( + { + onChange, + placeholder, + maxLength, + isDisabled = false, + elevation = '0', + onFocus = noOp, + onBlur = noOp, + value, + }, + ref, + ) => { + const { applyStyle, utils } = useNativeStyles(); + const [isFocused, setIsFocused] = useState(false); + const [isClearButtonVisible, setIsClearButtonVisible] = useState(false); + const searchInputRef = useRef(null); - const handleOnChangeText = (value: string) => { - setIsClearButtonVisible(!!value.length); - onChange(value); - }; + useImperativeHandle(ref, () => searchInputRef.current!, [searchInputRef]); - return ( - - - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - style={applyStyle(inputStyle)} - maxLength={maxLength} - /> - {isClearButtonVisible && ( - - - - )} - - - ); -}; + const handleClear = () => { + setIsClearButtonVisible(false); + searchInputRef.current?.clear(); + onChange(''); + }; + + const handleInputFocus = () => { + searchInputRef.current?.focus(); + }; + + const handleOnChangeText = (inputValue: string) => { + setIsClearButtonVisible(!!inputValue.length); + onChange(inputValue); + }; + + return ( + + + + { + setIsFocused(true); + onFocus(); + }} + onBlur={() => { + setIsFocused(false); + onBlur(); + }} + style={applyStyle(inputStyle)} + maxLength={maxLength} + value={value} + /> + {isClearButtonVisible && ( + + + + )} + + + ); + }, +); diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index ad3b806f22b0..2e2cd7c7f556 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -1230,6 +1230,19 @@ export const en = { description: 'We currently support staking as view-only in Trezor Suite Lite.', }, }, + moduleTrading: { + selectCoin: { + buttonTitle: 'Select coin', + }, + tradeableAssetsSheet: { + title: 'Coins', + favouritesTitle: 'Favourites', + allTitle: 'All coins', + favouritesAdd: 'Add to favourites', + favouritesRemove: 'Remove from favourites', + }, + defaultSearchLabel: 'Search', + }, }; export type Translations = typeof en; diff --git a/suite-native/module-trading/jest.config.js b/suite-native/module-trading/jest.config.js new file mode 100644 index 000000000000..4299995bffa7 --- /dev/null +++ b/suite-native/module-trading/jest.config.js @@ -0,0 +1,5 @@ +const { ...baseConfig } = require('../../jest.config.native'); + +module.exports = { + ...baseConfig, +}; diff --git a/suite-native/module-trading/package.json b/suite-native/module-trading/package.json index 25182f5e1e3e..bfaca191ab6e 100644 --- a/suite-native/module-trading/package.json +++ b/suite-native/module-trading/package.json @@ -8,14 +8,16 @@ "scripts": { "depcheck": "yarn g:depcheck", "type-check": "yarn g:tsc --build", - "test:unit": "yarn g:jest -c ../../jest.config.native.js" + "test:unit": "yarn g:jest" }, "dependencies": { "@react-navigation/native-stack": "6.11.0", "@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" + "react-native": "0.76.1", + "react-native-reanimated": "3.16.7" } } diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/FavouriteIcon.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/FavouriteIcon.tsx new file mode 100644 index 000000000000..fc69cd576a15 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/FavouriteIcon.tsx @@ -0,0 +1,31 @@ +import { TouchableWithoutFeedback } from 'react-native'; + +import { Icon, IconColor, IconName } from '@suite-native/icons'; +import { useTranslate } from '@suite-native/intl'; + +export type FavouriteIconProps = { + isFavourite: boolean; + onPress: () => void; +}; + +export const FavouriteIcon = ({ isFavourite, onPress }: FavouriteIconProps) => { + const { translate } = useTranslate(); + + const hint: string = isFavourite + ? translate('moduleTrading.tradeableAssetsSheet.favouritesRemove') + : translate('moduleTrading.tradeableAssetsSheet.favouritesAdd'); + const iconName: IconName = isFavourite ? 'starFilled' : 'star'; + // TODO 16600 - do I really want to use backgroundAlertYellowBold here? + // TODO outline? + const iconColor: IconColor = isFavourite ? 'backgroundAlertYellowBold' : 'textSubdued'; + + return ( + + + + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetListItem.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetListItem.tsx new file mode 100644 index 000000000000..8da94f9f2f85 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetListItem.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from 'react'; +import { Pressable } from 'react-native'; + +import { Badge, HStack, Text, VStack } from '@suite-native/atoms'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +import { FavouriteIcon } from './FavouriteIcon'; + +export type AssetListItemProps = { + assetName: ReactNode; + displaySymbol: ReactNode; + fiatRate: ReactNode; + + icon: ReactNode; + priceChange?: ReactNode; + onPress: () => void; + isFavourite?: boolean; + onFavouritePress: () => void; +}; + +const vStackStyle = prepareNativeStyle(utils => ({ + height: 68, + paddingVertical: utils.spacings.sp8, + flex: 1, + spacing: 0, +})); + +export const TradeableAssetListItem = ({ + assetName, + icon, + displaySymbol, + fiatRate, + priceChange, + onPress, + onFavouritePress, + isFavourite = false, +}: AssetListItemProps) => { + const { applyStyle } = useNativeStyles(); + + return ( + + + {icon} + + + + {assetName} + + + {fiatRate} + + + + + {displaySymbol} + + + + + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx new file mode 100644 index 000000000000..b2908f642c37 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { Button, HStack } from '@suite-native/atoms'; + +type FilterTabProps = { + children: React.ReactNode; + active: boolean; + onPress: () => void; +}; + +type TradeableAssetsFilterTabsProps = { + visible: boolean; + animationDuration: number; +}; + +const FilterTab = ({ active, onPress, children }: FilterTabProps) => { + const colorScheme = active ? 'tertiaryElevation0' : 'backgroundSurfaceElevation0'; + + return ( + + ); +}; + +export const TradeableAssetsFilterTabs = ({ + visible, + animationDuration, +}: TradeableAssetsFilterTabsProps) => { + const tabNames = [ + 'All', + 'Ethereum', + 'Solana', + 'Base', + 'Ethereum 2', + 'Solana 2', + 'Base 2', + 'Ethereum 3', + 'Solana 3', + 'Base 3', + ]; + + const [activeTab, setActiveTab] = useState(tabNames[0]); + + if (!visible) { + return null; + } + + return ( + + + {tabNames.map(tab => ( + setActiveTab(tab)} + > + {tab} + + ))} + + + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsList.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsList.tsx new file mode 100644 index 000000000000..3901ad162b67 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsList.tsx @@ -0,0 +1,63 @@ +import { ReactNode } from 'react'; +import { ScrollView } from 'react-native'; + +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { Box, Card, Text } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; + +import { TradeableNetworkListItem } from './TradeableNetworkListItem'; + +export type TradeableAssetsListProps = { + onItemSelected: (item: NetworkSymbol) => void; +}; + +const TradeableAssetsListSectionHeader = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +export const TradeableAssetsList = ({ onItemSelected }: TradeableAssetsListProps) => { + const favourites: NetworkSymbol[] = ['btc', 'ada', 'eth']; + const all: NetworkSymbol[] = ['xrp', 'ltc', 'arb', 'base', 'doge', 'sol', 'dsol', 'etc']; + + return ( + + + + + + {favourites.map(symbol => ( + onItemSelected(symbol)} + onFavouritePress={() => {}} + isFavourite + /> + ))} + + + + + + {all.map(symbol => ( + onItemSelected(symbol)} + onFavouritePress={() => {}} + /> + ))} + + + + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheet.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheet.tsx new file mode 100644 index 000000000000..8c34f8c14ef6 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheet.tsx @@ -0,0 +1,38 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { BottomSheet, Box } from '@suite-native/atoms'; + +import { TradeableAssetsList } from './TradeableAssetsList'; +import { TradeableAssetsSheetHeader } from './TradeableAssetsSheetHeader'; +import { PickerCloseButton } from '../general/PickerCloseButton'; + +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/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx new file mode 100644 index 000000000000..26db002bfb1a --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { StyleSheet } from 'react-native'; + +import { LinearGradient } from 'expo-linear-gradient'; + +import { hexToRgba } from '@suite-common/suite-utils'; +import { VStack } from '@suite-native/atoms'; +import { useNativeStyles } from '@trezor/styles'; + +import { TradeableAssetsFilterTabs } from './TradeableAssetsFilterTabs'; +import { TradeableAssetsSheetTitle } from './TradeableAssetsSheetTitle'; +import { SearchInputWithCancel } from '../general/SearchInputWithCancel'; + +const FOCUS_ANIMATION_DURATION = 500; + +const styles = StyleSheet.create({ + wrapper: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}); + +export const TradeableAssetsSheetHeader = () => { + const { + utils: { colors }, + } = useNativeStyles(); + const [isFilterActive, setIsFilterActive] = useState(false); + const [filterValue, setFilterValue] = useState(''); + + return ( + + + + { + setIsFilterActive(true); + }} + onBlur={() => { + setIsFilterActive(false); + }} + value={filterValue} + /> + + + + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheetTitle.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheetTitle.tsx new file mode 100644 index 000000000000..f63cbba5075e --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableAssetsSheetTitle.tsx @@ -0,0 +1,26 @@ +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { Text } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; + +export type TradeableAssetsSheetTitleProps = { + visible: boolean; + animationDuration: number; +}; + +export const TradeableAssetsSheetTitle = ({ + visible, + animationDuration, +}: TradeableAssetsSheetTitleProps) => { + if (!visible) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableNetworkListItem.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableNetworkListItem.tsx new file mode 100644 index 000000000000..757933404a55 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/TradeableNetworkListItem.tsx @@ -0,0 +1,34 @@ +import { useFormatters } from '@suite-common/formatters'; +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { RoundedIcon } from '@suite-native/atoms'; + +import { TradeableAssetListItem } from './TradeableAssetListItem'; + +export type TradeableNetworkListItemProps = { + symbol: NetworkSymbol; + onPress: () => void; + onFavouritePress: () => void; + isFavourite?: boolean; +}; + +export const TradeableNetworkListItem = ({ + symbol, + onPress, + onFavouritePress, + isFavourite, +}: TradeableNetworkListItemProps) => { + const { DisplaySymbolFormatter, NetworkNameFormatter } = useFormatters(); + + return ( + } + displaySymbol={} + priceChange="1.23%" + fiatRate="$ 123.45" + icon={} + onPress={onPress} + onFavouritePress={onFavouritePress} + isFavourite={isFavourite} + /> + ); +}; diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/FavouriteIcon.comp.test.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/FavouriteIcon.comp.test.tsx new file mode 100644 index 000000000000..c6f3326d6ec5 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/FavouriteIcon.comp.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render } from '@suite-native/test-utils'; + +import { FavouriteIcon } from '../FavouriteIcon'; + +describe('FavouriteIcon', () => { + it('should has correct hint when marked as favourite', () => { + const { getByA11yHint } = render(); + expect(getByA11yHint('Remove from favourites')).toBeDefined(); + }); + + it('should has correct hint when not marked as favourite', () => { + const { getByA11yHint } = render(); + expect(getByA11yHint('Add to favourites')).toBeDefined(); + }); + + it('should call onPress callback', () => { + const pressSpy = jest.fn(); + const { getByA11yHint } = render(); + + const button = getByA11yHint('Add to favourites'); + fireEvent.press(button); + + expect(pressSpy).toHaveBeenCalledWith(); + }); +}); diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/TradeableAssetsSheetHeader.comp.test.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/TradeableAssetsSheetHeader.comp.test.tsx new file mode 100644 index 000000000000..793d05be4101 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/TradeableAssetsSheetHeader.comp.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render } from '@suite-native/test-utils'; + +import { TradeableAssetsSheetHeader } from '../TradeableAssetsSheetHeader'; + +describe('TradeableAssetsSheetHeader', () => { + it('should display "Coins" and do not display tabs by default', () => { + const { getByText, queryByText } = render(); + + expect(getByText('Coins')).toBeDefined(); + expect(queryByText('All')).toBeNull(); + }); + + it('should display tabs after focusing search input', () => { + const { getByPlaceholderText, getByText, queryByText } = render( + , + ); + + fireEvent(getByPlaceholderText('Search'), 'focus'); + + expect(getByText('All')).toBeDefined(); + expect(queryByText('Coins')).toBeNull(); + }); + + it('should not display cancel button by default', () => { + const { queryByText } = render(); + + expect(queryByText('Cancel')).toBeNull(); + }); + + it('should display cancel button after focusing search input', () => { + const { getByPlaceholderText, getByText } = render(); + + fireEvent(getByPlaceholderText('Search'), 'focus'); + + expect(getByText('Cancel')).toBeDefined(); + }); +}); diff --git a/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/TradeableNetworkListItem.comp.test.tsx b/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/TradeableNetworkListItem.comp.test.tsx new file mode 100644 index 000000000000..e884d8a86737 --- /dev/null +++ b/suite-native/module-trading/src/components/TradeableAssetsSheet/__tests__/TradeableNetworkListItem.comp.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render } from '@suite-native/test-utils'; + +import { TradeableNetworkListItem } from '../TradeableNetworkListItem'; + +describe('TradeableNetworkListItem', () => { + it('should render with correct labels', () => { + const { getByText } = render( + , + ); + + expect(getByText('Bitcoin')).toBeDefined(); + 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(); + }); + + it('should call onFavouritePress when star is clicked', () => { + const onFavouritePress = jest.fn(); + const { getByAccessibilityHint } = render( + , + ); + + fireEvent.press(getByAccessibilityHint('Add to favourites')); + + expect(onFavouritePress).toHaveBeenCalledWith(); + }); +}); 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..0f05a951f300 --- /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 { useTradeableAssetsSheetControls } from '../../hooks/useTradeableAssetsSheetControls'; +import { TradeableAssetsSheet } from '../TradeableAssetsSheet/TradeableAssetsSheet'; +import { SelectTradeableAssetButton } from '../general/SelectTradeableAssetButton'; + +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..c856da6b541e --- /dev/null +++ b/suite-native/module-trading/src/components/buy/__tests__/AmountCard.comp.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render } 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('Coins')).toBeNull(); + }); + + it('should display AssetsSheet after button click', () => { + const { getByText } = render(); + + fireEvent.press(getByText('Select coin')); + + expect(getByText('Coins')).toBeDefined(); + }); + + it('should display selected network from AssetsSheet', () => { + const { getByText, queryByText } = render(); + + fireEvent.press(getByText('Select coin')); + fireEvent.press(getByText('BTC')); + + expect(queryByText('Coins')).toBeNull(); + expect(getByText('BTC')).toBeDefined(); + }); +}); diff --git a/suite-native/module-trading/src/components/general/PickerCloseButton.tsx b/suite-native/module-trading/src/components/general/PickerCloseButton.tsx index 20e9c48bb428..3fc7b94498c0 100644 --- a/suite-native/module-trading/src/components/general/PickerCloseButton.tsx +++ b/suite-native/module-trading/src/components/general/PickerCloseButton.tsx @@ -1,13 +1,46 @@ -import { Button, ButtonProps } from '@suite-native/atoms'; +import { StyleSheet } from 'react-native'; + +import { LinearGradient } from 'expo-linear-gradient'; + +import { hexToRgba } from '@suite-common/suite-utils'; +import { Box, Button, ButtonProps } from '@suite-native/atoms'; import { Translation } from '@suite-native/intl'; +import { useNativeStyles } from '@trezor/styles'; export type PickerCloseButtonProps = Omit< ButtonProps, 'children' | 'colorScheme' | 'viewLeft' | 'viewRight' >; -export const PickerCloseButton = (props: PickerCloseButtonProps) => ( - -); +const styles = StyleSheet.create({ + wrapper: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + }, +}); + +export const PickerCloseButton = (props: PickerCloseButtonProps) => { + const { + utils: { colors }, + } = useNativeStyles(); + + return ( + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/PickerHeader.tsx b/suite-native/module-trading/src/components/general/PickerHeader.tsx deleted file mode 100644 index bfd2ca6ef144..000000000000 --- a/suite-native/module-trading/src/components/general/PickerHeader.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ReactNode } from 'react'; - -import { HStack, SearchInput, Text, VStack } from '@suite-native/atoms'; - -type PickerHeaderSearchInputProps = { - onSearchInputChange: (value: string) => void; - searchInputPlaceholder?: string; - isSearchInputDisabled?: boolean; - maxSearchInputLength?: number; -}; - -export type PickerHeaderProps = { - title: ReactNode; - children?: ReactNode; -} & ( - | { isSearchInputVisible?: false } - | ({ isSearchInputVisible: true } & PickerHeaderSearchInputProps) -); - -const PickerHeaderSearchInput = ({ - onSearchInputChange, - searchInputPlaceholder, - isSearchInputDisabled, - maxSearchInputLength, -}: PickerHeaderSearchInputProps) => ( - -); - -export const PickerHeader = ({ - title, - isSearchInputVisible, - children, - ...searchInputProps -}: PickerHeaderProps) => ( - - - - {title} - - {children} - - {isSearchInputVisible && ( - - )} - -); diff --git a/suite-native/module-trading/src/components/general/SearchInputWithCancel.tsx b/suite-native/module-trading/src/components/general/SearchInputWithCancel.tsx new file mode 100644 index 000000000000..0ff88706a80c --- /dev/null +++ b/suite-native/module-trading/src/components/general/SearchInputWithCancel.tsx @@ -0,0 +1,49 @@ +import { useRef, useState } from 'react'; +import { TextInput } from 'react-native'; + +import { Box, HStack, SearchInput, SearchInputProps, TextButton } from '@suite-native/atoms'; +import { Translation, useTranslate } from '@suite-native/intl'; + +export type SearchInputWithCancelProps = Omit; + +const noOp = () => {}; + +export const SearchInputWithCancel = ({ + onFocus = noOp, + onBlur = noOp, + ...props +}: SearchInputWithCancelProps) => { + const { translate } = useTranslate(); + const [isInputActive, setIsInputActive] = useState(false); + const inputRef = useRef(null); + + return ( + + + { + setIsInputActive(true); + onFocus(); + }} + onBlur={() => { + setIsInputActive(false); + onBlur(); + }} + {...props} + /> + + {isInputActive && ( + { + inputRef.current?.clear(); + inputRef.current?.blur(); + }} + > + + + )} + + ); +}; 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..ade83eda0c07 --- /dev/null +++ b/suite-native/module-trading/src/components/general/SelectTradeableAssetButton.tsx @@ -0,0 +1,29 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { Button, buttonSchemeToColorsMap } from '@suite-native/atoms'; +import { Icon } from '@suite-native/icons'; +import { Translation } from '@suite-native/intl'; + +import { TradeableNetworkButton } from './TradeableNetworkButton'; + +export type SelectAssetButtonProps = { + onPress: () => void; + selectedAsset: NetworkSymbol | undefined; +}; + +export const SelectTradeableAssetButton = ({ onPress, selectedAsset }: SelectAssetButtonProps) => { + 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/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/__tests__/PickerHeader.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/PickerHeader.comp.test.tsx deleted file mode 100644 index 76f1783459e3..000000000000 --- a/suite-native/module-trading/src/components/general/__tests__/PickerHeader.comp.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Text } from '@suite-native/atoms'; -import { fireEvent, render } from '@suite-native/test-utils'; - -import { PickerHeader } from '../PickerHeader'; - -describe('PickerHeader', () => { - it('should render without children', () => { - const { getByText } = render(); - expect(getByText('Title')).toBeDefined(); - }); - - it('should render with children', () => { - const { getByText } = render( - - Child - , - ); - expect(getByText('Title')).toBeDefined(); - expect(getByText('Child')).toBeDefined(); - }); - - it('should render search input when `isSearchInputVisible`', () => { - const searchInputSpy = jest.fn(); - const { getByPlaceholderText } = render( - , - ); - const searchInput = getByPlaceholderText('Search placeholder'); - - fireEvent.changeText(searchInput, 'search'); - - expect(searchInputSpy).toHaveBeenCalledWith('search'); - }); -}); 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..6b9c6b8f28bb --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/TradeableNetworkButton.comp.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render } 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/hooks/__tests__/useAssetsSheetControls.test.ts b/suite-native/module-trading/src/hooks/__tests__/useAssetsSheetControls.test.ts new file mode 100644 index 000000000000..0fb0b5962576 --- /dev/null +++ b/suite-native/module-trading/src/hooks/__tests__/useAssetsSheetControls.test.ts @@ -0,0 +1,52 @@ +import { act, renderHook } 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 1d4842c8b930..ce79ae9a4a63 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 { Card, Text } from '@suite-native/atoms'; +import { Text } from '@suite-native/atoms'; import { DeviceManagerScreenHeader } from '@suite-native/device-manager'; import { Screen } from '@suite-native/navigation'; +import { AmountCard } from '../components/buy/AmountCard'; + export const TradingScreen = () => ( }> - - Trading placeholder - + Trading placeholder + ); diff --git a/yarn.lock b/yarn.lock index a776e12c9025..93a0a5418131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10942,8 +10942,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