From 0f40b8d57f9766c6f80ec42c396367ba924a1c33 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 3 Jul 2024 11:16:46 +0200 Subject: [PATCH] feat: add increase decrease token percentage (#10144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This pull request introduces a new feature to the MetaMask mobile that enhances user experience by displaying the percentage increase or decrease for each token directly within the UI. This update aims to provide users with immediate visual feedback on the performance of their tokens, helping them make more informed decisions based on recent market trends. core PR: https://github.com/MetaMask/core/pull/4206 figma: https://www.figma.com/design/aMYisczaJyEsYl1TYdcPUL/Wallet-Assets?node-id=1620-23897&t=EJSzfKoFvTJ5LuK0-0 ## **Related issues** Fixes: #9635 ## **Manual testing steps** 1. Go to the wallet view 2. You should see the percentage of increase/decrease ## **Screenshots/Recordings** ### **Before** Screenshot 2024-05-15 at 16 54 25 ### **After** ![Screenshot 2024-05-15 at 16 39 31](https://github.com/MetaMask/metamask-mobile/assets/26223211/3792f1d2-6300-4cad-ad5a-6c49e1697773) ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .storybook/storybook.requires.js | 2 + .../AggregatedPercentage.stories.tsx | 63 ++ .../AggregatedPercentage.styles.ts | 20 + .../AggregatedPercentage.test.tsx | 68 ++ .../AggregatedPercentage.tsx | 76 ++ .../AggregatedPercentage.test.tsx.snap | 43 + .../Price/AggregatedPercentage/index.ts | 1 + .../PercentageChange.stories.tsx | 56 ++ .../PercentageChange.test.tsx | 40 + .../PercentageChange/PercentageChange.tsx | 28 + .../PercentageChange.test.tsx.snap | 21 + .../Price/PercentageChange/index.ts | 1 + .../__snapshots__/index.test.tsx.snap | 12 +- app/components/UI/AssetElement/index.tsx | 61 +- .../Tokens/__snapshots__/index.test.tsx.snap | 820 ++++++++++++------ app/components/UI/Tokens/index.tsx | 44 +- app/core/Engine.ts | 31 +- storybook/storyLoader.js | 4 + 18 files changed, 1099 insertions(+), 292 deletions(-) create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/index.ts create mode 100644 app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx create mode 100644 app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx create mode 100644 app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx create mode 100644 app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap create mode 100644 app/component-library/components-temp/Price/PercentageChange/index.ts diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index 8c929c9fe7d..4c8963175cc 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -117,6 +117,8 @@ const getStories = () => { './app/components/UI/Name/Name.stories.tsx': require('../app/components/UI/Name/Name.stories.tsx'), "./app/components/UI/SimulationDetails/SimulationDetails.stories.tsx": require("../app/components/UI/SimulationDetails/SimulationDetails.stories.tsx"), "./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"), + './app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories': require('../app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx'), + './app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories': require('../app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx'), }; }; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx new file mode 100644 index 00000000000..d9706d5e2e6 --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import AggregatedPercentage from './AggregatedPercentage'; +import { createStore } from 'redux'; +import initialBackgroundState from '../../../../util/test/initial-background-state.json'; + +const mockInitialState = { + wizard: { + step: 1, + }, + engine: { + backgroundState: initialBackgroundState, + }, +}; + +const rootReducer = (state = mockInitialState) => state; +const store = createStore(rootReducer); + +export default { + title: 'Component Library / AggregatedPercentage', + component: AggregatedPercentage, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + ethFiat: 1000, + tokenFiat: 500, + tokenFiat1dAgo: 950, + ethFiat1dAgo: 450, +}; + +export const NegativePercentageChange = Template.bind({}); +NegativePercentageChange.args = { + ethFiat: 900, + tokenFiat: 400, + tokenFiat1dAgo: 950, + ethFiat1dAgo: 1000, +}; + +export const PositivePercentageChange = Template.bind({}); +PositivePercentageChange.args = { + ethFiat: 1100, + tokenFiat: 600, + tokenFiat1dAgo: 500, + ethFiat1dAgo: 1000, +}; + +export const MixedPercentageChange = Template.bind({}); +MixedPercentageChange.args = { + ethFiat: 1050, + tokenFiat: 450, + tokenFiat1dAgo: 500, + ethFiat1dAgo: 1000, +}; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts new file mode 100644 index 00000000000..4852ce1d684 --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts @@ -0,0 +1,20 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +/** + * Style sheet function for AggregatedPercentage component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = () => + StyleSheet.create({ + wrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + }); + +export default styleSheet; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx new file mode 100644 index 00000000000..affec361321 --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import AggregatedPercentage from './AggregatedPercentage'; +import { mockTheme } from '../../../../util/theme'; +import { useSelector } from 'react-redux'; +import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +describe('AggregatedPercentage', () => { + beforeEach(() => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectCurrentCurrency) return 'USD'; + }); + }); + afterEach(() => { + (useSelector as jest.Mock).mockClear(); + }); + it('should render correctly', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders positive percentage change correctly', () => { + const { getByText } = render( + , + ); + + expect(getByText('(+25.00%)')).toBeTruthy(); + expect(getByText('+100 USD')).toBeTruthy(); + + expect(getByText('(+25.00%)').props.style).toMatchObject({ + color: mockTheme.colors.success.default, + }); + }); + + it('renders negative percentage change correctly', () => { + const { getByText } = render( + , + ); + + expect(getByText('(-30.00%)')).toBeTruthy(); + expect(getByText('-150 USD')).toBeTruthy(); + + expect(getByText('(-30.00%)').props.style).toMatchObject({ + color: mockTheme.colors.error.default, + }); + }); +}); diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx new file mode 100644 index 00000000000..3e0907f937b --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; +import { renderFiat } from '../../../../util/number'; +import { useSelector } from 'react-redux'; +import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; +import styleSheet from './AggregatedPercentage.styles'; +import { useStyles } from '../../../hooks'; + +const isValidAmount = (amount: number | null | undefined): boolean => + amount !== null && amount !== undefined && !Number.isNaN(amount); + +const AggregatedPercentage = ({ + ethFiat, + tokenFiat, + tokenFiat1dAgo, + ethFiat1dAgo, +}: { + ethFiat: number; + tokenFiat: number; + tokenFiat1dAgo: number; + ethFiat1dAgo: number; +}) => { + const { styles } = useStyles(styleSheet, {}); + + const currentCurrency = useSelector(selectCurrentCurrency); + const DECIMALS_TO_SHOW = 2; + + const totalBalance = ethFiat + tokenFiat; + const totalBalance1dAgo = ethFiat1dAgo + tokenFiat1dAgo; + + const amountChange = totalBalance - totalBalance1dAgo; + + const percentageChange = + ((totalBalance - totalBalance1dAgo) / totalBalance1dAgo) * 100 || 0; + + let percentageTextColor = TextColor.Default; + + if (percentageChange === 0) { + percentageTextColor = TextColor.Default; + } else if (percentageChange > 0) { + percentageTextColor = TextColor.Success; + } else { + percentageTextColor = TextColor.Error; + } + + const formattedPercentage = isValidAmount(percentageChange) + ? `(${(percentageChange as number) >= 0 ? '+' : ''}${( + percentageChange as number + ).toFixed(2)}%)` + : ''; + + const formattedValuePrice = isValidAmount(amountChange) + ? `${(amountChange as number) >= 0 ? '+' : ''}${renderFiat( + amountChange, + currentCurrency, + DECIMALS_TO_SHOW, + )} ` + : ''; + + return ( + + + {formattedValuePrice} + + + {formattedPercentage} + + + ); +}; + +export default AggregatedPercentage; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap b/app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap new file mode 100644 index 00000000000..7eea6a3bd9f --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AggregatedPercentage should render correctly 1`] = ` + + + +20 USD + + + (+11.11%) + + +`; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/index.ts b/app/component-library/components-temp/Price/AggregatedPercentage/index.ts new file mode 100644 index 00000000000..3e7965d02fa --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/index.ts @@ -0,0 +1 @@ +export { default } from './AggregatedPercentage'; diff --git a/app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx new file mode 100644 index 00000000000..59612dcf9ba --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PercentageChange from './PercentageChange'; +import { createStore } from 'redux'; +import initialBackgroundState from '../../../../util/test/initial-background-state.json'; + +const mockInitialState = { + wizard: { + step: 1, + }, + engine: { + backgroundState: initialBackgroundState, + }, +}; + +const rootReducer = (state = mockInitialState) => state; +const store = createStore(rootReducer); + +export default { + title: 'Component Library / PercentageChange', + component: PercentageChange, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + value: 0, +}; + +export const PositiveChange = Template.bind({}); +PositiveChange.args = { + value: 5.5, +}; + +export const NegativeChange = Template.bind({}); +NegativeChange.args = { + value: -3.75, +}; + +export const NoChange = Template.bind({}); +NoChange.args = { + value: 0, +}; + +export const InvalidValue = Template.bind({}); +InvalidValue.args = { + value: null, +}; diff --git a/app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx new file mode 100644 index 00000000000..f01d3e7606f --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PercentageChange from './PercentageChange'; +import { mockTheme } from '../../../../util/theme'; + +describe('PercentageChange', () => { + it('should render correctly', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + it('displays a positive value correctly', () => { + const { getByText } = render(); + const positiveText = getByText('+5.50%'); + expect(positiveText).toBeTruthy(); + expect(positiveText.props.style).toMatchObject({ + color: mockTheme.colors.success.default, + }); + }); + + it('displays a negative value correctly', () => { + const { getByText } = render(); + const negativeText = getByText('-3.25%'); + expect(negativeText).toBeTruthy(); + expect(negativeText.props.style).toMatchObject({ + color: mockTheme.colors.error.default, + }); + }); + + it('handles null value correctly', () => { + const { queryByText } = render(); + expect(queryByText(/\+/)).toBeNull(); + expect(queryByText(/-/)).toBeNull(); + }); + + it('handles undefined value correctly', () => { + const { queryByText } = render(); + expect(queryByText(/\+/)).toBeNull(); + expect(queryByText(/-/)).toBeNull(); + }); +}); diff --git a/app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx new file mode 100644 index 00000000000..206b905fc92 --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; + +const PercentageChange = ({ value }: { value: number | null | undefined }) => { + const percentageColorText = + value && value >= 0 ? TextColor.Success : TextColor.Error; + + const isValidAmount = (amount: number | null | undefined): boolean => + amount !== null && amount !== undefined && !Number.isNaN(amount); + + const formattedValue = isValidAmount(value) + ? `${(value as number) >= 0 ? '+' : ''}${(value as number).toFixed(2)}%` + : ''; + + return ( + + + {formattedValue} + + + ); +}; + +export default PercentageChange; diff --git a/app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap b/app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap new file mode 100644 index 00000000000..571dec70ebc --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PercentageChange should render correctly 1`] = ` + + + +5.50% + + +`; diff --git a/app/component-library/components-temp/Price/PercentageChange/index.ts b/app/component-library/components-temp/Price/PercentageChange/index.ts new file mode 100644 index 00000000000..6a3f076bb2a --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/index.ts @@ -0,0 +1 @@ +export { default } from './PercentageChange'; diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap index 5704e5960e9..99fc71bcbfc 100644 --- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap @@ -14,5 +14,15 @@ exports[`AssetElement should render correctly 1`] = ` } } testID="asset-DAI" -/> +> + + `; diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index e95c3a3a1f6..fdf638bb125 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import { TouchableOpacity, StyleSheet, Platform, View } from 'react-native'; import Text, { TextVariant, } from '../../../component-library/components/Texts/Text'; @@ -12,15 +12,20 @@ import { TOKEN_BALANCE_LOADING, TOKEN_RATE_UNDEFINED, } from '../Tokens/constants'; +import { Colors } from '../../../util/theme/models'; +import { fontStyles } from '../../../styles/common'; +import { useTheme } from '../../../util/theme'; + interface AssetElementProps { children?: React.ReactNode; asset: TokenI; onPress?: (asset: TokenI) => void; onLongPress?: ((asset: TokenI) => void) | null; balance?: string; + mainBalance?: string | null; } -const createStyles = () => +const createStyles = (colors: Colors) => StyleSheet.create({ itemWrapper: { flex: 1, @@ -32,6 +37,7 @@ const createStyles = () => arrow: { flex: 1, alignSelf: 'flex-end', + alignItems: 'flex-end', }, arrowIcon: { marginTop: 16, @@ -39,6 +45,12 @@ const createStyles = () => skeleton: { width: 50, }, + balanceFiat: { + color: colors.text.alternative, + paddingHorizontal: 0, + ...fontStyles.normal, + textTransform: 'uppercase', + }, }); /** @@ -48,10 +60,12 @@ const AssetElement: React.FC = ({ children, balance, asset, + mainBalance = null, onPress, onLongPress, }) => { - const styles = createStyles(); + const { colors } = useTheme(); + const styles = createStyles(colors); const handleOnPress = () => { onPress?.(asset); @@ -70,21 +84,32 @@ const AssetElement: React.FC = ({ > {children} - {balance && ( - - {balance === TOKEN_BALANCE_LOADING ? ( - - ) : ( - balance - )} - - )} + + {balance && ( + + {balance === TOKEN_BALANCE_LOADING ? ( + + ) : ( + balance + )} + + )} + {mainBalance ? ( + + {mainBalance === TOKEN_BALANCE_LOADING ? ( + + ) : ( + mainBalance + )} + + ) : null} + ); }; diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap index 4aaccc2fc58..5e8f8125c9b 100644 --- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap @@ -327,19 +327,22 @@ exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` } } > - + - 0 USD - + testID="total-balance-text" + > + 0 USD + + +