ADD: show MAX sendable amount

When MAX is selected, display the calculated sendable amount below.
  - Works only for 1 recipient (Main goal of feature is this for atomic swap)
  - Shows ≈ prefix when recipient address is unknown (using P2TR estimate)
  - Shows exact amount when valid address is entered
  - Forces displayed amount into transaction (no recalculation on Next)
This commit is contained in:
Adam SHaY 2026-01-27 21:40:40 +01:00 committed by Overtorment
parent 248182c1f6
commit 0f8b04d43a
2 changed files with 64 additions and 4 deletions

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Badge, Icon, Text } from '@rneui/themed';
import Clipboard from '@react-native-clipboard/clipboard';
import BigNumber from 'bignumber.js';
import dayjs from 'dayjs';
import {
@ -24,6 +25,7 @@ import {
satoshiToBTC,
updateExchangeRate,
} from '../blue_modules/currency';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
import { BlueText } from '../BlueComponents';
import confirm from '../helpers/confirm';
import loc, { formatBalancePlain, formatBalanceWithoutSuffix, removeTrailingZeros } from '../loc';
@ -68,13 +70,31 @@ type AmountInputProps = Omit<TextInputProps, 'onChangeText' | 'value'> & {
* Returns a BitcoinUnit value
*/
onAmountUnitChange: (unit: BitcoinUnit) => void;
/**
* Estimated sendable amount in satoshis when MAX is selected.
* Displayed below the MAX label. Pass null to hide.
*/
maxSendableAmount?: number | null;
/**
* When true, shows prefix for maxSendableAmount (indicates estimate).
*/
isMaxAmountEstimate?: boolean;
};
export const AmountInput: React.FC<AmountInputProps> = props => {
const textInputRef = useRef<TextInput>(null);
const { colors } = useTheme();
const amount = props.amount || '0'; // internally amount is aways a string with a correct number
const { onChangeText, unit, onAmountUnitChange, disabled = false, isLoading = false, ...otherProps } = props;
const {
onChangeText,
unit,
onAmountUnitChange,
disabled = false,
isLoading = false,
maxSendableAmount,
isMaxAmountEstimate,
...otherProps
} = props;
const [isRateBeingUpdatedLocal, setIsRateBeingUpdatedLocal] = useState(false);
const [outdatedRefreshRate, setOutdatedRefreshRate] = useState<CurrencyRate | undefined>();
@ -239,6 +259,13 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
}
}, [onChangeText]);
const copyMaxEstimate = useCallback(() => {
if (maxSendableAmount == null) return;
const btcValue = removeTrailingZeros(new BigNumber(maxSendableAmount).dividedBy(100000000).toFixed(8));
Clipboard.setString(btcValue);
triggerHapticFeedback(HapticFeedbackTypes.Selection);
}, [maxSendableAmount]);
const handleSelectionChange = useCallback(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const { selection } = event.nativeEvent;
@ -283,6 +310,14 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
) : (
<Pressable onPress={resetAmount}>
<Text style={[styles.input, stylesHook.input]}>{BitcoinUnit.MAX}</Text>
{maxSendableAmount != null && (
<Text style={[styles.maxEstimate, stylesHook.localCurrency]} onLongPress={copyMaxEstimate}>
{(isMaxAmountEstimate ? '≈ ' : '') +
removeTrailingZeros(new BigNumber(maxSendableAmount).dividedBy(100000000).toFixed(8)) +
' ' +
loc.units[BitcoinUnit.BTC]}
</Text>
)}
</Pressable>
)}
{unit !== BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
@ -387,6 +422,11 @@ const styles = StyleSheet.create({
color: '#9BA0A9',
fontWeight: '600',
},
maxEstimate: {
fontSize: 16,
textAlign: 'center',
marginTop: 4,
},
changeAmountUnit: {
alignSelf: 'center',
marginRight: 16,

View File

@ -111,6 +111,15 @@ const SendDetails = () => {
const balance: number = utxos ? utxos.reduce((prev, curr) => prev + curr.value, 0) : (wallet?.getBalance() ?? 0);
const allBalance = formatBalanceWithoutSuffix(balance, BitcoinUnit.BTC, true);
// estimated sendable amount when MAX is selected (null if not applicable)
const maxSendableAmount = useMemo(() => {
if (addresses.length !== 1) return null;
if (addresses[0].amount !== BitcoinUnit.MAX) return null;
if (feePrecalc.current === null) return null;
const sendable = balance - feePrecalc.current;
return sendable > 0 ? sendable : null;
}, [addresses, balance, feePrecalc]);
// if cutomFee is not set, we need to choose highest possible fee for wallet balance
// if there are no funds for even Slow option, use 1 sat/vbyte fee
const feeRate = useMemo(() => {
@ -316,8 +325,12 @@ const SendDetails = () => {
let targets = [];
for (const transaction of addresses) {
if (transaction.amount === BitcoinUnit.MAX) {
// single output with MAX
targets = [{ address: transaction.address }];
// single output with MAX — use P2TR dummy (43-byte output) for conservative estimate
const addr =
transaction.address && wallet.isAddressValid(transaction.address)
? transaction.address
: 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0';
targets = [{ address: addr }];
break;
}
const value = transaction.amountSats;
@ -586,7 +599,12 @@ const SendDetails = () => {
for (const transaction of addresses) {
if (transaction.amount === BitcoinUnit.MAX) {
// output with MAX
targets.push({ address: transaction.address });
if (maxSendableAmount != null && addresses.length === 1) {
// Force the exact displayed amount — remainder goes to fee
targets.push({ address: transaction.address, value: maxSendableAmount });
} else {
targets.push({ address: transaction.address });
}
continue;
}
const value = parseInt(String(transaction.amountSats), 10);
@ -1399,6 +1417,8 @@ const SendDetails = () => {
editable={isEditable}
disabled={!isEditable}
inputAccessoryViewID={InputAccessoryAllFundsAccessoryViewID}
maxSendableAmount={index === scrollIndex.current ? maxSendableAmount : null}
isMaxAmountEstimate={!(item.address && wallet?.isAddressValid(item.address))}
/>
</View>