mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-10 16:43:19 +00:00
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
import React, { forwardRef, useState, useEffect } from "react"
|
|
import { TextInput, TextStyle, View } from "react-native"
|
|
import Animated, {
|
|
useSharedValue,
|
|
useAnimatedStyle,
|
|
withTiming,
|
|
} from "react-native-reanimated"
|
|
import { spacing, useThemeColor, typography } from "../theme"
|
|
import { verticalScale } from "@gocodingnow/rn-size-matters"
|
|
import {
|
|
Currencies,
|
|
CurrencyCode,
|
|
MintUnit,
|
|
convertToFromSats,
|
|
formatCurrency,
|
|
getCurrency,
|
|
} from "../services/wallet/currency"
|
|
import { round, toNumber } from "../utils/number"
|
|
import { useStores } from "../models"
|
|
import { Text } from "./Text"
|
|
import { format } from "util"
|
|
import { log } from "../services"
|
|
|
|
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
|
|
|
interface AmountInputProps {
|
|
value: string
|
|
onChangeText: (text: string) => void
|
|
unit?: MintUnit
|
|
onEndEditing?: () => void
|
|
editable?: boolean
|
|
selectTextOnFocus?: boolean
|
|
onFocus?: () => void
|
|
onBlur?: () => void
|
|
style?: TextStyle
|
|
}
|
|
|
|
export const AmountInput = forwardRef<TextInput, AmountInputProps>(
|
|
(
|
|
{
|
|
value,
|
|
onChangeText,
|
|
unit = "sat",
|
|
onEndEditing,
|
|
editable = true,
|
|
selectTextOnFocus,
|
|
onFocus,
|
|
onBlur,
|
|
style,
|
|
...rest
|
|
},
|
|
ref
|
|
) => {
|
|
const { walletStore, userSettingsStore } = useStores()
|
|
|
|
const [focused, setFocused] = useState<"top" | "bottom">("top")
|
|
const [isConvertedValueVisible, setIsConvertedValueVisible] = useState<boolean>(false)
|
|
const [hasTopAmountFocusedOnce, setHasTopAmountFocusedOnce] = useState(false)
|
|
const [hasBottomAmountFocusedOnce, setHasBottomAmountFocusedOnce] = useState(false)
|
|
const [hasBeenFirstTimeConverted, setHasBeenFirstTimeConverted] = useState<boolean>(false)
|
|
|
|
const focusedInputColor = useThemeColor("amountInput")
|
|
const convertedAmountColor = useThemeColor("headerSubTitle")
|
|
const symbolColor = useThemeColor("headerSubTitle")
|
|
|
|
// which fiat are we converting to/from (major units)
|
|
const fiatCode = userSettingsStore.exchangeCurrency
|
|
const fiatPrecision = fiatCode ? Currencies[fiatCode]!.precision ?? 100 : null// cents per unit
|
|
const topIsSat = getCurrency(unit).code === CurrencyCode.SAT
|
|
|
|
// values shown in inputs
|
|
const [topValue, setTopValue] = useState(value)
|
|
const [bottomValue, setBottomValue] = useState("0")
|
|
|
|
const handleTopFocus = () => {
|
|
setHasTopAmountFocusedOnce(true)
|
|
setFocused('top')
|
|
onFocus?.()
|
|
}
|
|
|
|
const handleTopBlur = () => {
|
|
setHasTopAmountFocusedOnce(false)
|
|
onBlur?.()
|
|
}
|
|
|
|
const handleBottomFocus = () => {
|
|
setHasBottomAmountFocusedOnce(true)
|
|
setFocused('bottom')
|
|
onFocus?.()
|
|
}
|
|
|
|
const handleBottomBlur = () => {
|
|
setHasBottomAmountFocusedOnce(false)
|
|
onBlur?.()
|
|
}
|
|
|
|
// --- conversion helpers (IMPORTANT: cents ↔ major handling) ---
|
|
|
|
// SAT (or other top unit) -> bottom display value
|
|
const recalcBottom = (v: string) => {
|
|
log.trace(`[AmountInput] recalcBottom: ${v}`)
|
|
if (!walletStore.exchangeRate || !fiatCode || !fiatPrecision) return "0"
|
|
|
|
if (topIsSat) {
|
|
// top is sat/msat etc. Convert to sats first, then sats -> cents -> major
|
|
const satPrecision = getCurrency(unit).precision // 1
|
|
const sats = round(toNumber(v) * satPrecision, 0) || 0
|
|
|
|
// convertToFromSats(from SAT) returns FIAT PRECISION UNITS (cents)
|
|
const cents = convertToFromSats(sats, CurrencyCode.SAT, walletStore.exchangeRate)
|
|
return formatCurrency(cents, fiatCode, true)
|
|
} else {
|
|
// top is FIAT major -> cents -> sats (bottom shows sats)
|
|
const cents = round(toNumber(v) * fiatPrecision, 0) || 0
|
|
|
|
// convertToFromSats(from FIAT) returns SATS
|
|
const sats = convertToFromSats(cents, fiatCode, walletStore.exchangeRate)
|
|
return formatCurrency(sats, CurrencyCode.SAT, true)// bottom is sats in this branch
|
|
}
|
|
}
|
|
|
|
// bottom (display) -> top value
|
|
const recalcTop = (v: string) => {
|
|
if (!walletStore.exchangeRate || !fiatCode || !fiatPrecision) return "0"
|
|
|
|
if (topIsSat) {
|
|
// bottom is FIAT major. Convert major -> cents -> sats -> top (sat/msat)
|
|
const cents = round(toNumber(v) * fiatPrecision, 0) || 0
|
|
const sats = convertToFromSats(cents, fiatCode, walletStore.exchangeRate)
|
|
return formatCurrency(sats, CurrencyCode.SAT, true)
|
|
} else {
|
|
// bottom is SATS. Convert sats -> cents -> FIAT major (top)
|
|
const sats = round(toNumber(v), 0) || 0
|
|
const cents = convertToFromSats(sats, CurrencyCode.SAT, walletStore.exchangeRate)
|
|
return formatCurrency(cents, fiatCode, true)
|
|
}
|
|
}
|
|
|
|
// keep internal state in sync with external `value`
|
|
useEffect(() => {
|
|
log.trace(`[AmountInput.useEffect:mount] setTopValue`, value)
|
|
setTopValue(value)
|
|
|
|
// only show if
|
|
// - user has set conversion currency in settings
|
|
// - we have an exchange rate
|
|
// - mint unit is SATS or mint unit is the one we have exchange rate for (so we convert to SATS):
|
|
const canShow =
|
|
fiatCode &&
|
|
!!walletStore.exchangeRate &&
|
|
(topIsSat || getCurrency(unit).code === fiatCode)
|
|
|
|
setIsConvertedValueVisible(!!canShow)
|
|
|
|
if (canShow) {
|
|
log.trace(`[AmountInput.useEffect:mount] recalcBottom`, value)
|
|
setBottomValue(recalcBottom(value)) // ✅ always compute bottom from current top
|
|
} else {
|
|
setBottomValue("0")
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
log.trace(`[AmountInput.useEffect:value] setTopValue`, value)
|
|
setTopValue(value)
|
|
if(!hasBeenFirstTimeConverted && value && toNumber(value) > 0) {
|
|
setBottomValue(recalcBottom(value))
|
|
setHasBeenFirstTimeConverted(true)
|
|
}
|
|
|
|
}, [value])
|
|
|
|
// input change handlers (bi-directional)
|
|
const handleTopChange = (text: string) => {
|
|
const normalized = text.replace(',', '.')
|
|
setTopValue(normalized)
|
|
if (focused === "top") {
|
|
setBottomValue(recalcBottom(normalized))
|
|
onChangeText?.(normalized) // parent receives "top" value (sat or fiat, as per `unit`)
|
|
}
|
|
}
|
|
|
|
const handleBottomChange = (text: string) => {
|
|
const normalized = text.replace(',', '.')
|
|
setBottomValue(normalized)
|
|
if (focused === "bottom") {
|
|
const newTop = recalcTop(normalized)
|
|
setTopValue(newTop)
|
|
onChangeText?.(newTop) // keep parent synced to "top" side
|
|
}
|
|
}
|
|
|
|
|
|
const onAmountEndEditing = () => {
|
|
// const formattedTop = recalcTop(bottomValue)
|
|
const formattedBottom = recalcBottom(topValue)
|
|
// setTopValue(formattedTop)
|
|
setBottomValue(formattedBottom)
|
|
return onEndEditing?.()
|
|
}
|
|
|
|
// --- animations (unchanged behavior) ---
|
|
const topScale = useSharedValue(1)
|
|
const bottomScale = useSharedValue(1)
|
|
|
|
useEffect(() => {
|
|
if (focused === "top") {
|
|
topScale.value = withTiming(1, { duration: 250 })
|
|
bottomScale.value = withTiming(1, { duration: 250 })
|
|
} else {
|
|
topScale.value = withTiming(0.6, { duration: 250 })
|
|
bottomScale.value = withTiming(1.65, { duration: 250 })
|
|
}
|
|
}, [focused, topScale, bottomScale])
|
|
|
|
const defaultTopStyle: TextStyle = {
|
|
//marginTop: spacing.small,
|
|
padding: 0,
|
|
fontSize: verticalScale(56),
|
|
fontFamily: typography.primary?.bold,
|
|
fontWeight: 'bold', // android
|
|
textAlign: "center",
|
|
color: focusedInputColor,
|
|
}
|
|
|
|
const animatedTopStyle = useAnimatedStyle(() => ({
|
|
transform: [{ scale: topScale.value }],
|
|
fontSize: 56 * topScale.value,
|
|
}))
|
|
|
|
|
|
const defaultBottomStyle: TextStyle = {
|
|
margin: 0,
|
|
marginBottom: spacing.tiny,
|
|
padding: 0,
|
|
fontSize: spacing.medium,
|
|
fontFamily: typography.primary?.bold,
|
|
fontWeight: 'bold', // android
|
|
color: convertedAmountColor,
|
|
}
|
|
|
|
const animatedBottomStyle = useAnimatedStyle(() => ({
|
|
transform: [{ scale: bottomScale.value }],
|
|
fontSize: spacing.medium * bottomScale.value,
|
|
}))
|
|
|
|
// symbol style
|
|
const defaultSymbolStyle: TextStyle = {
|
|
color: symbolColor,
|
|
fontSize: spacing.extraSmall,
|
|
fontFamily: typography.primary?.light,
|
|
alignSelf: "center",
|
|
marginLeft: focused === 'bottom' ? - spacing.large * 1.7: undefined
|
|
}
|
|
|
|
// animated scale for symbol
|
|
const animatedSymbolStyle = useAnimatedStyle(() => ({
|
|
transform: [{ scale: bottomScale.value }], // sync with bottom input
|
|
fontSize: spacing.extraSmall * bottomScale.value, // scale font size
|
|
marginRight: focused === 'bottom' ? spacing.medium + bottomValue.length * 4.5 : spacing.tiny,
|
|
}))
|
|
|
|
const bottomCurrencyCode = topIsSat ? fiatCode : CurrencyCode.SAT
|
|
const currencySymbol = bottomCurrencyCode ? Currencies[bottomCurrencyCode]!.symbol : null
|
|
|
|
return (
|
|
<>
|
|
{/* Top input */}
|
|
<AnimatedTextInput
|
|
ref={ref}
|
|
value={topValue}
|
|
onChangeText={handleTopChange}
|
|
onEndEditing={onAmountEndEditing}
|
|
onFocus={handleTopFocus}
|
|
onBlur={handleTopBlur}
|
|
style={[
|
|
defaultTopStyle,
|
|
style,
|
|
animatedTopStyle,
|
|
{ color: focused === 'top' ? focusedInputColor : convertedAmountColor }
|
|
]}
|
|
maxLength={9}
|
|
keyboardType="decimal-pad"
|
|
returnKeyType="done"
|
|
selectTextOnFocus={!hasTopAmountFocusedOnce}
|
|
editable={editable}
|
|
{...rest}
|
|
/>
|
|
|
|
{isConvertedValueVisible && (
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Animated.Text
|
|
style={[defaultSymbolStyle, animatedSymbolStyle,
|
|
]}
|
|
>
|
|
{currencySymbol}
|
|
</Animated.Text>
|
|
|
|
<AnimatedTextInput
|
|
value={bottomValue}
|
|
onChangeText={handleBottomChange}
|
|
onEndEditing={onAmountEndEditing}
|
|
onFocus={handleBottomFocus}
|
|
onBlur={handleBottomBlur}
|
|
style={[
|
|
defaultBottomStyle,
|
|
style,
|
|
{ color: convertedAmountColor },
|
|
animatedBottomStyle,
|
|
{ color: focused === 'bottom' ? focusedInputColor : convertedAmountColor }
|
|
]}
|
|
maxLength={9}
|
|
keyboardType="decimal-pad"
|
|
returnKeyType="done"
|
|
selectTextOnFocus={!hasBottomAmountFocusedOnce}
|
|
editable={editable}
|
|
{...rest}
|
|
/>
|
|
</View>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
)
|
|
|
|
AmountInput.displayName = "AmountInput"
|