Skip to content

refactor: decimal places #671

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 79 additions & 59 deletions src/components/AmountTextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,64 +5,79 @@
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import { StyleSheet, TextInput } from 'react-native';
import { getAmountParsed, getIntegerAmount } from '../utils';
import { COLORS } from '../styles/themes';

class AmountTextInput extends React.Component {
constructor(props) {
super(props);

this.inputRef = React.createRef();

// Store the text value
this.state = {
text: props.value || '',
};
}
/**
* Text input component specifically for handling token amounts with BigInt validation.
*
* @param {Object} props
* @param {string} [props.value] - Initial input value
* @param {Function} props.onAmountUpdate - Callback when amount changes:
* (text, bigIntValue) => void where text is the
* formatted string and bigIntValue is the parsed BigInt
* @param {boolean} [props.allowOnlyInteger=false] - If true, only allow integer values
* (no decimals)
* @param {Object} [props.style] - Additional styles for the TextInput
* @param {boolean} [props.autoFocus] - Whether the input should be focused on mount
* @param {number} [props.decimalPlaces] - Number of decimal places to use
* @param {React.Ref} ref - Forwarded ref, exposes the focus() method
* @returns {React.ReactElement} A formatted amount input component
*/
const AmountTextInput = forwardRef((props, ref) => {
const inputRef = useRef(null);
const [text, setText] = useState(props.value || '');
const { decimalPlaces } = props;

// Expose the focus method to parent components
useImperativeHandle(ref, () => ({
focus: () => {
// Add a safety check to prevent null reference errors
if (inputRef.current) {
/* After the focus method is called, the screen is still re-rendered at least once more.
* Requesting a delay before the focus command ensures it is executed on the final rendering
* of the component.
*/
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 50);
}
}
}));

componentDidUpdate(prevProps) {
useEffect(() => {
// Update internal state if value prop changes externally
if (prevProps.value !== this.props.value && this.props.value !== this.state.text) {
this.setState({
text: this.props.value || '',
});
if (props.value !== text) {
setText(props.value || '');
}
}

focus = () => {
/* After the focus method is called, the screen is still re-rendered at least once more.
* Requesting a delay before the focus command ensures it is executed on the final rendering
* of the component.
*/
setTimeout(() => {
this.inputRef.current.focus();
}, 50);
}
}, [props.value]);

onChangeText = (text) => {
if (text === '') {
const onChangeText = (newText) => {
if (newText === '') {
// Need to handle empty string separately
this.setState({ text: '' });
this.props.onAmountUpdate(text, null);
setText('');
props.onAmountUpdate(newText, null);
return;
}

let parsedText = text;
let parsedText = newText;
let bigIntValue;
if (this.props.allowOnlyInteger) {
if (props.allowOnlyInteger) {
// We allow only integers for NFT
parsedText = parsedText.replace(/[^0-9]/g, '');
}

parsedText = getAmountParsed(parsedText);
parsedText = getAmountParsed(parsedText, decimalPlaces);

// There is no NaN in BigInt, it either returns a valid bigint or throws
// an error.
let isValid = true;
try {
bigIntValue = getIntegerAmount(parsedText);
bigIntValue = getIntegerAmount(parsedText, decimalPlaces);

if (bigIntValue < 0n) {
isValid = false;
Expand All @@ -72,33 +87,38 @@ class AmountTextInput extends React.Component {
}

if (isValid) {
this.setState({ text: parsedText });
setText(parsedText);
// Pass both text and BigInt value to parent
this.props.onAmountUpdate(parsedText, bigIntValue);
props.onAmountUpdate(parsedText, bigIntValue);
}
};

let placeholder;
if (props.allowOnlyInteger) {
placeholder = '0';
} else {
const zeros = '0'.repeat(decimalPlaces);
placeholder = `0.${zeros}`;
}

render() {
const placeholder = this.props.allowOnlyInteger ? '0' : '0.00';
const { style: customStyle, ...props } = this.props;

return (
<TextInput
ref={this.inputRef}
style={[style.input, customStyle]}
onChangeText={this.onChangeText}
value={this.state.text}
textAlign='center'
textAlignVertical='bottom'
keyboardAppearance='dark'
keyboardType='numeric'
placeholder={placeholder}
placeholderTextColor={COLORS.midContrastDetail}
{...props}
/>
);
}
}
const { style: customStyle, ...restProps } = props;

return (
<TextInput
ref={inputRef}
style={[style.input, customStyle]}
onChangeText={onChangeText}
value={text}
textAlign='center'
textAlignVertical='bottom'
keyboardAppearance='dark'
keyboardType='numeric'
placeholder={placeholder}
placeholderTextColor={COLORS.midContrastDetail}
{...restProps}
/>
);
});

const style = StyleSheet.create({
input: {
Expand Down
6 changes: 4 additions & 2 deletions src/components/NewPaymentRequest.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const mapStateToProps = (state) => ({
selectedToken: state.selectedToken,
wallet: state.wallet,
tokenMetadata: state.tokenMetadata,
decimalPlaces: state.serverInfo?.decimal_places,
});

class NewPaymentRequest extends React.Component {
Expand Down Expand Up @@ -100,7 +101,7 @@ class NewPaymentRequest extends React.Component {
if (isTokenNFT(this.getTokenUID(), this.props.tokenMetadata)) {
amount = parseInt(this.state.amount, 10);
} else {
amount = getIntegerAmount(this.state.amount);
amount = getIntegerAmount(this.state.amount, this.props.decimalPlaces);
}

this.props.dispatch(newInvoice(address, amount, this.state.token));
Expand All @@ -113,7 +114,7 @@ class NewPaymentRequest extends React.Component {
return true;
}

if (getIntegerAmount(this.state.amount) === 0) {
if (getIntegerAmount(this.state.amount, this.props.decimalPlaces) === 0) {
return true;
}

Expand Down Expand Up @@ -173,6 +174,7 @@ class NewPaymentRequest extends React.Component {
<AmountTextInput
ref={this.inputRef}
onAmountUpdate={this.onAmountUpdate}
decimalPlaces={this.props.decimalPlaces}
value={this.state.amount}
style={{ flex: 1 }}
allowOnlyInteger={isNFT}
Expand Down
10 changes: 7 additions & 3 deletions src/screens/CreateTokenAmount.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ const CreateTokenAmount = () => {
const [amount, setAmount] = useState(0n);
const [deposit, setDeposit] = useState(0n);
const [error, setError] = useState(null);
const wallet = useSelector((state) => state.wallet);
const { wallet, decimalPlaces } = useSelector((state) => ({
wallet: state.wallet,
decimalPlaces: state.serverInfo?.decimal_places
}));
const navigation = useNavigation();
const params = useParams();

Expand Down Expand Up @@ -103,8 +106,8 @@ const CreateTokenAmount = () => {
navigation.navigate('CreateTokenConfirm', {
name: params.name,
symbol: params.symbol,
amount, // BigInt value - will be automatically serialized
deposit // BigInt value - will be automatically serialized
amount,
deposit,
});
};

Expand Down Expand Up @@ -157,6 +160,7 @@ const CreateTokenAmount = () => {
<AmountTextInput
ref={inputRef}
autoFocus
decimalPlaces={decimalPlaces}
onAmountUpdate={onAmountChange}
value={amountText}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/screens/CreateTokenConfirm.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const CreateTokenConfirm = () => {
const wallet = useSelector((state) => state.wallet);
const useWalletService = useSelector((state) => state.useWalletService);
const isShowingPinScreen = useSelector((state) => state.isShowingPinScreen);
const decimalPlaces = useSelector((state) => state.serverInfo?.decimal_places);

const dispatch = useDispatch();
const dispatchNewToken = (token) => dispatch(newToken(token));
Expand Down Expand Up @@ -184,6 +185,7 @@ const CreateTokenConfirm = () => {
</InputLabel>
<AmountTextInput
editable={false}
decimalPlaces={decimalPlaces}
value={hathorLib.numberUtils.prettyValue(amount)}
/>
</View>
Expand Down
2 changes: 2 additions & 0 deletions src/screens/SendAmountInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const mapStateToProps = (state) => ({
selectedToken: state.selectedToken,
tokensBalance: state.tokensBalance,
tokenMetadata: state.tokenMetadata,
decimalPlaces: state.serverInfo?.decimal_places,
});

class SendAmountInput extends React.Component {
Expand Down Expand Up @@ -172,6 +173,7 @@ class SendAmountInput extends React.Component {
onAmountUpdate={this.onAmountChange}
value={this.state.amount}
allowOnlyInteger={this.isNFT()}
decimalPlaces={this.props.decimalPlaces}
style={{ flex: 1 }} // we need this so the placeholder doesn't break in android
// devices after erasing the text
// https://github.com/facebook/react-native/issues/30666
Expand Down
2 changes: 2 additions & 0 deletions src/screens/SendConfirmScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mapStateToProps = (state) => ({
useWalletService: state.useWalletService,
tokenMetadata: state.tokenMetadata,
isShowingPinScreen: state.isShowingPinScreen,
decimalPlaces: state.serverInfo?.decimal_places,
});

class SendConfirmScreen extends React.Component {
Expand Down Expand Up @@ -155,6 +156,7 @@ class SendConfirmScreen extends React.Component {
<AmountTextInput
editable={false}
value={this.amountAndToken}
decimalPlaces={this.props.decimalPlaces}
/>
<InputLabel style={{ marginTop: 8 }}>
{getAvailableString()}
Expand Down
24 changes: 18 additions & 6 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,25 @@ export const getShortContent = (content, length = 4) => (
* "1000.00" => 100000n
*
* @param {string} value - The amount as a string
* @param {number} decimalPlaces - Number of decimal places
* @return {BigInt} The integer value as a BigInt
* @throws {Error} When the input value cannot be parsed to a BigInt
*/
export const getIntegerAmount = (value) => {
export const getIntegerAmount = (value, decimalPlaces) => {
let finalDecimalPlaces = decimalPlaces;
if (decimalPlaces == null) { // matches null and undefined
console.warn('Decimal places is null in getIntegerAmount! Please check if there is something wrong in serverInfo. Defaulting to 2.');
finalDecimalPlaces = 2;
}

// Remove any whitespace and standardize decimal separator
const cleanValue = value.trim().replace(',', '.');

// Split into integer and decimal parts
const [integerPart, decimalPart = ''] = cleanValue.split('.');

// Pad decimal part with zeros if needed
const decimalPlaces = hathorLib.constants.DECIMAL_PLACES;
const paddedDecimal = (decimalPart + '0'.repeat(decimalPlaces)).slice(0, decimalPlaces);
const paddedDecimal = (decimalPart + '0'.repeat(finalDecimalPlaces)).slice(0, finalDecimalPlaces);

// Combine string parts without decimal point
const fullNumberStr = integerPart + paddedDecimal;
Expand All @@ -82,7 +88,13 @@ export const getIntegerAmount = (value) => {
return bigIntCoercibleSchema.parse(fullNumberStr);
};

export const getAmountParsed = (text) => {
export const getAmountParsed = (text, decimalPlaces) => {
let finalDecimalPlaces = decimalPlaces;
if (decimalPlaces == null) { // matches null and undefined
console.warn('Decimal places is null in getAmountParsed! Please check if there is something wrong in serverInfo. Defaulting to 2.');
finalDecimalPlaces = 2;
}

let parts = [];
let separator = '';
if (text.indexOf('.') > -1) {
Expand All @@ -99,8 +111,8 @@ export const getAmountParsed = (text) => {
parts = parts.slice(0, 2);

if (parts[1]) {
if (parts[1].length > hathorLib.constants.DECIMAL_PLACES) {
return `${parts[0]}${separator}${parts[1].slice(0, 2)}`;
if (parts[1].length > decimalPlaces) {
return `${parts[0]}${separator}${parts[1].slice(0, finalDecimalPlaces)}`;
}
}

Expand Down