From aa8b7221746637a375c2081998221b3151c97713 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 26 Oct 2022 11:59:54 -0600 Subject: [PATCH] basic desktop send layout --- .../wallet_view/desktop_wallet_view.dart | 58 +- .../wallet_view/receive/desktop_receive.dart | 17 + .../wallet_view/send/desktop_send.dart | 1171 +++++++++++++++++ 3 files changed, 1208 insertions(+), 38 deletions(-) create mode 100644 lib/pages_desktop_specific/home/my_stack_view/wallet_view/receive/desktop_receive.dart create mode 100644 lib/pages_desktop_specific/home/my_stack_view/wallet_view/send/desktop_send.dart diff --git a/lib/pages_desktop_specific/home/my_stack_view/wallet_view/desktop_wallet_view.dart b/lib/pages_desktop_specific/home/my_stack_view/wallet_view/desktop_wallet_view.dart index f8cc7e2dc..959f9c052 100644 --- a/lib/pages_desktop_specific/home/my_stack_view/wallet_view/desktop_wallet_view.dart +++ b/lib/pages_desktop_specific/home/my_stack_view/wallet_view/desktop_wallet_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:stackwallet/pages_desktop_specific/home/my_stack_view/wallet_view/receive/desktop_receive.dart'; +import 'package:stackwallet/pages_desktop_specific/home/my_stack_view/wallet_view/send/desktop_send.dart'; import 'package:stackwallet/providers/providers.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/constants.dart'; @@ -171,11 +173,13 @@ class _DesktopWalletViewState extends ConsumerState { ), Expanded( child: Row( - children: const [ + children: [ Expanded( - child: MyWallet(), + child: MyWallet( + walletId: walletId, + ), ), - SizedBox( + const SizedBox( width: 16, ), Expanded( @@ -192,7 +196,12 @@ class _DesktopWalletViewState extends ConsumerState { } class MyWallet extends StatefulWidget { - const MyWallet({Key? key}) : super(key: key); + const MyWallet({ + Key? key, + required this.walletId, + }) : super(key: key); + + final String walletId; @override State createState() => _MyWalletState(); @@ -246,10 +255,15 @@ class _MyWalletState extends State { Tab(text: "Receive"), ], ), - const Expanded( + Expanded( child: TabBarView( children: [ - DesktopSend(), + Padding( + padding: const EdgeInsets.all(20), + child: DesktopSend( + walletId: widget.walletId, + ), + ), DesktopReceive(), ], ), @@ -264,38 +278,6 @@ class _MyWalletState extends State { } } -class DesktopReceive extends StatefulWidget { - const DesktopReceive({Key? key}) : super(key: key); - - @override - State createState() => _DesktopReceiveState(); -} - -class _DesktopReceiveState extends State { - @override - Widget build(BuildContext context) { - return Container( - color: Colors.green, - ); - } -} - -class DesktopSend extends StatefulWidget { - const DesktopSend({Key? key}) : super(key: key); - - @override - State createState() => _DesktopSendState(); -} - -class _DesktopSendState extends State { - @override - Widget build(BuildContext context) { - return Container( - color: Colors.red, - ); - } -} - class RecentDesktopTransactions extends StatefulWidget { const RecentDesktopTransactions({Key? key}) : super(key: key); diff --git a/lib/pages_desktop_specific/home/my_stack_view/wallet_view/receive/desktop_receive.dart b/lib/pages_desktop_specific/home/my_stack_view/wallet_view/receive/desktop_receive.dart new file mode 100644 index 000000000..319076dfe --- /dev/null +++ b/lib/pages_desktop_specific/home/my_stack_view/wallet_view/receive/desktop_receive.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class DesktopReceive extends StatefulWidget { + const DesktopReceive({Key? key}) : super(key: key); + + @override + State createState() => _DesktopReceiveState(); +} + +class _DesktopReceiveState extends State { + @override + Widget build(BuildContext context) { + return Container( + color: Colors.green, + ); + } +} diff --git a/lib/pages_desktop_specific/home/my_stack_view/wallet_view/send/desktop_send.dart b/lib/pages_desktop_specific/home/my_stack_view/wallet_view/send/desktop_send.dart new file mode 100644 index 000000000..8f45b70c4 --- /dev/null +++ b/lib/pages_desktop_specific/home/my_stack_view/wallet_view/send/desktop_send.dart @@ -0,0 +1,1171 @@ +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/flutter_svg.dart'; +import 'package:stackwallet/models/send_view_auto_fill_data.dart'; +import 'package:stackwallet/pages/address_book_views/address_book_view.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/firo_balance_selection_sheet.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/providers/ui/fee_rate_type_state_provider.dart'; +import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart'; +import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart'; +import 'package:stackwallet/route_generator.dart'; +import 'package:stackwallet/services/coins/firo/firo_wallet.dart'; +import 'package:stackwallet/services/coins/manager.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; +import 'package:stackwallet/utilities/clipboard_interface.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/theme/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/animated_text.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/icon_widgets/addressbook_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart'; +import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:stackwallet/widgets/stack_text_field.dart'; +import 'package:stackwallet/widgets/textfield_icon_button.dart'; + +import '../../../../../pages/send_view/confirm_transaction_view.dart'; +import '../../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; + +class DesktopSend extends ConsumerStatefulWidget { + const DesktopSend({ + Key? key, + required this.walletId, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + this.barcodeScanner = const BarcodeScannerWrapper(), + }) : super(key: key); + + final String walletId; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + final BarcodeScannerInterface barcodeScanner; + + @override + ConsumerState createState() => _DesktopSendState(); +} + +class _DesktopSendState extends ConsumerState { + late final String walletId; + late final Coin coin; + late final ClipboardInterface clipboard; + late final BarcodeScannerInterface scanner; + + late TextEditingController sendToController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController noteController; + late TextEditingController feeController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _noteFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + + Decimal? _amountToSend; + Decimal? _cachedAmountToSend; + String? _address; + + String? _privateBalanceString; + String? _publicBalanceString; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + Decimal? _cachedBalance; + + Future previewSend() async { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + final manager = + ref.read(walletsChangeNotifierProvider).getManager(walletId); + + // TODO: remove the need for this!! + final bool isOwnAddress = await manager.isOwnAddress(_address!); + if (isOwnAddress) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: "Sending to self is currently disabled", + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonColor(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + return; + } + + final amount = Format.decimalAmountToSatoshis(_amountToSend!); + int availableBalance; + if ((coin == Coin.firo || coin == Coin.firoTestNet)) { + if (ref.read(publicPrivateBalanceStateProvider.state).state == + "Private") { + availableBalance = Format.decimalAmountToSatoshis( + await (manager.wallet as FiroWallet).availablePrivateBalance()); + } else { + availableBalance = Format.decimalAmountToSatoshis( + await (manager.wallet as FiroWallet).availablePublicBalance()); + } + } else { + availableBalance = + Format.decimalAmountToSatoshis(await manager.availableBalance); + } + + // confirm send all + if (amount == availableBalance) { + final bool? shouldSendAll = await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Confirm send all", + message: + "You are about to send your entire balance. Would you like to continue?", + leftButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonColor(context), + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonColor(context), + child: Text( + "Yes", + style: STextStyles.button(context), + ), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ); + }, + ); + + if (shouldSendAll == null || shouldSendAll == false) { + // cancel preview + return; + } + } + + try { + bool wasCancelled = false; + + unawaited(showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return BuildingTransactionDialog( + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ); + }, + )); + + Map txData; + + if ((coin == Coin.firo || coin == Coin.firoTestNet) && + ref.read(publicPrivateBalanceStateProvider.state).state != + "Private") { + txData = await (manager.wallet as FiroWallet).prepareSendPublic( + address: _address!, + satoshiAmount: amount, + args: {"feeRate": ref.read(feeRateTypeStateProvider)}, + ); + } else { + txData = await manager.prepareSend( + address: _address!, + satoshiAmount: amount, + args: {"feeRate": ref.read(feeRateTypeStateProvider)}, + ); + } + + if (!wasCancelled && mounted) { + // pop building dialog + Navigator.of(context).pop(); + txData["note"] = noteController.text; + txData["address"] = _address; + + unawaited(Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + transactionInfo: txData, + walletId: walletId, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + )); + } + } catch (e) { + if (mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited(showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonColor(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + )); + } + } + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + final String cryptoAmount = cryptoAmountController.text; + if (cryptoAmount.isNotEmpty && + cryptoAmount != "." && + cryptoAmount != ",") { + _amountToSend = cryptoAmount.contains(",") + ? Decimal.parse(cryptoAmount.replaceFirst(",", ".")) + : Decimal.parse(cryptoAmount); + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + Logging.instance.log("it changed $_amountToSend $_cachedAmountToSend", + level: LogLevel.Info); + + final price = + ref.read(priceAnd24hChangeNotifierProvider).getPrice(coin).item1; + + if (price > Decimal.zero) { + final String fiatAmountString = Format.localizedStringAsFixed( + value: _amountToSend! * price, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + decimalPlaces: 2, + ); + + baseAmountController.text = fiatAmountString; + } + } else { + _amountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + } + + String? _updateInvalidAddressText(String address, Manager manager) { + if (_data != null && _data!.contactLabel == address) { + return null; + } + if (address.isNotEmpty && !manager.validateAddress(address)) { + return "Invalid address"; + } + return null; + } + + void _updatePreviewButtonState(String? address, Decimal? amount) { + final isValidAddress = ref + .read(walletsChangeNotifierProvider) + .getManager(walletId) + .validateAddress(address ?? ""); + ref.read(previewTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Decimal.zero); + } + + late Future _calculateFeesFuture; + + Map cachedFees = {}; + Map cachedFiroPrivateFees = {}; + Map cachedFiroPublicFees = {}; + + Future calculateFees(int amount) async { + if (amount <= 0) { + return "0"; + } + + if (coin == Coin.firo || coin == Coin.firoTestNet) { + if (ref.read(publicPrivateBalanceStateProvider.state).state == + "Private") { + if (cachedFiroPrivateFees[amount] != null) { + return cachedFiroPrivateFees[amount]!; + } + } else { + if (cachedFiroPublicFees[amount] != null) { + return cachedFiroPublicFees[amount]!; + } + } + } else if (cachedFees[amount] != null) { + return cachedFees[amount]!; + } + + final manager = + ref.read(walletsChangeNotifierProvider).getManager(walletId); + final feeObject = await manager.fees; + + late final int feeRate; + + switch (ref.read(feeRateTypeStateProvider.state).state) { + case FeeRateType.fast: + feeRate = feeObject.fast; + break; + case FeeRateType.average: + feeRate = feeObject.medium; + break; + case FeeRateType.slow: + feeRate = feeObject.slow; + break; + } + + int fee; + + if (coin == Coin.firo || coin == Coin.firoTestNet) { + if (ref.read(publicPrivateBalanceStateProvider.state).state == + "Private") { + fee = await manager.estimateFeeFor(amount, feeRate); + + cachedFiroPrivateFees[amount] = Format.satoshisToAmount(fee) + .toStringAsFixed(Constants.decimalPlaces); + + return cachedFiroPrivateFees[amount]!; + } else { + fee = await (manager.wallet as FiroWallet) + .estimateFeeForPublic(amount, feeRate); + + cachedFiroPublicFees[amount] = Format.satoshisToAmount(fee) + .toStringAsFixed(Constants.decimalPlaces); + + return cachedFiroPublicFees[amount]!; + } + } else { + fee = await manager.estimateFeeFor(amount, feeRate); + cachedFees[amount] = + Format.satoshisToAmount(fee).toStringAsFixed(Constants.decimalPlaces); + + return cachedFees[amount]!; + } + } + + Future _firoBalanceFuture( + ChangeNotifierProvider provider, String locale) async { + final wallet = ref.read(provider).wallet as FiroWallet?; + + if (wallet != null) { + Decimal? balance; + if (ref.read(publicPrivateBalanceStateProvider.state).state == + "Private") { + balance = await wallet.availablePrivateBalance(); + } else { + balance = await wallet.availablePublicBalance(); + } + + return Format.localizedStringAsFixed( + value: balance, locale: locale, decimalPlaces: 8); + } + + return null; + } + + @override + void initState() { + ref.refresh(feeSheetSessionCacheProvider); + + _calculateFeesFuture = calculateFees(0); + _data = widget.autoFillData; + walletId = widget.walletId; + coin = ref.read(walletsChangeNotifierProvider).getManager(walletId).coin; + clipboard = widget.clipboard; + scanner = widget.barcodeScanner; + + sendToController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + noteController = TextEditingController(); + feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + + if (_data != null) { + if (_data!.amount != null) { + cryptoAmountController.text = _data!.amount!.toString(); + } + sendToController.text = _data!.contactLabel; + _address = _data!.address; + _addressToggleFlag = true; + } + + _cryptoFocus.addListener(() { + if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { + if (_amountToSend == null) { + setState(() { + _calculateFeesFuture = calculateFees(0); + }); + } else { + setState(() { + _calculateFeesFuture = + calculateFees(Format.decimalAmountToSatoshis(_amountToSend!)); + }); + } + } + }); + + _baseFocus.addListener(() { + if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { + if (_amountToSend == null) { + setState(() { + _calculateFeesFuture = calculateFees(0); + }); + } else { + setState(() { + _calculateFeesFuture = + calculateFees(Format.decimalAmountToSatoshis(_amountToSend!)); + }); + } + } + }); + + super.initState(); + } + + @override + void dispose() { + cryptoAmountController.removeListener(onCryptoAmountChanged); + + sendToController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + noteController.dispose(); + feeController.dispose(); + + _noteFocusNode.dispose(); + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final provider = ref.watch(walletsChangeNotifierProvider + .select((value) => value.getManagerProvider(walletId))); + final String locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale)); + + if (coin == Coin.firo || coin == Coin.firoTestNet) { + ref.listen(publicPrivateBalanceStateProvider, (previous, next) { + if (_amountToSend == null) { + setState(() { + _calculateFeesFuture = calculateFees(0); + }); + } else { + setState(() { + _calculateFeesFuture = + calculateFees(Format.decimalAmountToSatoshis(_amountToSend!)); + }); + } + }); + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 4, + ), + if (coin == Coin.firo) + Text( + "Send from", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + if (coin == Coin.firo) + const SizedBox( + height: 10, + ), + if (coin == Coin.firo) + Stack( + children: [ + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + readOnly: true, + textInputAction: TextInputAction.none, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: RawMaterialButton( + splashColor: + Theme.of(context).extension()!.highlight, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (_) => FiroBalanceSelectionSheet( + walletId: walletId, + ), + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + "${ref.watch(publicPrivateBalanceStateProvider.state).state} balance", + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox( + width: 10, + ), + FutureBuilder( + future: _firoBalanceFuture(provider, locale), + builder: + (context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == + ConnectionState.done && + snapshot.hasData) { + if (ref + .read( + publicPrivateBalanceStateProvider + .state) + .state == + "Private") { + _privateBalanceString = snapshot.data!; + } else { + _publicBalanceString = snapshot.data!; + } + } + if (ref + .read( + publicPrivateBalanceStateProvider + .state) + .state == + "Private" && + _privateBalanceString != null) { + return Text( + "$_privateBalanceString ${coin.ticker}", + style: STextStyles.itemSubtitle(context), + ); + } else if (ref + .read( + publicPrivateBalanceStateProvider + .state) + .state == + "Public" && + _publicBalanceString != null) { + return Text( + "$_publicBalanceString ${coin.ticker}", + style: STextStyles.itemSubtitle(context), + ); + } else { + return AnimatedText( + stringsToLoopThrough: const [ + "Loading balance", + "Loading balance.", + "Loading balance..", + "Loading balance...", + ], + style: STextStyles.itemSubtitle(context), + ); + } + }, + ), + ], + ), + SvgPicture.asset( + Assets.svg.chevronDown, + width: 8, + height: 4, + color: Theme.of(context) + .extension()! + .textSubtitle2, + ), + ], + ), + ), + ) + ], + ), + if (coin == Coin.firo) + const SizedBox( + height: 20, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + BlueTextButton( + text: "Send all ${coin.ticker}", + onTap: () async { + if (coin == Coin.firo || coin == Coin.firoTestNet) { + final firoWallet = ref.read(provider).wallet as FiroWallet; + if (ref + .read(publicPrivateBalanceStateProvider.state) + .state == + "Private") { + cryptoAmountController.text = + (await firoWallet.availablePrivateBalance()) + .toStringAsFixed(Constants.decimalPlaces); + } else { + cryptoAmountController.text = + (await firoWallet.availablePublicBalance()) + .toStringAsFixed(Constants.decimalPlaces); + } + } else { + cryptoAmountController.text = + (await ref.read(provider).availableBalance) + .toStringAsFixed(Constants.decimalPlaces); + } + }, + ), + ], + ), + const SizedBox( + height: 10, + ), + 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("amountInputFieldCryptoTextFieldKey"), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + // regex to validate a crypto amount with 8 decimal places + TextInputFormatter.withFunction((oldValue, newValue) => + RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') + .hasMatch(newValue.text) + ? newValue + : oldValue), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel(context).copyWith( + fontSize: 14, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + coin.ticker, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + ), + ), + if (Prefs.instance.externalCalls) + const SizedBox( + height: 10, + ), + if (Prefs.instance.externalCalls) + 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("amountInputFieldFiatTextFieldKey"), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + // regex to validate a fiat amount with 2 decimal places + TextInputFormatter.withFunction((oldValue, newValue) => + RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + .hasMatch(newValue.text) + ? newValue + : oldValue), + ], + onChanged: (baseAmountString) { + if (baseAmountString.isNotEmpty && + baseAmountString != "." && + baseAmountString != ",") { + final baseAmount = baseAmountString.contains(",") + ? Decimal.parse(baseAmountString.replaceFirst(",", ".")) + : Decimal.parse(baseAmountString); + + var _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getPrice(coin) + .item1; + + if (_price == Decimal.zero) { + _amountToSend = Decimal.zero; + } else { + _amountToSend = baseAmount <= Decimal.zero + ? Decimal.zero + : (baseAmount / _price).toDecimal( + scaleOnInfinitePrecision: Constants.decimalPlaces); + } + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + Logging.instance.log( + "it changed $_amountToSend $_cachedAmountToSend", + level: LogLevel.Info); + + final amountString = Format.localizedStringAsFixed( + value: _amountToSend!, + locale: + ref.read(localeServiceChangeNotifierProvider).locale, + decimalPlaces: Constants.decimalPlaces, + ); + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = amountString; + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Decimal.zero; + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + // setState(() { + // _calculateFeesFuture = calculateFees( + // Format.decimalAmountToSatoshis( + // _amountToSend!)); + // }); + _updatePreviewButtonState(_address, _amountToSend); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel(context).copyWith( + fontSize: 14, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch(prefsChangeNotifierProvider + .select((value) => value.currency)), + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ), + ), + ), + const SizedBox( + height: 20, + ), + Text( + "Send to", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 10, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("sendViewAddressFieldKey"), + controller: sendToController, + 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; + _updatePreviewButtonState(_address, _amountToSend); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter ${coin.ticker} address", + _addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.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( + "sendViewClearAddressFieldButtonKey"), + onTap: () { + sendToController.text = ""; + _address = ""; + _updatePreviewButtonState( + _address, _amountToSend); + setState(() { + _addressToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendViewPasteAddressFieldButtonKey"), + 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")); + } + + sendToController.text = content; + _address = content; + + _updatePreviewButtonState( + _address, _amountToSend); + setState(() { + _addressToggleFlag = + sendToController.text.isNotEmpty; + }); + } + }, + child: sendToController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key("sendViewAddressBookButtonKey"), + onTap: () { + Navigator.of(context).pushNamed( + AddressBookView.routeName, + arguments: coin, + ); + }, + child: const AddressBookIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key("sendViewScanQrButtonKey"), + 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 && + results["scheme"] == coin.uriScheme) { + // auto fill address + _address = results["address"] ?? ""; + sendToController.text = _address!; + + // autofill notes field + if (results["message"] != null) { + noteController.text = results["message"]!; + } else if (results["label"] != null) { + noteController.text = results["label"]!; + } + + // autofill amount field + if (results["amount"] != null) { + final amount = + Decimal.parse(results["amount"]!); + cryptoAmountController.text = + Format.localizedStringAsFixed( + value: amount, + locale: ref + .read( + localeServiceChangeNotifierProvider) + .locale, + decimalPlaces: Constants.decimalPlaces, + ); + amount.toString(); + _amountToSend = amount; + } + + _updatePreviewButtonState( + _address, _amountToSend); + setState(() { + _addressToggleFlag = + sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else if (ref + .read(walletsChangeNotifierProvider) + .getManager(walletId) + .validateAddress(qrResult.rawContent)) { + _address = qrResult.rawContent; + sendToController.text = _address ?? ""; + + _updatePreviewButtonState( + _address, _amountToSend); + setState(() { + _addressToggleFlag = + sendToController.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(), + ) + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText( + _address ?? "", + ref.read(walletsChangeNotifierProvider).getManager(walletId), + ); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + top: 4.0, + ), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of(context) + .extension()! + .textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox( + height: 20, + ), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 10, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox( + height: 36, + ), + PrimaryButton( + label: "Preview send", + enabled: ref.watch(previewTxButtonStateProvider.state).state, + onPressed: ref.watch(previewTxButtonStateProvider.state).state + ? previewSend + : null, + ) + ], + ), + ); + } +}