diff --git a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx index 179ef8defdf..60dd457215d 100644 --- a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx @@ -11,6 +11,8 @@ import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton'; import { SwapActionButton } from '@/__swaps__/screens/Swap/components/SwapActionButton'; import { SwapInput } from '@/__swaps__/screens/Swap/components/SwapInput'; +import { SwapNativeInput } from '@/__swaps__/screens/Swap/components/SwapNativeInput'; +import { SwapInputValuesCaret } from '@/__swaps__/screens/Swap/components/SwapInputValuesCaret'; import { TokenList } from '@/__swaps__/screens/Swap/components/TokenList/TokenList'; import { BASE_INPUT_WIDTH, INPUT_INNER_WIDTH, INPUT_PADDING, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; @@ -50,7 +52,7 @@ function SwapInputActionButton() { } function SwapInputAmount() { - const { focusedInput, SwapTextStyles, SwapInputController, AnimatedSwapStyles } = useSwapContext(); + const { focusedInput, SwapTextStyles, SwapInputController } = useSwapContext(); return ( {SwapInputController.formattedInputAmount} - - - + @@ -119,15 +119,7 @@ function InputAssetBalanceBadge() { } export function SwapInputAsset() { - const { - outputProgress, - inputProgress, - AnimatedSwapStyles, - SwapTextStyles, - SwapInputController, - internalSelectedInputAsset, - SwapNavigation, - } = useSwapContext(); + const { outputProgress, inputProgress, AnimatedSwapStyles, internalSelectedInputAsset, SwapNavigation } = useSwapContext(); return ( @@ -143,9 +135,7 @@ export function SwapInputAsset() { - - {SwapInputController.formattedInputNativeValue} - + @@ -174,14 +164,6 @@ export const styles = StyleSheet.create({ backgroundOverlay: { backgroundColor: 'rgba(0, 0, 0, 0.88)', }, - caret: { - height: 32, - width: 2, - }, - caretContainer: { - flexGrow: 100, - flexShrink: 0, - }, flipButton: { borderRadius: 15, height: 30, diff --git a/src/__swaps__/screens/Swap/components/SwapInputValuesCaret.tsx b/src/__swaps__/screens/Swap/components/SwapInputValuesCaret.tsx new file mode 100644 index 00000000000..99c57675935 --- /dev/null +++ b/src/__swaps__/screens/Swap/components/SwapInputValuesCaret.tsx @@ -0,0 +1,95 @@ +import { Box, useColorMode } from '@/design-system'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { Easing, SharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming } from 'react-native-reanimated'; +import { SLIDER_COLLAPSED_HEIGHT, SLIDER_HEIGHT, caretConfig } from '@/__swaps__/screens/Swap/constants'; +import { equalWorklet } from '@/__swaps__/safe-math/SafeMath'; +import { NavigationSteps } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation'; +import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { inputKeys } from '@/__swaps__/types/swap'; +import { getColorValueForThemeWorklet } from '@/__swaps__/utils/swaps'; + +export function SwapInputValuesCaret({ inputCaretType, disabled }: { inputCaretType: inputKeys; disabled?: SharedValue }) { + const { isDarkMode } = useColorMode(); + const { + configProgress, + focusedInput, + inputProgress, + internalSelectedInputAsset, + internalSelectedOutputAsset, + isQuoteStale, + outputProgress, + SwapInputController, + sliderPressProgress, + } = useSwapContext(); + + const inputMethod = SwapInputController.inputMethod; + const inputValues = SwapInputController.inputValues; + + const caretStyle = useAnimatedStyle(() => { + const shouldShow = + !disabled?.value && + configProgress.value === NavigationSteps.INPUT_ELEMENT_FOCUSED && + focusedInput.value === inputCaretType && + inputProgress.value === 0 && + outputProgress.value === 0 && + (inputMethod.value !== 'slider' || + (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)) || + (sliderPressProgress.value === SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT && isQuoteStale.value === 0)); + + const opacity = shouldShow + ? withRepeat( + withSequence( + withTiming(1, { duration: 0 }), + withTiming(1, { duration: 400, easing: Easing.bezier(0.87, 0, 0.13, 1) }), + withTiming(0, caretConfig), + withTiming(1, caretConfig) + ), + -1, + true + ) + : withTiming(0, caretConfig); + + const isZero = + (inputMethod.value !== 'slider' && inputValues.value[inputCaretType] === 0) || + (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)); + + return { + display: shouldShow ? 'flex' : 'none', + opacity, + position: isZero ? 'absolute' : 'relative', + }; + }); + + const assetCaretStyle = useAnimatedStyle(() => { + const selectedAsset = + inputCaretType === 'inputAmount' || inputCaretType === 'inputNativeValue' ? internalSelectedInputAsset : internalSelectedOutputAsset; + return { + backgroundColor: getColorValueForThemeWorklet(selectedAsset.value?.highContrastColor, isDarkMode, true), + }; + }); + + const caretSizeStyle = + inputCaretType === 'inputNativeValue' || inputCaretType === 'outputNativeValue' ? styles.nativeCaret : styles.inputCaret; + + return ( + + + + ); +} + +export const styles = StyleSheet.create({ + nativeCaret: { + height: 19, + width: 1.5, + }, + inputCaret: { + height: 32, + width: 2, + }, + caretContainer: { + flexGrow: 100, + flexShrink: 0, + }, +}); diff --git a/src/__swaps__/screens/Swap/components/SwapNativeInput.tsx b/src/__swaps__/screens/Swap/components/SwapNativeInput.tsx new file mode 100644 index 00000000000..7432d641750 --- /dev/null +++ b/src/__swaps__/screens/Swap/components/SwapNativeInput.tsx @@ -0,0 +1,82 @@ +import { AnimatedText, Box } from '@/design-system'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { runOnJS, useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'; + +import { SwapInputValuesCaret } from '@/__swaps__/screens/Swap/components/SwapInputValuesCaret'; +import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton'; +import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { equalWorklet } from '@/__swaps__/safe-math/SafeMath'; + +export function SwapNativeInput({ + nativeInputType, + handleTapWhileDisabled, +}: { + nativeInputType: 'inputNativeValue' | 'outputNativeValue'; + handleTapWhileDisabled?: () => void; +}) { + const { + focusedInput, + internalSelectedInputAsset, + internalSelectedOutputAsset, + outputQuotesAreDisabled, + SwapTextStyles, + SwapInputController, + } = useSwapContext(); + + const formattedNativeInput = + nativeInputType === 'inputNativeValue' ? SwapInputController.formattedInputNativeValue : SwapInputController.formattedOutputNativeValue; + + const textStyle = nativeInputType === 'inputNativeValue' ? SwapTextStyles.inputNativeValueStyle : SwapTextStyles.outputNativeValueStyle; + + const nativeCurrencySymbol = formattedNativeInput.value.slice(0, 1); + const formattedNativeValue = useDerivedValue(() => { + return formattedNativeInput.value.slice(1); + }); + + const disabled = useDerivedValue(() => { + if (nativeInputType === 'outputNativeValue' && outputQuotesAreDisabled.value) return true; + + // disable caret and pointer events for native inputs when corresponding asset is missing price + const asset = nativeInputType === 'inputNativeValue' ? internalSelectedInputAsset : internalSelectedOutputAsset; + const assetPrice = asset.value?.nativePrice || asset.value?.price?.value || 0; + return !assetPrice || equalWorklet(assetPrice, 0); + }); + + const pointerEventsStyle = useAnimatedStyle(() => { + return { + pointerEvents: disabled.value ? 'none' : 'box-only', + }; + }); + + return ( + { + 'worklet'; + if (outputQuotesAreDisabled.value && handleTapWhileDisabled && nativeInputType === 'outputNativeValue') { + runOnJS(handleTapWhileDisabled)(); + } else { + focusedInput.value = nativeInputType; + } + }} + > + + + {nativeCurrencySymbol} + + + + {formattedNativeValue} + + + + + + ); +} + +export const styles = StyleSheet.create({ + nativeContainer: { alignItems: 'center', flexDirection: 'row', height: 17, pointerEvents: 'box-only' }, + nativeRowContainer: { alignItems: 'center', flexDirection: 'row' }, +}); diff --git a/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx b/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx index a90b5dace9d..b9b9a5286a4 100644 --- a/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx +++ b/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx @@ -11,9 +11,10 @@ import Animated, { withDelay, withTiming, } from 'react-native-reanimated'; - +import { supportedNativeCurrencies } from '@/references'; import { Bleed, Box, Columns, HitSlop, Separator, Text, useColorMode, useForegroundColor } from '@/design-system'; -import { stripCommas } from '@/__swaps__/utils/swaps'; +import { equalWorklet } from '@/__swaps__/safe-math/SafeMath'; +import { stripNonDecimalNumbers } from '@/__swaps__/utils/swaps'; import { CUSTOM_KEYBOARD_HEIGHT, LIGHT_SEPARATOR_COLOR, @@ -30,6 +31,7 @@ import { colors } from '@/styles'; import { NavigationSteps, useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { IS_IOS } from '@/env'; import { inputKeys } from '@/__swaps__/types/swap'; +import { useAccountSettings } from '@/hooks'; type numberPadCharacter = number | 'backspace' | '.'; @@ -49,23 +51,81 @@ const getFormattedInputKey = (inputKey: inputKeys) => { export const SwapNumberPad = () => { const { isDarkMode } = useColorMode(); - const { focusedInput, isQuoteStale, SwapInputController, configProgress, outputQuotesAreDisabled } = useSwapContext(); + const { nativeCurrency } = useAccountSettings(); + const { + focusedInput, + internalSelectedInputAsset, + internalSelectedOutputAsset, + isQuoteStale, + SwapInputController, + configProgress, + outputQuotesAreDisabled, + } = useSwapContext(); const longPressTimer = useSharedValue(0); - const addNumber = (number?: number) => { + const removeFormatting = (inputKey: inputKeys) => { 'worklet'; + return stripNonDecimalNumbers(SwapInputController[getFormattedInputKey(inputKey)].value); + }; + + const ignoreChange = ({ currentValue, addingDecimal = false }: { currentValue?: string; addingDecimal?: boolean }) => { + 'worklet'; + // ignore when: outputQuotesAreDisabled and we are updating the output amount or output native value if ((focusedInput.value === 'outputAmount' || focusedInput.value === 'outputNativeValue') && outputQuotesAreDisabled.value) { + return true; + } + + // ignore when: corresponding asset does not have a price and we are updating native inputs + const inputAssetPrice = internalSelectedInputAsset.value?.nativePrice || internalSelectedInputAsset.value?.price?.value || 0; + const outputAssetPrice = internalSelectedOutputAsset.value?.nativePrice || internalSelectedOutputAsset.value?.price?.value || 0; + const outputAssetHasNoPrice = !outputAssetPrice || equalWorklet(outputAssetPrice, 0); + const inputAssetHasNoPrice = !inputAssetPrice || equalWorklet(inputAssetPrice, 0); + if ( + (focusedInput.value === 'outputNativeValue' && outputAssetHasNoPrice) || + (focusedInput.value === 'inputNativeValue' && inputAssetHasNoPrice) + ) { + return true; + } + + // ignore when: decimals exceed native currency decimals + if (currentValue) { + const currentValueDecimals = currentValue.split('.')?.[1]?.length ?? -1; + const nativeCurrencyDecimals = supportedNativeCurrencies[nativeCurrency].decimals; + + const isNativePlaceholderValue = equalWorklet(currentValue, 0) && SwapInputController.inputMethod.value !== focusedInput.value; + + if (addingDecimal && nativeCurrencyDecimals === 0) { + return true; + } else if ( + (focusedInput.value === 'inputNativeValue' || focusedInput.value === 'outputNativeValue') && + !isNativePlaceholderValue && + currentValueDecimals >= nativeCurrencyDecimals + ) { + return true; + } + } + return false; + }; + + const addNumber = (number?: number) => { + 'worklet'; + const inputKey = focusedInput.value; + const currentValue = removeFormatting(inputKey); + + if (ignoreChange({ currentValue })) { return; } // Immediately stop the quote fetching interval SwapInputController.quoteFetchingInterval.stop(); - const inputKey = focusedInput.value; - const currentValue = stripCommas(SwapInputController[getFormattedInputKey(inputKey)].value); + const inputMethod = SwapInputController.inputMethod.value; - const newValue = currentValue === '0' ? `${number}` : `${currentValue}${number}`; + const isNativePlaceholderValue = + equalWorklet(currentValue, 0) && inputMethod !== inputKey && (inputKey === 'inputNativeValue' || inputKey === 'outputNativeValue'); + + const newValue = currentValue === '0' || isNativePlaceholderValue ? `${number}` : `${currentValue}${number}`; // For a uint256, the maximum value is: // 2e256 − 1 =115792089237316195423570985008687907853269984665640564039457584007913129639935 @@ -79,7 +139,7 @@ export const SwapNumberPad = () => { isQuoteStale.value = 1; } - if (SwapInputController.inputMethod.value !== inputKey) { + if (inputMethod !== inputKey) { SwapInputController.inputMethod.value = inputKey; } @@ -94,7 +154,11 @@ export const SwapNumberPad = () => { const addDecimalPoint = () => { 'worklet'; const inputKey = focusedInput.value; - const currentValue = stripCommas(SwapInputController[getFormattedInputKey(inputKey)].value); + const currentValue = removeFormatting(inputKey); + + if (ignoreChange({ currentValue, addingDecimal: true })) { + return; + } if (!currentValue.includes('.')) { if (SwapInputController.inputMethod.value !== inputKey) { @@ -115,7 +179,7 @@ export const SwapNumberPad = () => { const deleteLastCharacter = () => { 'worklet'; - if ((focusedInput.value === 'outputAmount' || focusedInput.value === 'outputNativeValue') && outputQuotesAreDisabled.value) { + if (ignoreChange({})) { return; } @@ -125,7 +189,7 @@ export const SwapNumberPad = () => { SwapInputController.inputMethod.value = inputKey; } - const currentValue = stripCommas(SwapInputController[getFormattedInputKey(inputKey)].value); + const currentValue = removeFormatting(inputKey); // Handle deletion, ensuring a placeholder zero remains if the entire number is deleted const newValue = currentValue.length > 1 ? currentValue.slice(0, -1) : 0; diff --git a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx index 3ccde4f4748..e3a5557ead5 100644 --- a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx @@ -7,6 +7,8 @@ import { ScreenCornerRadius } from 'react-native-screen-corner-radius'; import { AnimatedSwapCoinIcon } from '@/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon'; import { BalanceBadge } from '@/__swaps__/screens/Swap/components/BalanceBadge'; +import { SwapNativeInput } from '@/__swaps__/screens/Swap/components/SwapNativeInput'; +import { SwapInputValuesCaret } from '@/__swaps__/screens/Swap/components/SwapInputValuesCaret'; import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton'; import { SwapActionButton } from '@/__swaps__/screens/Swap/components/SwapActionButton'; @@ -20,7 +22,6 @@ import * as i18n from '@/languages'; import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; import { useSwapsStore } from '@/state/swaps/swapsStore'; -import { ethereumUtils } from '@/utils'; import Clipboard from '@react-native-clipboard/clipboard'; import { CopyPasteMenu } from './CopyPasteMenu'; @@ -51,27 +52,8 @@ function SwapOutputActionButton() { ); } -function SwapOutputAmount() { - const { navigate } = useNavigation(); - const { focusedInput, SwapTextStyles, SwapInputController, AnimatedSwapStyles, outputQuotesAreDisabled } = useSwapContext(); - - const handleTapWhileDisabled = useCallback(() => { - const { inputAsset, outputAsset } = useSwapsStore.getState(); - const inputTokenSymbol = inputAsset?.symbol; - const outputTokenSymbol = outputAsset?.symbol; - const isCrosschainSwap = inputAsset?.chainId !== outputAsset?.chainId; - const isBridgeSwap = inputTokenSymbol === outputTokenSymbol; - - navigate(Routes.EXPLAIN_SHEET, { - inputToken: inputTokenSymbol, - fromChainId: inputAsset?.chainId ?? ChainId.mainnet, - toChainId: outputAsset?.chainId ?? ChainId.mainnet, - isCrosschainSwap, - isBridgeSwap, - outputToken: outputTokenSymbol, - type: 'output_disabled', - }); - }, [navigate]); +function SwapOutputAmount({ handleTapWhileDisabled }: { handleTapWhileDisabled: () => void }) { + const { focusedInput, SwapTextStyles, SwapInputController, outputQuotesAreDisabled } = useSwapContext(); const [isPasteEnabled, setIsPasteEnabled] = useState(() => !outputQuotesAreDisabled.value); useAnimatedReaction( @@ -115,9 +97,7 @@ function SwapOutputAmount() { {SwapInputController.formattedOutputAmount} - - - + @@ -147,15 +127,26 @@ function OutputAssetBalanceBadge() { } export function SwapOutputAsset() { - const { - outputProgress, - inputProgress, - AnimatedSwapStyles, - SwapTextStyles, - SwapInputController, - internalSelectedOutputAsset, - SwapNavigation, - } = useSwapContext(); + const { outputProgress, inputProgress, AnimatedSwapStyles, internalSelectedOutputAsset, SwapNavigation } = useSwapContext(); + const { navigate } = useNavigation(); + + const handleTapWhileDisabled = useCallback(() => { + const { inputAsset, outputAsset } = useSwapsStore.getState(); + const inputTokenSymbol = inputAsset?.symbol; + const outputTokenSymbol = outputAsset?.symbol; + const isCrosschainSwap = inputAsset?.chainId !== outputAsset?.chainId; + const isBridgeSwap = inputTokenSymbol === outputTokenSymbol; + + navigate(Routes.EXPLAIN_SHEET, { + inputToken: inputTokenSymbol, + fromChainId: inputAsset?.chainId ?? ChainId.mainnet, + toChainId: outputAsset?.chainId ?? ChainId.mainnet, + isCrosschainSwap, + isBridgeSwap, + outputToken: outputTokenSymbol, + type: 'output_disabled', + }); + }, [navigate]); return ( @@ -165,15 +156,13 @@ export function SwapOutputAsset() { - + - - {SwapInputController.formattedOutputNativeValue} - + @@ -203,14 +192,6 @@ export const styles = StyleSheet.create({ backgroundOverlay: { backgroundColor: 'rgba(0, 0, 0, 0.88)', }, - caret: { - height: 32, - width: 2, - }, - caretContainer: { - flexGrow: 100, - flexShrink: 0, - }, flipButton: { borderRadius: 15, height: 30, diff --git a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts index 6e74ff1b4f1..244dd9f993e 100644 --- a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts +++ b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts @@ -212,18 +212,6 @@ export function useAnimatedSwapStyles({ }; }); - const assetToSellCaretStyle = useAnimatedStyle(() => { - return { - backgroundColor: getColorValueForThemeWorklet(internalSelectedInputAsset.value?.highContrastColor, isDarkMode, true), - }; - }); - - const assetToBuyCaretStyle = useAnimatedStyle(() => { - return { - backgroundColor: getColorValueForThemeWorklet(internalSelectedOutputAsset.value?.highContrastColor, isDarkMode, true), - }; - }); - const flipButtonFetchingStyle = useAnimatedStyle(() => { if (IS_ANDROID) return { borderWidth: 0 }; return { @@ -309,9 +297,7 @@ export function useAnimatedSwapStyles({ outputTokenListStyle, swapActionWrapperStyle, assetToSellIconStyle, - assetToSellCaretStyle, assetToBuyIconStyle, - assetToBuyCaretStyle, hideWhileReviewingOrConfiguringGas, flipButtonFetchingStyle, searchInputAssetButtonStyle, diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts index e82fa75fc39..030f7297458 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts @@ -1,4 +1,11 @@ -import { divWorklet, equalWorklet, greaterThanWorklet, isNumberStringWorklet, mulWorklet } from '@/__swaps__/safe-math/SafeMath'; +import { + divWorklet, + equalWorklet, + greaterThanWorklet, + isNumberStringWorklet, + mulWorklet, + toFixedWorklet, +} from '@/__swaps__/safe-math/SafeMath'; import { SCRUBBER_WIDTH, SLIDER_WIDTH, snappySpringConfig } from '@/__swaps__/screens/Swap/constants'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; import { ChainId } from '@/chains/types'; @@ -10,7 +17,14 @@ import { convertRawAmountToDecimalFormat, handleSignificantDecimalsWorklet, } from '@/__swaps__/utils/numbers'; -import { addCommasToNumber, buildQuoteParams, clamp, getDefaultSlippageWorklet, trimTrailingZeros } from '@/__swaps__/utils/swaps'; +import { + addCommasToNumber, + addSymbolToNativeDisplayWorklet, + buildQuoteParams, + clamp, + getDefaultSlippageWorklet, + trimTrailingZeros, +} from '@/__swaps__/utils/swaps'; import { analyticsV2 } from '@/analytics'; import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; import { useAccountSettings } from '@/hooks'; @@ -102,7 +116,7 @@ export function useSwapInputsController({ return addCommasToNumber(inputValues.value.inputAmount, '0'); } - if (inputMethod.value === 'outputAmount') { + if (inputMethod.value === 'outputAmount' || inputMethod.value === 'inputNativeValue' || inputMethod.value === 'outputNativeValue') { return valueBasedDecimalFormatter({ amount: inputValues.value.inputAmount, nativePrice: inputNativePrice.value, @@ -121,16 +135,19 @@ export function useSwapInputsController({ }); const formattedInputNativeValue = useDerivedValue(() => { + if (inputMethod.value === 'inputNativeValue') { + return addSymbolToNativeDisplayWorklet(inputValues.value.inputNativeValue, currentCurrency); + } if ( (inputMethod.value === 'slider' && percentageToSwap.value === 0) || !inputValues.value.inputNativeValue || !isNumberStringWorklet(inputValues.value.inputNativeValue.toString()) || equalWorklet(inputValues.value.inputNativeValue, 0) ) { - return convertAmountToNativeDisplayWorklet(0, currentCurrency); + return convertAmountToNativeDisplayWorklet(0, currentCurrency, false, true); } - return convertAmountToNativeDisplayWorklet(inputValues.value.inputNativeValue, currentCurrency); + return convertAmountToNativeDisplayWorklet(inputValues.value.inputNativeValue, currentCurrency, false, true); }); const formattedOutputAmount = useDerivedValue(() => { @@ -154,16 +171,19 @@ export function useSwapInputsController({ }); const formattedOutputNativeValue = useDerivedValue(() => { + if (inputMethod.value === 'outputNativeValue') { + return addSymbolToNativeDisplayWorklet(inputValues.value.outputNativeValue, currentCurrency); + } if ( (inputMethod.value === 'slider' && percentageToSwap.value === 0) || !inputValues.value.outputNativeValue || !isNumberStringWorklet(inputValues.value.outputNativeValue.toString()) || equalWorklet(inputValues.value.outputNativeValue, 0) ) { - return convertAmountToNativeDisplayWorklet(0, currentCurrency); + return convertAmountToNativeDisplayWorklet(0, currentCurrency, false, true); } - return convertAmountToNativeDisplayWorklet(inputValues.value.outputNativeValue, currentCurrency); + return convertAmountToNativeDisplayWorklet(inputValues.value.outputNativeValue, currentCurrency, false, true); }); const updateNativePriceForAsset = useCallback( @@ -206,7 +226,7 @@ export function useSwapInputsController({ } // NOTE: if we encounter a quote error, let's make sure to update the outputAmount and inputAmount to 0 accordingly - if (lastTypedInput.value === 'inputAmount') { + if (lastTypedInput.value === 'inputAmount' || lastTypedInput.value === 'inputNativeValue') { inputValues.modify(prev => { return { ...prev, @@ -214,7 +234,7 @@ export function useSwapInputsController({ outputNativeValue: 0, }; }); - } else if (lastTypedInput.value === 'outputAmount') { + } else if (lastTypedInput.value === 'outputAmount' || lastTypedInput.value === 'outputNativeValue') { inputValues.modify(prev => { return { ...prev, @@ -246,7 +266,6 @@ export function useSwapInputsController({ quoteFetchingInterval: ReturnType; }) => { 'worklet'; - // Check whether the quote has been superseded by new user input so we don't introduce conflicting updates const isLastTypedInputStillValid = originalQuoteParams.lastTypedInput === lastTypedInput.value; @@ -259,7 +278,9 @@ export function useSwapInputsController({ const isInputAmountStillValid = originalQuoteParams.inputAmount === inputValues.value.inputAmount; const isOutputAmountStillValid = originalQuoteParams.outputAmount === inputValues.value.outputAmount; const areInputAmountsStillValid = - originalQuoteParams.lastTypedInput === 'inputAmount' ? isInputAmountStillValid : isOutputAmountStillValid; + originalQuoteParams.lastTypedInput === 'inputAmount' || originalQuoteParams.lastTypedInput === 'inputNativeValue' + ? isInputAmountStillValid + : isOutputAmountStillValid; // Set prices first regardless of the quote status, as long as the same assets are still selected if (inputPrice && isInputUniqueIdStillValid) { @@ -476,7 +497,7 @@ export function useSwapInputsController({ } const quotedInputAmount = - lastTypedInputParam === 'outputAmount' + lastTypedInputParam === 'outputAmount' || lastTypedInputParam === 'outputNativeValue' ? Number( convertRawAmountToDecimalFormat( quoteResponse.sellAmount.toString(), @@ -486,7 +507,7 @@ export function useSwapInputsController({ : undefined; const quotedOutputAmount = - lastTypedInputParam === 'inputAmount' + lastTypedInputParam === 'inputAmount' || lastTypedInputParam === 'inputNativeValue' ? Number( convertRawAmountToDecimalFormat( quoteResponse.buyAmountMinusFees.toString(), @@ -762,7 +783,7 @@ export function useSwapInputsController({ /** * Observes value changes in the active inputMethod, which can be any of the following: * - inputAmount - * - inputNativeValue (TODO) + * - inputNativeValue * - outputAmount * - outputNativeValue (TODO) * - sliderXPosition @@ -870,6 +891,56 @@ export function useSwapInputsController({ }; }); + runOnJS(debouncedFetchQuote)(); + } + } + const inputMethodValue = inputMethod.value; + const isNativeInputMethod = inputMethodValue === 'inputNativeValue'; + const isNativeOutputMethod = inputMethodValue === 'outputNativeValue'; + if ( + (isNativeInputMethod || isNativeOutputMethod) && + !equalWorklet(current.values[inputMethodValue], previous.values[inputMethodValue]) + ) { + // If the number in the native field changes + lastTypedInput.value = inputMethodValue; + if (equalWorklet(current.values[inputMethodValue], 0)) { + // If the native amount was set to 0 + resetValuesToZeroWorklet({ updateSlider: true, inputKey: inputMethodValue }); + } else { + // If the native amount was set to a non-zero value + if (isNativeInputMethod && !internalSelectedInputAsset.value) return; + if (isNativeOutputMethod && !internalSelectedOutputAsset.value) return; + + // If the asset price is zero + if (isNativeInputMethod && equalWorklet(inputNativePrice.value, 0)) return; + if (isNativeOutputMethod && equalWorklet(outputNativePrice.value, 0)) return; + + if (isQuoteStale.value !== 1) isQuoteStale.value = 1; + const nativePrice = isNativeInputMethod ? inputNativePrice.value : outputNativePrice.value; + const decimalPlaces = isNativeInputMethod + ? internalSelectedInputAsset.value?.decimals + : internalSelectedOutputAsset.value?.decimals; + const amount = toFixedWorklet(divWorklet(current.values[inputMethodValue], nativePrice), decimalPlaces || 18); + const amountKey = isNativeInputMethod ? 'inputAmount' : 'outputAmount'; + + inputValues.modify(values => { + return { + ...values, + [amountKey]: amount, + }; + }); + + if (isNativeInputMethod) { + const inputAssetBalance = internalSelectedInputAsset.value?.maxSwappableAmount || '0'; + + if (equalWorklet(inputAssetBalance, 0)) { + sliderXPosition.value = withSpring(0, snappySpringConfig); + } else { + const updatedSliderPosition = clamp(Number(divWorklet(amount, inputAssetBalance)) * SLIDER_WIDTH, 0, SLIDER_WIDTH); + sliderXPosition.value = withSpring(updatedSliderPosition, snappySpringConfig); + } + } + runOnJS(debouncedFetchQuote)(); } } diff --git a/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts b/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts index 92facea2c77..be633447abb 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts @@ -1,5 +1,4 @@ import { - Easing, SharedValue, interpolateColor, useAnimatedStyle, @@ -11,45 +10,27 @@ import { } from 'react-native-reanimated'; import { useColorMode, useForegroundColor } from '@/design-system'; -import { - ETH_COLOR_DARK, - ETH_COLOR_DARK_ACCENT, - SLIDER_COLLAPSED_HEIGHT, - SLIDER_HEIGHT, - caretConfig, - pulsingConfig, -} from '@/__swaps__/screens/Swap/constants'; -import { inputKeys, inputMethods, inputValuesType } from '@/__swaps__/types/swap'; +import { ETH_COLOR_DARK, ETH_COLOR_DARK_ACCENT, pulsingConfig } from '@/__swaps__/screens/Swap/constants'; +import { inputMethods, inputValuesType } from '@/__swaps__/types/swap'; import { getColorValueForThemeWorklet, opacity } from '@/__swaps__/utils/swaps'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; import { equalWorklet } from '@/__swaps__/safe-math/SafeMath'; import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; -import { NavigationSteps } from './useSwapNavigation'; export function useSwapTextStyles({ - configProgress, inputMethod, inputValues, internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, - focusedInput, - inputProgress, - outputProgress, - sliderPressProgress, }: { - configProgress: SharedValue; inputMethod: SharedValue; inputValues: SharedValue; internalSelectedInputAsset: SharedValue; internalSelectedOutputAsset: SharedValue; isFetching: SharedValue; isQuoteStale: SharedValue; - focusedInput: SharedValue; - inputProgress: SharedValue; - outputProgress: SharedValue; - sliderPressProgress: SharedValue; }) { const { isDarkMode } = useColorMode(); @@ -79,19 +60,30 @@ export function useSwapTextStyles({ }); const isInputZero = useDerivedValue(() => { - const isZero = - !internalSelectedInputAsset.value || - (inputValues.value.inputAmount === 0 && inputMethod.value !== 'slider') || - (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)); - return isZero; + const isInputAmountZero = inputValues.value.inputAmount === 0; + if (!internalSelectedInputAsset.value) return true; + + if (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)) return true; + + if (inputMethod.value === 'inputNativeValue' && isInputAmountZero) { + return inputValues.value.inputNativeValue === 0; + } + + return isInputAmountZero; }); const isOutputZero = useDerivedValue(() => { - const isZero = - !internalSelectedOutputAsset.value || - (inputValues.value.outputAmount === 0 && inputMethod.value !== 'slider') || - (inputMethod.value === 'slider' && equalWorklet(inputValues.value.outputAmount, 0)); - return isZero; + const isOutputAmountZero = inputValues.value.outputAmount === 0; + + if (!internalSelectedOutputAsset.value) return true; + + if (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)) return true; + + if (inputMethod.value === 'outputNativeValue' && isOutputAmountZero) { + return inputValues.value.outputNativeValue === 0; + } + + return isOutputAmountZero; }); const inputAssetColor = useDerivedValue(() => { @@ -170,81 +162,10 @@ export function useSwapTextStyles({ }; }); - // TODO: Create a reusable InputCaret component - const inputCaretStyle = useAnimatedStyle(() => { - const shouldShow = - configProgress.value === NavigationSteps.INPUT_ELEMENT_FOCUSED && - focusedInput.value === 'inputAmount' && - inputProgress.value === 0 && - outputProgress.value === 0 && - (inputMethod.value !== 'slider' || - (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)) || - (sliderPressProgress.value === SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT && isQuoteStale.value === 0)); - - const opacity = shouldShow - ? withRepeat( - withSequence( - withTiming(1, { duration: 0 }), - withTiming(1, { duration: 400, easing: Easing.bezier(0.87, 0, 0.13, 1) }), - withTiming(0, caretConfig), - withTiming(1, caretConfig) - ), - -1, - true - ) - : withTiming(0, caretConfig); - - const isZero = - (inputMethod.value !== 'slider' && inputValues.value.inputAmount === 0) || - (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)); - - return { - display: shouldShow ? 'flex' : 'none', - opacity, - position: isZero ? 'absolute' : 'relative', - }; - }); - - const outputCaretStyle = useAnimatedStyle(() => { - const shouldShow = - configProgress.value === NavigationSteps.INPUT_ELEMENT_FOCUSED && - focusedInput.value === 'outputAmount' && - inputProgress.value === 0 && - outputProgress.value === 0 && - (inputMethod.value !== 'slider' || - (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)) || - (sliderPressProgress.value === SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT && isQuoteStale.value === 0)); - - const opacity = shouldShow - ? withRepeat( - withSequence( - withTiming(1, { duration: 0 }), - withTiming(1, { duration: 400, easing: Easing.bezier(0.87, 0, 0.13, 1) }), - withTiming(0, caretConfig), - withTiming(1, caretConfig) - ), - -1, - true - ) - : withTiming(0, caretConfig); - - const isZero = - (inputMethod.value !== 'slider' && inputValues.value.outputAmount === 0) || - (inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)); - - return { - display: shouldShow ? 'flex' : 'none', - opacity, - position: isZero ? 'absolute' : 'relative', - }; - }); - return { inputAmountTextStyle, - inputCaretStyle, inputNativeValueStyle, outputAmountTextStyle, - outputCaretStyle, outputNativeValueStyle, }; } diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index 7e1698d7f3d..abefed50f50 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -439,17 +439,12 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }); const SwapTextStyles = useSwapTextStyles({ - configProgress, - focusedInput, inputMethod: SwapInputController.inputMethod, - inputProgress, inputValues: SwapInputController.inputValues, internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, - outputProgress, - sliderPressProgress, }); const SwapNavigation = useSwapNavigation({ diff --git a/src/__swaps__/types/swap.ts b/src/__swaps__/types/swap.ts index 3bf0134c6d3..6a513b68024 100644 --- a/src/__swaps__/types/swap.ts +++ b/src/__swaps__/types/swap.ts @@ -1,5 +1,4 @@ -import { ExtendedAnimatedAssetWithColors, UniqueId } from './assets'; -import { SearchAsset } from './search'; +import { ExtendedAnimatedAssetWithColors } from './assets'; export type inputKeys = 'inputAmount' | 'inputNativeValue' | 'outputAmount' | 'outputNativeValue'; export type inputMethods = inputKeys | 'slider'; diff --git a/src/__swaps__/utils/__tests__/numbers.test.ts b/src/__swaps__/utils/__tests__/numbers.test.ts index 609ebc1186b..faf3be13c6b 100644 --- a/src/__swaps__/utils/__tests__/numbers.test.ts +++ b/src/__swaps__/utils/__tests__/numbers.test.ts @@ -13,7 +13,7 @@ const testCases = [ { value: 1234.56, currency: supportedCurrencies.NZD, expected: 'NZ$1,234.56' }, { value: 1234.56, currency: supportedCurrencies.GBP, expected: '£1,234.56' }, { value: 1234.56, currency: supportedCurrencies.CNY, expected: '¥1,234.56' }, - { value: 1234.56, currency: supportedCurrencies.JPY, expected: '¥1,234.56' }, + { value: 1234.56, currency: supportedCurrencies.JPY, expected: '¥1,235' }, { value: 1234.56, currency: supportedCurrencies.INR, expected: '₹1,234.56' }, { value: 1234.56, currency: supportedCurrencies.TRY, expected: '₺1,234.56' }, { value: 1234.56, currency: supportedCurrencies.ZAR, expected: 'R1,234.56' }, diff --git a/src/__swaps__/utils/numbers.ts b/src/__swaps__/utils/numbers.ts index 38969306888..c6c3e11a115 100644 --- a/src/__swaps__/utils/numbers.ts +++ b/src/__swaps__/utils/numbers.ts @@ -314,7 +314,8 @@ export const convertBipsToPercentage = (value: BigNumberish, decimals = 2): stri export const convertAmountToNativeDisplayWorklet = ( value: number | string, nativeCurrency: keyof nativeCurrencyType, - useThreshold = false + useThreshold = false, + ignoreAlignment = false ) => { 'worklet'; @@ -338,7 +339,7 @@ export const convertAmountToNativeDisplayWorklet = ( maximumFractionDigits: decimals, }); - const nativeDisplay = `${thresholdReached ? '<' : ''}${alignment === 'left' ? symbol : ''}${nativeValue}${alignment === 'right' ? symbol : ''}`; + const nativeDisplay = `${thresholdReached ? '<' : ''}${alignment === 'left' || ignoreAlignment ? symbol : ''}${nativeValue}${!ignoreAlignment && alignment === 'right' ? symbol : ''}`; return nativeDisplay; }; diff --git a/src/__swaps__/utils/swaps.ts b/src/__swaps__/utils/swaps.ts index 6b70afdf030..fc81b10cb35 100644 --- a/src/__swaps__/utils/swaps.ts +++ b/src/__swaps__/utils/swaps.ts @@ -17,7 +17,7 @@ import { TokenColors } from '@/graphql/__generated__/metadata'; import * as i18n from '@/languages'; import { RainbowConfig } from '@/model/remoteConfig'; import store from '@/redux/store'; -import { ETH_ADDRESS } from '@/references'; +import { ETH_ADDRESS, supportedNativeCurrencies } from '@/references'; import { userAssetsStore } from '@/state/assets/userAssets'; import { colors } from '@/styles'; import { BigNumberish } from '@ethersproject/bignumber'; @@ -197,6 +197,8 @@ export const findNiceIncrement = (availableBalance: string | number | undefined) // /---- 🔵 Worklet utils 🔵 ----/ // // +type nativeCurrencyType = typeof supportedNativeCurrencies; + export function addCommasToNumber(number: string | number, fallbackValue: T = 0 as T): T | string { 'worklet'; if (isNaN(Number(number))) { @@ -217,14 +219,25 @@ export function addCommasToNumber(number: string | n } } +export const addSymbolToNativeDisplayWorklet = (value: number | string, nativeCurrency: keyof nativeCurrencyType): string => { + 'worklet'; + + const nativeSelected = supportedNativeCurrencies?.[nativeCurrency]; + const { symbol } = nativeSelected; + + const nativeValueWithCommas = addCommasToNumber(value, '0'); + + return `${symbol}${nativeValueWithCommas}`; +}; + export function clamp(value: number, lowerBound: number, upperBound: number) { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); } -export function stripCommas(value: string) { +export function stripNonDecimalNumbers(value: string) { 'worklet'; - return value.replace(/,/g, ''); + return value.replace(/[^0-9.]/g, ''); } export function trimTrailingZeros(value: string) { @@ -628,7 +641,6 @@ export const buildQuoteParams = ({ fromAddress: currentAddress, sellTokenAddress: inputAsset.isNativeAsset ? ETH_ADDRESS_AGGREGATOR : inputAsset.address, buyTokenAddress: outputAsset.isNativeAsset ? ETH_ADDRESS_AGGREGATOR : outputAsset.address, - // TODO: Handle native input cases below sellAmount: lastTypedInput === 'inputAmount' || lastTypedInput === 'inputNativeValue' ? convertAmountToRawAmount(inputAmount.toString(), inputAsset.decimals) diff --git a/src/references/supportedCurrencies.ts b/src/references/supportedCurrencies.ts index 9c356d87fd4..ef1588a377a 100644 --- a/src/references/supportedCurrencies.ts +++ b/src/references/supportedCurrencies.ts @@ -98,8 +98,8 @@ export const supportedCurrencies = { emoji: '🇰🇷', emojiName: 'south_korea', label: i18n.t(i18n.l.settings.currency.KRW), - mask: '[099999999999]{.}[00]', - placeholder: '0.00', + mask: '[099999999999]', + placeholder: '0', userAssetsSmallThreshold: 100, smallThreshold: 1000, symbol: '₩', @@ -139,12 +139,12 @@ export const supportedCurrencies = { alignment: 'left', assetLimit: 1, currency: 'JPY', - decimals: 2, + decimals: 0, emoji: '🇯🇵', emojiName: 'japan', label: i18n.t(i18n.l.settings.currency.JPY), - mask: '[099999999999]{.}[00]', - placeholder: '0.00', + mask: '[099999999999]', + placeholder: '0', userAssetsSmallThreshold: 10, smallThreshold: 100, symbol: '¥',