- editable locale-aware text for mobile and desktop send flows - parse scanned payment amounts canonically, preserve values across locale changes, validate nonce text, and keep displayed fees separate from editable controller text
What changed, and why it matters
This commit is a hardening and bug-fix patch for Stack Wallet's send screens. It changes how payment amounts are entered, parsed, and displayed so the wallet respects the user's locale (for example, comma vs. period as a decimal separator) without losing or misinterpreting values. It also stops using formatted fee strings for internal calculations, instead keeping fees as raw numeric Amount objects, and adds validation for Ethereum transaction nonces so only whole numbers are accepted. The changes reduce the chance that a user accidentally sends the wrong amount because the app misread a pasted QR-code amount or a locale-formatted number.
Review the new amount parsing helpers (Amount.tryParseCanonicalAmount, formatEditable, tryParseEditable, listenForAmountRelocalization) and the integer_input utility for correctness and edge cases. Test send flows with locales that use comma decimal separators, pasted BIP21/URI amounts with high precision, and Ethereum custom-fee/nonce inputs. Verify that cached fee Amount objects are not accidentally mutated and that preview/build transaction paths use the validated nonce and fee values consistently.
Security signals we found
Locale-aware amount parsing and formatting reduces decimal-separator confusion and amount-mismatch bugs
Canonical parsing of scanned QR payment amounts with overprecision truncation and null handling
Separation of internal fee Amount objects from displayed/formatted fee strings prevents parsing round-trips
Ethereum nonce input validated as non-negative integer before transaction preview
Custom EIP-1559 fee now required before preview button is enabled
Evidence from the diff
The diff refactors send-flow UI code across mobile and desktop. Key technical changes: (1) replaces Decimal.parse() and .toAmount() for scanned payment amounts with Amount.tryParseCanonicalAmount(…, truncateOverprecision: true), handling invalid/over-precise amounts gracefully; (2) introduces formatEditable()/tryParseEditable() and Amount.formatEditableDecimal() for locale-aware amount text, plus listenForAmountRelocalization() to preserve values when the locale changes; (3) changes fee calculation futures/caches from Future
Changed components
lib/pages/send_view/send_view.dartlib/pages/send_view/token_send_view.dartlib/pages/send_view/sol_token_send_view.dartlib/pages/send_view/frost_ms/frost_send_view.dartlib/pages/send_view/frost_ms/recipient.dartlib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dartInspect captured patch +670 / −517
diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart
index 59bdc84..9f3c8d5 100644
--- a/lib/pages/send_view/frost_ms/frost_send_view.dart
+++ b/lib/pages/send_view/frost_ms/frost_send_view.dart
@@ -410,9 +410,8 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
sendAllTapped: () {
return ref
.read(pAmountFormatter(coin))
- .format(
+ .formatEditable(
ref.read(pWalletBalance(walletId)).spendable,
- withUnitName: false,
);
},
),
diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart
index ccfb33d..6c31d92 100644
--- a/lib/pages/send_view/frost_ms/recipient.dart
+++ b/lib/pages/send_view/frost_ms/recipient.dart
@@ -1,4 +1,3 @@
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -7,6 +6,7 @@ import '../../../providers/providers.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/address_utils.dart';
import '../../../utilities/amount/amount.dart';
+import '../../../utilities/amount/amount_field_relocalization.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/amount/amount_input_formatter.dart';
import '../../../utilities/amount/amount_unit.dart';
@@ -74,7 +74,7 @@ class _RecipientState extends ConsumerState<Recipient> {
final address = addressController.text;
final amount = ref
.read(pAmountFormatter(widget.coin))
- .tryParse(amountController.text);
+ .tryParseEditable(amountController.text);
ref.read(pRecipient(widget.index).notifier).state = (
address: address,
@@ -87,7 +87,7 @@ class _RecipientState extends ConsumerState<Recipient> {
if (!_cryptoAmountChangeLock) {
Amount? cryptoAmount = ref
.read(pAmountFormatter(widget.coin))
- .tryParse(amountController.text);
+ .tryParseEditable(amountController.text);
if (cryptoAmount != null) {
if (ref.read(pRecipient(widget.index))?.amount != null &&
ref.read(pRecipient(widget.index))?.amount == cryptoAmount) {
@@ -142,12 +142,18 @@ class _RecipientState extends ConsumerState<Recipient> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: widget.coin.fractionDigits);
- amountController.text = ref
- .read(pAmountFormatter(widget.coin))
- .format(amount, withUnitName: false);
+ fractionDigits: widget.coin.fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ amountController.text = ref
+ .read(pAmountFormatter(widget.coin))
+ .formatEditable(amount);
+ } else {
+ amountController.clear();
+ }
}
} else {
addressController.text = qrResult.rawContent!.trim();
@@ -193,7 +199,7 @@ class _RecipientState extends ConsumerState<Recipient> {
if (amount != null) {
amountController.text = ref
.read(pAmountFormatter(widget.coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
}
addressController.text = ref.read(pRecipient(widget.index))?.address ?? "";
@@ -228,6 +234,7 @@ class _RecipientState extends ConsumerState<Recipient> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(ref.listen, controllers: [amountController]);
return RoundedContainer(
color: Colors.transparent,
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index 6ffc241..9475ca1 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -34,6 +34,7 @@ import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/amount/amount_unit.dart';
@@ -170,13 +171,21 @@ class _SendViewState extends ConsumerState<SendView> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: coin.fractionDigits);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
- ref.read(pSendAmount.notifier).state = amount;
+ fractionDigits: coin.fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ ref.read(pSendAmount.notifier).state = amount;
+ } else {
+ cryptoAmountController.clear();
+ _cachedAmountToSend = null;
+ ref.read(pSendAmount.notifier).state = null;
+ }
}
// Extract OP_RETURN data if present (for Rosen Bridge and other protocols)
@@ -392,13 +401,14 @@ class _SendViewState extends ConsumerState<SendView> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
_cryptoAmountChangeLock = false;
} else {
amount = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -415,7 +425,7 @@ class _SendViewState extends ConsumerState<SendView> {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(cryptoAmountController.text);
+ .tryParseEditable(cryptoAmountController.text);
final Amount? amount;
if (cryptoAmount != null) {
amount = cryptoAmount;
@@ -430,14 +440,17 @@ class _SendViewState extends ConsumerState<SendView> {
?.value;
if (price != null && price > Decimal.zero) {
- baseAmountController.text = (amount.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (amount.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
} else {
amount = null;
+ _cachedAmountToSend = null;
baseAmountController.text = "";
}
@@ -482,29 +495,6 @@ class _SendViewState extends ConsumerState<SendView> {
late Amount _currentFee;
- void _setCurrentFee(String fee, bool shouldSetState) {
- fee = fee.trim();
-
- if (fee.startsWith("~")) {
- fee = fee.substring(1);
- }
- if (fee.contains(" ")) {
- fee = fee.split(" ").first;
- }
-
- final value = fee.contains(",")
- ? Decimal.parse(
- fee.replaceFirst(",", "."),
- ).toAmount(fractionDigits: coin.fractionDigits)
- : Decimal.parse(fee).toAmount(fractionDigits: coin.fractionDigits);
-
- if (shouldSetState) {
- setState(() => _currentFee = value);
- } else {
- _currentFee = value;
- }
- }
-
void _setValidAddressProviders(String? address) {
if (isPaynymSend) {
ref.read(pValidSendToAddress.notifier).state = true;
@@ -538,11 +528,11 @@ class _SendViewState extends ConsumerState<SendView> {
}
}
- late Future<String> _calculateFeesFuture;
+ late Future<Amount> _calculateFeesFuture;
- Map<Amount, String> cachedFees = {};
- Map<Amount, String> cachedFiroSparkFees = {};
- Map<Amount, String> cachedFiroPublicFees = {};
+ final Map<(Amount, FeeRateType), Amount> cachedFees = {};
+ final Map<(Amount, FeeRateType), Amount> cachedFiroSparkFees = {};
+ final Map<(Amount, FeeRateType), Amount> cachedFiroPublicFees = {};
void _setOpReturnData(String? data) {
if (!mounted) {
@@ -578,7 +568,9 @@ class _SendViewState extends ConsumerState<SendView> {
);
}
- Future<String> calculateFees(Amount amount) async {
+ Future<Amount> calculateFees(Amount amount) async {
+ final feeRateType = ref.read(feeRateTypeMobileStateProvider);
+ final cacheKey = (amount, feeRateType);
final hasOpReturnData =
isFiro &&
ref.read(publicPrivateBalanceStateProvider) == BalanceType.public &&
@@ -587,18 +579,18 @@ class _SendViewState extends ConsumerState<SendView> {
if (isFiro) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case BalanceType.public:
- if (!hasOpReturnData && cachedFiroPublicFees[amount] != null) {
- return cachedFiroPublicFees[amount]!;
+ if (!hasOpReturnData && cachedFiroPublicFees[cacheKey] != null) {
+ return cachedFiroPublicFees[cacheKey]!;
}
break;
case BalanceType.private:
- if (cachedFiroSparkFees[amount] != null) {
- return cachedFiroSparkFees[amount]!;
+ if (cachedFiroSparkFees[cacheKey] != null) {
+ return cachedFiroSparkFees[cacheKey]!;
}
break;
}
- } else if (cachedFees[amount] != null) {
- return cachedFees[amount]!;
+ } else if (cachedFees[cacheKey] != null) {
+ return cachedFees[cacheKey]!;
}
final wallet = ref.read(pWallets).getWallet(walletId);
@@ -606,7 +598,7 @@ class _SendViewState extends ConsumerState<SendView> {
late final BigInt feeRate;
- switch (ref.read(feeRateTypeMobileStateProvider.state).state) {
+ switch (feeRateType) {
case FeeRateType.fast:
feeRate = feeObject.fast;
break;
@@ -623,7 +615,7 @@ class _SendViewState extends ConsumerState<SendView> {
Amount fee;
if (coin is CryptonoteCurrency) {
final int specialMoneroId;
- switch (ref.read(feeRateTypeMobileStateProvider.state).state) {
+ switch (feeRateType) {
case FeeRateType.fast:
specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityHigh();
break;
@@ -638,11 +630,8 @@ class _SendViewState extends ConsumerState<SendView> {
}
fee = await wallet.estimateFeeFor(amount, BigInt.from(specialMoneroId));
- cachedFees[amount] = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees[amount]!;
+ cachedFees[cacheKey] = fee;
+ return fee;
} else if (isFiro) {
final firoWallet = wallet as FiroWallet;
@@ -654,28 +643,20 @@ class _SendViewState extends ConsumerState<SendView> {
feeRate: feeRate,
wallet: firoWallet,
);
- final formatted = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
if (!hasOpReturnData) {
- cachedFiroPublicFees[amount] = formatted;
+ cachedFiroPublicFees[cacheKey] = fee;
}
- return formatted;
+ return fee;
case BalanceType.private:
fee = await firoWallet.estimateFeeForSpark(amount);
- cachedFiroSparkFees[amount] = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
- return cachedFiroSparkFees[amount]!;
+ cachedFiroSparkFees[cacheKey] = fee;
+ return fee;
}
} else {
fee = await wallet.estimateFeeFor(amount, feeRate);
- cachedFees[amount] = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees[amount]!;
+ cachedFees[cacheKey] = fee;
+ return fee;
}
}
@@ -1108,7 +1089,7 @@ class _SendViewState extends ConsumerState<SendView> {
memo: memo,
feeRateType: feeRateType,
satsPerVByte: satsPerVByte,
- ethEIP1559Fee: ethFee,
+ ethEIP1559Fee: _ethFee.value,
utxos:
(wallet is CoinControlInterface &&
wallet is! SalviumWallet &&
@@ -1256,14 +1237,14 @@ class _SendViewState extends ConsumerState<SendView> {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChanged();
}
bool get isPaynymSend => widget.accountLite != null;
int customFeeRate = 1;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
late final bool hasFees;
@@ -1277,24 +1258,25 @@ class _SendViewState extends ConsumerState<SendView> {
builder: (_) => TransactionFeeSelectionSheet(
walletId: walletId,
amount:
- (Decimal.tryParse(cryptoAmountController.text) ??
- ref.watch(pSendAmount)?.decimal ??
- Decimal.zero)
- .toAmount(fractionDigits: coin.fractionDigits),
+ ref.read(pSendAmount) ??
+ Amount.zeroWith(fractionDigits: coin.fractionDigits),
updateChosen: (feeRateType, fee) {
if (feeRateType.isCustom) {
return;
}
- if (fee != null) {
- _setCurrentFee(fee, true);
- }
setState(() {
if (fee != null) {
- _calculateFeesFuture = Future(() => fee);
+ _currentFee = fee;
+ _calculateFeesFuture = Future.value(fee);
+ } else {
+ _calculateFeesFuture = calculateFees(
+ ref.read(pSendAmount) ??
+ Amount.zeroWith(fractionDigits: coin.fractionDigits),
+ );
}
customFeeRate = 1;
- ethFee = null;
+ _ethFee.value = null;
});
},
),
@@ -1346,7 +1328,7 @@ class _SendViewState extends ConsumerState<SendView> {
_cryptoAmountChangeLock = true;
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChangeLock = false;
}
sendToController.text = _data.contactLabel;
@@ -1407,6 +1389,7 @@ class _SendViewState extends ConsumerState<SendView> {
void dispose() {
_cryptoAmountChangedFeeUpdateTimer?.cancel();
_baseAmountChangedFeeUpdateTimer?.cancel();
+ _ethFee.dispose();
cryptoAmountController.removeListener(onCryptoAmountChanged);
baseAmountController.removeListener(_baseAmountChanged);
@@ -1435,8 +1418,20 @@ class _SendViewState extends ConsumerState<SendView> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ final amountFormatter = ref.watch(pAmountFormatter(coin));
final balType = ref.watch(publicPrivateBalanceStateProvider);
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled =
+ ref.watch(pPreviewTxButtonEnabled(coin)) &&
+ (ref.watch(pOpReturnData) == null || balType != BalanceType.private);
+ final needsEthFee = isEth && isCustomFee;
final isMwebEnabled = ref.watch(
pWalletInfo(walletId).select((s) => s.isMwebEnabled),
@@ -1634,10 +1629,7 @@ class _SendViewState extends ConsumerState<SendView> {
onTap: () {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(
- amount,
- withUnitName: false,
- );
+ .formatEditable(amount);
},
child: Container(
color: Colors.transparent,
@@ -2617,12 +2609,18 @@ class _SendViewState extends ConsumerState<SendView> {
ConnectionState
.done &&
snapshot.hasData) {
- _setCurrentFee(
- snapshot.data!,
- false,
- );
+ _currentFee =
+ snapshot.data!;
+ final formattedFee =
+ amountFormatter.format(
+ snapshot.data!,
+ withUnitName:
+ true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
- "~${snapshot.data!}",
+ "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
@@ -2672,14 +2670,21 @@ class _SendViewState extends ConsumerState<SendView> {
.done &&
snapshot
.hasData) {
- _setCurrentFee(
- snapshot.data!,
- false,
- );
+ _currentFee =
+ snapshot.data!;
+ final formattedFee =
+ amountFormatter.format(
+ snapshot
+ .data!,
+ withUnitName:
+ true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
isCustomFee
? ""
- : "~${snapshot.data!}",
+ : "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
@@ -2735,7 +2740,9 @@ class _SendViewState extends ConsumerState<SendView> {
EthFeeForm(
locale: locale,
minGasLimit: kEthereumMinGasLimit,
- stateChanged: (fee) => ethFee = fee,
+ stateChanged: (fee) {
+ _ethFee.value = fee;
+ },
),
const Spacer(),
const SizedBox(height: 12),
@@ -2760,31 +2767,39 @@ class _SendViewState extends ConsumerState<SendView> {
),
),
),
- TextButton(
- onPressed:
- ref.watch(pPreviewTxButtonEnabled(coin)) &&
- (ref.watch(pOpReturnData) == null ||
- balType != BalanceType.private)
- ? isMwcSlatepack
- ? _createSlatepack
- : isEpicSlatepack
- ? _createEpicSlatepack
- : _previewTransaction
- : null,
- style:
- ref.watch(pPreviewTxButtonEnabled(coin)) &&
- (ref.watch(pOpReturnData) == null ||
- balType != BalanceType.private)
- ? Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryEnabledButtonStyle(context)
- : Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryDisabledButtonStyle(context),
- child: Text(
- isSlatepackMode ? "Create slate" : "Preview",
- style: STextStyles.button(context),
- ),
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ (!needsEthFee || ethFee != null);
+ return TextButton(
+ onPressed: enabled
+ ? isMwcSlatepack
+ ? _createSlatepack
+ : isEpicSlatepack
+ ? _createEpicSlatepack
+ : _previewTransaction
+ : null,
+ style: enabled
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryEnabledButtonStyle(
+ context,
+ )
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryDisabledButtonStyle(
+ context,
+ ),
+ child: Text(
+ isSlatepackMode
+ ? "Create slate"
+ : "Preview",
+ style: STextStyles.button(context),
+ ),
+ );
+ },
),
const SizedBox(height: 16),
],
diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart
index 036266c..4bfca58 100644
--- a/lib/pages/send_view/sol_token_send_view.dart
+++ b/lib/pages/send_view/sol_token_send_view.dart
@@ -25,6 +25,7 @@ import '../../route_generator.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/amount/amount_unit.dart';
@@ -62,20 +63,15 @@ Amount? parseMobileSolTokenAmount(
required String locale,
required CryptoCurrency coin,
required SolContract tokenContract,
-}) {
- if (value.contains(RegExp(r'[+\- ]'))) return null;
- return AmountUnit.normal.tryParse(
- value,
- locale: locale,
- coin: coin,
- tokenContract: tokenContract,
- );
-}
+}) => AmountUnit.normal.tryParse(
+ value,
+ locale: locale,
+ coin: coin,
+ tokenContract: tokenContract,
+);
-Amount? parseMobileSolTokenFiatAmount(String value, {required String locale}) {
- if (value.contains(RegExp(r'[+\- ]'))) return null;
- return Amount.tryParseFiatString(value, locale: locale);
-}
+Amount? parseMobileSolTokenFiatAmount(String value, {required String locale}) =>
+ Amount.tryParseFiatString(value, locale: locale);
class SolTokenSendView extends ConsumerStatefulWidget {
const SolTokenSendView({
@@ -130,8 +126,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
Timer? _cryptoAmountChangedFeeUpdateTimer;
Timer? _baseAmountChangedFeeUpdateTimer;
- late Future<String> _calculateFeesFuture;
- String cachedFees = "";
+ late Future<Amount> _calculateFeesFuture;
void _onTokenSendViewPasteAddressFieldButtonPressed() async {
final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain);
@@ -185,11 +180,22 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (paymentData.amount != null) {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet);
if (tokenWallet != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: tokenWallet.tokenDecimals);
- cryptoAmountController.text = _formatTokenAmount(amount);
- _amountToSend = amount;
+ fractionDigits: tokenWallet.tokenDecimals,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ _amountToSend = amount;
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ }
}
}
@@ -232,14 +238,13 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
}
}
- String _formatTokenAmount(Amount amount) {
+ String _formatTokenBalance(Amount amount) {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
return AmountUnit.normal.displayAmount(
amount: amount,
locale: ref.read(localeServiceChangeNotifierProvider).locale,
coin: tokenWallet.cryptoCurrency,
maxDecimalPlaces: tokenWallet.tokenDecimals,
- withUnitName: false,
tokenContract: tokenWallet.solContract,
);
}
@@ -276,10 +281,14 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
_cachedAmountToSend = _amountToSend;
_cryptoAmountChangeLock = true;
- cryptoAmountController.text = _formatTokenAmount(_amountToSend!);
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ _amountToSend!.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Amount.zero;
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -312,14 +321,17 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
?.value;
if (price != null && price > Decimal.zero) {
- baseAmountController.text = (_amountToSend!.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (_amountToSend!.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
} else {
_amountToSend = null;
+ _cachedAmountToSend = null;
baseAmountController.text = "";
}
@@ -368,11 +380,16 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
(isValidAddress && amount != null && amount > Amount.zero);
}
- Future<String> calculateFees() async {
+ Future<Amount> calculateFees() async {
+ final solana = Solana(CryptoCurrencyNetwork.main);
+ final minimumFee = Amount(
+ rawValue: BigInt.from(5000),
+ fractionDigits: solana.fractionDigits,
+ );
try {
final wallet = ref.read(pCurrentSolanaTokenWallet);
if (wallet == null) {
- return "0.000005 SOL";
+ return minimumFee;
}
final feeObject = await wallet.fees;
@@ -394,19 +411,14 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
}
final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate);
- cachedFees = ref
- .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main)))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees;
+ return fee;
} catch (e, s) {
Logging.instance.w(
"Failed to calculate Solana token fees: ",
error: e,
stackTrace: s,
);
- // Return minimum fee as fallback.
- return "0.000005 SOL";
+ return minimumFee;
}
}
@@ -610,11 +622,13 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (_data != null) {
if (_data.amount != null) {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
- cryptoAmountController.text = _formatTokenAmount(
- Amount.fromDecimal(
- _data.amount!,
- fractionDigits: tokenWallet.tokenDecimals,
- ),
+ final amount = Amount.fromDecimal(
+ _data.amount!,
+ fractionDigits: tokenWallet.tokenDecimals,
+ );
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
);
}
sendToController.text = _data.contactLabel;
@@ -654,6 +668,11 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
final tokenWallet = ref.watch(pCurrentSolanaTokenWallet);
@@ -765,16 +784,18 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
const Spacer(),
GestureDetector(
onTap: () {
+ final amount = ref
+ .read(
+ pSolanaTokenBalance((
+ walletId: widget.walletId,
+ tokenMint: tokenMint,
+ )),
+ )
+ .spendable;
cryptoAmountController.text =
- _formatTokenAmount(
- ref
- .read(
- pSolanaTokenBalance((
- walletId: widget.walletId,
- tokenMint: tokenMint,
- )),
- )
- .spendable,
+ Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: locale,
);
},
child: Container(
@@ -784,27 +805,17 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
CrossAxisAlignment.end,
children: [
Text(
- ref
- .watch(
- pAmountFormatter(
- Solana(
- CryptoCurrencyNetwork
- .main,
- ),
- ),
- )
- .format(
- ref
- .watch(
- pSolanaTokenBalance((
- walletId:
- widget.walletId,
- tokenMint:
- tokenMint,
- )),
- )
- .spendable,
- ),
+ _formatTokenBalance(
+ ref
+ .watch(
+ pSolanaTokenBalance((
+ walletId:
+ widget.walletId,
+ tokenMint: tokenMint,
+ )),
+ )
+ .spendable,
+ ),
style: STextStyles.titleBold12(
context,
).copyWith(fontSize: 10),
@@ -1279,23 +1290,18 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
walletId: walletId,
isToken: true,
amount:
- (Decimal.tryParse(
- cryptoAmountController
- .text,
- ) ??
- Decimal.zero)
- .toAmount(
- fractionDigits:
- tokenWallet
- .tokenDecimals,
- ),
+ _amountToSend ??
+ Amount.zeroWith(
+ fractionDigits: tokenWallet
+ .tokenDecimals,
+ ),
updateChosen: (_, fee) {
- if (fee != null) {
- setState(() {
- _calculateFeesFuture =
- Future(() => fee);
- });
- }
+ setState(() {
+ _calculateFeesFuture =
+ fee == null
+ ? calculateFees()
+ : Future.value(fee);
+ });
},
),
);
@@ -1325,8 +1331,21 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (snapshot.connectionState ==
ConnectionState.done &&
snapshot.hasData) {
+ final formattedFee = ref
+ .watch(
+ pAmountFormatter(
+ tokenWallet
+ .cryptoCurrency,
+ ),
+ )
+ .format(
+ snapshot.data!,
+ withUnitName: true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
- "~${snapshot.data!}",
+ "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
index 333606f..732e5e1 100644
--- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
+++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
@@ -22,7 +22,6 @@ import '../../../utilities/amount/amount.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/constants.dart';
import '../../../utilities/enums/fee_rate_type_enum.dart';
-import '../../../utilities/logger.dart';
import '../../../utilities/text_styles.dart';
import '../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart';
@@ -58,7 +57,7 @@ class TransactionFeeSelectionSheet extends ConsumerStatefulWidget {
final String walletId;
final Amount amount;
- final void Function(FeeRateType feeRateType, String? fee) updateChosen;
+ final void Function(FeeRateType feeRateType, Amount? fee) updateChosen;
final bool isToken;
@override
@@ -80,11 +79,11 @@ class _TransactionFeeSelectionSheetState
"Calculating...",
];
- void _selectFeeRate(FeeRateType feeRateType, CryptoCurrency coin) {
+ void _selectFeeRate(FeeRateType feeRateType) {
ref.read(feeRateTypeMobileStateProvider.state).state = feeRateType;
widget.updateChosen(
feeRateType,
- feeRateType.isCustom ? null : getAmount(feeRateType, coin),
+ feeRateType.isCustom ? null : getAmount(feeRateType),
);
Navigator.of(context).pop();
@@ -359,7 +358,7 @@ class _TransactionFeeSelectionSheetState
),
const SizedBox(height: 16),
GestureDetector(
- onTap: () => _selectFeeRate(FeeRateType.fast, coin),
+ onTap: () => _selectFeeRate(FeeRateType.fast),
child: Container(
color: Colors.transparent,
child: Row(
@@ -382,7 +381,7 @@ class _TransactionFeeSelectionSheetState
)
.state,
onChanged: (_) =>
- _selectFeeRate(FeeRateType.fast, coin),
+ _selectFeeRate(FeeRateType.fast),
),
),
],
@@ -471,7 +470,7 @@ class _TransactionFeeSelectionSheetState
),
const SizedBox(height: 16),
GestureDetector(
- onTap: () => _selectFeeRate(FeeRateType.average, coin),
+ onTap: () => _selectFeeRate(FeeRateType.average),
child: Container(
color: Colors.transparent,
child: Row(
@@ -492,10 +491,8 @@ class _TransactionFeeSelectionSheetState
feeRateTypeMobileStateProvider.state,
)
.state,
- onChanged: (_) => _selectFeeRate(
- FeeRateType.average,
- coin,
- ),
+ onChanged: (_) =>
+ _selectFeeRate(FeeRateType.average),
),
),
],
@@ -584,7 +581,7 @@ class _TransactionFeeSelectionSheetState
),
const SizedBox(height: 16),
GestureDetector(
- onTap: () => _selectFeeRate(FeeRateType.slow, coin),
+ onTap: () => _selectFeeRate(FeeRateType.slow),
child: Container(
color: Colors.transparent,
child: Row(
@@ -606,7 +603,7 @@ class _TransactionFeeSelectionSheetState
)
.state,
onChanged: (_) =>
- _selectFeeRate(FeeRateType.slow, coin),
+ _selectFeeRate(FeeRateType.slow),
),
),
],
@@ -696,7 +693,7 @@ class _TransactionFeeSelectionSheetState
const SizedBox(height: 24),
if (wallet is ElectrumXInterface || coin is Ethereum)
GestureDetector(
- onTap: () => _selectFeeRate(FeeRateType.custom, coin),
+ onTap: () => _selectFeeRate(FeeRateType.custom),
child: Container(
color: Colors.transparent,
child: Row(
@@ -718,10 +715,8 @@ class _TransactionFeeSelectionSheetState
.state,
)
.state,
- onChanged: (_) => _selectFeeRate(
- FeeRateType.custom,
- coin,
- ),
+ onChanged: (_) =>
+ _selectFeeRate(FeeRateType.custom),
),
),
],
@@ -762,50 +757,16 @@ class _TransactionFeeSelectionSheetState
);
}
- String? getAmount(FeeRateType feeRateType, CryptoCurrency coin) {
- try {
- switch (feeRateType) {
- case FeeRateType.fast:
- if (ref.read(feeSheetSessionCacheProvider).fast[amount] != null) {
- return ref
- .read(pAmountFormatter(coin))
- .format(
- ref.read(feeSheetSessionCacheProvider).fast[amount]!,
- indicatePrecisionLoss: false,
- withUnitName: false,
- );
- }
- return null;
-
- case FeeRateType.average:
- if (ref.read(feeSheetSessionCacheProvider).average[amount] != null) {
- return ref
- .read(pAmountFormatter(coin))
- .format(
- ref.read(feeSheetSessionCacheProvider).average[amount]!,
- indicatePrecisionLoss: false,
- withUnitName: false,
- );
- }
- return null;
-
- case FeeRateType.slow:
- if (ref.read(feeSheetSessionCacheProvider).slow[amount] != null) {
- return ref
- .read(pAmountFormatter(coin))
- .format(
- ref.read(feeSheetSessionCacheProvider).slow[amount]!,
- indicatePrecisionLoss: false,
- withUnitName: false,
- );
- }
- return null;
- case FeeRateType.custom:
- return null;
- }
- } catch (e, s) {
- Logging.instance.w("$e $s", error: e, stackTrace: s);
- return null;
+ Amount? getAmount(FeeRateType feeRateType) {
+ switch (feeRateType) {
+ case FeeRateType.fast:
+ return ref.read(feeSheetSessionCacheProvider).fast[amount];
+ case FeeRateType.average:
+ return ref.read(feeSheetSessionCacheProvider).average[amount];
+ case FeeRateType.slow:
+ return ref.read(feeSheetSessionCacheProvider).slow[amount];
+ case FeeRateType.custom:
+ return null;
}
}
}
diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart
index 9ede510..5d25e24 100644
--- a/lib/pages/send_view/token_send_view.dart
+++ b/lib/pages/send_view/token_send_view.dart
@@ -25,6 +25,7 @@ import '../../route_generator.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/amount/amount_unit.dart';
@@ -115,10 +116,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
Timer? _cryptoAmountChangedFeeUpdateTimer;
Timer? _baseAmountChangedFeeUpdateTimer;
- late Future<String> _calculateFeesFuture;
- String cachedFees = "";
+ late Future<Amount> _calculateFeesFuture;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
void _onTokenSendViewPasteAddressFieldButtonPressed() async {
final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain);
@@ -185,17 +185,21 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: tokenContract.decimals);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(
- amount,
- withUnitName: false,
- indicatePrecisionLoss: false,
- );
- _amountToSend = amount;
+ fractionDigits: tokenContract.decimals,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ _amountToSend = amount;
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ }
}
_updatePreviewButtonState(_address, _amountToSend);
@@ -273,10 +277,11 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
_cryptoAmountChangeLock = true;
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(_amountToSend!, withUnitName: false);
+ .formatEditable(_amountToSend!);
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Amount.zero;
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -293,7 +298,10 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(cryptoAmountController.text, tokenContract: tokenContract);
+ .tryParseEditable(
+ cryptoAmountController.text,
+ tokenContract: tokenContract,
+ );
if (cryptoAmount != null) {
_amountToSend = cryptoAmount;
if (_cachedAmountToSend != null &&
@@ -308,14 +316,17 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
?.value;
if (price != null && price > Decimal.zero) {
- baseAmountController.text = (_amountToSend!.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (_amountToSend!.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
} else {
_amountToSend = null;
+ _cachedAmountToSend = null;
baseAmountController.text = "";
}
@@ -368,7 +379,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
(isValidAddress && amount != null && amount > Amount.zero);
}
- Future<String> calculateFees() async {
+ Future<Amount> calculateFees() async {
final wallet = ref.read(pCurrentTokenWallet)!;
final feeObject = await wallet.fees;
@@ -389,11 +400,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
}
final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate);
- cachedFees = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees;
+ return fee;
}
Future<void> _previewTransaction() async {
@@ -499,7 +506,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
],
feeRateType: ref.read(feeRateTypeMobileStateProvider),
note: noteController.text,
- ethEIP1559Fee: ethFee,
+ ethEIP1559Fee: _ethFee.value,
),
);
@@ -604,7 +611,11 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (_data != null) {
if (_data.amount != null) {
- cryptoAmountController.text = _data.amount!.toString();
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(
+ _data.amount!.toAmount(fractionDigits: tokenContract.decimals),
+ );
}
sendToController.text = _data.contactLabel;
_address = _data.address.trim();
@@ -618,6 +629,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
void dispose() {
_cryptoAmountChangedFeeUpdateTimer?.cancel();
_baseAmountChangedFeeUpdateTimer?.cancel();
+ _ethFee.dispose();
cryptoAmountController.removeListener(onCryptoAmountChanged);
baseAmountController.removeListener(_baseAmountChanged);
@@ -642,6 +654,17 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled = ref
+ .watch(previewTokenTxButtonStateProvider.state)
+ .state;
+ final needsEthFee = isCustomFee;
Decimal? price;
if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) {
@@ -731,7 +754,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
onTap: () {
cryptoAmountController.text = ref
.watch(pAmountFormatter(coin))
- .format(
+ .formatEditable(
ref
.read(
pTokenBalance((
@@ -741,9 +764,6 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
)),
)
.spendable,
- tokenContract: tokenContract,
- withUnitName: false,
- indicatePrecisionLoss: true,
);
},
child: Container(
@@ -1197,16 +1217,11 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
walletId: walletId,
isToken: true,
amount:
- (Decimal.tryParse(
- cryptoAmountController
- .text,
- ) ??
- Decimal.zero)
- .toAmount(
- fractionDigits:
- tokenContract
- .decimals,
- ),
+ _amountToSend ??
+ Amount.zeroWith(
+ fractionDigits:
+ tokenContract.decimals,
+ ),
updateChosen: (feeRateType, fee) {
if (feeRateType.isCustom) {
return;
@@ -1215,9 +1230,12 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
setState(() {
if (fee != null) {
_calculateFeesFuture =
- Future(() => fee);
+ Future.value(fee);
+ } else {
+ _calculateFeesFuture =
+ calculateFees();
}
- ethFee = null;
+ _ethFee.value = null;
});
},
),
@@ -1248,10 +1266,20 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (snapshot.connectionState ==
ConnectionState.done &&
snapshot.hasData) {
+ final formattedFee = ref
+ .watch(
+ pAmountFormatter(coin),
+ )
+ .format(
+ snapshot.data!,
+ withUnitName: true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
isCustomFee
? ""
- : "~${snapshot.data!}",
+ : "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
@@ -1298,35 +1326,39 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
EthFeeForm(
locale: locale,
minGasLimit: kEthereumTokenMinGasLimit,
- stateChanged: (value) => ethFee = value,
+ stateChanged: (value) {
+ _ethFee.value = value;
+ },
),
const Spacer(),
const SizedBox(height: 12),
- TextButton(
- onPressed:
- ref
- .watch(
- previewTokenTxButtonStateProvider.state,
- )
- .state
- ? _previewTransaction
- : null,
- style:
- ref
- .watch(
- previewTokenTxButtonStateProvider.state,
- )
- .state
- ? Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryEnabledButtonStyle(context)
- : Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryDisabledButtonStyle(context),
- child: Text(
- "Preview",
- style: STextStyles.button(context),
- ),
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ (!needsEthFee || ethFee != null);
+ return TextButton(
+ onPressed: enabled
+ ? _previewTransaction
+ : null,
+ style: enabled
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryEnabledButtonStyle(
+ context,
+ )
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryDisabledButtonStyle(
+ context,
+ ),
+ child: Text(
+ "Preview",
+ style: STextStyles.button(context),
+ ),
+ );
+ },
),
const SizedBox(height: 16),
],
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
index 9b0bf87..259ff8e 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -38,6 +38,7 @@ import '../../../../services/spark_names_service.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
+import '../../../../utilities/amount/amount_field_relocalization.dart';
import '../../../../utilities/amount/amount_formatter.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
import '../../../../utilities/amount/amount_unit.dart';
@@ -45,6 +46,7 @@ import '../../../../utilities/assets.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
import '../../../../utilities/enums/fee_rate_type_enum.dart';
+import '../../../../utilities/integer_input.dart';
import '../../../../utilities/logger.dart';
import '../../../../utilities/prefs.dart';
import '../../../../utilities/show_loading.dart';
@@ -141,8 +143,13 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
bool get isPaynymSend => widget.accountLite != null;
+ ({bool isValid, int? value}) get _nonceInput =>
+ parseOptionalIntegerInput(nonceController.text, minimum: 0);
+
+ bool get _nonceIsValid => _nonceInput.isValid;
+
int customFeeRate = 1;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
Future<void> scanWebcam() async {
try {
@@ -424,6 +431,10 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
Future<void> previewSend() async {
+ final nonceInput = _nonceInput;
+ if (!nonceInput.isValid) return;
+ final nonce = nonceInput.value;
+
final wallet = ref.read(pWallets).getWallet(walletId);
// Handle MWC slatepack transactions directly.
@@ -724,9 +735,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
memo: memo,
feeRateType: feeRateType,
satsPerVByte: satsPerVByte,
- nonce: wallet.cryptoCurrency is Ethereum
- ? int.tryParse(nonceController.text)
- : null,
+ nonce: wallet.cryptoCurrency is Ethereum ? nonce : null,
utxos:
(wallet is CoinControlInterface &&
wallet is! SalviumWallet &&
@@ -734,7 +743,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
ref.read(pDesktopUseUTXOs).isNotEmpty)
? ref.read(pDesktopUseUTXOs)
: null,
- ethEIP1559Fee: ethFee,
+ ethEIP1559Fee: _ethFee.value,
),
);
}
@@ -857,10 +866,16 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
nonceController.text = "";
_address = "";
_addressToggleFlag = false;
+ _syncFeeAmount(null);
_setOpReturnData(null);
setState(() {});
}
+ void _syncFeeAmount(Amount? amount) {
+ ref.read(sendAmountProvider.notifier).state =
+ amount ?? Amount.zeroWith(fractionDigits: coin.fractionDigits);
+ }
+
void _setOpReturnData(String? data) {
if (!mounted) {
return;
@@ -872,7 +887,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(cryptoAmountController.text);
+ .tryParseEditable(cryptoAmountController.text);
final Amount? amount;
if (cryptoAmount != null) {
amount = cryptoAmount;
@@ -888,11 +903,13 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
?.value;
if (price != null && price > Decimal.zero) {
- final String fiatAmountString = (amount.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (amount.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ final fiatAmountString = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
baseAmountController.text = fiatAmountString;
}
@@ -999,13 +1016,23 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
// autofill amount field
if (paymentData.amount != null) {
- final amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: coin.fractionDigits);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
- ref.read(pSendAmount.notifier).state = amount;
+ fractionDigits: coin.fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ ref.read(pSendAmount.notifier).state = amount;
+ _syncFeeAmount(amount);
+ } else {
+ cryptoAmountController.clear();
+ _cachedAmountToSend = null;
+ ref.read(pSendAmount.notifier).state = null;
+ _syncFeeAmount(null);
+ }
}
// Trigger validation after pasting.
@@ -1149,13 +1176,14 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
_cryptoAmountChangeLock = false;
} else {
amount = Decimal.zero.toAmount(fractionDigits: coin.fractionDigits);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -1203,7 +1231,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
+ _syncFeeAmount(amount);
}
void _showDesktopCoinControl() async {
@@ -1251,9 +1280,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
_cryptoAmountChangeLock = true;
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(
+ .formatEditable(
_data.amount!.toAmount(fractionDigits: coin.fractionDigits),
- withUnitName: false,
);
_cryptoAmountChangeLock = false;
}
@@ -1264,6 +1292,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (hasAmount) {
_cryptoAmountChanged();
+ _syncFeeAmount(ref.read(pSendAmount));
}
_setValidAddressProviders(_address);
});
@@ -1304,6 +1333,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
@override
void dispose() {
cryptoAmountController.removeListener(onCryptoAmountChanged);
+ _ethFee.dispose();
sendToController.dispose();
cryptoAmountController.dispose();
@@ -1325,6 +1355,16 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin));
+ final needsEthFee = coin is Ethereum && isCustomFee;
// add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address)
if (coin is Epiccash) {
@@ -2134,9 +2174,11 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
onCustomFeeSliderChanged: (value) => customFeeRate = value,
onCustomFeeOptionChanged: () {
customFeeRate = 1;
- ethFee = null;
+ _ethFee.value = null;
+ },
+ onCustomEip1559FeeOptionChanged: (value) {
+ _ethFee.value = value;
},
- onCustomEip1559FeeOptionChanged: (value) => ethFee = value,
),
if (coin is Ethereum) const SizedBox(height: 20),
if (coin is Ethereum)
@@ -2163,8 +2205,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
readOnly: false,
autocorrect: false,
enableSuggestions: false,
- keyboardType: const TextInputType.numberWithOptions(),
+ keyboardType: TextInputType.number,
focusNode: _nonceFocusNode,
+ onChanged: (_) => setState(() {}),
style: STextStyles.desktopTextExtraSmall(context).copyWith(
color: Theme.of(
context,
@@ -2187,16 +2230,31 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
),
),
+ if (coin is Ethereum && !_nonceIsValid)
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 12),
+ child: Text(
+ "Enter a non-negative whole number",
+ style: STextStyles.errorSmall(context),
+ ),
+ ),
const SizedBox(height: 36),
- PrimaryButton(
- buttonHeight: ButtonHeight.l,
- label: ref.watch(pIsSlatepack(widget.walletId))
- ? "Create slatepack"
- : "Preview send",
- enabled: ref.watch(pPreviewTxButtonEnabled(coin)),
- onPressed: ref.watch(pPreviewTxButtonEnabled(coin))
- ? previewSend
- : null,
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ _nonceIsValid &&
+ (!needsEthFee || ethFee != null);
+ return PrimaryButton(
+ buttonHeight: ButtonHeight.l,
+ label: ref.watch(pIsSlatepack(widget.walletId))
+ ? "Create slatepack"
+ : "Preview send",
+ enabled: enabled,
+ onPressed: enabled ? previewSend : null,
+ );
+ },
),
],
);
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
index b416d39..67c4fff 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
@@ -26,6 +26,7 @@ import '../../../../providers/ui/preview_tx_button_state_provider.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
+import '../../../../utilities/amount/amount_field_relocalization.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
import '../../../../utilities/amount/amount_unit.dart';
import '../../../../utilities/clipboard_interface.dart';
@@ -56,20 +57,17 @@ Amount? parseDesktopSolTokenAmount(
required String locale,
required CryptoCurrency coin,
required SolContract tokenContract,
-}) {
- if (value.contains(RegExp(r'[+\- ]'))) return null;
- return AmountUnit.normal.tryParse(
- value,
- locale: locale,
- coin: coin,
- tokenContract: tokenContract,
- );
-}
-
-Amount? parseDesktopSolTokenFiatAmount(String value, {required String locale}) {
- if (value.contains(RegExp(r'[+\- ]'))) return null;
- return Amount.tryParseFiatString(value, locale: locale);
-}
+}) => AmountUnit.normal.tryParse(
+ value,
+ locale: locale,
+ coin: coin,
+ tokenContract: tokenContract,
+);
+
+Amount? parseDesktopSolTokenFiatAmount(
+ String value, {
+ required String locale,
+}) => Amount.tryParseFiatString(value, locale: locale);
class DesktopSolTokenSend extends ConsumerStatefulWidget {
const DesktopSolTokenSend({
@@ -391,67 +389,47 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
}
}
- String _formatTokenAmount(Amount amount) {
- final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
- return AmountUnit.normal.displayAmount(
- amount: amount,
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- coin: coin,
- maxDecimalPlaces: tokenWallet.tokenDecimals,
- withUnitName: false,
- tokenContract: tokenWallet.solContract,
- );
- }
-
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
- // Get the token's decimal places for proper amount parsing
- final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
+ // Get the token's decimal places for proper amount parsing. May still
+ // be null while the token wallet is loading (e.g. a locale change
+ // fires the relocalization listener before init completes).
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet);
+ if (tokenWallet == null) return;
if (cryptoAmountController.text.isNotEmpty &&
cryptoAmountController.text != "." &&
cryptoAmountController.text != ",") {
- try {
- // Parse the amount using the token's decimal places, not the coin's
- final parsedAmount = parseDesktopSolTokenAmount(
- cryptoAmountController.text,
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- coin: coin,
- tokenContract: tokenWallet.solContract,
- );
- if (parsedAmount == null) {
- throw const FormatException();
+ final parsedAmount = parseDesktopSolTokenAmount(
+ cryptoAmountController.text,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ coin: coin,
+ tokenContract: tokenWallet.solContract,
+ );
+ if (parsedAmount != null) {
+ _amountToSend = parsedAmount;
+ if (_cachedAmountToSend != null &&
+ _cachedAmountToSend == _amountToSend) {
+ return;
}
- // Only proceed if the parsed amount is valid
- if (parsedAmount.raw > BigInt.zero) {
- _amountToSend = parsedAmount;
- if (_cachedAmountToSend != null &&
- _cachedAmountToSend == _amountToSend) {
- return;
- }
- _cachedAmountToSend = _amountToSend;
-
- final price = ref
- .read(priceAnd24hChangeNotifierProvider)
- .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint)
- ?.value;
-
- if (price != null && price > Decimal.zero) {
- final String fiatAmountString =
- Amount.fromDecimal(
- _amountToSend!.decimal * price,
- fractionDigits: 2,
- ).fiatString(
- locale: ref
- .read(localeServiceChangeNotifierProvider)
- .locale,
- );
-
- baseAmountController.text = fiatAmountString;
- }
+ _cachedAmountToSend = _amountToSend;
+
+ final price = ref
+ .read(priceAnd24hChangeNotifierProvider)
+ .getTokenPrice(tokenWallet.tokenMint)
+ ?.value;
+
+ if (price != null && price > Decimal.zero) {
+ final fiatAmount = Amount.fromDecimal(
+ _amountToSend!.decimal * price,
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
- } catch (e) {
- // Probably an invalid decimal input.
+ } else {
_amountToSend = null;
_cachedAmountToSend = null;
baseAmountController.text = "";
@@ -530,12 +508,22 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(paymentData.amount!).toAmount(
+ final amount = Amount.tryParseCanonicalAmount(
+ paymentData.amount!,
fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals,
+ truncateOverprecision: true,
);
- cryptoAmountController.text = _formatTokenAmount(amount);
-
- _amountToSend = amount;
+ if (amount != null) {
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ _amountToSend = amount;
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ }
}
_updatePreviewButtonState(_address, _amountToSend);
@@ -613,10 +601,14 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
_cachedAmountToSend = _amountToSend;
_cryptoAmountChangeLock = true;
- cryptoAmountController.text = _formatTokenAmount(_amountToSend!);
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ _amountToSend!.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -634,7 +626,10 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
)),
);
- cryptoAmountController.text = _formatTokenAmount(balance.spendable);
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ balance.spendable.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
@override
@@ -663,11 +658,13 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
if (_data != null) {
if (_data!.amount != null) {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
- cryptoAmountController.text = _formatTokenAmount(
- Amount.fromDecimal(
- _data!.amount!,
- fractionDigits: tokenWallet.tokenDecimals,
- ),
+ final amount = Amount.fromDecimal(
+ _data!.amount!,
+ fractionDigits: tokenWallet.tokenDecimals,
+ );
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
);
}
sendToController.text = _data!.contactLabel;
@@ -699,6 +696,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+
final tokenWallet = ref.watch(pCurrentSolanaTokenWallet);
// If wallet is not initialized, show a placeholder.
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
index 0813d1c..4fe3f5b 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
@@ -27,11 +27,14 @@ import '../../../../providers/wallet/desktop_fee_providers.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
+import '../../../../utilities/amount/amount_field_relocalization.dart';
import '../../../../utilities/amount/amount_formatter.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
import '../../../../utilities/amount/amount_unit.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
+import '../../../../utilities/enums/fee_rate_type_enum.dart';
+import '../../../../utilities/integer_input.dart';
import '../../../../utilities/logger.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
@@ -55,10 +58,8 @@ import '../../../desktop_home_view.dart';
import 'address_book_address_chooser/address_book_address_chooser.dart';
import 'desktop_send_fee_form.dart';
-Amount? parseDesktopTokenFiatAmount(String value, {required String locale}) {
- if (value.contains(RegExp(r'[+\- ]'))) return null;
- return Amount.tryParseFiatString(value, locale: locale);
-}
+Amount? parseDesktopTokenFiatAmount(String value, {required String locale}) =>
+ Amount.tryParseFiatString(value, locale: locale);
class DesktopTokenSend extends ConsumerStatefulWidget {
const DesktopTokenSend({
@@ -107,9 +108,18 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
bool _cryptoAmountChangeLock = false;
late VoidCallback onCryptoAmountChanged;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
+
+ ({bool isValid, int? value}) get _nonceInput =>
+ parseOptionalIntegerInput(nonceController.text, minimum: 0);
+
+ bool get _nonceIsValid => _nonceInput.isValid;
Future<void> previewSend() async {
+ final nonceInput = _nonceInput;
+ if (!nonceInput.isValid) return;
+ final nonce = nonceInput.value;
+
final tokenWallet = ref.read(pCurrentTokenWallet)!;
final Amount amount = _amountToSend!;
@@ -247,8 +257,8 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
],
feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- nonce: int.tryParse(nonceController.text),
- ethEIP1559Fee: ethFee,
+ nonce: nonce,
+ ethEIP1559Fee: _ethFee.value,
),
);
@@ -353,16 +363,23 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
nonceController.text = "";
_address = "";
_addressToggleFlag = false;
+ _syncFeeAmount(null);
if (mounted) {
setState(() {});
}
}
+ void _syncFeeAmount(Amount? amount) {
+ final tokenDecimals = ref.read(pCurrentTokenWallet)!.tokenContract.decimals;
+ ref.read(sendAmountProvider.notifier).state =
+ amount ?? Amount.zeroWith(fractionDigits: tokenDecimals);
+ }
+
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(
+ .tryParseEditable(
cryptoAmountController.text,
tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract,
);
@@ -381,13 +398,14 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
?.value;
if (price != null && price > Decimal.zero) {
- final String fiatAmountString =
- Amount.fromDecimal(
- _amountToSend!.decimal * price,
- fractionDigits: 2,
- ).fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = Amount.fromDecimal(
+ _amountToSend!.decimal * price,
+ fractionDigits: 2,
+ );
+ final fiatAmountString = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
baseAmountController.text = fiatAmountString;
}
@@ -402,7 +420,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
}
String? _updateInvalidAddressText(String address) {
- if (_data != null && _data!.contactLabel == address) {
+ if (_data != null && _data.contactLabel == address) {
return null;
}
if (address.isNotEmpty &&
@@ -465,17 +483,26 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(paymentData.amount!).toAmount(
+ final amount = Amount.tryParseCanonicalAmount(
+ paymentData.amount!,
fractionDigits: ref
.read(pCurrentTokenWallet)!
.tokenContract
.decimals,
+ truncateOverprecision: true,
);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
-
- _amountToSend = amount;
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ _amountToSend = amount;
+ _syncFeeAmount(amount);
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ _syncFeeAmount(null);
+ }
}
_updatePreviewButtonState(_address, _amountToSend);
@@ -555,17 +582,14 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(
- _amountToSend!,
- withUnitName: false,
- tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract,
- );
+ .formatEditable(_amountToSend!);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -575,19 +599,17 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
}
Future<void> sendAllTapped() async {
+ final tokenWallet = ref.read(pCurrentTokenWallet)!;
+ final balance = ref.read(
+ pTokenBalance((
+ walletId: walletId,
+ contractAddress: tokenWallet.tokenContract.address,
+ )),
+ );
cryptoAmountController.text = ref
- .read(
- pTokenBalance((
- walletId: walletId,
- contractAddress: ref
- .read(pCurrentTokenWallet)!
- .tokenContract
- .address,
- )),
- )
- .spendable
- .decimal
- .toStringAsFixed(ref.read(pCurrentTokenWallet)!.tokenContract.decimals);
+ .read(pAmountFormatter(coin))
+ .formatEditable(balance.spendable);
+ _syncFeeAmount(balance.spendable);
}
@override
@@ -613,11 +635,17 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
cryptoAmountController.addListener(onCryptoAmountChanged);
if (_data != null) {
- if (_data!.amount != null) {
- cryptoAmountController.text = _data!.amount!.toString();
+ if (_data.amount != null) {
+ final amount = _data.amount!.toAmount(
+ fractionDigits: ref.read(pCurrentTokenWallet)!.tokenContract.decimals,
+ );
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ _syncFeeAmount(amount);
}
- sendToController.text = _data!.contactLabel;
- _address = _data!.address;
+ sendToController.text = _data.contactLabel;
+ _address = _data.address;
_addressToggleFlag = true;
}
@@ -651,6 +679,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
@override
void dispose() {
cryptoAmountController.removeListener(onCryptoAmountChanged);
+ _ethFee.dispose();
sendToController.dispose();
cryptoAmountController.dispose();
@@ -670,6 +699,18 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
debugPrint("BUILD: $runtimeType");
final tokenContract = ref.watch(pCurrentTokenWallet)!.tokenContract;
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled = ref
+ .watch(previewTokenTxButtonStateProvider.state)
+ .state;
+ final needsEthFee = isCustomFee;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1041,9 +1082,11 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
isToken: true,
onCustomFeeSliderChanged: (value) => {},
onCustomFeeOptionChanged: () {
- ethFee = null;
+ _ethFee.value = null;
+ },
+ onCustomEip1559FeeOptionChanged: (value) {
+ _ethFee.value = value;
},
- onCustomEip1559FeeOptionChanged: (value) => ethFee = value,
),
const SizedBox(height: 20),
Text(
@@ -1068,8 +1111,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
readOnly: false,
autocorrect: false,
enableSuggestions: false,
- keyboardType: const TextInputType.numberWithOptions(),
+ keyboardType: TextInputType.number,
focusNode: _nonceFocusNode,
+ onChanged: (_) => setState(() {}),
style: STextStyles.desktopTextExtraSmall(context).copyWith(
color: Theme.of(
context,
@@ -1092,14 +1136,29 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
),
),
+ if (!_nonceIsValid)
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 12),
+ child: Text(
+ "Enter a non-negative whole number",
+ style: STextStyles.errorSmall(context),
+ ),
+ ),
const SizedBox(height: 36),
- PrimaryButton(
- buttonHeight: ButtonHeight.l,
- label: "Preview send",
- enabled: ref.watch(previewTokenTxButtonStateProvider.state).state,
- onPressed: ref.watch(previewTokenTxButtonStateProvider.state).state
- ? previewSend
- : null,
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ _nonceIsValid &&
+ (!needsEthFee || ethFee != null);
+ return PrimaryButton(
+ buttonHeight: ButtonHeight.l,
+ label: "Preview send",
+ enabled: enabled,
+ onPressed: enabled ? previewSend : null,
+ );
+ },
),
],
);
Why this scored 47/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.