/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack * All Rights Reserved. * The code is distributed under GPLv3 license, see LICENSE file for details. * Generated by Cypher Stack on 2023-05-26 * */ import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; import '../../app_config.dart'; import '../../models/buy/response_objects/crypto.dart'; import '../../models/buy/response_objects/fiat.dart'; import '../../models/buy/response_objects/quote.dart'; import '../../models/contact_address_entry.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart'; import '../../providers/providers.dart'; import '../../services/buy/buy_response.dart'; import '../../services/buy/simplex/simplex_api.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/assets.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/icon_widgets/addressbook_icon.dart'; import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../exchange_view/choose_from_stack_view.dart'; import 'buy_quote_preview.dart'; import 'sub_widgets/crypto_selection_view.dart'; import 'sub_widgets/fiat_selection_view.dart'; class BuyForm extends ConsumerStatefulWidget { const BuyForm({ super.key, this.coin, this.tokenContract, this.clipboard = const ClipboardWrapper(), this.scanner = const BarcodeScannerWrapper(), }); final CryptoCurrency? coin; final ClipboardInterface clipboard; final BarcodeScannerInterface scanner; final EthContract? tokenContract; @override ConsumerState createState() => _BuyFormState(); } class _BuyFormState extends ConsumerState { late final CryptoCurrency? coin; late final ClipboardInterface clipboard; late final BarcodeScannerInterface scanner; late final TextEditingController _receiveAddressController; late final TextEditingController _buyAmountController; final FocusNode _receiveAddressFocusNode = FocusNode(); final FocusNode _fiatFocusNode = FocusNode(); final FocusNode _cryptoFocusNode = FocusNode(); final FocusNode _buyAmountFocusNode = FocusNode(); final isDesktop = Util.isDesktop; List? coins; List? fiats; String? _address; static Fiat? selectedFiat; static Crypto? selectedCrypto; SimplexQuote quote = SimplexQuote( crypto: Crypto.fromJson({'ticker': 'BTC', 'name': 'Bitcoin'}), fiat: Fiat.fromJson({'ticker': 'USD', 'name': 'United States Dollar'}), youPayFiatPrice: Decimal.parse("100"), youReceiveCryptoAmount: Decimal.parse("1.0238917"), id: "someID", receivingAddress: '', buyWithFiat: true, ); // TODO enum this or something static bool buyWithFiat = true; bool _addressToggleFlag = false; bool _hovering1 = false; bool _hovering2 = false; // TODO actually check USD min and max, these could get updated by Simplex static Decimal minFiat = Decimal.fromInt(50); static Decimal maxFiat = Decimal.fromInt(20000); // // We can't get crypto min and max without asking for a quote // static Decimal minCrypto = Decimal.parse((0.00000001) // .toString()); // lol how to go from double->Decimal more easily? // static Decimal maxCrypto = Decimal.parse((10000.00000000).toString()); // static String boundedCryptoTicker = ''; String _amountOutOfRangeErrorString = ""; void validateAmount() { if (_buyAmountController.text.isEmpty) { setState(() { _amountOutOfRangeErrorString = ""; }); return; } final value = Decimal.tryParse(_buyAmountController.text); if (value == null) { setState(() { _amountOutOfRangeErrorString = "Invalid amount"; }); } else if (value > maxFiat && buyWithFiat) { setState(() { _amountOutOfRangeErrorString = "Maximum amount: ${maxFiat.toStringAsFixed(2)}"; }); } else if (value < minFiat && buyWithFiat) { setState(() { _amountOutOfRangeErrorString = "Minimum amount: ${minFiat.toStringAsFixed(2)}"; }); } else { setState(() { _amountOutOfRangeErrorString = ""; }); } } String _receivingAddressValidationErrorString = ""; void selectCrypto() async { if (ref.read(simplexProvider).supportedCryptos.isEmpty) { bool shouldPop = false; unawaited( showDialog( context: context, builder: (context) => WillPopScope( child: const CustomLoadingOverlay( message: "Loading currency data", eventBus: null, ), onWillPop: () async => shouldPop, ), ), ); await _loadSimplexCryptos(); shouldPop = true; if (mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } } await _showFloatingCryptoSelectionSheet( coins: ref.read(simplexProvider).supportedCryptos, onSelected: (crypto) { setState(() { // if (selectedCrypto?.ticker != _BuyFormState.boundedCryptoTicker) { // // Reset crypto mins and maxes ... we don't know these bounds until we request a quote // _BuyFormState.minCrypto = Decimal.parse((0.00000001) // .toString()); // lol how to go from double->Decimal more easily? // _BuyFormState.maxCrypto = // Decimal.parse((10000.00000000).toString()); // } selectedCrypto = crypto; }); }, ); } Future _showFloatingCryptoSelectionSheet({ required List coins, required void Function(Crypto) onSelected, }) async { _fiatFocusNode.unfocus(); _cryptoFocusNode.unfocus(); final result = isDesktop ? await showDialog( context: context, builder: (context) { return DesktopDialog( maxHeight: 700, maxWidth: 580, child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.only( left: 32, ), child: Text( "Choose a crypto to buy", style: STextStyles.desktopH3(context), ), ), const DesktopDialogCloseButton(), ], ), Expanded( child: Padding( padding: const EdgeInsets.only( left: 32, right: 32, bottom: 32, ), child: Row( children: [ Expanded( child: RoundedWhiteContainer( padding: const EdgeInsets.all(16), borderColor: Theme.of(context) .extension()! .background, child: CryptoSelectionView( coins: coins, ), ), ), ], ), ), ), ], ), ); }, ) : await Navigator.of(context).push( MaterialPageRoute( builder: (_) => CryptoSelectionView( coins: coins, ), ), ); if (mounted && result is Crypto) { onSelected(result); } } Future selectFiat() async { if (ref.read(simplexProvider).supportedFiats.isEmpty) { bool shouldPop = false; unawaited( showDialog( context: context, builder: (context) => WillPopScope( child: const CustomLoadingOverlay( message: "Loading currency data", eventBus: null, ), onWillPop: () async => shouldPop, ), ), ); await _loadSimplexFiats(); shouldPop = true; if (mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } } await _showFloatingFiatSelectionSheet( fiats: ref.read(simplexProvider).supportedFiats, onSelected: (fiat) { setState(() { selectedFiat = fiat; minFiat = fiat.minAmount != minFiat ? fiat.minAmount : minFiat; maxFiat = fiat.maxAmount != maxFiat ? fiat.maxAmount : maxFiat; }); validateAmount(); }, ); } Future _loadSimplexCryptos() async { final response = await SimplexAPI.instance.getSupportedCryptos(); if (response.value != null) { ref .read(simplexProvider) .updateSupportedCryptos(response.value!); // TODO validate } else { Logging.instance.log( "_loadSimplexCurrencies: $response", level: LogLevel.Warning, ); } } Future _loadSimplexFiats() async { final response = await SimplexAPI.instance.getSupportedFiats(); if (response.value != null) { ref .read(simplexProvider) .updateSupportedFiats(response.value!); // TODO validate } else { Logging.instance.log( "_loadSimplexCurrencies: $response", level: LogLevel.Warning, ); } } Future _showFloatingFiatSelectionSheet({ required List fiats, required void Function(Fiat) onSelected, }) async { _fiatFocusNode.unfocus(); _cryptoFocusNode.unfocus(); final result = isDesktop ? await showDialog( context: context, builder: (context) { return DesktopDialog( maxHeight: 700, maxWidth: 580, child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.only( left: 32, ), child: Text( "Choose a fiat with which to pay", style: STextStyles.desktopH3(context), ), ), const DesktopDialogCloseButton(), ], ), Expanded( child: Padding( padding: const EdgeInsets.only( left: 32, right: 32, bottom: 32, ), child: Row( children: [ Expanded( child: RoundedWhiteContainer( padding: const EdgeInsets.all(16), borderColor: Theme.of(context) .extension()! .background, child: FiatSelectionView( fiats: fiats, ), ), ), ], ), ), ), ], ), ); }, ) : await Navigator.of(context).push( MaterialPageRoute( builder: (_) => FiatSelectionView( fiats: fiats, ), ), ); if (mounted && result is Fiat) { onSelected(result); } } // String? _fetchIconUrlFromTicker(String? ticker) { // if (ticker == null) return null; // // return null; // } Future previewQuote(SimplexQuote quote) async { bool shouldPop = false; unawaited( showDialog( context: context, builder: (context) => WillPopScope( child: const CustomLoadingOverlay( message: "Loading quote data", eventBus: null, ), onWillPop: () async => shouldPop, ), ), ); quote = SimplexQuote( crypto: selectedCrypto!, fiat: selectedFiat!, youPayFiatPrice: buyWithFiat ? Decimal.parse(_buyAmountController.text) : Decimal.parse("100"), // dummy value youReceiveCryptoAmount: buyWithFiat ? Decimal.parse("0.000420282") // dummy value : Decimal.parse(_buyAmountController.text), // Ternary for this id: "id", // anything; we get an ID back receivingAddress: _receiveAddressController.text, buyWithFiat: buyWithFiat, ); final BuyResponse quoteResponse = await _loadQuote(quote); shouldPop = true; if (mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } if (quoteResponse.exception == null) { quote = quoteResponse.value as SimplexQuote; if (quote.id != 'id' && quote.id != 'someID') { // TODO detect default quote better await _showFloatingBuyQuotePreviewSheet( quote: ref.read(simplexProvider).quote, onSelected: (quote) { // TODO launch URL }, ); } else if (mounted) { await showDialog( context: context, barrierDismissible: true, builder: (context) { if (isDesktop) { return DesktopDialog( maxWidth: 450, child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Simplex API unresponsive", style: STextStyles.desktopH3(context), ), const SizedBox( height: 24, ), Text( "Simplex API unresponsive, please try again later", style: STextStyles.smallMed14(context), ), const SizedBox( height: 56, ), Row( children: [ const Spacer(), Expanded( child: PrimaryButton( buttonHeight: ButtonHeight.l, label: "Ok", onPressed: Navigator.of(context).pop, ), ), ], ), ], ), ), ); } else { return StackDialog( title: "Simplex API error", message: "${quoteResponse.exception?.errorMessage}", rightButton: TextButton( style: Theme.of(context) .extension()! .getSecondaryEnabledButtonStyle(context), child: Text( "Ok", style: STextStyles.button(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), onPressed: () { Navigator.of(context).pop(); }, ), ); } }, ); } } else if (mounted) { // Error; probably amount out of bounds // String errorMessage = "${quoteResponse.exception?.errorMessage}"; // if (errorMessage.contains('must be between')) { // errorMessage = errorMessage.substring( // errorMessage.indexOf('getQuote exception: ') + 20, // errorMessage.indexOf(", value: null")); // _BuyFormState.boundedCryptoTicker = errorMessage.substring( // errorMessage.indexOf('The ') + 4, // errorMessage.indexOf(' amount must be between')); // _BuyFormState.minCrypto = Decimal.parse(errorMessage.substring( // errorMessage.indexOf('must be between ') + 16, // errorMessage.indexOf(' and '))); // _BuyFormState.maxCrypto = Decimal.parse(errorMessage.substring( // errorMessage.indexOf("$minCrypto and ") + "$minCrypto and ".length, // errorMessage.length)); // if (Decimal.parse(_buyAmountController.text) > // _BuyFormState.maxCrypto) { // _buyAmountController.text = _BuyFormState.maxCrypto.toString(); // } // } await showDialog( context: context, barrierDismissible: true, builder: (context) { if (isDesktop) { return DesktopDialog( maxWidth: 450, child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Simplex API error", style: STextStyles.desktopH3(context), ), const SizedBox( height: 24, ), Text( quoteResponse.exception!.errorMessage, style: STextStyles.smallMed14(context), ), const SizedBox( height: 56, ), Row( children: [ const Spacer(), Expanded( child: PrimaryButton( buttonHeight: ButtonHeight.l, label: "Ok", onPressed: Navigator.of(context).pop, ), ), ], ), ], ), ), ); } else { return StackDialog( title: "Simplex API error", message: "${quoteResponse.exception?.errorMessage}", // "${quoteResponse.exception?.errorMessage.substring(8, (quoteResponse.exception?.errorMessage?.length ?? 109) - (8 + 6))}", rightButton: TextButton( style: Theme.of(context) .extension()! .getSecondaryEnabledButtonStyle(context), child: Text( "Ok", style: STextStyles.button(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), onPressed: () { Navigator.of(context).pop(); }, ), ); } }, ); } } Future> _loadQuote(SimplexQuote quote) async { final response = await SimplexAPI.instance.getQuote(quote); if (response.value != null) { // TODO check for error key ref.read(simplexProvider).updateQuote(response.value!); return BuyResponse(value: response.value!); } else { Logging.instance.log( "_loadQuote: $response", level: LogLevel.Warning, ); return BuyResponse( exception: response.exception ?? BuyException( response.toString(), BuyExceptionType.generic, ), ); } } Future _showFloatingBuyQuotePreviewSheet({ required SimplexQuote quote, required void Function(SimplexQuote) onSelected, }) async { _fiatFocusNode.unfocus(); _cryptoFocusNode.unfocus(); final result = isDesktop ? await showDialog( context: context, builder: (context) { return DesktopDialog( maxHeight: 700, maxWidth: 580, child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.only( left: 32, ), child: Text( "Preview quote", style: STextStyles.desktopH3(context), ), ), const DesktopDialogCloseButton(), ], ), Expanded( child: Padding( padding: const EdgeInsets.only( left: 32, right: 32, bottom: 32, ), child: Row( children: [ Expanded( child: RoundedWhiteContainer( padding: const EdgeInsets.all(16), borderColor: Theme.of(context) .extension()! .background, child: BuyQuotePreviewView( quote: quote, ), ), ), ], ), ), ), ], ), ); }, ) : await Navigator.of(context).push( MaterialPageRoute( builder: (_) => BuyQuotePreviewView( quote: quote, ), ), ); if (mounted && result is SimplexQuote) { onSelected(result); } } @override void initState() { _receiveAddressController = TextEditingController(); _buyAmountController = TextEditingController(); clipboard = widget.clipboard; scanner = widget.scanner; coins = ref.read(simplexProvider).supportedCryptos; fiats = ref.read(simplexProvider).supportedFiats; // quote = ref.read(simplexProvider).quote; quote = SimplexQuote( crypto: Crypto.fromJson({'ticker': 'BTC', 'name': 'Bitcoin'}), fiat: Fiat.fromJson({'ticker': 'USD', 'name': 'United States Dollar'}), youPayFiatPrice: Decimal.parse("100"), youReceiveCryptoAmount: Decimal.parse("1.0238917"), id: "someID", receivingAddress: '', buyWithFiat: true, ); // TODO enum this or something // TODO set defaults better; should probably explicitly enumerate the coins & fiats used and pull the specific ones we need rather than generating them as defaults here selectedFiat = Fiat.fromJson({'ticker': 'USD', 'name': 'United States Dollar'}); selectedCrypto = Crypto.fromJson({ 'ticker': widget.coin?.ticker ?? 'BTC', 'name': widget.coin?.prettyName ?? 'Bitcoin', }); // THIS IS BAD. No way to be certain the simplex ticker points to the same // contract as the ticker symbol of this contract // if (widget.tokenContract != null && // DefaultTokens.list // .where((e) => e.address == widget.tokenContract!.address) // .isNotEmpty) { // selectedCrypto = Crypto.fromJson({ // 'ticker': widget.tokenContract!.symbol, // 'name': widget.tokenContract!.name, // }); // } // TODO set initial crypto to open wallet if a wallet is open super.initState(); } @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); final Locale locale = Localizations.localeOf(context); final format = NumberFormat.simpleCurrency(locale: locale.toString()); // See https://stackoverflow.com/a/67055685 return ConditionalParent( condition: isDesktop, builder: (child) => SizedBox( width: 458, child: child, ), child: ConditionalParent( condition: !isDesktop, builder: (child) => LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: IntrinsicHeight( child: child, ), ), ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( "I want to buy", style: STextStyles.itemSubtitle(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), ), SizedBox( height: isDesktop ? 10 : 4, ), MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovering1 = true), onExit: (_) => setState(() => _hovering1 = false), child: GestureDetector( onTap: () { selectCrypto(); }, child: RoundedContainer( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2), color: _hovering1 ? Theme.of(context) .extension()! .currencyListItemBG .withOpacity(_hovering1 ? 0.3 : 0) : Theme.of(context) .extension()! .textFieldDefaultBG, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ CoinIconForTicker( ticker: selectedCrypto?.ticker ?? "BTC", size: 20, ), const SizedBox( width: 10, ), Expanded( child: Text( selectedCrypto?.ticker ?? "ERR", style: STextStyles.largeMedium14(context), ), ), SvgPicture.asset( Assets.svg.chevronDown, color: Theme.of(context) .extension()! .buttonTextSecondaryDisabled, width: 10, height: 5, ), ], ), ), ), ), ), SizedBox( height: isDesktop ? 20 : 12, ), Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "I want to pay with", style: STextStyles.itemSubtitle(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), ), ], ), SizedBox( height: isDesktop ? 10 : 4, ), MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovering2 = true), onExit: (_) => setState(() => _hovering2 = false), child: GestureDetector( onTap: () { selectFiat(); }, child: RoundedContainer( padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 2), color: _hovering2 ? Theme.of(context) .extension()! .currencyListItemBG .withOpacity(_hovering2 ? 0.3 : 0) : Theme.of(context) .extension()! .textFieldDefaultBG, child: Padding( padding: const EdgeInsets.only( left: 12.0, top: 12.0, right: 12.0, bottom: 12.0, ), child: Row( children: [ Container( padding: const EdgeInsets.symmetric( vertical: 3, horizontal: 6, ), decoration: BoxDecoration( color: Theme.of(context) .extension()! .currencyListItemBG, borderRadius: BorderRadius.circular(4), ), child: Text( format.simpleCurrencySymbol( selectedFiat?.ticker ?? "ERR".toUpperCase(), ), textAlign: TextAlign.center, style: STextStyles.smallMed12(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), ), const SizedBox( width: 8, ), Text( selectedFiat?.ticker ?? 'ERR', style: STextStyles.largeMedium14(context), ), const SizedBox( width: 12, ), Expanded( child: Text( selectedFiat?.name ?? 'Error', style: STextStyles.largeMedium14(context), ), ), SvgPicture.asset( Assets.svg.chevronDown, color: Theme.of(context) .extension()! .buttonTextSecondaryDisabled, width: 10, height: 5, ), ], ), ), ), ), ), SizedBox( height: isDesktop ? 10 : 4, ), Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( buyWithFiat ? "Enter amount" : "Enter crypto amount", style: STextStyles.itemSubtitle(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), ), CustomTextButton( text: buyWithFiat ? "Use crypto amount" : "Use fiat amount", onTap: () { setState(() { buyWithFiat = !buyWithFiat; }); validateAmount(); }, ), ], ), SizedBox( height: isDesktop ? 10 : 4, ), TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), key: const Key("buyAmountInputFieldTextFieldKey"), controller: _buyAmountController, // note: setting the text value here will set it every time this widget rebuilds // ..text = _BuyFormState.buyWithFiat // ? _BuyFormState.minFiat.toStringAsFixed(2) ?? '50.00' // : _BuyFormState.minCrypto.toStringAsFixed(8), focusNode: _buyAmountFocusNode, keyboardType: Util.isDesktop ? null : const TextInputType.numberWithOptions( signed: false, decimal: true, ), textAlign: TextAlign.left, // inputFormatters: [NumericalRangeFormatter()], onChanged: (_) { validateAmount(); }, decoration: InputDecoration( contentPadding: const EdgeInsets.only( // top: 22, // right: 12, // bottom: 22, left: 0, top: 8, bottom: 10, right: 5, ), hintText: "0", hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) .extension()! .textFieldDefaultText, ), prefixIcon: FittedBox( fit: BoxFit.scaleDown, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ const SizedBox(width: 2), buyWithFiat ? Container( padding: const EdgeInsets.symmetric( vertical: 3, horizontal: 6, ), decoration: BoxDecoration( color: Theme.of(context) .extension()! .currencyListItemBG, borderRadius: BorderRadius.circular(4), ), child: Text( format.simpleCurrencySymbol( selectedFiat?.ticker.toUpperCase() ?? "ERR", ), textAlign: TextAlign.center, style: STextStyles.smallMed12(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), ) : CoinIconForTicker( ticker: selectedCrypto?.ticker ?? "BTC", size: 20, ), SizedBox( width: buyWithFiat ? 8 : 10, ), // maybe make isDesktop-aware? Text( buyWithFiat ? selectedFiat?.ticker ?? "ERR" : selectedCrypto?.ticker ?? "ERR", style: STextStyles.smallMed14(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), ], ), ), ), suffixIcon: Padding( padding: const EdgeInsets.all(0), child: UnconstrainedBox( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buyAmountController.text.isNotEmpty ? TextFieldIconButton( key: const Key( "buyViewClearAmountFieldButtonKey", ), onTap: () { // if (_BuyFormState.buyWithFiat) { // _buyAmountController.text = _BuyFormState // .minFiat // .toStringAsFixed(2); // } else { // if (selectedCrypto?.ticker == // _BuyFormState.boundedCryptoTicker) { // _buyAmountController.text = _BuyFormState // .minCrypto // .toStringAsFixed(8); // } // } _buyAmountController.text = ""; validateAmount(); }, child: const XIcon(), ) : TextFieldIconButton( key: const Key( "buyViewPasteAddressFieldButtonKey", ), onTap: () async { final ClipboardData? data = await clipboard .getData(Clipboard.kTextPlain); final amountString = Decimal.tryParse(data?.text ?? ""); if (amountString != null) { _buyAmountController.text = amountString.toString(); validateAmount(); } }, child: _buyAmountController.text.isEmpty ? const ClipboardIcon() : const XIcon(), ), ], ), ), ), ), ), SizedBox( height: isDesktop ? 10 : 4, ), if (_amountOutOfRangeErrorString.isNotEmpty) Text( _amountOutOfRangeErrorString, style: STextStyles.errorSmall(context), ), SizedBox( height: isDesktop ? 20 : 12, ), Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Enter receiving address", style: STextStyles.itemSubtitle(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), ), if (AppConfig.isStackCoin(selectedCrypto?.ticker)) CustomTextButton( text: "Choose from ${AppConfig.prefix}", onTap: () { try { final coin = AppConfig.getCryptoCurrencyForTicker( selectedCrypto!.ticker, ); Navigator.of(context) .pushNamed( ChooseFromStackView.routeName, arguments: coin, ) .then((value) async { if (value is String) { final wallet = ref.read(pWallets).getWallet(value); // _toController.text = manager.walletName; // model.recipientAddress = // await manager.currentReceivingAddress; _receiveAddressController.text = (await wallet.getCurrentReceivingAddress())! .value; setState(() { _addressToggleFlag = _receiveAddressController.text.isNotEmpty; }); validateAmount(); } }); } catch (e, s) { Logging.instance.log("$e\n$s", level: LogLevel.Info); } }, ), ], ), SizedBox( height: isDesktop ? 10 : 4, ), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( key: const Key("buyViewReceiveAddressFieldKey"), controller: _receiveAddressController, readOnly: false, autocorrect: false, enableSuggestions: false, // inputFormatters: [ // FilteringTextInputFormatter.allow( // RegExp("[a-zA-Z0-9]{34}")), // ], toolbarOptions: const ToolbarOptions( copy: false, cut: false, paste: true, selectAll: false, ), onChanged: (newValue) { _address = newValue; setState(() { _addressToggleFlag = newValue.isNotEmpty; // TODO [prio=low]: Validate address. if (newValue.startsWith("bc1p")) { // Display an error message or handle invalid address _receivingAddressValidationErrorString = "Taproot addresses are not allowed."; } else { _receivingAddressValidationErrorString = ""; } }); }, focusNode: _receiveAddressFocusNode, style: STextStyles.field(context), decoration: standardInputDecoration( "Enter ${selectedCrypto?.ticker} address", _receiveAddressFocusNode, context, ).copyWith( contentPadding: const EdgeInsets.only( left: 13, top: 6, bottom: 8, right: 5, ), suffixIcon: Padding( padding: _receiveAddressController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _addressToggleFlag ? TextFieldIconButton( key: const Key( "buyViewClearAddressFieldButtonKey", ), onTap: () { _receiveAddressController.text = ""; _address = ""; setState(() { _addressToggleFlag = false; }); }, child: const XIcon(), ) : TextFieldIconButton( key: const Key( "buyViewPasteAddressFieldButtonKey", ), onTap: () async { final ClipboardData? data = await clipboard .getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { String content = data.text!.trim(); if (content.contains("\n")) { content = content.substring( 0, content.indexOf("\n"), ); } _receiveAddressController.text = content; _address = content; setState(() { _addressToggleFlag = _receiveAddressController .text.isNotEmpty; }); } }, child: _receiveAddressController.text.isEmpty ? const ClipboardIcon() : const XIcon(), ), if (_receiveAddressController.text.isEmpty && AppConfig.isStackCoin(selectedCrypto?.ticker) && isDesktop) TextFieldIconButton( key: const Key("buyViewAddressBookButtonKey"), onTap: () async { final entry = await showDialog( context: context, builder: (context) => DesktopDialog( maxWidth: 696, maxHeight: 600, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.only( left: 32, ), child: Text( "Address book", style: STextStyles.desktopH3( context, ), ), ), const DesktopDialogCloseButton(), ], ), Expanded( child: AddressBookAddressChooser( coin: AppConfig.coins.firstWhere( (e) => e.ticker.toLowerCase() == selectedCrypto!.ticker .toString() .toLowerCase(), ), ), ), ], ), ), ); if (entry != null) { _receiveAddressController.text = entry.address; _address = entry.address; setState(() { _addressToggleFlag = true; }); } }, child: const AddressBookIcon(), ), if (_receiveAddressController.text.isEmpty && AppConfig.isStackCoin(selectedCrypto?.ticker) && !isDesktop) TextFieldIconButton( key: const Key("buyViewAddressBookButtonKey"), onTap: () { Navigator.of(context, rootNavigator: isDesktop) .pushNamed( AddressBookView.routeName, ); }, child: const AddressBookIcon(), ), if (_receiveAddressController.text.isEmpty && !isDesktop) TextFieldIconButton( key: const Key("buyViewScanQrButtonKey"), onTap: () async { try { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); await Future.delayed( const Duration(milliseconds: 75), ); } final qrResult = await scanner.scan(); Logging.instance.log( "qrResult content: ${qrResult.rawContent}", level: LogLevel.Info, ); final results = AddressUtils.parseUri( qrResult.rawContent, ); Logging.instance.log( "qrResult parsed: $results", level: LogLevel.Info, ); if (results.isNotEmpty) { // auto fill address _address = results["address"] ?? ""; _receiveAddressController.text = _address!; setState(() { _addressToggleFlag = _receiveAddressController .text.isNotEmpty; }); // now check for non standard encoded basic address } else { _address = qrResult.rawContent; _receiveAddressController.text = _address ?? ""; setState(() { _addressToggleFlag = _receiveAddressController .text.isNotEmpty; }); } } on PlatformException catch (e, s) { // here we ignore the exception caused by not giving permission // to use the camera to scan a qr code Logging.instance.log( "Failed to get camera permissions while trying to scan qr code in SendView: $e\n$s", level: LogLevel.Warning, ); } }, child: const QrCodeIcon(), ), ], ), ), ), ), ), ), SizedBox( height: isDesktop ? 10 : 4, ), if (_receivingAddressValidationErrorString.isNotEmpty) Text( _receivingAddressValidationErrorString, style: STextStyles.errorSmall(context), ), SizedBox( height: isDesktop ? 20 : 12, ), PrimaryButton( buttonHeight: isDesktop ? ButtonHeight.l : null, enabled: _addressToggleFlag && _amountOutOfRangeErrorString.isEmpty && _buyAmountController.text.isNotEmpty, onPressed: () { previewQuote(quote); }, label: "Preview quote", ), ], ), ), ); } } // might need this again in the future // // See https://stackoverflow.com/a/68072967 // class NumericalRangeFormatter extends TextInputFormatter { // NumericalRangeFormatter(); // // @override // TextEditingValue formatEditUpdate( // TextEditingValue oldValue, // TextEditingValue newValue, // ) { // TextSelection newSelection = newValue.selection; // String newVal = _BuyFormState.buyWithFiat // ? Decimal.parse(newValue.text).toStringAsFixed(2) // : Decimal.parse(newValue.text).toStringAsFixed(8); // if (newValue.text == '') { // return newValue; // } else { // if (_BuyFormState.buyWithFiat) { // if (Decimal.parse(newValue.text) < _BuyFormState.minFiat) { // newVal = _BuyFormState.minFiat.toStringAsFixed(2); // // _BuyFormState._buyAmountController.selection = // // TextSelection.collapsed( // // offset: _BuyFormState.buyWithFiat // // ? _BuyFormState._buyAmountController.text.length - 2 // // : _BuyFormState._buyAmountController.text.length - 8); // } else if (Decimal.parse(newValue.text) > _BuyFormState.maxFiat) { // newVal = _BuyFormState.maxFiat.toStringAsFixed(2); // } // } else if (!_BuyFormState.buyWithFiat && // _BuyFormState.selectedCrypto?.ticker == // _BuyFormState.boundedCryptoTicker) { // if (Decimal.parse(newValue.text) < _BuyFormState.minCrypto) { // newVal = _BuyFormState.minCrypto.toStringAsFixed(8); // } else if (Decimal.parse(newValue.text) > _BuyFormState.maxCrypto) { // newVal = _BuyFormState.maxCrypto.toStringAsFixed(8); // } // } // } // // final regexString = _BuyFormState.buyWithFiat // ? r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$' // : r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$'; // // // return RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') // return RegExp(regexString).hasMatch(newVal) // ? TextEditingValue(text: newVal, selection: newSelection) // : oldValue; // } // }