From 719823800f10e4d68c827de970c376523b84b507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jirka=20Ba=C5=BEant?= Date: Fri, 31 Jan 2025 11:19:48 +0100 Subject: [PATCH] feat(suite-native): Mobile Trade: Token picker modal visual stub --- packages/theme/src/spacings.ts | 3 +- suite-common/icons/generateIconFont.ts | 2 + .../iconFontsMobile/TrezorSuiteIcons.json | 162 +++++++------- .../iconFontsMobile/TrezorSuiteIcons.ttf | Bin 22196 -> 22572 bytes suite-common/suite-utils/src/hexToRgba.ts | 4 +- suite-native/app/app.config.ts | 2 +- suite-native/atoms/src/Input/SearchInput.tsx | 122 ++++++----- .../atoms/src/Sheet/BottomSheetFlashList.tsx | 35 ++- suite-native/intl/src/en.ts | 16 ++ suite-native/module-trading/jest.config.js | 5 + suite-native/module-trading/package.json | 6 +- .../src/components/buy/AmountCard.tsx | 33 +++ .../components/general/PickerCloseButton.tsx | 49 ++++- .../src/components/general/PickerHeader.tsx | 51 ----- .../general/SearchInputWithCancel.tsx | 49 +++++ .../general/SelectTradeableAssetButton.tsx | 38 ++++ .../general/TradeableAssetButton.tsx | 82 +++++++ .../TradeableAssetsSheet/FavouriteIcon.tsx | 37 ++++ .../TradeAssetsListEmptyComponent.tsx | 30 +++ .../TradeableAssetListItem.tsx | 107 ++++++++++ .../TradeableAssetsFilterTabs.tsx | 72 +++++++ .../TradeableAssetsSheet.tsx | 202 ++++++++++++++++++ .../TradeableAssetsSheetHeader.tsx | 57 +++++ .../__tests__/FavouriteIcon.comp.test.tsx | 25 +++ .../TradeableAssetListItem.comp.test.tsx | 79 +++++++ .../TradeableAssetsSheetHeader.comp.test.tsx | 37 ++++ .../__tests__/PickerHeader.comp.test.tsx | 38 ---- .../SelectTradeableAssetButton.comp.test.tsx | 34 +++ .../TradeableAssetButton.comp.test.tsx | 36 ++++ .../__tests__/useAssetsSheetControls.test.ts | 52 +++++ .../useTradeableAssetDominantColor.test.tsx | 43 ++++ .../hooks/useTradeableAssetDominantColor.ts | 17 ++ .../hooks/useTradeableAssetsSheetControls.ts | 26 +++ .../src/screens/TradingScreen.tsx | 9 +- suite-native/module-trading/src/types.ts | 9 + yarn.lock | 14 +- 36 files changed, 1325 insertions(+), 258 deletions(-) create mode 100644 suite-native/module-trading/jest.config.js create mode 100644 suite-native/module-trading/src/components/buy/AmountCard.tsx delete mode 100644 suite-native/module-trading/src/components/general/PickerHeader.tsx create mode 100644 suite-native/module-trading/src/components/general/SearchInputWithCancel.tsx create mode 100644 suite-native/module-trading/src/components/general/SelectTradeableAssetButton.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetButton.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/FavouriteIcon.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeAssetsListEmptyComponent.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/FavouriteIcon.comp.test.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/TradeableAssetListItem.comp.test.tsx create mode 100644 suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/TradeableAssetsSheetHeader.comp.test.tsx delete mode 100644 suite-native/module-trading/src/components/general/__tests__/PickerHeader.comp.test.tsx create mode 100644 suite-native/module-trading/src/components/general/__tests__/SelectTradeableAssetButton.comp.test.tsx create mode 100644 suite-native/module-trading/src/components/general/__tests__/TradeableAssetButton.comp.test.tsx create mode 100644 suite-native/module-trading/src/hooks/__tests__/useAssetsSheetControls.test.ts create mode 100644 suite-native/module-trading/src/hooks/__tests__/useTradeableAssetDominantColor.test.tsx create mode 100644 suite-native/module-trading/src/hooks/useTradeableAssetDominantColor.ts create mode 100644 suite-native/module-trading/src/hooks/useTradeableAssetsSheetControls.ts create mode 100644 suite-native/module-trading/src/types.ts diff --git a/packages/theme/src/spacings.ts b/packages/theme/src/spacings.ts index 5483d2f1ce8..2b677997cc7 100644 --- a/packages/theme/src/spacings.ts +++ b/packages/theme/src/spacings.ts @@ -43,7 +43,7 @@ export const spacingsPx = (Object.keys(spacings) as Array).reduce((resu return result; }, {} as SpacingPx); -type NativeSpacingValue = 1 | 2 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 52 | 64; +type NativeSpacingValue = 1 | 2 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 36 | 40 | 52 | 64; export const nativeSpacings = { sp1: 1, @@ -55,6 +55,7 @@ export const nativeSpacings = { sp20: 20, sp24: 24, sp32: 32, + sp36: 36, sp40: 40, sp52: 52, sp64: 64, diff --git a/suite-common/icons/generateIconFont.ts b/suite-common/icons/generateIconFont.ts index da79eab2b9d..613062fac5f 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 b4b2da1b616..ae44b085364 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 a5518161b6d8f9feb8488ca4786fdef6945ee3f9..1de8a6ca8bd58ed98ab29ea5db31f0969db3eeba 100644 GIT binary patch delta 847 zcmZWnO=uHQ5T1Ga-fnj{*~A+2pUozX7DYB1suiuNg2|~uRV<~57;T#fiB=6-f3TiB zh}5=GK@d@ts-RNZUMxj=5WVyuB0Z>xf{2tN_;c`})JeSQTfUunGaui43~%xwJ{ZHH zhJi}}co6{h9Vq4cexGa_1d!;i@O<%P@1CiZLjZa=z_y=-o?Lh5<(u1y`<}4AKn2!` zS16w*BnqW+|A{+`i=?+exLG`WAouwD{UU%y8t}4|>+i$U@Q8YTx?6{GrJlPVg3-4C z?Rx;&@xH^y%H!{^k=V{x0Aly>bc3@1NL)fFKQceOuoepy6OUkHWe8g_UwMJ|Mp)fM zKa1i@t40M3f>g^^Q`AV;0uEnDgwsbtU-Ok2>5bA0dD4|{-{M$LO$Pz^$LgO>equ~K z;oh&kL?=Ks61@t@SBhG4z*NpDuj}^LO{s=@T%FT;wd>lv?$Pf!9F9@Pg41-iInNtl zoHZtmAFeLftb3LFlzYbgZHc{Pw7#i+Z0QY8!gFES8ZUSUz3+Tw-<%)(+x+7JPvB}` zHrNsT97=@-!=7+k_-y!T_+!M16eH&&Pa^Lkzsz95OquQG<)|6G5q)OWTb$oJTayG1pDexm$!LHn|w*O&OuGP>5|-MRrR327#N)Q zWTYmhL`+%W3sn06h|My90!$H%lY#6#K&+CHTT)T#^Z!54yf;8RBPTyOapvL9c?=9( zIY9Yyxrr48jFk-QfN~N*zCvDNZtC)fQu22g82o-PFff%BYX$W5}2`S&p%gQEl=BMtjE8$rl(`Y;ItZXVjDdt7BjkV_*ie zbbvG$&`=o$7KT?q6Ig%{Xv)Xb$tRd^1C_8%-pKN2@&OihejcDK1Irl@2?VK|_pt6` znLLS0x88>R2!{+u0mlta3(gaq|G0d(%DCCM^LRLT{CFC8*73aHP2zpPC&m}Xw})Sk zzlZ;hfRaFnzyv`K!D&K5!VJP~!f!+-i6)7D5OWZ_CT<~~B)&{SM50XMhoqk54k<6G zDN-M#%cR#yKaf$Aag%A1IVbZ%RzcQ5Hcqxic8csK1=(+M4e|`~8uBIbyA+fZJQNBP sRw%qv6jSt3Y*O5zcyDtO_i^6MsZtXxfPwNY=*;9}!GAVi3W;I{0Ok#Yp#T5? diff --git a/suite-common/suite-utils/src/hexToRgba.ts b/suite-common/suite-utils/src/hexToRgba.ts index 6da917e07ad..d1f02016c17 100644 --- a/suite-common/suite-utils/src/hexToRgba.ts +++ b/suite-common/suite-utils/src/hexToRgba.ts @@ -4,8 +4,8 @@ export function hexToRgba(hex: string, alpha: number) { const b = parseInt(hex.slice(5, 7), 16); if (alpha > 0) { - return `rgba(${r}, ${g}, ${b}, ${alpha})`; + return `rgba(${r}, ${g}, ${b}, ${alpha})` as `rgba(${number}, ${number}, ${number}, ${number})`; } - return `rgb(${r}, ${g}, ${b})`; + return `rgb(${r}, ${g}, ${b})` as `rgb(${number}, ${number}, ${number})`; } diff --git a/suite-native/app/app.config.ts b/suite-native/app/app.config.ts index 9d08145f22b..9a16e080467 100644 --- a/suite-native/app/app.config.ts +++ b/suite-native/app/app.config.ts @@ -168,7 +168,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { slug: appSlugs[buildType], owner: appOwners[buildType], version: suiteNativeVersion, - runtimeVersion: '24', + runtimeVersion: '25', ...(buildType === 'production' ? {} : { diff --git a/suite-native/atoms/src/Input/SearchInput.tsx b/suite-native/atoms/src/Input/SearchInput.tsx index 5c2b4ae7dc9..f35e6e57a5f 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/atoms/src/Sheet/BottomSheetFlashList.tsx b/suite-native/atoms/src/Sheet/BottomSheetFlashList.tsx index f16c5df611e..0b22a38dd41 100644 --- a/suite-native/atoms/src/Sheet/BottomSheetFlashList.tsx +++ b/suite-native/atoms/src/Sheet/BottomSheetFlashList.tsx @@ -20,8 +20,8 @@ export type BottomSheetFlashListProps = { title?: ReactNode; subtitle?: ReactNode; estimatedListHeight?: number; - StickyListHeaderComponent?: ReactNode; - StickyListFooterComponent?: ReactNode; + stickyListHeaderComponent?: ReactNode; + stickyListFooterComponent?: ReactNode; } & FlashListProps; const DEFAULT_INSET_BOTTOM = 25; @@ -44,16 +44,15 @@ const handleStyle = prepareNativeStyle(utils => ({ backgroundColor: utils.colors.borderDashed, })); -const styles = StyleSheet.create({ - stickyListHeader: { - ...StyleSheet.absoluteFillObject, - bottom: undefined, - }, - stickyListFooter: { - ...StyleSheet.absoluteFillObject, - top: undefined, - }, -}); +const stickyListHeaderStyle = prepareNativeStyle(_ => ({ + ...StyleSheet.absoluteFillObject, + bottom: undefined, +})); + +const stickyListFooterStyle = prepareNativeStyle(_ => ({ + ...StyleSheet.absoluteFillObject, + top: undefined, +})); const WindowOverlay = ({ children }: { children: ReactNode }) => ( {children} @@ -66,8 +65,8 @@ export const BottomSheetFlashList = ({ title, subtitle, estimatedListHeight = 0, - StickyListHeaderComponent, - StickyListFooterComponent, + stickyListHeaderComponent, + stickyListFooterComponent, ...flashListProps }: BottomSheetFlashListProps) => { const { applyStyle } = useNativeStyles(); @@ -130,11 +129,11 @@ export const BottomSheetFlashList = ({ insetBottom, })} /> - {StickyListHeaderComponent ? ( - {StickyListHeaderComponent} + {stickyListHeaderComponent ? ( + {stickyListHeaderComponent} ) : null} - {StickyListFooterComponent ? ( - {StickyListFooterComponent} + {stickyListFooterComponent ? ( + {stickyListFooterComponent} ) : null} ); diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index 8e110e5b1fd..7c668595f49 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -1245,6 +1245,22 @@ 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', + emptyTitle: 'No coin found', + emptyDescription: + 'We couldn’t find a coin matching your search. Try checking the spelling or exploring the list for the right option.', + }, + 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 00000000000..4299995bffa --- /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 25182f5e1e3..bfaca191ab6 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/buy/AmountCard.tsx b/suite-native/module-trading/src/components/buy/AmountCard.tsx new file mode 100644 index 00000000000..03cd3010000 --- /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 { SelectTradeableAssetButton } from '../general/SelectTradeableAssetButton'; +import { TradeableAssetsSheet } from '../general/TradeableAssetsSheet/TradeableAssetsSheet'; + +export const AmountCard = () => { + const { + isTradeableAssetsSheetVisible, + showTradeableAssetsSheet, + hideTradeableAssetsSheet, + selectedTradeableAsset, + setSelectedTradeableAsset, + } = useTradeableAssetsSheetControls(); + + return ( + + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/PickerCloseButton.tsx b/suite-native/module-trading/src/components/general/PickerCloseButton.tsx index 20e9c48bb42..3d2f2a3ca7c 100644 --- a/suite-native/module-trading/src/components/general/PickerCloseButton.tsx +++ b/suite-native/module-trading/src/components/general/PickerCloseButton.tsx @@ -1,13 +1,50 @@ -import { Button, ButtonProps } from '@suite-native/atoms'; +import { useMemo } from 'react'; + +import { LinearGradient } from 'expo-linear-gradient'; + +import { hexToRgba } from '@suite-common/suite-utils'; +import { Box, Button, ButtonProps, VStack } from '@suite-native/atoms'; import { Translation } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; export type PickerCloseButtonProps = Omit< ButtonProps, 'children' | 'colorScheme' | 'viewLeft' | 'viewRight' >; -export const PickerCloseButton = (props: PickerCloseButtonProps) => ( - -); +export const PICKER_BUTTON_HEIGHT = 52 as const; + +const GRADIENT_START = { x: 0.5, y: 0.5 } as const; +const GRADIENT_END = { x: 0.5, y: 0 } as const; + +const spacerStyle = prepareNativeStyle(({ colors, spacings }) => ({ + backgroundColor: colors.backgroundSurfaceElevation0, + height: spacings.sp32, +})); + +export const PickerCloseButton = (props: PickerCloseButtonProps) => { + const { + utils: { + colors: { backgroundSurfaceElevation0 }, + }, + applyStyle, + } = useNativeStyles(); + + const gradientColors = useMemo<[string, string]>( + () => [backgroundSurfaceElevation0, hexToRgba(backgroundSurfaceElevation0, 0.1)], + [backgroundSurfaceElevation0], + ); + + 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 bfd2ca6ef14..00000000000 --- 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 00000000000..1b55c13ac6f --- /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); + + const handleCancel = () => { + inputRef.current?.clear(); + inputRef.current?.blur(); + }; + + return ( + + + { + setIsInputActive(true); + onFocus(); + }} + onBlur={() => { + setIsInputActive(false); + onBlur(); + }} + {...props} + /> + + {isInputActive && ( + + + + )} + + ); +}; 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 00000000000..249fbc33ab2 --- /dev/null +++ b/suite-native/module-trading/src/components/general/SelectTradeableAssetButton.tsx @@ -0,0 +1,38 @@ +import { Button, buttonSchemeToColorsMap } from '@suite-native/atoms'; +import { Icon } from '@suite-native/icons'; +import { Translation, useTranslate } from '@suite-native/intl'; + +import { TradeableAssetButton } from './TradeableAssetButton'; +import { TradeableAsset } from '../../types'; + +export type SelectAssetButtonProps = { + onPress: () => void; + selectedAsset: TradeableAsset | undefined; +}; + +export const SelectTradeableAssetButton = ({ onPress, selectedAsset }: SelectAssetButtonProps) => { + const { translate } = useTranslate(); + 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 00000000000..079c0f15004 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetButton.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { Pressable } from 'react-native'; + +import { LinearGradient } from 'expo-linear-gradient'; + +import { useFormatters } from '@suite-common/formatters'; +import { hexToRgba } from '@suite-common/suite-utils'; +import { Text } from '@suite-native/atoms'; +import { CryptoIcon, Icon } from '@suite-native/icons'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +import { useTradeableAssetDominantColor } from '../../hooks/useTradeableAssetDominantColor'; +import { TradeableAsset } from '../../types'; + +export type TradeableAssetButtonProps = { + asset: TradeableAsset; + caret?: boolean; + onPress: () => void; + accessibilityLabel: string; +}; + +const GRADIENT_START = { x: 0, y: 0.5 } as const; +const GRADIENT_END = { x: 1, y: 0.5 } as const; + +const buttonStyle = prepareNativeStyle(({ spacings }) => ({ + height: spacings.sp36, + padding: spacings.sp4, + paddingRight: spacings.sp12, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: spacings.sp8, +})); + +const gradientBackgroundStyle = prepareNativeStyle(({ colors, borders }) => ({ + borderRadius: borders.radii.round, + borderWidth: borders.widths.small, + borderColor: hexToRgba(colors.iconDefault, 0.06), +})); + +export const TradeableAssetButton = ({ + asset: { symbol, contractAddress, name }, + caret, + onPress, + accessibilityLabel, +}: TradeableAssetButtonProps) => { + const { applyStyle } = useNativeStyles(); + const { DisplaySymbolFormatter } = useFormatters(); + + const dominantAssetColor = useTradeableAssetDominantColor(symbol, contractAddress); + const gradientColors = useMemo<[string, string]>( + () => [hexToRgba(dominantAssetColor, 0.3), hexToRgba(dominantAssetColor, 0.01)], + [dominantAssetColor], + ); + + const displayName = name ?? ( + + ); + + return ( + + + + + {displayName} + + {caret && } + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/FavouriteIcon.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/FavouriteIcon.tsx new file mode 100644 index 00000000000..b605f7bee61 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/FavouriteIcon.tsx @@ -0,0 +1,37 @@ +import { Pressable } from 'react-native'; + +import { Box } from '@suite-native/atoms'; +import { Icon, IconProps } from '@suite-native/icons'; +import { useTranslate } from '@suite-native/intl'; + +export type FavouriteIconProps = { + isFavourite: boolean; + onPress: () => void; +}; + +const getIconProps = (isFavourite: boolean): IconProps => + isFavourite + ? { name: 'starFilled', color: 'backgroundAlertYellowBold' } + : { name: 'star', color: 'textSubdued' }; + +const useA11yButtonHint = (isFavourite: boolean): string => { + const { translate } = useTranslate(); + + return isFavourite + ? translate('moduleTrading.tradeableAssetsSheet.favouritesRemove') + : translate('moduleTrading.tradeableAssetsSheet.favouritesAdd'); +}; + +export const FavouriteIcon = ({ isFavourite, onPress }: FavouriteIconProps) => { + const buttonA11yHint = useA11yButtonHint(isFavourite); + + const iconProps = getIconProps(isFavourite); + + return ( + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeAssetsListEmptyComponent.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeAssetsListEmptyComponent.tsx new file mode 100644 index 00000000000..12df3893a95 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeAssetsListEmptyComponent.tsx @@ -0,0 +1,30 @@ +import { Text, VStack } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +import { SHEET_HEADER_HEIGHT } from './TradeableAssetsSheetHeader'; +import { PICKER_BUTTON_HEIGHT } from '../PickerCloseButton'; + +const emptyComponentStyle = prepareNativeStyle(({ spacings }) => ({ + paddingTop: SHEET_HEADER_HEIGHT + spacings.sp12, + paddingBottom: PICKER_BUTTON_HEIGHT + spacings.sp12, + paddingHorizontal: spacings.sp52, + alignContent: 'center', + justifyContent: 'center', + gap: spacings.sp12, +})); + +export const TradeAssetsListEmptyComponent = () => { + const { applyStyle } = useNativeStyles(); + + return ( + + + + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx new file mode 100644 index 00000000000..e380214162c --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx @@ -0,0 +1,107 @@ +import { Pressable } from 'react-native'; + +import { useFormatters } from '@suite-common/formatters'; +import { Box, HStack, PriceChangeBadge, RoundedIcon, Text, VStack } from '@suite-native/atoms'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +import { FavouriteIcon } from './FavouriteIcon'; +import { TradeableAsset } from '../../../types'; + +export type AssetListItemProps = { + asset: TradeableAsset; + fiatRate: number; + priceChange: number; + onPress: () => void; + onFavouritePress: () => void; + isFavourite?: boolean; + isFirst?: boolean; + isLast?: boolean; +}; + +type AssetItemStyleProps = { + isFirst: boolean; + isLast: boolean; +}; + +export const ASSET_ITEM_HEIGHT = 68; + +const itemStyle = prepareNativeStyle( + ({ colors, spacings, borders }, { isFirst, isLast }) => ({ + backgroundColor: colors.backgroundSurfaceElevation1, + paddingHorizontal: spacings.sp12, + extend: [ + { + condition: isFirst, + style: { + borderTopLeftRadius: borders.radii.r20, + borderTopRightRadius: borders.radii.r20, + }, + }, + { + condition: isLast, + style: { + borderBottomLeftRadius: borders.radii.r20, + borderBottomRightRadius: borders.radii.r20, + }, + }, + ], + }), +); + +const vStackStyle = prepareNativeStyle(({ spacings }) => ({ + height: ASSET_ITEM_HEIGHT, + justifyContent: 'center', + flex: 1, + gap: 0, + paddingVertical: spacings.sp12, +})); + +export const TradeableAssetListItem = ({ + asset: { symbol, contractAddress, name }, + fiatRate, + priceChange, + onPress, + onFavouritePress, + isFavourite = false, + isFirst = false, + isLast = false, +}: AssetListItemProps) => { + const { applyStyle } = useNativeStyles(); + const { DisplaySymbolFormatter, FiatAmountFormatter, NetworkNameFormatter } = useFormatters(); + + const assetName = name ?? ; + + return ( + + + + + + + + + {assetName} + + + + + + + + + + + + + + + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx new file mode 100644 index 00000000000..aed42e65034 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsFilterTabs.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { Button } from '@suite-native/atoms'; + +type FilterTabProps = { + children: React.ReactNode; + active: boolean; + onPress: () => void; +}; + +type TradeableAssetsFilterTabsProps = { + visible: boolean; + animationDuration: number; +}; + +const mockTabNames = [ + 'All', + 'Ethereum', + 'Solana', + 'Base', + 'Ethereum 2', + 'Solana 2', + 'Base 2', + 'Ethereum 3', + 'Solana 3', + 'Base 3', +]; + +const FilterTab = ({ active, onPress, children }: FilterTabProps) => { + const colorScheme = active ? 'tertiaryElevation0' : 'backgroundSurfaceElevation0'; + + return ( + + ); +}; + +export const TradeableAssetsFilterTabs = ({ + visible, + animationDuration, +}: TradeableAssetsFilterTabsProps) => { + const [activeTab, setActiveTab] = useState(mockTabNames[0]); + + if (!visible) { + return null; + } + + return ( + item} + accessible={true} + accessibilityRole="tablist" + renderItem={({ item }) => ( + setActiveTab(item)}> + {item} + + )} + /> + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx new file mode 100644 index 00000000000..8b81558e2c3 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx @@ -0,0 +1,202 @@ +import { ReactNode, useMemo } from 'react'; + +import { UnreachableCaseError } from '@suite-common/suite-utils'; +import { TokenAddress } from '@suite-common/wallet-types'; +import { BottomSheetFlashList, Box, Text } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; + +import { TradeAssetsListEmptyComponent } from './TradeAssetsListEmptyComponent'; +import { ASSET_ITEM_HEIGHT, TradeableAssetListItem } from './TradeableAssetListItem'; +import { SHEET_HEADER_HEIGHT, TradeableAssetsSheetHeader } from './TradeableAssetsSheetHeader'; +import { TradeableAsset } from '../../../types'; +import { PICKER_BUTTON_HEIGHT, PickerCloseButton } from '../PickerCloseButton'; + +export type TradeableAssetsSheetProps = { + isVisible: boolean; + onClose: () => void; + onAssetSelect: (symbol: TradeableAsset) => void; +}; + +type ListInnerItemShape = + // [type, height, key] + | ['spacer', number, string] + // [type, text, key] + | ['sectionHeader', ReactNode, string] + // [type, data, isFavourite] + | ['asset', TradeableAsset, { isFavourite?: boolean; isFirst?: boolean; isLast?: boolean }]; + +const SECTION_HEADER_HEIGHT = 48 as const; + +const mockFavourites: TradeableAsset[] = [ + { symbol: 'btc' }, + { symbol: 'eth' }, + { + symbol: 'eth', + contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as TokenAddress, + name: 'USDC', + }, +]; +const mockAssets: TradeableAsset[] = [ + { symbol: 'doge' }, + { symbol: 'sol' }, + { symbol: 'dsol' }, + { symbol: 'etc' }, + { symbol: 'xrp' }, + { symbol: 'ltc' }, + { symbol: 'arb' }, + { symbol: 'base' }, + { symbol: 'btc' }, + { symbol: 'eth' }, + { symbol: 'ada' }, + { symbol: 'bsc' }, +]; +const getMockFiatRate = () => Math.random() * 1000; +const getMockPriceChange = () => Math.random() * 3 - 1; + +const getEstimatedListHeight = (itemsCount: number) => + itemsCount * ASSET_ITEM_HEIGHT + + SHEET_HEADER_HEIGHT + + PICKER_BUTTON_HEIGHT + + 2 * SECTION_HEADER_HEIGHT; + +const transformToInnerFlatListData = ( + favourites: TradeableAsset[], + assetsData: TradeableAsset[], +): ListInnerItemShape[] => [ + ['spacer', SHEET_HEADER_HEIGHT, 'spacer_top'], + [ + 'sectionHeader', + , + 'section_favourites', + ], + ...favourites.map( + (asset, index) => + [ + 'asset', + asset, + { + isFavourite: true, + isFirst: index === 0, + isLast: index === favourites.length - 1, + }, + ] as ListInnerItemShape, + ), + [ + 'sectionHeader', + , + 'section_all', + ], + ...assetsData.map( + (asset, index) => + [ + 'asset', + asset, + { + isFavourite: false, + isFirst: index === 0, + isLast: index === assetsData.length - 1, + }, + ] as ListInnerItemShape, + ), + ['spacer', PICKER_BUTTON_HEIGHT, 'spacer_bottom'], +]; + +const keyExtractor = (item: ListInnerItemShape) => { + switch (item[0]) { + case 'spacer': + case 'sectionHeader': + return item[2]; + + case 'asset': { + const [_, { symbol, contractAddress }, { isFavourite }] = item; + + return `asset_${symbol}_${contractAddress ?? ''}_${isFavourite ? 'favourite' : ''}`; + } + + default: + throw new UnreachableCaseError(item[0]); + } +}; + +const renderItem = (data: ListInnerItemShape, onAssetSelect: (asset: TradeableAsset) => void) => { + switch (data[0]) { + case 'spacer': { + const height = data[1]; + + return ; + } + + case 'sectionHeader': { + const text = data[1]; + + return ( + + + {text} + + + ); + } + + case 'asset': { + const [_, asset, { isFavourite, isFirst, isLast }] = data; + const toggleFavourite = () => { + // TODO: Implement + // eslint-disable-next-line no-console + console.log('Not implemented!'); + }; + + return ( + onAssetSelect(asset)} + onFavouritePress={toggleFavourite} + priceChange={getMockPriceChange()} + fiatRate={getMockFiatRate()} + isFavourite={isFavourite} + isFirst={isFirst} + isLast={isLast} + /> + ); + } + + default: + throw new UnreachableCaseError(data[0]); + } +}; + +export const TradeableAssetsSheet = ({ + isVisible, + onClose, + onAssetSelect, +}: TradeableAssetsSheetProps) => { + const onAssetSelectCallback = (asset: TradeableAsset) => { + onAssetSelect(asset); + onClose(); + }; + + const favourites = mockFavourites; + const assetsData = mockAssets; + const estimatedListHeight = getEstimatedListHeight(favourites.length + assetsData.length); + + const data: ListInnerItemShape[] = useMemo( + () => transformToInnerFlatListData(favourites, assetsData), + [favourites, assetsData], + ); + + return ( + + isVisible={isVisible} + onClose={onClose} + isCloseDisplayed={false} + ListEmptyComponent={} + stickyListHeaderComponent={} + stickyListFooterComponent={} + data={data} + keyExtractor={keyExtractor} + estimatedListHeight={estimatedListHeight} + estimatedItemSize={ASSET_ITEM_HEIGHT} + renderItem={({ item }) => renderItem(item, onAssetSelectCallback)} + /> + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx new file mode 100644 index 00000000000..abd0970f088 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheetHeader.tsx @@ -0,0 +1,57 @@ +import { useMemo, useState } from 'react'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { LinearGradient } from 'expo-linear-gradient'; + +import { hexToRgba } from '@suite-common/suite-utils'; +import { Text, VStack } from '@suite-native/atoms'; +import { Translation } from '@suite-native/intl'; +import { useNativeStyles } from '@trezor/styles'; + +import { TradeableAssetsFilterTabs } from './TradeableAssetsFilterTabs'; +import { SearchInputWithCancel } from '../SearchInputWithCancel'; + +export const SHEET_HEADER_HEIGHT = 110 as const; +const FOCUS_ANIMATION_DURATION = 500 as const; + +const GRADIENT_START = { x: 0.5, y: 0.9 } as const; +const GRADIENT_END = { x: 0.5, y: 1 } as const; + +export const TradeableAssetsSheetHeader = () => { + const { + utils: { + colors: { backgroundSurfaceElevation0 }, + }, + } = useNativeStyles(); + const [isFilterActive, setIsFilterActive] = useState(false); + const [filterValue, setFilterValue] = useState(''); + + const gradientColors = useMemo<[string, string]>( + () => [backgroundSurfaceElevation0, hexToRgba(backgroundSurfaceElevation0, 0.1)], + [backgroundSurfaceElevation0], + ); + + return ( + + + {!isFilterActive && ( + + + + + + )} + setIsFilterActive(true)} + onBlur={() => setIsFilterActive(false)} + value={filterValue} + /> + + + + ); +}; diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/FavouriteIcon.comp.test.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/FavouriteIcon.comp.test.tsx new file mode 100644 index 00000000000..f9d96cd125d --- /dev/null +++ b/suite-native/module-trading/src/components/general/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 have correct hint when marked as favourite', () => { + const { getByA11yHint } = render(); + expect(getByA11yHint('Remove from favourites')).toBeDefined(); + }); + + it('should have 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/general/TradeableAssetsSheet/__tests__/TradeableAssetListItem.comp.test.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/TradeableAssetListItem.comp.test.tsx new file mode 100644 index 00000000000..773f604a5d0 --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/TradeableAssetListItem.comp.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render } from '@suite-native/test-utils'; + +import { TradeableAsset } from '../../../../types'; +import { TradeableAssetListItem } from '../TradeableAssetListItem'; + +describe('TradeableAssetListItem', () => { + const btcAsset: TradeableAsset = { symbol: 'btc' }; + const usdcAsset = { + symbol: 'eth', + name: 'USDC', + contractAddress: 'contractAddress', + } as TradeableAsset; + + it('should render with correct labels when no name is specified', () => { + const { getByText } = render( + , + ); + + expect(getByText('Bitcoin')).toBeDefined(); + expect(getByText('BTC')).toBeDefined(); + expect(getByText('$100.00')).toBeDefined(); + expect(getByText('90.0%')).toBeDefined(); + }); + + it('should render with correct labels when name is specified', () => { + const { getByText } = render( + , + ); + + expect(getByText('USDC')).toBeDefined(); + expect(getByText('ETH')).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/general/TradeableAssetsSheet/__tests__/TradeableAssetsSheetHeader.comp.test.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/__tests__/TradeableAssetsSheetHeader.comp.test.tsx new file mode 100644 index 00000000000..793d05be410 --- /dev/null +++ b/suite-native/module-trading/src/components/general/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/general/__tests__/PickerHeader.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/PickerHeader.comp.test.tsx deleted file mode 100644 index 76f1783459e..00000000000 --- 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 00000000000..3ad9b5fd45c --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/SelectTradeableAssetButton.comp.test.tsx @@ -0,0 +1,34 @@ +import { render } from '@suite-native/test-utils'; + +import { SelectTradeableAssetButton } from '../SelectTradeableAssetButton'; + +describe('SelectTradeableAssetButton', () => { + it('should render "Select coin" when no network is selected', () => { + const { getByLabelText } = render( + , + ); + + const button = getByLabelText('Select coin'); + expect(button).toHaveTextContent(/^Select coin.$/); + }); + + it('should render TradeableAssetButton when network is selected', () => { + const { getByLabelText } = render( + , + ); + const button = getByLabelText('Select coin'); + expect(button).toHaveTextContent(/^ADA.$/); + }); + + it('should render asset name when specified in selectedAsset', () => { + const { getByLabelText } = render( + , + ); + + const button = getByLabelText('Select coin'); + expect(button).toHaveTextContent(/^USDC.$/); + }); +}); diff --git a/suite-native/module-trading/src/components/general/__tests__/TradeableAssetButton.comp.test.tsx b/suite-native/module-trading/src/components/general/__tests__/TradeableAssetButton.comp.test.tsx new file mode 100644 index 00000000000..d8e28d7bce0 --- /dev/null +++ b/suite-native/module-trading/src/components/general/__tests__/TradeableAssetButton.comp.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render } from '@suite-native/test-utils'; + +import { TradeableAsset } from '../../../types'; +import { TradeableAssetButton } from '../TradeableAssetButton'; + +describe('TradeableAssetButton', () => { + const btcAsset: TradeableAsset = { symbol: 'btc' }; + + 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 00000000000..690ce6e2920 --- /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('isTradeableAssetsSheetVisible', () => { + it('should be false by default', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + expect(result.current.isTradeableAssetsSheetVisible).toBe(false); + }); + + it('should be true after showTradeableAssetsSheet call', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + act(() => { + result.current.showTradeableAssetsSheet(); + }); + + expect(result.current.isTradeableAssetsSheetVisible).toBe(true); + }); + + it('should be false after hideTradeableAssetsSheet call', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + act(() => { + result.current.showTradeableAssetsSheet(); + result.current.hideTradeableAssetsSheet(); + }); + + expect(result.current.isTradeableAssetsSheetVisible).toBe(false); + }); + }); + + describe('selectedTradeableAsset', () => { + it('should be undefined by default', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + expect(result.current.selectedTradeableAsset).toBeUndefined(); + }); + + it('should be set after setSelectedTradeableAsset call', () => { + const { result } = renderHook(() => useTradeableAssetsSheetControls()); + + act(() => { + result.current.setSelectedTradeableAsset({ symbol: 'btc' }); + }); + + expect(result.current.selectedTradeableAsset?.symbol).toBe('btc'); + }); + }); +}); diff --git a/suite-native/module-trading/src/hooks/__tests__/useTradeableAssetDominantColor.test.tsx b/suite-native/module-trading/src/hooks/__tests__/useTradeableAssetDominantColor.test.tsx new file mode 100644 index 00000000000..4b677dcf2d4 --- /dev/null +++ b/suite-native/module-trading/src/hooks/__tests__/useTradeableAssetDominantColor.test.tsx @@ -0,0 +1,43 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { TokenAddress } from '@suite-common/wallet-types'; +import { BasicProviderForTests, renderHook } from '@suite-native/test-utils'; +import { useNativeStyles } from '@trezor/styles'; +import { CoinsColors, Colors } from '@trezor/theme'; + +import { TradeableAsset } from '../../types'; +import { useTradeableAssetDominantColor } from '../useTradeableAssetDominantColor'; + +describe('useTradeableAssetDominantColor', () => { + let colors: Colors; + let coinsColors: CoinsColors; + + const renderTradeableAssetDominantColorHook = ( + givenSymbol: NetworkSymbol, + givenContractAddress?: TokenAddress, + ) => + renderHook( + ({ symbol, contractAddress }: TradeableAsset) => + useTradeableAssetDominantColor(symbol, contractAddress), + { + initialProps: { symbol: givenSymbol, contractAddress: givenContractAddress }, + wrapper: BasicProviderForTests, + }, + ); + + beforeAll(() => { + const { result } = renderHook(useNativeStyles, { wrapper: BasicProviderForTests }); + ({ coinsColors, colors } = result.current.utils); + }); + + it('should return network color', () => { + const { result } = renderTradeableAssetDominantColorHook('btc'); + + expect(result.current).toBe(coinsColors.btc); + }); + + it('should fallback to backgroundNeutralBold for undefined networks', () => { + const { result } = renderTradeableAssetDominantColorHook('und' as NetworkSymbol); + + expect(result.current).toBe(colors.backgroundNeutralBold); + }); +}); diff --git a/suite-native/module-trading/src/hooks/useTradeableAssetDominantColor.ts b/suite-native/module-trading/src/hooks/useTradeableAssetDominantColor.ts new file mode 100644 index 00000000000..103be78ebcf --- /dev/null +++ b/suite-native/module-trading/src/hooks/useTradeableAssetDominantColor.ts @@ -0,0 +1,17 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { TokenAddress } from '@suite-common/wallet-types'; +import { useNativeStyles } from '@trezor/styles'; + +export const useTradeableAssetDominantColor = ( + symbol: NetworkSymbol, + _contractAddress?: TokenAddress, +) => { + const { + utils: { colors, coinsColors }, + } = useNativeStyles(); + + const defaultColor = colors.backgroundNeutralBold; + const networkColor = coinsColors[symbol]; + + return networkColor ?? defaultColor; +}; 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 00000000000..9d8006f336c --- /dev/null +++ b/suite-native/module-trading/src/hooks/useTradeableAssetsSheetControls.ts @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +import { TradeableAsset } from '../types'; + +export const useTradeableAssetsSheetControls = () => { + const [isTradeableAssetsSheetVisible, setIsTradeableAssetsSheetVisible] = useState(false); + const [selectedTradeableAsset, setSelectedTradeableAsset] = useState< + undefined | TradeableAsset + >(); + + 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 1d4842c8b93..ce79ae9a4a6 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/suite-native/module-trading/src/types.ts b/suite-native/module-trading/src/types.ts new file mode 100644 index 00000000000..870227d38b1 --- /dev/null +++ b/suite-native/module-trading/src/types.ts @@ -0,0 +1,9 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { TokenAddress } from '@suite-common/wallet-types'; + +// NOTE: in production code we probably want to use `TokenInfoBranded` or something similar instead +export type TradeableAsset = { + symbol: NetworkSymbol; + contractAddress?: TokenAddress; + name?: string; +}; diff --git a/yarn.lock b/yarn.lock index bb21ffbb11a..32c1c2b7b84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1760,11 +1760,11 @@ __metadata: linkType: hard "@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.5, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.25.6 - resolution: "@babel/runtime@npm:7.25.6" + version: 7.26.7 + resolution: "@babel/runtime@npm:7.26.7" dependencies: regenerator-runtime: "npm:^0.14.0" - checksum: 10/0c4134734deb20e1005ffb9165bf342e1074576621b246d8e5e41cc7cb315a885b7d98950fbf5c63619a2990a56ae82f444d35fe8c4691a0b70c2fe5673667dc + checksum: 10/c7a661a6836b332d9d2e047cba77ba1862c1e4f78cec7146db45808182ef7636d8a7170be9797e5d8fd513180bffb9fa16f6ca1c69341891efec56113cf22bfc languageName: node linkType: hard @@ -10950,8 +10950,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 @@ -13912,9 +13914,9 @@ __metadata: linkType: hard "@types/lodash@npm:^4, @types/lodash@npm:^4.14.136, @types/lodash@npm:^4.14.167, @types/lodash@npm:^4.14.182": - version: 4.17.14 - resolution: "@types/lodash@npm:4.17.14" - checksum: 10/6ee40725f3e192f5ef1f493caca19210aa7acd7adc3136b8dba84d418a35be0abea0668105aed9f696ad62a54310a9c0d328971ad4b157f5bcda700424ed5aae + version: 4.17.15 + resolution: "@types/lodash@npm:4.17.15" + checksum: 10/27b348b5971b9c670215331b52448a13d7d65bf1fbd320a7049c9c153c1186ff5d116ba75f05f07d32d7ece8a992b26a30c7bdc9be22a3d1e4e3e6068aa04603 languageName: node linkType: hard