diff --git a/lib/app/features/tokenized_communities/views/trade_community_token_dialog.dart b/lib/app/features/tokenized_communities/views/trade_community_token_dialog.dart index 8af983a668..fb8da37c4f 100644 --- a/lib/app/features/tokenized_communities/views/trade_community_token_dialog.dart +++ b/lib/app/features/tokenized_communities/views/trade_community_token_dialog.dart @@ -80,6 +80,8 @@ class TradeCommunityTokenDialog extends HookConsumerWidget { return const SheetContent(body: SizedBox.shrink()); } + final buttonError = useState(null); + final params = ( externalAddress: resolvedExternalAddress, externalAddressType: resolvedExternalAddressType, @@ -165,6 +167,10 @@ class TradeCommunityTokenDialog extends HookConsumerWidget { ), if (communityGroup != null) _TokenCards( + isError: buttonError.value != null, + onValidationError: (error) { + buttonError.value = error; + }, state: state, controller: controller, communityTokenGroup: communityGroup, @@ -185,6 +191,7 @@ class TradeCommunityTokenDialog extends HookConsumerWidget { ), SizedBox(height: 16.0.s), ContinueButton( + error: buttonError.value, isEnabled: state.mode == CommunityTokenTradeMode.buy ? _isBuyContinueButtonEnabled(state) : _isSellContinueButtonEnabled(state), @@ -397,6 +404,8 @@ class _TokenCards extends HookConsumerWidget { required this.communityAvatarWidget, required this.isPaymentTokenSelectable, required this.onTokenTap, + required this.onValidationError, + required this.isError, }); final TradeCommunityTokenState state; @@ -405,6 +414,8 @@ class _TokenCards extends HookConsumerWidget { final Widget? communityAvatarWidget; final bool isPaymentTokenSelectable; final VoidCallback onTokenTap; + final ValueChanged? onValidationError; + final bool isError; @override Widget build(BuildContext context, WidgetRef ref) { @@ -438,6 +449,8 @@ class _TokenCards extends HookConsumerWidget { showSelectButton: isPaymentTokenSelectable, onPercentageChanged: controller.setAmountByPercentage, skipAmountFormatting: true, + onValidationError: onValidationError, + isError: isError, ), SizedBox(height: 10.0.s), TokenCard( @@ -463,7 +476,9 @@ class _TokenCards extends HookConsumerWidget { showSelectButton: false, onPercentageChanged: controller.setAmountByPercentage, skipAmountFormatting: true, + onValidationError: onValidationError, onTap: () {}, + isError: isError, ), SizedBox(height: 10.0.s), TokenCard( diff --git a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/continue_button.dart b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/continue_button.dart index 42a6c6c847..18c066e59b 100644 --- a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/continue_button.dart +++ b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/continue_button.dart @@ -9,33 +9,38 @@ class ContinueButton extends StatelessWidget { const ContinueButton({ required this.isEnabled, required this.onPressed, + this.error, super.key, }); final bool isEnabled; final VoidCallback onPressed; + final String? error; @override Widget build(BuildContext context) { final colors = context.theme.appColors; final textStyles = context.theme.appTextThemes; + final enabled = error == null && isEnabled; return Container( margin: EdgeInsets.symmetric(horizontal: 16.0.s), child: Button( - onPressed: onPressed, + onPressed: enabled ? onPressed : null, label: Text( - context.i18n.wallet_swap_coins_continue, + error ?? context.i18n.wallet_swap_coins_continue, style: textStyles.body.copyWith( color: colors.secondaryBackground, ), ), - backgroundColor: isEnabled ? colors.primaryAccent : colors.sheetLine.withValues(alpha: 0.5), + backgroundColor: enabled ? colors.primaryAccent : colors.sheetLine.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(16.0.s), - trailingIcon: Assets.svg.iconButtonNext.icon( - color: colors.secondaryBackground, - size: 24.0.s, - ), + trailingIcon: error == null + ? Assets.svg.iconButtonNext.icon( + color: colors.secondaryBackground, + size: 24.0.s, + ) + : null, style: ElevatedButton.styleFrom( minimumSize: Size(double.infinity, 56.0.s), padding: EdgeInsets.symmetric( diff --git a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/token_card.dart b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/token_card.dart index d605f3426d..bb250a7760 100644 --- a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/token_card.dart +++ b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/components/token_card.dart @@ -9,10 +9,10 @@ import 'package:ion/app/extensions/extensions.dart'; import 'package:ion/app/features/wallets/model/coin_in_wallet_data.f.dart'; import 'package:ion/app/features/wallets/model/coins_group.f.dart'; import 'package:ion/app/features/wallets/model/network_data.f.dart'; -import 'package:ion/app/features/wallets/utils/crypto_amount_converter.dart'; import 'package:ion/app/features/wallets/views/components/coin_icon_with_network.dart'; import 'package:ion/app/features/wallets/views/pages/coins_flow/swap_coins/components/sum_percentage_action.dart'; import 'package:ion/app/features/wallets/views/pages/coins_flow/swap_coins/enums/coin_swap_type.dart'; +import 'package:ion/app/features/wallets/views/pages/coins_flow/swap_coins/hooks/use_validate_amount.dart'; import 'package:ion/app/features/wallets/views/pages/coins_flow/swap_coins/providers/swap_coins_controller_provider.r.dart'; import 'package:ion/app/features/wallets/views/pages/coins_flow/swap_coins/utils/swap_constants.dart'; import 'package:ion/app/features/wallets/views/utils/amount_parser.dart'; @@ -25,6 +25,7 @@ class TokenCard extends HookConsumerWidget { const TokenCard({ required this.type, required this.onTap, + this.onValidationError, this.coinsGroup, this.network, this.controller, @@ -34,9 +35,9 @@ class TokenCard extends HookConsumerWidget { this.showSelectButton = true, this.showArrow = true, this.skipValidation = false, - this.isInsufficientFundsError = false, this.enabled = true, this.skipAmountFormatting = false, + this.isError = false, super.key, }); @@ -51,9 +52,10 @@ class TokenCard extends HookConsumerWidget { final bool showSelectButton; final bool showArrow; final bool skipValidation; - final bool isInsufficientFundsError; + final bool isError; final bool enabled; final bool skipAmountFormatting; + final ValueChanged? onValidationError; void _onPercentageChanged(int percentage, WidgetRef ref) { final coin = coinsGroup?.coins.firstWhereOrNull( @@ -88,6 +90,16 @@ class TokenCard extends HookConsumerWidget { [controller?.text, coinForNetwork?.coin.priceUSD], ); + useValidateAmount( + controller: controller, + focusNode: focusNode, + coinForNetwork: coinForNetwork, + coinsGroup: coinsGroup, + onValidationError: onValidationError, + context: context, + skipValidation: skipValidation, + ); + useEffect( () { void formatAmount() { @@ -366,9 +378,8 @@ class TokenCard extends HookConsumerWidget { focusNode: focusNode, readOnly: isReadOnly ?? coinsGroup == null, keyboardType: const TextInputType.numberWithOptions(decimal: true), - autovalidateMode: AutovalidateMode.always, style: textStyles.headline2.copyWith( - color: isInsufficientFundsError ? colors.attentionRed : colors.primaryText, + color: isError ? colors.attentionRed : colors.primaryText, ), cursorHeight: 24.0.s, cursorWidth: 3.0.s, @@ -391,34 +402,6 @@ class TokenCard extends HookConsumerWidget { color: colors.attentionRed, ), ), - validator: (value) { - if (skipValidation) return null; - - final trimmedValue = value?.trim() ?? ''; - if (trimmedValue.isEmpty) return null; - - final parsed = parseAmount(trimmedValue); - if (parsed == null) return ''; - - final maxValue = coinForNetwork?.amount; - if (maxValue != null && (parsed > maxValue || parsed < 0)) { - return context.i18n.wallet_coin_amount_insufficient_funds; - } else if (parsed < 0) { - return context.i18n.wallet_coin_amount_must_be_positive; - } - - // If we know decimals for the selected network, enforce min amount check - - final decimals = coinForNetwork?.coin.decimals; - if (decimals != null) { - final amount = toBlockchainUnits(parsed, decimals); - if (amount == BigInt.zero && parsed > 0) { - return context.i18n.wallet_coin_amount_too_low_for_sending; - } - } - - return null; - }, ), ), ), @@ -435,7 +418,7 @@ class TokenCard extends HookConsumerWidget { child: Row( children: [ Assets.svg.iconWallet.icon( - color: isInsufficientFundsError ? colors.attentionRed : colors.tertiaryText, + color: isError ? colors.attentionRed : colors.tertiaryText, size: 12.0.s, ), SizedBox( @@ -453,9 +436,7 @@ class TokenCard extends HookConsumerWidget { ? '${maxValue.formatWithDecimals(decimals)} ${coinsGroup!.abbreviation}' : '0.00', style: textStyles.caption2.copyWith( - color: isInsufficientFundsError - ? colors.attentionRed - : colors.tertiaryText, + color: isError ? colors.attentionRed : colors.tertiaryText, ), overflow: TextOverflow.ellipsis, ); diff --git a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/hooks/use_validate_amount.dart b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/hooks/use_validate_amount.dart new file mode 100644 index 0000000000..8322287199 --- /dev/null +++ b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/hooks/use_validate_amount.dart @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/wallets/model/coin_in_wallet_data.f.dart'; +import 'package:ion/app/features/wallets/model/coins_group.f.dart'; +import 'package:ion/app/features/wallets/utils/crypto_amount_converter.dart'; +import 'package:ion/app/features/wallets/views/utils/amount_parser.dart'; + +void useValidateAmount({ + required TextEditingController? controller, + required FocusNode focusNode, + required CoinInWalletData? coinForNetwork, + required BuildContext context, + required ValueChanged? onValidationError, + required CoinsGroup? coinsGroup, + required bool skipValidation, +}) { + useEffect( + () { + void validateAndNotify() { + if (onValidationError == null || skipValidation) return; + + final error = _validateAmount( + controller?.text, + context, + coinForNetwork, + coinsGroup, + ); + onValidationError(error); + } + + void onTextChanged() { + validateAndNotify(); + } + + void onFocusChanged() { + validateAndNotify(); + } + + controller?.addListener(onTextChanged); + focusNode.addListener(onFocusChanged); + + // Validate initially + WidgetsBinding.instance.addPostFrameCallback((_) { + validateAndNotify(); + }); + + return () { + controller?.removeListener(onTextChanged); + focusNode.removeListener(onFocusChanged); + }; + }, + [ + controller, + focusNode, + coinForNetwork, + onValidationError, + coinsGroup, + ], + ); +} + +String? _validateAmount( + String? value, + BuildContext context, + CoinInWalletData? coinForNetwork, + CoinsGroup? coinsGroup, +) { + final trimmedValue = value?.trim() ?? ''; + if (trimmedValue.isEmpty) return null; + + final parsed = parseAmount(trimmedValue); + if (parsed == null) return ''; + + final maxValue = coinForNetwork?.amount; + if (maxValue != null && (parsed > maxValue || parsed < 0)) { + final abbreviation = coinsGroup?.abbreviation ?? ''; + return '${context.i18n.wallet_coin_amount_insufficient} $abbreviation'; + } else if (parsed < 0) { + return context.i18n.wallet_coin_amount_must_be_positive; + } + + // If we know decimals for the selected network, enforce min amount check + final decimals = coinForNetwork?.coin.decimals; + if (decimals != null) { + final amount = toBlockchainUnits(parsed, decimals); + if (amount == BigInt.zero && parsed > 0) { + return context.i18n.wallet_coin_amount_too_low_for_sending; + } + } + + return null; +} diff --git a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/swap_coins_modal_page.dart b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/swap_coins_modal_page.dart index 4f22cd280c..2d7e4fd533 100644 --- a/lib/app/features/wallets/views/pages/coins_flow/swap_coins/swap_coins_modal_page.dart +++ b/lib/app/features/wallets/views/pages/coins_flow/swap_coins/swap_coins_modal_page.dart @@ -49,30 +49,11 @@ class SwapCoinsModalPage extends HookConsumerWidget { final amountController = useTextEditingController(); final quoteController = useTextEditingController(); - final isInsufficientFundsErrorState = useState(false); - useEffect( - () { - var isCancelled = false; - - () async { - final result = - await ref.read(swapCoinsControllerProvider.notifier).isInsufficientFundsError(); - - if (!isCancelled) { - isInsufficientFundsErrorState.value = result; - } - }(); - - return () { - isCancelled = true; - }; - }, - [amount, sellCoins, sellNetwork], - ); - final sellCoinDecimals = _getCoinDecimals(sellCoins, sellNetwork); final buyCoinDecimals = _getCoinDecimals(buyCoins, buyNetwork); + final buttonError = useState(null); + useAmountListener( amountController, controller, @@ -126,8 +107,7 @@ class SwapCoinsModalPage extends HookConsumerWidget { children: [ TokenCard( skipAmountFormatting: true, - isInsufficientFundsError: isInsufficientFundsErrorState.value, - skipValidation: true, + isError: buttonError.value != null, controller: amountController, type: CoinSwapType.sell, coinsGroup: sellNetwork != null ? sellCoins : null, @@ -137,6 +117,9 @@ class SwapCoinsModalPage extends HookConsumerWidget { coinType: CoinSwapType.sell, ).push(context); }, + onValidationError: (error) { + buttonError.value = error; + }, ), SizedBox( height: 10.0.s, @@ -180,6 +163,7 @@ class SwapCoinsModalPage extends HookConsumerWidget { height: 32.0.s, ), ContinueButton( + error: buttonError.value, isEnabled: isContinueButtonEnabled, onPressed: () async { if (isContinueButtonEnabled) { diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0a2632f463..7dd4778cf3 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1075,6 +1075,7 @@ "wallet_coin_address": "عنوان {coin}", "wallet_coin_amount": "كمية {coin}", "wallet_coin_amount_insufficient_funds": "رصيد غير كافٍ", + "wallet_coin_amount_insufficient": "غير كافٍ", "wallet_coin_amount_must_be_positive": "يجب أن يكون المبلغ موجبًا", "wallet_coin_amount_too_low_for_sending": "المبلغ المُدخل منخفض جدًا للإرسال", "wallet_coins_search_hint": "البحث باسم الرمز المميز", diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 3503613724..16db1b90a1 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Адрес {coin}", "wallet_coin_amount": "Сума {coin}", "wallet_coin_amount_insufficient_funds": "Недостатъчни средства", + "wallet_coin_amount_insufficient": "Недостатъчни", "wallet_coin_amount_must_be_positive": "Сумата трябва да е положителна", "wallet_coin_amount_too_low_for_sending": "Въведената сума е твърде ниска за изпращане", "wallet_coins_search_hint": "Търсете по име на токен", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fdae60d6a1..4a5dc3cc9d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "{coin}-Adresse", "wallet_coin_amount": "{coin}-Betrag", "wallet_coin_amount_insufficient_funds": "Unzureichendes Guthaben", + "wallet_coin_amount_insufficient": "Unzureichend", "wallet_coin_amount_must_be_positive": "Betrag muss positiv sein", "wallet_coin_amount_too_low_for_sending": "Eingegebener Betrag ist zu niedrig zum Senden", "wallet_coins_search_hint": "Nach Token-Namen suchen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f23ccfb21f..19e5b08bf1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "{coin} address", "wallet_coin_amount": "{coin} amount", "wallet_coin_amount_insufficient_funds": "Insufficient funds", + "wallet_coin_amount_insufficient": "Insufficient", "wallet_coin_amount_must_be_positive": "Amount must be positive", "wallet_coin_amount_too_low_for_sending": "Entered amount is too low for sending", "wallet_coins_search_hint": "Search by token name", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5beee78851..5e11b86984 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Dirección {coin}", "wallet_coin_amount": "Cantidad {coin}", "wallet_coin_amount_insufficient_funds": "Fondos insuficientes", + "wallet_coin_amount_insufficient": "Insuficiente", "wallet_coin_amount_must_be_positive": "El monto debe ser positivo", "wallet_coin_amount_too_low_for_sending": "El monto ingresado es demasiado bajo para enviar", "wallet_coins_search_hint": "Buscar por nombre de token", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 733db9a72e..02b458b328 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Adresse {coin}", "wallet_coin_amount": "Montant {coin}", "wallet_coin_amount_insufficient_funds": "Fonds insuffisants", + "wallet_coin_amount_insufficient": "Insuffisant", "wallet_coin_amount_must_be_positive": "Le montant doit être positif", "wallet_coin_amount_too_low_for_sending": "Le montant saisi est trop faible pour l'envoi", "wallet_coins_search_hint": "Rechercher par nom de jeton", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 38528b9ee8..83fd298e5b 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Indirizzo {coin}", "wallet_coin_amount": "Importo {coin}", "wallet_coin_amount_insufficient_funds": "Fondi insufficienti", + "wallet_coin_amount_insufficient": "Insufficienti", "wallet_coin_amount_must_be_positive": "L'importo deve essere positivo", "wallet_coin_amount_too_low_for_sending": "L'importo inserito è troppo basso per l'invio", "wallet_coins_search_hint": "Cerca per nome del token", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a61df767a7..84ecab6c37 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Adres {coin}", "wallet_coin_amount": "Kwota {coin}", "wallet_coin_amount_insufficient_funds": "Niewystarczające środki", + "wallet_coin_amount_insufficient": "Niewystarczające", "wallet_coin_amount_must_be_positive": "Kwota musi być dodatnia", "wallet_coin_amount_too_low_for_sending": "Wprowadzona kwota jest zbyt niska do wysłania", "wallet_coins_search_hint": "Szukaj według nazwy tokena", diff --git a/lib/l10n/app_ro.arb b/lib/l10n/app_ro.arb index 00eaf66037..d06eb8a633 100644 --- a/lib/l10n/app_ro.arb +++ b/lib/l10n/app_ro.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Adresă {coin}", "wallet_coin_amount": "Sumă {coin}", "wallet_coin_amount_insufficient_funds": "Fonduri insuficiente", + "wallet_coin_amount_insufficient": "Insuficient", "wallet_coin_amount_must_be_positive": "Suma trebuie să fie pozitivă", "wallet_coin_amount_too_low_for_sending": "Suma introdusă este prea mică pentru trimitere", "wallet_coins_search_hint": "Caută după numele tokenului", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e3ab61dfd8..a5792ece59 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "Адрес {coin}", "wallet_coin_amount": "Сумма {coin}", "wallet_coin_amount_insufficient_funds": "Недостаточно средств", + "wallet_coin_amount_insufficient": "Недостаточно", "wallet_coin_amount_must_be_positive": "Сумма должна быть положительной", "wallet_coin_amount_too_low_for_sending": "Введённая сумма слишком мала для отправки", "wallet_coins_search_hint": "Искать по названию токена", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index d3f5dfc169..7878d3e433 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "{coin} adresi", "wallet_coin_amount": "{coin} tutarı", "wallet_coin_amount_insufficient_funds": "Yetersiz bakiye", + "wallet_coin_amount_insufficient": "Yetersiz", "wallet_coin_amount_must_be_positive": "Tutar pozitif olmalıdır", "wallet_coin_amount_too_low_for_sending": "Girilen tutar göndermek için çok düşük", "wallet_coins_search_hint": "Token adına göre ara", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2e74a2a6d6..37348e8af3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1061,6 +1061,7 @@ "wallet_coin_address": "{coin} 地址", "wallet_coin_amount": "{coin} 数量", "wallet_coin_amount_insufficient_funds": "资金不足", + "wallet_coin_amount_insufficient": "不足", "wallet_coin_amount_must_be_positive": "金额必须为正数", "wallet_coin_amount_too_low_for_sending": "输入的金额太低,无法发送", "wallet_coins_search_hint": "按代币名称搜索",