From 34efa8cb9a05fa5198fb6211e7fda60d96f901ca Mon Sep 17 00:00:00 2001 From: KraXen72 Date: Mon, 18 Aug 2025 21:54:51 +0200 Subject: [PATCH] Enter amount in foreign currency, auto-calculate SAT equivalent (#164) Co-authored-by: minibits-cash <138401554+minibits-cash@users.noreply.github.com> --- .gitignore | 14 +- .vscode/settings.json | 10 +- src/components/AmountInput.tsx | 30 +-- src/components/AmountInputHeader.tsx | 272 +++++++++++++++++++++++++++ src/components/CadIcon.tsx | 1 + src/components/Icon.tsx | 3 +- src/components/index.ts | 2 + src/screens/Mints/MintHeader.tsx | 14 +- src/screens/SendScreen.tsx | 120 ++---------- src/screens/SettingsScreen.tsx | 7 +- src/screens/Wallet/CurrencySign.tsx | 5 +- src/services/wallet/currency.ts | 30 ++- 12 files changed, 364 insertions(+), 144 deletions(-) create mode 100644 src/components/AmountInputHeader.tsx create mode 100644 src/components/CadIcon.tsx diff --git a/.gitignore b/.gitignore index 3bcf34b6..fd7cacfb 100644 --- a/.gitignore +++ b/.gitignore @@ -91,5 +91,17 @@ mise.yaml # temp dev scripts 2025-07-14 and beyond. will remove afterwards key-mappings.json replace-translation-keys.js + + +# for AI agents' one-off jobs, it's useful to use this workflow: tell them: + +# explore the codebase and create a detailed, step by step plan how you're going to implement this feature. +# don't do any implementations yet +# write this plan down in tasks.md as a list of markdown tasks - [ ], in root of the respoitory. +# then, start working on it, constantly re-evaluating how much his done and if it's done, then check off that particular task. +# once they are all checked off, review the task list and double-check everything +tasks.md + # hot-updater -.hot-updater/output \ No newline at end of file +.hot-updater/output + diff --git a/.vscode/settings.json b/.vscode/settings.json index 3d8caac0..4231f1a6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,6 @@ { - "[javascriptreact][typescriptreact]": { + "[javascriptreact][typescriptreact][javascript][typescript]": { "editor.insertSpaces": true, - "editor.tabSize": 2 - }, - "[javascript][typescript]": { - "editor.insertSpaces": true, - "editor.tabSize": 2 - } + "editor.tabSize": 4 + } } \ No newline at end of file diff --git a/src/components/AmountInput.tsx b/src/components/AmountInput.tsx index c8aae140..ad05fe90 100644 --- a/src/components/AmountInput.tsx +++ b/src/components/AmountInput.tsx @@ -9,6 +9,11 @@ interface AmountInputProps { value: string onChangeText: (text: string) => void unit?: MintUnit + /** + * Override default formatting options (thousandSeparated, mantissa, etc.) for onEndEditing formatting. + * Useful for fiat currencies that need custom decimal precision or thousand separator behavior. + */ + formatOptions?: Partial onEndEditing?: () => void editable?: boolean selectTextOnFocus?: boolean @@ -23,6 +28,7 @@ export const AmountInput = forwardRef( value, onChangeText, unit = 'sat', + formatOptions, onEndEditing, editable = true, selectTextOnFocus, @@ -47,18 +53,18 @@ export const AmountInput = forwardRef( } const handleEndEditing = () => { - if (onEndEditing) { - onEndEditing() - } - // Default formatting behavior - const formattedValue = typeof value === "undefined" - ? "" - : numbro(value).format({ - thousandSeparated: true, - mantissa: getCurrency(unit).mantissa - }) - onChangeText(formattedValue) - + if (onEndEditing) onEndEditing(); + if (!value) value = "0"; + // ^^ needed as previous '=== undefined' check didn't trigger on value = "", + // which would still crash numbro's formatter + + // Merge default format options with overrides + const formatOptionsMerged = Object.assign( + { thousandSeparated: true, mantissa: getCurrency(unit).mantissa }, + formatOptions + ); + const formattedValue = numbro(value).format(formatOptionsMerged); + onChangeText(formattedValue) } const defaultStyle: TextStyle = { diff --git a/src/components/AmountInputHeader.tsx b/src/components/AmountInputHeader.tsx new file mode 100644 index 00000000..fefebfbf --- /dev/null +++ b/src/components/AmountInputHeader.tsx @@ -0,0 +1,272 @@ +import { verticalScale } from '@gocodingnow/rn-size-matters' +import React, { useEffect, useState } from 'react' +import { TextInput, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native' +import { useStores } from '../models' +import type { Mint } from '../models/Mint' +import { TransactionStatus } from '../models/Transaction' +import { MintHeader } from '../screens/Mints/MintHeader' +import { CurrencyAmount } from '../screens/Wallet/CurrencyAmount' +import { + availableExchangeCurrencies, + convertToFromSats, + CurrencyCode, + formatCurrency, + getCurrency, + getCurrencyByCode, + MintUnit +} from '../services/wallet/currency' +import { spacing, useThemeColor } from '../theme' +import { round, toNumber } from '../utils/number' +import { AmountInput, Icon, Text } from './index' + +interface IAmountInputHeaderProps { + amountInputRef: React.RefObject + amountToSend: string + setAmountToSend: (amount: string) => void + unit: MintUnit + onAmountEndEditing?: () => void + transactionStatus?: TransactionStatus + isCashuPrWithAmount?: boolean + lockedPubkey?: string + unitRef: React.RefObject + mintHeaderMint: Mint +} + +const $amountContainer: ViewStyle = { + alignItems: 'center', + justifyContent: 'center', +} + +const $headerContainer: TextStyle = { + alignItems: 'center', + padding: spacing.extraSmall, + paddingTop: 0, + height: spacing.screenHeight * 0.31, +} + +const $pubKey: ViewStyle = { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: -spacing.extraSmall +} + +export function AmountInputHeader(props: IAmountInputHeaderProps) { + const { userSettingsStore, walletStore } = useStores() + + /** displayed converted amount for */ + const [currencyAmount, setCurrencyAmount] = useState(0); + const [isFiatMode, setIsFiatMode] = useState(false) + const [amountFiat, setAmountFiat] = useState("0"); + const [shouldTriggerSubmit, setShouldTriggerSubmit] = useState(false); + + const amountInputColor = useThemeColor('amountInput'); + const convertedAmountColor = useThemeColor('headerSubTitle'); + const headerBg = useThemeColor('header') + const buttonIconColor = useThemeColor('buttonIcon'); + + const { + amountInputRef, + amountToSend, + setAmountToSend, + unit, + onAmountEndEditing, + transactionStatus, + isCashuPrWithAmount, + lockedPubkey, + unitRef, + mintHeaderMint + } = props + + const fiatCurrency = userSettingsStore.exchangeCurrency + const isFiatSupported = availableExchangeCurrencies.includes(fiatCurrency as typeof availableExchangeCurrencies[number]); + const canUseFiatMode = isFiatSupported && walletStore.exchangeRate && unit === 'sat' + + const getFiatUnit = () => { + const currencyData = getCurrencyByCode(fiatCurrency) + return currencyData?.mintUnit || 'sat' + } + + const roundToSatPrecision = (value: number) => round(value, getCurrency(unitRef.current).mantissa) + const roundToFiatPrecision = (value: number) => round(value, getCurrencyByCode(fiatCurrency)?.mantissa || 2) + + /** @returns converted number or 0 if conversion fails for any reason */ + const FIATtoSATS = (inputAmount: string) => { + if (!walletStore.exchangeRate || !inputAmount || inputAmount.trim() === '') return 0; + + const fiatCurrencyData = getCurrencyByCode(fiatCurrency) + if (!fiatCurrencyData) return 0; + + const precision = fiatCurrencyData.precision + const converted = convertToFromSats( + round(toNumber(inputAmount) * precision, 0) || 0, + fiatCurrency, + walletStore.exchangeRate + ) + return converted ? roundToSatPrecision(converted) : 0; + } + + /** @returns converted number or 0 if conversion fails for any reason */ + const SATStoFIAT = (inputAmount: string) => { + if (!walletStore.exchangeRate || !inputAmount || inputAmount.trim() === '') return 0; + + const precision = getCurrency(unitRef.current).precision + const converted = convertToFromSats( + round(toNumber(inputAmount) * precision, 0) || 0, + getCurrency(unitRef.current).code, + walletStore.exchangeRate + ) + return converted ? roundToFiatPrecision(converted) : 0; + } + + const isConvertedAmountVisible = () => { + return canUseFiatMode && walletStore.exchangeRate + } + + useEffect(() => { + if (!canUseFiatMode) return; + + // the conversions between the two *looks* asymmetric, but it works: + // the reason it's like this is because formats each currency differently, + // so setting currencyAmount to 9.97 often produces stuff like 0.1 EUR + + // there is possibly a better way to solve this: + // if CurrencyAmount would just display raw values, we could just call FIATtoSATS and SATStoFIAT respectively + // feel free to refactor/change this; i spent way too long trying to get it to work + + if (isFiatMode) { + // SATS -> FIAT mode + // for 1067 sats, even though currencyAmount is 106.26, after formatting it's the desired 1.06 + // we can simply do the same formatting as to get 1.06 for our input + // since formats SATS normally, we can pass in the raw value (e.g. 1067) + const newVal = formatCurrency(currencyAmount, fiatCurrency, false); + setAmountFiat(newVal); + setCurrencyAmount(toNumber(amountToSend.trim() || "0")); + } else { + // FIAT -> SATS mode + // we convert our FIAT "result" (e.g. 1.06) using FIATtoSATS + // since editing the SATS input always correctly calculates the FIAT currency on editd, we simply call handleAmountChange + const newVal = FIATtoSATS(amountFiat).toString(); + setAmountToSend(newVal); + handleAmountChange(newVal) + if (shouldTriggerSubmit && onAmountEndEditing) onAmountEndEditing(); + } + + }, [isFiatMode]) + + const handleAmountChange = (amount: string) => { + setShouldTriggerSubmit(false); + setAmountToSend(amount) + if (canUseFiatMode) { + const convertedAmount = SATStoFIAT(amount) + setCurrencyAmount(convertedAmount) + } + } + + const handleFiatAmountChange = (amount: string) => { + setAmountFiat(amount) + setCurrencyAmount(FIATtoSATS(amount)) + } + + const handleAmountEndEditing = () => { + if (canUseFiatMode) { + const convertedAmount = SATStoFIAT(amountToSend) + setCurrencyAmount(convertedAmount) + } + if (onAmountEndEditing) onAmountEndEditing(); + } + + /** when FIAT input is submitted, it just recalculates to SATS and submits that amount */ + const onFiatAmountEndEditing = () => { + const convertedAmount = FIATtoSATS(amountFiat) + setCurrencyAmount(convertedAmount) + setAmountToSend(convertedAmount.toString()) + + if (amountFiat.trim()) { + setShouldTriggerSubmit(true); + setIsFiatMode(false); + } + } + + return + + + {isFiatMode && canUseFiatMode ? ( + + ) : ( + + )} + {isConvertedAmountVisible() && ( + { + if (!canUseFiatMode) return; + setIsFiatMode(!isFiatMode); + }} + style={{ position: 'relative', flexDirection: 'row', alignItems: "center" }} + > + + + + )} + {lockedPubkey ? ( + + + + + + ) : ( + + )} + + +} \ No newline at end of file diff --git a/src/components/CadIcon.tsx b/src/components/CadIcon.tsx new file mode 100644 index 00000000..e47b93e0 --- /dev/null +++ b/src/components/CadIcon.tsx @@ -0,0 +1 @@ +export const CadIcon = ` ` \ No newline at end of file diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index fd23b177..75d2aebb 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -98,13 +98,14 @@ import { faArrowRotateLeft } from "@fortawesome/free-solid-svg-icons/faArrowRota import { faHeartPulse } from "@fortawesome/free-solid-svg-icons/faHeartPulse" import { faSeedling } from "@fortawesome/free-solid-svg-icons/faSeedling" import { faChevronLeft } from '@fortawesome/free-solid-svg-icons/faChevronLeft' +import { faArrowRightArrowLeft } from "@fortawesome/free-solid-svg-icons" export type IconTypes = keyof typeof iconRegistry // TODO remove need for manual iconregistry? // would be best to just import all of them, i guess, or figure out something smart -export const iconRegistry = { faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft} +export const iconRegistry = { faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft } interface IconProps extends TouchableOpacityProps { diff --git a/src/components/index.ts b/src/components/index.ts index 06c0da7a..c78a0dd0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,8 @@ export * from "./AmountInput" +export * from "./AmountInputHeader" export * from "./AutoImage" export * from "./Button" +export * from "./CadIcon" export * from "./Card" export * from "./Header" export * from "./Icon" diff --git a/src/screens/Mints/MintHeader.tsx b/src/screens/Mints/MintHeader.tsx index e51f1c77..c556fcee 100644 --- a/src/screens/Mints/MintHeader.tsx +++ b/src/screens/Mints/MintHeader.tsx @@ -2,11 +2,10 @@ import React from "react" import { Header, Text } from "../../components" import { spacing, useThemeColor } from "../../theme" import { Mint } from "../../models/Mint" -import { MintUnit } from "../../services/wallet/currency" +import { CurrencyCode, MintUnit } from "../../services/wallet/currency" import { CurrencySign } from "../Wallet/CurrencySign" import { CurrencyAmount } from "../Wallet/CurrencyAmount" import { observer } from "mobx-react-lite" -import { StackNavigationProp } from "@react-navigation/stack" import { moderateScale } from "@gocodingnow/rn-size-matters" import { useNavigation } from "@react-navigation/native" @@ -15,10 +14,11 @@ export const MintHeader = observer(function(props: { mint?: Mint hideBalance?: boolean onBackPress?: () => void + displayCurrency?: CurrencyCode } ) { const navigation = useNavigation() - const {mint, unit, hideBalance, onBackPress} = props + const {mint, unit, hideBalance, onBackPress, displayCurrency} = props const getActiveUnitColor = () => { /* switch (props.unit) { @@ -47,7 +47,8 @@ export const MintHeader = observer(function(props: { size='xxs' />)} - - - - setAmountToSend(amount)} - unit={unitRef.current} - onEndEditing={transactionStatus !== TransactionStatus.PENDING ? onAmountEndEditing : undefined} - editable={(transactionStatus === TransactionStatus.PENDING || isCashuPrWithAmount) - ? false - : true - } - style={{color: amountInputColor}} - /> - {isConvertedAmountVisible() && ( - - )} - {lockedPubkey ? ( - - - - - - ) : ( - - )} - - {!encodedTokenToSend && ( - {[CurrencyCode.USD, CurrencyCode.EUR].map(code => + {availableExchangeCurrencies.map(code => { +export const formatCurrency = (amount: number, code: CurrencyCode, thousandSeparated: boolean = true) => { const c = Currencies[code] if(!c || !c.precision) { throw new AppError(Err.VALIDATION_ERROR, `Currency code ${code} is not yet supported by Minibits. Submit request to add it on our Github.`) } - return numbro(amount / c.precision).format({ mantissa: c.mantissa, thousandSeparated: true }) + return numbro(amount / c.precision).format({ mantissa: c.mantissa, thousandSeparated }) } export const getCurrency = (unit: MintUnit) => { @@ -146,6 +158,15 @@ export const getCurrency = (unit: MintUnit) => { return currencyData as CurrencyData } +export const getCurrencyByCode = (code: CurrencyCode): CurrencyData | undefined => { + for (const [currencyCode, currencyData] of Object.entries(Currencies)) { + if (currencyCode === code && currencyData) { + return currencyData + } + } + return void 0; +} + export const convertToFromSats = (amount: number, currencyFrom: CurrencyCode, satExchangeRate: ExchangeRate) => { // exchangeRate is always 1 fiat precision unit (cent) in SAT {currency: 'EUR', rate: 15.69} @@ -154,5 +175,4 @@ export const convertToFromSats = (amount: number, currencyFrom: CurrencyCode, sa } return amount * satExchangeRate.rate - } \ No newline at end of file