← Code knowledge base
Reviewed Flutter implementation · 2026-10-28

exchange_flow_dialog.dart

mobile_flutter/lib/features/wallets/presentation/exchange_flow_dialog.dart

Immutable source snapshot based on revision ce45c9c3bbde. Original line numbers are retained. This is implementation evidence; check the public API catalogue before adapting it.

The mobile /api/v1/mobile routes belong to the example backend. Company credentials stay on that backend. Some sections outside the documented product scope may be omitted.

Lines 1–1082

import '../../../core/widgets/app_states.dart';import '../../../brands/cardeo/cardeo.dart';import 'dart:async';import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import '../../../core/models/platform_models.dart';import '../../../core/models/banking_models.dart';import '../../banking/application/banking_providers.dart';import '../../platform/application/platform_providers.dart';import '../domain/exchange_models.dart';import '../domain/wallet_models.dart';import '../domain/exchange_transfer_routes.dart';export '../domain/exchange_transfer_routes.dart' show exchangeChainId;import '../data/wallet_providers.dart';import '../domain/receiving_account_details.dart';import 'exchange_support.dart';import 'exchange_quote_error.dart';import '../../../core/l10n/app_localizations.dart'; enum ExchangeFlow { convert, fund, withdraw } String exchangeBudgetCurrency(PlatformResource budget) =>    (budget.metadata['currency'] ?? budget.metadata['Currency'] ?? 'EUR')        .toString()        .toUpperCase(); class ExchangeFlowDialog extends ConsumerStatefulWidget {  const ExchangeFlowDialog(      {super.key,      required this.flow,      required this.overview,      required this.outflowsEnabled,      this.onConvertToEur,      this.budgets = const [],      this.wallets = const []});  final ExchangeFlow flow;  final BoomFiExchangeOverview overview;  final bool outflowsEnabled;  final Future<void> Function(PlatformResource budget)? onConvertToEur;  final List<PlatformResource> budgets;  final List<HoppaWalletAsset> wallets;  @override  ConsumerState<ExchangeFlowDialog> createState() => _ExchangeFlowDialogState();} class _ExchangeFlowDialogState extends ConsumerState<ExchangeFlowDialog> {  String? sourceKey,      destinationKey,      budgetId,      walletKey,      selectedFundingCurrency;  String amount = '', address = '', bankChoice = 'new';  Map<String, dynamic> bank = {'beneficiaryType': 'individual'};  bool sendToCard = false;  AsyncValue<List<HoppaWalletAsset>> depositWallets = const AsyncLoading();  List<Payee> equalsPayees = [];  Map<String, dynamic>? quote;  List<Map<String, dynamic>> destinations = [];  List<ReceivingAccountDetails> receiving = [];  bool busy = false, accepted = false, review = false, submitted = false;  String? error, actionUrl;  List<PlatformResource>? refreshedBudgets;  bool fundingBalanceRefreshFailed = false;  String idempotencyKey =      'mobile-exchange-${DateTime.now().microsecondsSinceEpoch}';  final scrollController = ScrollController();  Timer? timer, quoteRefreshTimer;  bool refreshingQuote = false, quoteRefreshFailed = false, quoteChanged = false;  List<BoomFiExchangeBalance> get balances => widget.overview.balances      .where((b) =>          widget.flow == ExchangeFlow.withdraw ||          widget.overview.canTrade(b.currency))      .toList();  List<String> fundingCurrencies(PlatformResource b) {    // Equals can add EUR through conversion even when it is not funded yet.    return widget.overview.fiatFundingCurrencies.contains('EUR') ? ['EUR'] : [];  }   List<PlatformResource> get budgets => (refreshedBudgets ?? widget.budgets)      .where((b) => fundingCurrencies(b).isNotEmpty)      .toList();  String get fundingCurrency => budget == null      ? ''      : fundingCurrencies(budget!).contains(selectedFundingCurrency)          ? selectedFundingCurrency!          : fundingCurrencies(budget!).first;  List<HoppaWalletAsset> get wallets => exchangeTransferRoutes(          depositWallets.valueOrNull ?? widget.wallets, widget.wallets)      .where((w) =>          w.address.isNotEmpty &&          exchangeChainId(w.network) != 0 &&          ['USDC', 'USDT'].contains(w.symbol.toUpperCase()) &&          (widget.flow != ExchangeFlow.fund ||              (widget.overview.canTrade(w.symbol) &&                  widget.overview.balances.any((b) =>                      b.currency == w.symbol.toUpperCase() &&                      b.chainId == exchangeChainId(w.network)))))      .toList();  PlatformResource? get budget =>      budgets.where((x) => x.id == budgetId).firstOrNull ?? budgets.firstOrNull;  String walletId(HoppaWalletAsset w) =>      '${w.walletId}:${w.symbol}:${w.network}:${w.address}';  List<HoppaWalletAsset> get walletChoices => widget.flow == ExchangeFlow.fund      ? exchangeTransferCurrencyRoutes(wallets, canTrade: widget.overview.canTrade)      : wallets;  HoppaWalletAsset? get wallet =>      walletChoices.where((x) => walletId(x) == walletKey).firstOrNull ??      walletChoices.firstOrNull;  // Conversion spends the shared currency balance; retain a real provider  // account for quotes while keeping withdrawal network routes separate.  List<BoomFiExchangeBalance> get sources =>      (widget.flow == ExchangeFlow.convert              ? widget.overview.displayBalances                  .where((b) => widget.overview.canTrade(b.currency))              : balances)          .where((b) => widget.flow == ExchangeFlow.fund              ? !b.isCrypto && b.currency == fundingCurrency              : widget.flow == ExchangeFlow.withdraw                  ? b.isCrypto || b.currency == 'EUR'                  : true)          .toList();  BoomFiExchangeBalance? get source =>      sources.where((b) => b.key == sourceKey).firstOrNull ??      (widget.flow == ExchangeFlow.fund          ? null          : sources.where((b) => b.amount > 0).firstOrNull) ??      sources.firstOrNull;  List<BoomFiExchangeBalance> get targets => balances      .where((b) => widget.flow == ExchangeFlow.fund          ? wallet != null &&              b.currency == wallet!.symbol.toUpperCase() &&              b.chainId == exchangeChainId(wallet!.network)          : source != null &&              b.isCrypto != source!.isCrypto &&              (b.isCrypto || b.currency == 'EUR'))      .toList();  BoomFiExchangeBalance? get destination =>      targets.where((b) => b.key == destinationKey).firstOrNull ??      targets.firstOrNull;  Map<String, String> get cryptoDestinations => {        for (final d in destinations.where((d) =>            exchangeText(d, 'type') == 'Digital' &&            exchangeValue(d, 'enabled') == true &&            (exchangeValue(d, 'currencies') as List? ?? [])                .contains(source?.currency) &&            exchangeValue(d, 'chainId') == source?.chainId &&            exchangeText(d, 'address').isNotEmpty))          exchangeText(d, 'address'): exchangeText(d, 'name').isEmpty              ? exchangeText(d, 'address')              : exchangeText(d, 'name'),        for (final w in wallets.where((w) =>            w.symbol.toUpperCase() == source?.currency &&            exchangeChainId(w.network) == source?.chainId))          w.address: 'Crypto card · ${w.shortAddress}',      };  bool get canSendToCard =>      widget.flow == ExchangeFlow.convert &&      source?.isCrypto == false &&      destination?.isCrypto == true;  List<HoppaWalletAsset> get cardDestinations =>      (depositWallets.valueOrNull ?? const <HoppaWalletAsset>[])          .where((w) =>              w.symbol.toUpperCase() == destination?.currency &&              exchangeChainId(w.network) == destination?.chainId)          .toList();  HoppaWalletAsset? get cardDestination =>      cardDestinations.where((w) => walletId(w) == walletKey).firstOrNull ??      cardDestinations.firstOrNull;  List<Payee> get eligibleEqualsPayees => equalsPayees      .where((p) =>          (p.provider.isEmpty ||              p.provider                  .toLowerCase()                  .replaceAll(' ', '')                  .contains('equals')) &&          !p.requiresConfirmation &&          !['disabled', 'deleted', 'rejected', 'inactive']              .contains(p.status.toLowerCase()) &&          (p.currency.isEmpty || p.currency.toUpperCase() == bankCurrency) &&          RegExp(r'^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$')              .hasMatch(p.accountReference.replaceAll(' ', '').toUpperCase()))      .toList();  List<ReceivingAccountDetails> get eligibleReceiving => receiving      .where((r) => r.currencies.isEmpty || r.currencies.contains(bankCurrency))      .toList();  String payeeLabel(Payee p) {    final iban = p.accountReference.replaceAll(' ', '');    return 'Saved bank recipient · ${p.name} · ${iban.substring(0, 2)}…${iban.substring(iban.length - 4)}';  }   String? get bankCurrency => widget.flow == ExchangeFlow.withdraw      ? source?.currency      : destination?.currency;  List<Map<String, dynamic>> get savedBanks => destinations      .where((d) =>          exchangeText(d, 'type').toLowerCase() == 'fiat' &&          exchangeValue(d, 'enabled') == true &&          int.tryParse(exchangeText(d, 'id')) != null &&          ((exchangeValue(d, 'currencies') as List? ?? []).isEmpty ||              (exchangeValue(d, 'currencies') as List)                  .any((c) => c.toString().toUpperCase() == bankCurrency)))      .toList();  Map<String, dynamic>? get savedBank => savedBanks      .where((d) => 'saved:${exchangeText(d, 'id')}' == bankChoice)      .firstOrNull;  String bankLabel(Map<String, dynamic> d) => [        exchangeText(d, 'name'),        exchangeText(d, 'bankName'),        exchangeText(d, 'accountNumberHint')      ].where((v) => v.isNotEmpty).join(' · ');  bool get needsBank => widget.flow == ExchangeFlow.withdraw      ? source?.isCrypto == false      : widget.flow == ExchangeFlow.convert &&          source?.isCrypto == true &&          destination != null;  BoomFiFeesAndLimits? get inputLimits => widget.flow == ExchangeFlow.fund      ? widget.overview.fiatFundingFeesAndLimits[fundingCurrency]      : widget.flow == ExchangeFlow.withdraw && source != null          ? widget.overview.feesFor(source!)          : null;  bool get hasDeliveryWithdrawal =>      widget.flow == ExchangeFlow.fund ||      (widget.flow == ExchangeFlow.convert &&          (needsBank || (canSendToCard && sendToCard)));  BoomFiFeesAndLimits? get deliveryLimits =>      hasDeliveryWithdrawal && destination != null          ? widget.overview.feesFor(destination!)          : null;  String? inputLimitError() => source == null      ? null      : inputLimits?.validate(amount, source!.currency,          withdrawal: widget.flow == ExchangeFlow.withdraw);  String? deliveryLimitError(Map<String, dynamic>? value) {    final limits = deliveryLimits;    if (limits == null || !limits.hasWithdrawalLimits) { return null; }    final received = exchangeText(value ?? {}, 'buy_amount');    final currency = exchangeText(value ?? {}, 'buy_currency').toUpperCase();    if (received.isEmpty || currency != destination?.currency) {      return 'The provider has not returned an amount to check against the withdrawal limits. Request a new quote.';    }    return limits.validate(received, currency, withdrawal: true);  }   bool get locked => busy || review || submitted;  DateTime? get expiry =>      DateTime.tryParse(exchangeText(quote ?? {}, 'expiry'));  bool get expired =>      quote != null &&      (expiry == null ||          !expiry!.isAfter(DateTime.now().add(const Duration(seconds: 2))));  @override  void initState() {    super.initState();    if (widget.flow == ExchangeFlow.fund) {      budgetId = widget.budgets.firstOrNull?.id;    }    timer = Timer.periodic(const Duration(seconds: 1), (_) {      if (mounted && quote != null) setState(() {});    });    loadDestinations();  }   void scheduleQuoteRefresh({bool retry = false}) {    quoteRefreshTimer?.cancel();    if (!mounted || !review || submitted || quote == null) return;    final remaining = expiry?.difference(DateTime.now());    if (!retry && remaining == null) return;    final delay = retry        ? const Duration(seconds: 10)        : remaining! > const Duration(seconds: 6)            ? remaining - const Duration(seconds: 5)            : const Duration(seconds: 1);    quoteRefreshTimer = Timer(delay, () {      if (mounted && review && !submitted && !busy) {        unawaited(prepare(refresh: true));      }    });  }   bool sameQuoteTerms(Map<String, dynamic> previous, Map<String, dynamic> next) {    bool sameValue(String a, String b) {      final numberA = double.tryParse(a);      final numberB = double.tryParse(b);      return numberA != null && numberB != null ? numberA == numberB : a == b;    }    for (final field in ['buy_amount', 'buy_currency', 'rate']) {      if (!sameValue(exchangeText(previous, field), exchangeText(next, field))) return false;    }    final previousFees = exchangeMap(exchangeValue(previous, 'fees'));    final nextFees = exchangeMap(exchangeValue(next, 'fees'));    return sameValue(exchangeText(previousFees, 'total_fee'), exchangeText(nextFees, 'total_fee')) &&        exchangeText(previousFees, 'fee_ccy') == exchangeText(nextFees, 'fee_ccy');  }   Future<void> loadDestinations() async {    try {      final api = ref.read(mobilePlatformApiProvider);      final saved = await api.getExchangeDestinations();      if (mounted) setState(() => destinations = saved);    } catch (_) {/* Manual destination remains available. */}    try {      final accounts =          await ref.read(mobilePlatformApiProvider).getEqualsBankingInfos();      if (mounted) {        setState(() => receiving = {              for (final a in receivingAccountsFromResources(accounts)                  .where((a) => a.isIban))                a.accountNumber: a            }.values.toList());      }    } catch (_) {/* New payee remains available. */}    try {      final payees = await ref.read(payeesProvider.future);      if (mounted) setState(() => equalsPayees = payees);    } catch (_) {/* Manual bank details remain available. */}  }   @override  void dispose() {    timer?.cancel();    quoteRefreshTimer?.cancel();    scrollController.dispose();    super.dispose();  }   void edit(VoidCallback action) => setState(() {        quoteRefreshTimer?.cancel();        quoteRefreshFailed = false;        quoteChanged = false;        action();        quote = null;        review = false;        accepted = false;        error = null;      });  Widget balanceChoice(String label, List<BoomFiExchangeBalance> choices,          BoomFiExchangeBalance? selected, ValueChanged<String?> onChanged,          {bool showNetwork = true}) =>      ExchangeFieldLabel(          label: label,          child: DropdownButtonFormField<String>(              key: ValueKey('$label-${selected?.key}'),              initialValue: selected?.key,              isExpanded: true,              decoration:                  const InputDecoration(hintText: 'No available account'),              items: choices                  .map((b) => DropdownMenuItem(                      value: b.key,                      child: Text(                          '${b.currency}${showNetwork && b.isCrypto ? ' · ${b.chainName}' : ''} · ${b.amount}',                          overflow: TextOverflow.ellipsis)))                  .toList(),              onChanged: locked || choices.isEmpty ? null : onChanged));  void scrollToResult() {    WidgetsBinding.instance.addPostFrameCallback((_) {      if (!mounted || !scrollController.hasClients) return;      scrollController.animateTo(        scrollController.position.maxScrollExtent,        duration: const Duration(milliseconds: 250),        curve: Curves.easeOut,      );    });  }   Future<void> prepare({bool refresh = false}) async {    if (!refresh) FocusScope.of(context).unfocus();    await prepareQuote(refresh: refresh);    if (!refresh && mounted) scrollToResult();  }   Future<void> prepareQuote({bool refresh = false}) async {    if (busy || submitted || (refresh && !review)) return;    if (fundingBalanceRefreshFailed) return;    final number = double.tryParse(amount.replaceAll(',', '.'));    if (source == null || number == null || !number.isFinite || number <= 0) {      setState(() => error = 'Enter a valid amount and select an account.');      return;    }    final limitError = inputLimitError();    if (limitError != null) {      setState(() => error = limitError);      return;    }    if (widget.flow != ExchangeFlow.fund && number > source!.amount) {      setState(() => error = 'Amount exceeds the available balance.');      return;    }    if (widget.flow == ExchangeFlow.fund &&        number > (exchangeBudgetBalances(budget!)['EUR'] ?? 0)) {      setState(() => error =          'Convert funds to EUR in this Fiat account before transferring, or enter an amount within your EUR balance.');      return;    }    if (canSendToCard && sendToCard && cardDestination == null) {      setState(() => error =          'Select a currency and network supported by your payment card wallet.');      return;    }    if (needsBank && bankChoice.startsWith('saved:') && savedBank == null) {      setState(() => error =          'Select a saved bank account supporting the destination currency.');      return;    }    if (needsBank && savedBank == null && !exchangeBankComplete(bank)) {      setState(() =>          error = 'Enter a valid IBAN and complete the bank destination.');      return;    }    if (widget.flow == ExchangeFlow.withdraw &&        source!.isCrypto &&        address.trim().isEmpty) {      setState(() => error = 'Enter or select a crypto destination.');      return;    }    if (widget.flow != ExchangeFlow.withdraw && destination == null) {      setState(() => error = 'No matching exchange destination is available.');      return;    }    quoteRefreshTimer?.cancel();    final previousQuote = quote;    setState(() {      busy = true;      refreshingQuote = refresh;      error = null;    });    try {      Map<String, dynamic>? result;      if (widget.flow != ExchangeFlow.withdraw) {        result = await ref.read(mobilePlatformApiProvider).getExchangeQuote(            source: source!,            destination: destination!,            sellAmount: amount.replaceAll(',', '.'),            bankWithdrawal: needsBank ? Map.of(bank) : null,            interlaceDestinationAddress:                canSendToCard && sendToCard ? cardDestination!.address : null);      }      if (!mounted) return;      final limitError = deliveryLimitError(result);      if (limitError != null) {        setState(() {          error = limitError;          if (refresh) {            quoteRefreshFailed = true;            accepted = false;          }        });        if (refresh) scheduleQuoteRefresh(retry: true);        return;      }      if (result != null) {        final nextExpiry = DateTime.tryParse(exchangeText(result, 'expiry'));        if (nextExpiry == null || !nextExpiry.isAfter(DateTime.now().add(const Duration(seconds: 2)))) {          throw StateError('The provider returned an expired quote. Request a new quote.');        }      }      final changed = refresh && previousQuote != null && result != null &&          !sameQuoteTerms(previousQuote, result);      setState(() {        quote = result;        review = true;        quoteRefreshFailed = false;        quoteChanged = changed || (refresh && quoteChanged && !accepted);        accepted = refresh && !changed && accepted;      });      scheduleQuoteRefresh();    } catch (e) {      if (mounted) {        setState(() {          error = exchangeQuoteError(e, cardDelivery: canSendToCard && sendToCard);          if (refresh) {            quoteRefreshFailed = true;            accepted = false;          }        });        if (refresh) scheduleQuoteRefresh(retry: true);      }    } finally {      if (mounted) {        setState(() {          busy = false;          refreshingQuote = false;        });      }    }  }   Future<void> confirm() async {    if (busy ||        quoteRefreshFailed ||        submitted ||        !accepted ||        !widget.outflowsEnabled ||        !review ||        (quote != null && expired)) {      return;    }    final limitError = inputLimitError() ?? deliveryLimitError(quote);    if (limitError != null) {      setState(() => error = limitError);      return;    }    quoteRefreshTimer?.cancel();    setState(() {      busy = true;      error = null;    });    try {      final api = ref.read(mobilePlatformApiProvider);      Map<String, dynamic> result;      if (widget.flow == ExchangeFlow.fund) {        result = await api.fundExchange({          'idempotencyKey': idempotencyKey,          'sourceBudgetId': budget!.id,          'fiatCurrency': source!.currency,          'fiatAmount': double.parse(amount.replaceAll(',', '.')),          'cryptoCurrency': wallet!.symbol.toUpperCase(),          'chainId': exchangeChainId(wallet!.network),          'destinationAddress': wallet!.address        });      } else if (widget.flow == ExchangeFlow.withdraw) {        // A payout may have reached the provider even if its response was lost.        submitted = true;        result = source!.isCrypto            ? await api.withdrawExchangeCrypto({                'accountRef': source!.accountId.toString(),                'amount': amount.replaceAll(',', '.'),                'ccy': source!.currency,                'chainId': source!.chainId,                'walletAddress': address.trim(),                'tokenAddress': source!.tokenAddress              })            : await api.withdrawExchangeFiat({                ...bank,                'amount': amount.replaceAll(',', '.'),                'currency': source!.currency              });      } else {        final id = exchangeText(quote!, 'session').isNotEmpty            ? exchangeText(quote!, 'session')            : exchangeText(quote!, 'quoteId');        if (id.isEmpty) {          throw StateError(              'The quote identifier is missing. Request a new quote.');        }        final acceptedResult = await api.acceptExchangeQuote(id);        result = {'message': acceptedResult.message};      }      ref.invalidate(exchangeTransfersProvider);      ref.invalidate(exchangeOverviewProvider);      if (!mounted) return;      if (widget.flow == ExchangeFlow.fund && result['success'] == false) {        final transfer = result['transfer'];        final definitelyUnsent = transfer is Map && transfer['status'] == 'failed' &&            (transfer['sourceTransferId'] ?? '').toString().trim().isEmpty &&            (transfer['boomFiOrderId'] ?? '').toString().trim().isEmpty &&            (result['equalsPaymentId'] ?? '').toString().trim().isEmpty;        if (definitelyUnsent) {          // A saved terminal failure cannot be resubmitted with its old key.          // Start a new attempt only after the user reviews and confirms again.          idempotencyKey = 'mobile-exchange-${DateTime.now().microsecondsSinceEpoch}';        } else {          submitted = true;        }        throw StateError(exchangeText(result, 'message').isEmpty            ? 'The transfer was not completed. Check its status before trying again.'            : exchangeText(result, 'message'));      }      final url = exchangeActionUrl(result);      if (url != null) {        setState(() {          actionUrl = url;          submitted = true;        });        return;      }      Navigator.pop(context);      ScaffoldMessenger.of(context).showSnackBar(SnackBar(          content: Text(exchangeText(result, 'message').isEmpty              ? 'Exchange request submitted'              : exchangeText(result, 'message'))));    } catch (e) {      if (mounted) {        setState(() {          error = submitted              ? 'The transfer outcome needs checking. Refresh transfers before sending again.'              : friendlyErrorMessage(e);          if (widget.flow == ExchangeFlow.fund && !submitted) {            // Preserve the key unless a returned terminal result proved no payment was sent.            // No quote refresh is scheduled after a submission error.            quote = null;            review = false;            accepted = false;            quoteChanged = false;            quoteRefreshFailed = false;          }        });      }    } finally {      if (mounted) {        setState(() => busy = false);        if (error != null) scrollToResult();      }    }  }   @override  Widget build(BuildContext context) {    final inputFeeDetails = (inputLimits ?? const BoomFiFeesAndLimits()).descriptions(        source?.currency ?? '', withdrawal: widget.flow == ExchangeFlow.withdraw);    final deliveryFeeDetails = (deliveryLimits ?? const BoomFiFeesAndLimits()).descriptions(        destination?.currency ?? '', withdrawal: true);     depositWallets = ref.watch(exchangeCardDepositAddressesProvider);    if (widget.flow == ExchangeFlow.fund &&        (budget == null || wallet == null)) {      return AlertDialog(          title: const Text('Fiat to crypto'),          content: Text(budget == null              ? 'A Fiat account supporting EUR and Exchange EUR funding details are required.'              : depositWallets.isLoading                  ? 'Loading your Crypto card receiving networks…'                  : depositWallets.hasError                      ? 'Could not load your Crypto card receiving networks. Please retry.'                      : 'No Crypto card receiving address matches an available BoomFi crypto network.'),          actions: [            if (depositWallets.hasError)              TextButton(                  onPressed: () {                    ref.invalidate(depositAddressesProvider);                    ref.invalidate(exchangeCardDepositAddressesProvider);                  },                  child: const Text('Retry')),            TextButton(                onPressed: () => Navigator.pop(context),                child: const Text('Close'))          ]);    }    final title = widget.flow == ExchangeFlow.fund        ? (context.isCardeoTheme            ? 'Fiat account to Crypto card'            : 'Fiat account to Crypto card')        : widget.flow == ExchangeFlow.withdraw            ? 'Withdraw from Exchange'            : 'Convert currency';    final fees = exchangeMap(exchangeValue(quote ?? {}, 'fees'));    return PopScope(        canPop: !busy,        child: ExchangeDialog(            scrollController: scrollController,            title: title,            subtitle: widget.flow == ExchangeFlow.withdraw                ? 'Send funds to your bank account or crypto wallet.'                : 'Choose your accounts and amount, then review before confirming.',            actions: [              TextButton(                  onPressed: busy ? null : () => Navigator.pop(context),                  child: Text(submitted ? 'Close' : 'Cancel')),              if (review && !submitted)                TextButton(                    onPressed: busy ? null : () => edit(() {}),                    child: const Text('Edit')),              if (!submitted)                _primaryAction(context,                    onPressed: busy || quoteRefreshFailed ||                            fundingBalanceRefreshFailed ||                            source == null ||                            (widget.flow != ExchangeFlow.fund &&                                source!.amount <= 0) ||                            (widget.flow != ExchangeFlow.withdraw &&                                destination == null)                        ? null                        : review                            ? (!accepted ||                                    !widget.outflowsEnabled ||                                    (quote != null && expired)                                ? null                                : confirm)                            : prepare,                    label: review                        ? (widget.flow == ExchangeFlow.fund                            ? 'Confirm and fund'                            : widget.flow == ExchangeFlow.withdraw                                ? 'Confirm withdrawal'                                : 'Confirm exchange')                        : widget.flow == ExchangeFlow.withdraw                            ? 'Review withdrawal'                            : 'Get quote'),            ],            child: ExchangeFormColumn(children: [              if (widget.flow == ExchangeFlow.fund) ...[                const Text(                    'Transfer EUR from your Fiat account, exchange it, and send crypto to your Crypto card. Review the quote before confirming.'),                Text(                    'Available in Fiat account: ${(exchangeBudgetBalances(budget!)['EUR'] ?? 0).toStringAsFixed(2)} EUR'),                if (exchangeBudgetBalances(budget!)                    .entries                    .any((entry) => entry.key != 'EUR' && entry.value > 0)) ...[                  const Text(                      'Have funds in another currency? Convert them to EUR first, then continue here.'),                  if (widget.onConvertToEur != null)                    TextButton(                        onPressed: locked                            ? null                            : () async {                                final selectedBudget = budget!;                                setState(() {                                  busy = true;                                  error = null;                                  quote = null;                                  accepted = false;                                });                                try {                                  await widget.onConvertToEur!(selectedBudget);                                  final updated =                                      await ref.read(budgetsProvider.future);                                  if (mounted) {                                    setState(() => refreshedBudgets = updated);                                  }                                } catch (e) {                                  if (mounted) {                                    setState(() {                                      fundingBalanceRefreshFailed = true;                                      error =                                          'Could not refresh your Fiat account balance. Close and reopen this transfer before continuing.';                                    });                                  }                                } finally {                                  if (mounted) setState(() => busy = false);                                }                              },                        child: const Text('Convert to EUR')),                ],                ExchangeFieldLabel(                    label: 'Source fiat account',                    child: DropdownButtonFormField<String>(                        initialValue: budget!.id,                        isExpanded: true,                        decoration: const InputDecoration(                            hintText: 'Select an account'),                        items: budgets                            .map((b) => DropdownMenuItem(                                value: b.id,                                child: Text(b.title,                                    overflow: TextOverflow.ellipsis)))                            .toList(),                        onChanged: locked                            ? null                            : (v) => edit(() {                                  budgetId = v;                                  sourceKey = null;                                }))),                ExchangeFieldLabel(                    label: 'Fiat currency',                    child: DropdownButtonFormField<String>(                        key: ValueKey('funding-$fundingCurrency'),                        initialValue: fundingCurrency,                        isExpanded: true,                        decoration: const InputDecoration(                            hintText: 'Select an account'),                        items: fundingCurrencies(budget!)                            .map((c) =>                                DropdownMenuItem(value: c, child: Text(c)))                            .toList(),                        onChanged: locked                            ? null                            : (v) => edit(() {                                  selectedFundingCurrency = v;                                  sourceKey = null;                                }))),                ExchangeFieldLabel(                    label: 'Crypto currency',                    child: DropdownButtonFormField<String>(                        initialValue: walletId(wallet!),                        isExpanded: true,                        decoration: const InputDecoration(                            hintText: 'Select an account'),                        items: walletChoices                            .map((w) => DropdownMenuItem(                                value: walletId(w),                                child: Text(                                    w.symbol,                                    overflow: TextOverflow.ellipsis)))                            .toList(),                        onChanged:                            locked ? null : (v) => edit(() => walletKey = v))),              ],              if (widget.flow != ExchangeFlow.fund)                balanceChoice(                  'From',                  sources,                  source,                  (v) => edit(() {                        sourceKey = v;                        destinationKey = null;                        address = '';                        sendToCard = false;                        bankChoice = 'new';                        bank = {'beneficiaryType': 'individual'};                      }),                  showNetwork: widget.flow != ExchangeFlow.convert),              if (widget.flow == ExchangeFlow.convert)                balanceChoice(                    'To',                    targets,                    destination,                    (v) => edit(() {                          destinationKey = v;                          sendToCard = false;                          bankChoice = 'new';                          bank = {'beneficiaryType': 'individual'};                        })),              if (source != null &&                  source!.amount <= 0 &&                  widget.flow != ExchangeFlow.fund)                const ExchangeNotice(                    'There are no funds available in this account. Deposit funds to continue.'),              if (widget.flow != ExchangeFlow.withdraw && destination == null)                const ExchangeNotice(                    'No compatible currency account is available yet. Refresh your exchange accounts and try again.'),              ExchangeFieldLabel(                  label: 'Amount (${source?.currency ?? ''})',                  child: TextField(                      key: const ValueKey('exchange-amount'),                      enabled: !locked,                      keyboardType:                          const TextInputType.numberWithOptions(decimal: true),                      decoration: const InputDecoration(hintText: '0.00'),                      onChanged: (v) => edit(() => amount = v))),              if ((widget.flow == ExchangeFlow.fund || widget.flow == ExchangeFlow.withdraw) && inputFeeDetails.isNotEmpty)                ExchangeFeesSummary(                  title: widget.flow == ExchangeFlow.withdraw ? 'Withdrawal fees and limits' : 'Deposit limits',                  details: inputFeeDetails),              if (hasDeliveryWithdrawal && destination != null && deliveryFeeDetails.isNotEmpty)                ExchangeFeesSummary(title: 'After conversion', details: deliveryFeeDetails),              if (canSendToCard) ...[                SwitchListTile.adaptive(                  contentPadding: EdgeInsets.zero,                  title: Text(context.tr('Fund my payment card wallet')),                  subtitle: const Text(                      'Optional · Deliver the converted crypto after settlement, ready for a card top-up.'),                  value: sendToCard,                  onChanged: locked ||                          !widget.outflowsEnabled ||                          cardDestinations.isEmpty                      ? null                      : (v) => edit(() => sendToCard = v),                ),                if (widget.overview.canTrade('USDT') &&                    !balances.any((b) => b.currency == 'USDT' && b.isCrypto))                  const ExchangeNotice(                      'USDT is not enabled for conversion on your Exchange account.'),                if (depositWallets.isLoading)                  const ExchangeNotice(                      'Loading your payment card wallet addresses…')                else if (depositWallets.hasError) ...[                  const ExchangeNotice(                      'Could not load your payment card wallet addresses.'),                  TextButton.icon(                    onPressed: locked                        ? null                        : () {                            ref.invalidate(depositAddressesProvider);                            ref.invalidate(                                exchangeCardDepositAddressesProvider);                          },                    icon: const Icon(Icons.refresh),                    label: const Text('Retry deposit addresses'),                  ),                ] else if (cardDestinations.isEmpty)                  const ExchangeNotice(                      'No payment card wallet address matches this currency and network. Choose another network.'),                if (sendToCard && cardDestination != null)                  ExchangeFieldLabel(                      label: 'Payment card wallet',                      child: DropdownButtonFormField<String>(                          key: ValueKey('card-delivery-${destination?.key}'),                          initialValue: walletId(cardDestination!),                          isExpanded: true,                          items: cardDestinations                              .map((w) => DropdownMenuItem(                                  value: walletId(w),                                  child: Text(                                      '${w.symbol} · ${w.network} · ${w.shortAddress}',                                      overflow: TextOverflow.ellipsis)))                              .toList(),                          onChanged: locked                              ? null                              : (v) => edit(() => walletKey = v))),              ],              if (needsBank) ...[                const ExchangeNotice(                    'EUR proceeds are sent to your bank account after settlement. Fiat can remain in Exchange for up to 24 hours.'),                if (savedBanks.isNotEmpty ||                    eligibleReceiving.isNotEmpty ||                    eligibleEqualsPayees.isNotEmpty)                  ExchangeFieldLabel(                      label: 'Receive fiat in',                      child: DropdownButtonFormField<String>(                          key: ValueKey('bank-destination-$bankCurrency'),                          initialValue: bankChoice.startsWith('saved:') &&                                  savedBank == null                              ? 'new'                              : bankChoice,                          isExpanded: true,                          decoration: const InputDecoration(                              hintText: 'Select an account'),                          items: [                            const DropdownMenuItem(                                value: 'new', child: Text('New payee')),                            for (final d in savedBanks)                              DropdownMenuItem(                                  value: 'saved:${exchangeText(d, 'id')}',                                  child: Text(bankLabel(d),                                      overflow: TextOverflow.ellipsis)),                            for (final p in eligibleEqualsPayees)                              DropdownMenuItem(                                  value: 'equals-payee:${p.id}',                                  child: Text(payeeLabel(p),                                      overflow: TextOverflow.ellipsis)),                            for (final r in eligibleReceiving)                              DropdownMenuItem(                                  value: r.accountNumber,                                  child: Text(                                      'My Fiat account · ${r.holder} · ${r.maskedIdentifier}',                                      overflow: TextOverflow.ellipsis))                          ],                          onChanged: locked                              ? null                              : (v) => edit(() {                                    bankChoice = v ?? 'new';                                    if (savedBank != null) {                                      bank = {                                        'externalAccountId': int.parse(                                            exchangeText(savedBank!, 'id'))                                      };                                      return;                                    }                                    final p = eligibleEqualsPayees                                        .where(                                            (p) => 'equals-payee:${p.id}' == v)                                        .firstOrNull;                                    if (p != null) {                                      final iban = p.accountReference                                          .replaceAll(' ', '')                                          .toUpperCase();                                      bank = {                                        'beneficiaryType':                                            p.firstName.isNotEmpty &&                                                    p.lastName.isNotEmpty                                                ? 'individual'                                                : 'business',                                        'name': p.name,                                        'accountNumber': iban,                                        'bankName': p.bankName,                                        'accountHolderName': p.name,                                        'country': iban.substring(0, 2),                                        'firstName': p.firstName,                                        'lastName': p.lastName,                                      };                                      return;                                    }                                    final r = receiving                                        .where((r) => r.accountNumber == v)                                        .firstOrNull;                                    bank = {                                      'beneficiaryType': 'individual',                                      'name': r == null                                          ? ''                                          : 'Fiat receiving account',                                      'accountNumber': r?.accountNumber ?? '',                                      'bankName': r?.bankName ?? '',                                      'accountHolderName': r?.holder ?? '',                                      'country': r == null                                          ? ''                                          : r.accountNumber                                              .substring(0, 2)                                              .toUpperCase(),                                    };                                  }))),                if (savedBank != null)                  ExchangeNotice(bankLabel(savedBank!))                else                  ExchangeBankFields(                      key: ValueKey(bankChoice),                      value: bank,                      enabled: !locked,                      onChanged: (v) => edit(() => bank = v)),              ],              if (widget.flow == ExchangeFlow.withdraw &&                  source?.isCrypto == true) ...[                if (cryptoDestinations.isNotEmpty)                  ExchangeFieldLabel(                      label: 'Saved destination',                      child: DropdownButtonFormField<String>(                          key: ValueKey(source!.key),                          isExpanded: true,                          decoration: const InputDecoration(                              hintText: 'Choose a saved wallet'),                          items: [                            for (final entry in cryptoDestinations.entries)                              DropdownMenuItem(                                  value: entry.key,                                  child: Text(entry.value,                                      overflow: TextOverflow.ellipsis))                          ],                          onChanged: locked                              ? null                              : (v) => edit(() => address = v ?? ''))),                ExchangeFieldLabel(                    label: 'Destination address',                    child: TextFormField(                        key: ValueKey(                            'address-${source?.key}-${cryptoDestinations.containsKey(address) ? address : 'manual'}'),                        initialValue: address,                        enabled: !locked,                        decoration: InputDecoration(                            hintText: 'Enter ${source!.chainName} address'),                        onChanged: (v) {                          address = v;                          accepted = false;                        })),                ExchangeNotice(                    'Send only to a ${source!.chainName} address. Transfers to the wrong network cannot be recovered.'),              ],              if (review) ...[                const Divider(),                Text('You send $amount ${source?.currency ?? ''}'),                if (quote != null) ...[                  Text(                      'You receive ${exchangeText(quote!, 'buy_amount')} ${exchangeText(quote!, 'buy_currency')}'),                  Text('Rate: ${exchangeText(quote!, 'rate')}'),                  Text(                      'Fee: ${exchangeText(fees, 'total_fee')} ${exchangeText(fees, 'fee_ccy')}'),                  Text(refreshingQuote                      ? 'Updating quote…'                      : quoteRefreshFailed                          ? 'Quote refresh failed. Retrying automatically…'                          : expired                              ? 'Updating expired quote…'                              : 'Quote refreshes automatically.'),                  if (quoteChanged)                    const ExchangeNotice('The rate or fees changed. Review the updated quote and confirm your agreement again.'),                  if (quoteRefreshFailed && !busy)                    TextButton(onPressed: () => prepare(refresh: true),                        child: const Text('Retry quote now')),                ],                if (canSendToCard && sendToCard && cardDestination != null)                  SelectableText(                      'After settlement: ${destination!.currency} → Crypto card account\n${cardDestination!.network} · ${cardDestination!.address}'),                if (needsBank)                  SelectableText(savedBank != null                      ? bankLabel(savedBank!)                      : '${bank['accountHolderName']} · ${bank['accountNumber']}'),                if (widget.flow == ExchangeFlow.withdraw &&                    source?.isCrypto == true)                  SelectableText(address),                if (widget.flow == ExchangeFlow.fund)                  const Text(                      'After funding, rates within ±0.5% are accepted automatically. Larger changes require your approval.'),                ExchangeTerms(                    accepted: accepted,                    onChanged: (v) => setState(() {                      accepted = v;                      if (v) quoteChanged = false;                    }),                    enabled: !busy && !submitted && !quoteRefreshFailed && !expired,                    withdrawal: widget.flow == ExchangeFlow.withdraw),              ],              if (actionUrl != null)                TextButton(                    onPressed: () => openExchangeUrl(actionUrl!),                    child: const Text('Complete provider verification')),              if (error != null) ExchangeNotice(error!, isError: true),              if (busy) const LinearProgressIndicator(),            ])));  }} Widget _primaryAction(BuildContext context,        {required String label, required VoidCallback? onPressed}) =>    FilledButton(onPressed: onPressed, child: Text(label));
HOPPA DEVELOPER ASSISTANT

What are you building?

From a question to an implementation.

Get a step-by-step flow, example code and the docs behind it. Choose a starting point or describe your feature.

Answers use the public API guide and example app code. Check the linked reference before shipping. Explore code sources ↗

Your question and recent conversation are sent to our AI provider. Don’t include API keys, personal data or card details.