Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class TradeCommunityTokenDialog extends HookConsumerWidget {
return const SheetContent(body: SizedBox.shrink());
}

final buttonError = useState<String?>(null);

final params = (
externalAddress: resolvedExternalAddress,
externalAddressType: resolvedExternalAddressType,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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;
Expand All @@ -405,6 +414,8 @@ class _TokenCards extends HookConsumerWidget {
final Widget? communityAvatarWidget;
final bool isPaymentTokenSelectable;
final VoidCallback onTokenTap;
final ValueChanged<String?>? onValidationError;
final bool isError;

@override
Widget build(BuildContext context, WidgetRef ref) {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +25,7 @@ class TokenCard extends HookConsumerWidget {
const TokenCard({
required this.type,
required this.onTap,
this.onValidationError,
this.coinsGroup,
this.network,
this.controller,
Expand All @@ -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,
});

Expand All @@ -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<String?>? onValidationError;

void _onPercentageChanged(int percentage, WidgetRef ref) {
final coin = coinsGroup?.coins.firstWhereOrNull(
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
},
),
),
),
Expand All @@ -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(
Expand All @@ -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,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String?>? 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(
Copy link
Contributor

@ice-cerberus ice-cerberus Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have some veery similar logic here:

if (maxValue != null && (parsed > maxValue! || parsed < 0)) {

maybe something could be reused?

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(null);

useAmountListener(
amountController,
controller,
Expand Down Expand Up @@ -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,
Expand All @@ -137,6 +117,9 @@ class SwapCoinsModalPage extends HookConsumerWidget {
coinType: CoinSwapType.sell,
).push<void>(context);
},
onValidationError: (error) {
buttonError.value = error;
},
),
SizedBox(
height: 10.0.s,
Expand Down Expand Up @@ -180,6 +163,7 @@ class SwapCoinsModalPage extends HookConsumerWidget {
height: 32.0.s,
),
ContinueButton(
error: buttonError.value,
isEnabled: isContinueButtonEnabled,
onPressed: () async {
if (isContinueButtonEnabled) {
Expand Down
Loading