diff --git a/Navigation.ts b/Navigation.ts index ef4e0aed1..5e84f0de8 100644 --- a/Navigation.ts +++ b/Navigation.ts @@ -104,6 +104,8 @@ import AddNotes from './views/AddNotes'; import Contacts from './views/Settings/Contacts'; import AddContact from './views/Settings/AddContact'; import ContactDetails from './views/ContactDetails'; +import CurrencyConverter from './views/Settings/CurrencyConverter'; + // POS import Order from './views/Order'; @@ -423,6 +425,9 @@ const AppScenes = { ContactQR: { screen: ContactQR }, + CurrencyConverter: { + screen: CurrencyConverter + }, ChannelsSettings: { screen: ChannelsSettings }, diff --git a/assets/images/SVG/bitcoin-icon.svg b/assets/images/SVG/bitcoin-icon.svg new file mode 100644 index 000000000..68f758d5d --- /dev/null +++ b/assets/images/SVG/bitcoin-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/locales/en.json b/locales/en.json index bc9939dc6..7ad696424 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1001,6 +1001,8 @@ "views.Settings.Attestations.invalidAttestation": "Invalid attestation", "views.Settings.Nostr.addRelay": "Add relay", "views.Settings.Nostr.relays": "Relays", + "views.Settings.CurrencyConverter.title": "Currency Converter", + "views.Settings.CurrencyConverter.enterAmount": "Enter amount", "views.LspExplanation.title": "What are these fees?", "views.LspExplanation.text1": "Zeus is a self-custodial lightning wallet. In order to send or receive a lightning payment, you must open a lightning payment channel, which has a setup fee.", "views.LspExplanation.text2": "Once the channel is set up, you'll only have to pay normal network fees until your channel exhausts its capacity.", @@ -1035,4 +1037,4 @@ "time.1H": "1H", "time.1D": "1D", "time.1W": "1W" -} +} \ No newline at end of file diff --git a/stores/FiatStore.ts b/stores/FiatStore.ts index 311231f19..249d08263 100644 --- a/stores/FiatStore.ts +++ b/stores/FiatStore.ts @@ -24,6 +24,13 @@ export default class FiatStore { @observable public loading = false; @observable public error = false; + @observable public numberWithCommas = (x: string | number) => + x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + @observable public numberWithDecimals = (x: string | number) => + this.numberWithCommas(x).replace(/[,.]/g, (y: string) => + y === ',' ? '.' : ',' + ); private sourceOfCurrentFiatRates: string | undefined; getFiatRatesToken: any; @@ -34,17 +41,11 @@ export default class FiatStore { this.settingsStore = settingsStore; } - numberWithCommas = (x: string | number) => - x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); - - numberWithDecimals = (x: string | number) => - this.numberWithCommas(x).replace(/[,.]/g, (y: string) => - y === ',' ? '.' : ',' - ); - // Resource below may be helpful for formatting // https://fastspring.com/blog/how-to-format-30-currencies-from-countries-all-over-the-world/ - symbolLookup = (symbol: string): CurrencyDisplayRules => { + @observable public symbolLookup = ( + symbol: string + ): CurrencyDisplayRules => { const symbolPairs: any = { USD: { symbol: '$', diff --git a/views/Settings/Currency.tsx b/views/Settings/Currency.tsx index 4bf9896bf..c87763afe 100644 --- a/views/Settings/Currency.tsx +++ b/views/Settings/Currency.tsx @@ -183,6 +183,29 @@ export default class Currency extends React.Component< color={themeColor('secondaryText')} /> + navigation.navigate('CurrencyConverter')} + > + + + {localeString( + 'views.Settings.CurrencyConverter.title' + )} + + + + ); diff --git a/views/Settings/CurrencyConverter.tsx b/views/Settings/CurrencyConverter.tsx new file mode 100644 index 000000000..984fb8a56 --- /dev/null +++ b/views/Settings/CurrencyConverter.tsx @@ -0,0 +1,649 @@ +import * as React from 'react'; +import { observer, inject } from 'mobx-react'; +import { + TouchableOpacity, + View, + StyleSheet, + Animated, + Easing +} from 'react-native'; +import Svg, { Text } from 'react-native-svg'; +import DragList, { DragListRenderItemInfo } from 'react-native-draglist'; +import { Icon } from 'react-native-elements'; +import EncryptedStorage from 'react-native-encrypted-storage'; + +import Screen from '../../components/Screen'; +import Header from '../../components/Header'; +import TextInput from '../../components/TextInput'; +import { ErrorMessage } from '../../components/SuccessErrorMessage'; +import { Row } from '../../components/layout/Row'; + +import { themeColor } from '../../utils/ThemeUtils'; +import { localeString } from '../../utils/LocaleUtils'; +import FiatStore from '../../stores/FiatStore'; +import SettingsStore, { CURRENCY_KEYS } from '../../stores/SettingsStore'; + +import Add from '../../assets/images/SVG/Add.svg'; +import Edit from '../../assets/images/SVG/Pen.svg'; +import DragDots from '../../assets/images/SVG/DragDots.svg'; +import BitcoinIcon from '../../assets/images/SVG/bitcoin-icon.svg'; + +interface CurrencyConverterProps { + navigation: any; + FiatStore?: FiatStore; + SettingsStore?: SettingsStore; +} + +interface CurrencyConverterState { + inputValues: { [key: string]: string }; + selectedCurrency: string; + editMode: boolean; + fadeAnim: Animated.Value; +} + +@inject('FiatStore', 'SettingsStore') +@observer +export default class CurrencyConverter extends React.Component< + CurrencyConverterProps, + CurrencyConverterState +> { + constructor(props: CurrencyConverterProps) { + super(props); + this.state = { + inputValues: { + BTC: '', + sats: '' + }, + selectedCurrency: '', + editMode: false, + fadeAnim: new Animated.Value(0) + }; + } + + componentDidMount() { + const { navigation } = this.props; + this.retrieveInputValues(); + this.addDefaultCurrenciesToStorage(); + const selectedCurrency = navigation.getParam('selectedCurrency', ''); + if (selectedCurrency) { + this.handleCurrencySelect(selectedCurrency); + } + } + + componentDidUpdate(prevProps) { + const { navigation } = this.props; + const selectedCurrency = navigation.getParam('selectedCurrency', ''); + const prevSelectedCurrency = prevProps.navigation.getParam( + 'selectedCurrency', + '' + ); + + // Check if the selected currency prop has changed + if (selectedCurrency !== prevSelectedCurrency) { + this.handleCurrencySelect(selectedCurrency); + } + } + + addDefaultCurrenciesToStorage = async () => { + try { + const { inputValues } = this.state; + + const inputValuesString = await EncryptedStorage.getItem( + 'currency-codes' + ); + const existingInputValues = inputValuesString + ? JSON.parse(inputValuesString) + : {}; + + // Add default currencies from state to existing inputValues + for (const currency of Object.keys(inputValues)) { + if (!existingInputValues.hasOwnProperty(currency)) { + existingInputValues[currency] = ''; + } + } + + // Save updated inputValues to storage + await EncryptedStorage.setItem( + 'currency-codes', + JSON.stringify(existingInputValues) + ); + + // Update the state with the updated inputValues + this.setState({ inputValues: existingInputValues }); + } catch (error) { + console.error('Error adding default currencies:', error); + } + }; + + saveInputValues = async () => { + try { + await EncryptedStorage.setItem( + 'currency-codes', + JSON.stringify(this.state.inputValues) + ); + } catch (error) { + console.error('Error saving input values:', error); + } + }; + + retrieveInputValues = async () => { + try { + const inputValuesString = await EncryptedStorage.getItem( + 'currency-codes' + ); + if (inputValuesString) { + const inputValues = JSON.parse(inputValuesString); + this.setState({ inputValues }); + } + } catch (error) { + console.error('Error retrieving input values:', error); + } + }; + + handleCurrencySelect = (currency: string) => { + const { inputValues } = this.state; + + if (!inputValues.hasOwnProperty(currency)) { + const updatedInputValues = { ...inputValues, [currency]: '' }; + this.setState( + { + inputValues: updatedInputValues, + selectedCurrency: currency + }, + () => { + this.saveInputValues(); + } + ); + } else { + this.setState({ selectedCurrency: currency }); + } + }; + + handleInputChange = (value: string, currency: string) => { + const sanitizedValue = value.replace(/,/g, '').replace(/[^\d.]/g, ''); + + const { inputValues } = this.state; + const FiatStore = this.props.FiatStore!; + const fiatRates = FiatStore?.fiatRates || []; + + const formatNumber = (value: string, currency: string) => { + // Check if the currency is BTC + if (currency === 'BTC') { + // If BTC and the value is greater than 1, apply formatting with numberWithCommas + if (parseFloat(value) > 1) { + return FiatStore.numberWithCommas(value); + } else { + // Otherwise, return the value as it is + return value; + } + } else { + // For other currencies, apply formatting with numberWithCommas + return FiatStore.numberWithCommas(value); + } + }; + + const convertedValues: { [key: string]: string } = { ...inputValues }; + + // Apply formatting to the input value before conversion for all currencies + const formattedValue = formatNumber(sanitizedValue, currency); + + // Set the input value + convertedValues[currency] = formattedValue; + + // Convert the value to other currencies and BTC + Object.keys(convertedValues).forEach((key) => { + if (key !== currency) { + let convertedValue = ''; + + // Check if the value is empty + if (sanitizedValue === '') { + convertedValues[key] = ''; // Set the converted value to empty string + } else { + // Conversion from sats to currency + if (currency === 'sats') { + // Convert sats to BTC first + const btcValue = ( + parseFloat(sanitizedValue) / 100000000 + ).toFixed(8); + + if (key === 'BTC') { + convertedValue = btcValue; + } else { + // Then convert BTC to the target currency + const btcConversionRate = fiatRates.find( + (rate) => rate.currencyPair === `BTC_${key}` + )?.rate; + + if (btcConversionRate) { + convertedValue = ( + parseFloat(btcValue) * btcConversionRate + ).toFixed(2); + } + } + } else if (currency === 'BTC') { + // Conversion from BTC to currency + const directRate = fiatRates.find( + (rate) => rate.currencyPair === `${currency}_${key}` + ); + if (key === 'sats') { + convertedValue = ( + parseFloat(sanitizedValue) * 100000000 + ).toFixed(0); + } + if (directRate) { + convertedValue = ( + parseFloat(sanitizedValue) * directRate.rate + ).toFixed(2); + } + } else { + // Conversion from currency to currency + const btcToRate = fiatRates.find( + (rate) => rate.currencyPair === `BTC_${key}` + )?.rate; + const btcFromRate: any = fiatRates.find( + (rate) => rate.currencyPair === `BTC_${currency}` + )?.rate; + + if (btcFromRate) { + const btcValue = ( + parseFloat(sanitizedValue) / btcFromRate + ).toFixed(8); + if (key === 'BTC') { + convertedValue = + sanitizedValue === '' ? '' : btcValue; + } else if (key === 'sats') { + convertedValue = + sanitizedValue === '' + ? '' + : ( + parseFloat(btcValue) * 100000000 + ).toFixed(0); // Convert BTC to sats if sats is present + } + } + + if (btcToRate && btcFromRate) { + const btcValue = + parseFloat(sanitizedValue) / btcFromRate; + convertedValue = (btcValue * btcToRate).toFixed(2); + } + } + } + + // Apply formatting on currencies + convertedValues[key] = formatNumber(convertedValue, key); + } + }); + + // Update the state with the converted values + this.setState({ inputValues: convertedValues }); + }; + + handleDeleteCurrency = async (currency: string) => { + try { + // Retrieve inputValues from storage + const inputValuesString = await EncryptedStorage.getItem( + 'currency-codes' + ); + const existingInputValues = inputValuesString + ? JSON.parse(inputValuesString) + : {}; + + // Create a copy of the inputValues object + const updatedInputValues = { ...existingInputValues }; + + // Remove the currency code from the inputValues object + delete updatedInputValues[currency]; + + // Save updated inputValues to storage + await EncryptedStorage.setItem( + 'currency-codes', + JSON.stringify(updatedInputValues) + ); + + // Update the component state with the updated inputValues + this.setState({ inputValues: updatedInputValues }); + } catch (error) { + console.error('Error deleting currency:', error); + } + }; + + onReordered = async (fromIndex: number, toIndex: number) => { + const { inputValues } = this.state; + const keys = Object.keys(inputValues); + const copy: { [key: string]: string } = {}; + + // Create a copy of inputValues object + keys.forEach((key) => { + copy[key] = inputValues[key]; + }); + + // Reorder keys array + keys.splice(toIndex, 0, keys.splice(fromIndex, 1)[0]); + + // Create a new object with reordered inputValues + const reorderedValues: { [key: string]: string } = {}; + keys.forEach((key) => { + reorderedValues[key] = copy[key]; + }); + + // Update state with reordered inputValues + try { + await EncryptedStorage.setItem( + 'currency-codes', + JSON.stringify(reorderedValues) + ); + } catch (error) { + console.error('Error saving input values:', error); + } + this.setState({ + inputValues: reorderedValues + }); + }; + + toggleEditMode = () => { + const { editMode, fadeAnim } = this.state; + this.setState({ editMode: !editMode }); + + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: editMode ? 0 : 1, + duration: 350, + easing: Easing.ease, + useNativeDriver: false + }) + ]).start(); + }; + + render() { + const { navigation, SettingsStore } = this.props; + const { inputValues, editMode, fadeAnim } = this.state; + const { settings }: any = SettingsStore; + const { fiatEnabled } = settings; + + const AddButton = () => ( + + navigation.navigate('SelectCurrency', { + currencyConverter: true + }) + } + accessibilityLabel={localeString('general.add')} + > + + + ); + + const EditButton = () => ( + + + + ); + + const getFlagEmoji = (currencyValue: string) => { + const currency = CURRENCY_KEYS.find( + (currency) => currency.value === currencyValue + ); + if (currency) { + return currency.key.split(' ')[0]; + } + return ''; + }; + + const inputBoxWidth = fadeAnim.interpolate({ + inputRange: [0, 1], + outputRange: [1, editMode ? 1 : 0.8] + }); + + const slideInLeft = fadeAnim.interpolate({ + inputRange: [0, 1], + outputRange: [editMode ? -50 : 0, 0] + }); + + const slideInRight = fadeAnim.interpolate({ + inputRange: [0, 1], + outputRange: [editMode ? 50 : 0, 0] + }); + + return ( + +
+ {Object.keys(inputValues).length > 2 && ( + + )} + + + ) + } + centerComponent={{ + text: localeString( + 'views.Settings.CurrencyConverter.title' + ), + style: { + color: themeColor('text'), + fontFamily: 'PPNeueMontreal-Book' + } + }} + navigation={navigation} + /> + {!fiatEnabled ? ( + + + + ) : ( + + item} + renderItem={({ + item, + onDragStart, + onDragEnd + }: DragListRenderItemInfo) => { + const { inputValues } = this.state; + return ( + + + {editMode && + item !== 'BTC' && + item !== 'sats' && ( + + this.handleDeleteCurrency( + item + ) + } + > + + + + + )} + + + + {['BTC', 'sats'].includes( + item + ) ? ( + + ) : ( + + + {getFlagEmoji( + item + )} + + + )} + + + + this.handleInputChange( + value, + item + ) + } + autoCapitalize="none" + /> + + + {editMode && + item !== 'BTC' && + item !== 'sats' && ( + + + + + + )} + + + ); + }} + /> + + )} + + ); + } +} + +const styles = StyleSheet.create({ + inputContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + inputBox: { + flex: 1, + flexDirection: 'row-reverse', + alignItems: 'center' + }, + deleteIcon: { + marginRight: 16, + marginLeft: -6 + }, + draggableItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + dragHandle: { + marginLeft: 16, + marginRight: -6 + } +}); diff --git a/views/Settings/SelectCurrency.tsx b/views/Settings/SelectCurrency.tsx index 75efc8aef..5eb98c1f4 100644 --- a/views/Settings/SelectCurrency.tsx +++ b/views/Settings/SelectCurrency.tsx @@ -78,6 +78,11 @@ export default class SelectCurrency extends React.Component< this.state; const { updateSettings, getSettings }: any = SettingsStore; + const currencyConverter = navigation.getParam( + 'currencyConverter', + null + ); + return ( @@ -128,22 +133,32 @@ export default class SelectCurrency extends React.Component< backgroundColor: 'transparent' }} onPress={async () => { - await updateSettings({ - fiat: item.value - }).then(() => { - getSettings(); - navigation.navigate('Currency', { - refresh: true + if (currencyConverter) { + navigation.navigate( + 'CurrencyConverter', + { + selectedCurrency: item.value + } + ); + } else { + await updateSettings({ + fiat: item.value + }).then(() => { + getSettings(); + navigation.navigate('Currency', { + refresh: true + }); }); - }); + } }} > {(selectedCurrency === item.value || (!selectedCurrency && - item.value === DEFAULT_FIAT)) && ( - - - - )} + item.value === DEFAULT_FIAT)) && + !currencyConverter && ( + + + + )} )} keyExtractor={(item, index) => `${item.host}-${index}`}