diff --git a/cw_monero/ios/Classes/monero_api.cpp b/cw_monero/ios/Classes/monero_api.cpp index d58f4e509..7ab1780c8 100644 --- a/cw_monero/ios/Classes/monero_api.cpp +++ b/cw_monero/ios/Classes/monero_api.cpp @@ -495,6 +495,48 @@ extern "C" return true; } + bool transaction_create_mult_dest(char **addresses, char *payment_id, char **amounts, uint32_t size, + uint8_t priority_raw, uint32_t subaddr_account, Utf8Box &error, PendingTransactionRaw &pendingTransaction) + { + nice(19); + + std::vector _addresses; + std::vector _amounts; + + for (int i = 0; i < size; i++) { + _addresses.push_back(std::string(*addresses)); + _amounts.push_back(Monero::Wallet::amountFromString(std::string(*amounts))); + addresses++; + amounts++; + } + + auto priority = static_cast(priority_raw); + std::string _payment_id; + Monero::PendingTransaction *transaction; + + if (payment_id != nullptr) + { + _payment_id = std::string(payment_id); + } + + transaction = m_wallet->createTransactionMultDest(_addresses, _payment_id, _amounts, m_wallet->defaultMixin(), priority, subaddr_account); + + int status = transaction->status(); + + if (status == Monero::PendingTransaction::Status::Status_Error || status == Monero::PendingTransaction::Status::Status_Critical) + { + error = Utf8Box(strdup(transaction->errorString().c_str())); + return false; + } + + if (m_listener != nullptr) { + m_listener->m_new_transaction = true; + } + + pendingTransaction = PendingTransactionRaw(transaction); + return true; + } + bool transaction_commit(PendingTransactionRaw *transaction, Utf8Box &error) { bool committed = transaction->transaction->commit(); diff --git a/cw_monero/lib/monero_output.dart b/cw_monero/lib/monero_output.dart new file mode 100644 index 000000000..831ee1f22 --- /dev/null +++ b/cw_monero/lib/monero_output.dart @@ -0,0 +1,8 @@ +import 'package:flutter/foundation.dart'; + +class MoneroOutput { + MoneroOutput({@required this.address, @required this.amount}); + + final String address; + final String amount; +} \ No newline at end of file diff --git a/cw_monero/lib/signatures.dart b/cw_monero/lib/signatures.dart index 0f75288e7..1e8ad0fbd 100644 --- a/cw_monero/lib/signatures.dart +++ b/cw_monero/lib/signatures.dart @@ -95,6 +95,16 @@ typedef transaction_create = Int8 Function( Pointer error, Pointer pendingTransaction); +typedef transaction_create_mult_dest = Int8 Function( + Pointer> addresses, + Pointer paymentId, + Pointer> amounts, + Int32 size, + Int8 priorityRaw, + Int32 subaddrAccount, + Pointer error, + Pointer pendingTransaction); + typedef transaction_commit = Int8 Function(Pointer, Pointer); typedef secret_view_key = Pointer Function(); diff --git a/cw_monero/lib/transaction_history.dart b/cw_monero/lib/transaction_history.dart index 7e6f94c23..0ca584778 100644 --- a/cw_monero/lib/transaction_history.dart +++ b/cw_monero/lib/transaction_history.dart @@ -1,5 +1,6 @@ import 'dart:ffi'; import 'package:cw_monero/convert_utf8_to_string.dart'; +import 'package:cw_monero/monero_output.dart'; import 'package:cw_monero/structs/ut8_box.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; @@ -26,6 +27,10 @@ final transactionCreateNative = moneroApi .lookup>('transaction_create') .asFunction(); +final transactionCreateMultDestNative = moneroApi + .lookup>('transaction_create_mult_dest') + .asFunction(); + final transactionCommitNative = moneroApi .lookup>('transaction_commit') .asFunction(); @@ -102,6 +107,59 @@ PendingTransactionDescription createTransactionSync( pointerAddress: pendingTransactionRawPointer.address); } +PendingTransactionDescription createTransactionMultDestSync( + {List outputs, + String paymentId, + int priorityRaw, + int accountIndex = 0}) { + final int size = outputs.length; + final List> addressesPointers = outputs.map((output) => + Utf8.toUtf8(output.address)).toList(); + final Pointer> addressesPointerPointer = allocate(count: size); + final List> amountsPointers = outputs.map((output) => + Utf8.toUtf8(output.amount)).toList(); + final Pointer> amountsPointerPointer = allocate(count: size); + + for (int i = 0; i < size; i++) { + addressesPointerPointer[i] = addressesPointers[i]; + amountsPointerPointer[i] = amountsPointers[i]; + } + + final paymentIdPointer = Utf8.toUtf8(paymentId); + final errorMessagePointer = allocate(); + final pendingTransactionRawPointer = allocate(); + final created = transactionCreateMultDestNative( + addressesPointerPointer, + paymentIdPointer, + amountsPointerPointer, + size, + priorityRaw, + accountIndex, + errorMessagePointer, + pendingTransactionRawPointer) != + 0; + + free(addressesPointerPointer); + free(amountsPointerPointer); + + addressesPointers.forEach((element) => free(element)); + amountsPointers.forEach((element) => free(element)); + + free(paymentIdPointer); + + if (!created) { + final message = errorMessagePointer.ref.getValue(); + free(errorMessagePointer); + throw CreationTransactionException(message: message); + } + + return PendingTransactionDescription( + amount: pendingTransactionRawPointer.ref.amount, + fee: pendingTransactionRawPointer.ref.fee, + hash: pendingTransactionRawPointer.ref.getHash(), + pointerAddress: pendingTransactionRawPointer.address); +} + void commitTransactionFromPointerAddress({int address}) => commitTransaction( transactionPointer: Pointer.fromAddress(address)); @@ -132,9 +190,22 @@ PendingTransactionDescription _createTransactionSync(Map args) { accountIndex: accountIndex); } +PendingTransactionDescription _createTransactionMultDestSync(Map args) { + final outputs = args['outputs'] as List; + final paymentId = args['paymentId'] as String; + final priorityRaw = args['priorityRaw'] as int; + final accountIndex = args['accountIndex'] as int; + + return createTransactionMultDestSync( + outputs: outputs, + paymentId: paymentId, + priorityRaw: priorityRaw, + accountIndex: accountIndex); +} + Future createTransaction( {String address, - String paymentId, + String paymentId = '', String amount, int priorityRaw, int accountIndex = 0}) => @@ -145,3 +216,15 @@ Future createTransaction( 'priorityRaw': priorityRaw, 'accountIndex': accountIndex }); + +Future createTransactionMultDest( + {List outputs, + String paymentId = '', + int priorityRaw, + int accountIndex = 0}) => + compute(_createTransactionMultDestSync, { + 'outputs': outputs, + 'paymentId': paymentId, + 'priorityRaw': priorityRaw, + 'accountIndex': accountIndex + }); diff --git a/cw_monero/lib/types.dart b/cw_monero/lib/types.dart index 1cc1a6055..b972231e6 100644 --- a/cw_monero/lib/types.dart +++ b/cw_monero/lib/types.dart @@ -93,6 +93,16 @@ typedef TransactionCreate = int Function( Pointer error, Pointer pendingTransaction); +typedef TransactionCreateMultDest = int Function( + Pointer> addresses, + Pointer paymentId, + Pointer> amounts, + int size, + int priorityRaw, + int subaddrAccount, + Pointer error, + Pointer pendingTransaction); + typedef TransactionCommit = int Function(Pointer, Pointer); typedef SecretViewKey = Pointer Function(); diff --git a/lib/bitcoin/bitcoin_amount_format.dart b/lib/bitcoin/bitcoin_amount_format.dart index fa00387f1..714fc5a97 100644 --- a/lib/bitcoin/bitcoin_amount_format.dart +++ b/lib/bitcoin/bitcoin_amount_format.dart @@ -16,24 +16,12 @@ double bitcoinAmountToDouble({int amount}) => cryptoAmountToDouble(amount: amount, divider: bitcoinAmountDivider); int stringDoubleToBitcoinAmount(String amount) { - final splitted = amount.split(''); - final dotIndex = amount.indexOf('.'); int result = 0; - - for (var i = 0; i < splitted.length; i++) { - try { - if (dotIndex == i) { - continue; - } - - final char = splitted[i]; - final multiplier = dotIndex < i - ? bitcoinAmountDivider ~/ pow(10, (i - dotIndex)) - : (bitcoinAmountDivider * pow(10, (dotIndex - i -1))).toInt(); - final num = int.parse(char) * multiplier; - result += num; - } catch (_) {} + try { + result = (double.parse(amount) * bitcoinAmountDivider).toInt(); + } catch (e) { + result = 0; } return result; diff --git a/lib/bitcoin/bitcoin_transaction_credentials.dart b/lib/bitcoin/bitcoin_transaction_credentials.dart index 0c977fef8..2799dbb28 100644 --- a/lib/bitcoin/bitcoin_transaction_credentials.dart +++ b/lib/bitcoin/bitcoin_transaction_credentials.dart @@ -1,9 +1,9 @@ import 'package:cake_wallet/bitcoin/bitcoin_transaction_priority.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; class BitcoinTransactionCredentials { - BitcoinTransactionCredentials(this.address, this.amount, this.priority); + BitcoinTransactionCredentials(this.outputs, this.priority); - final String address; - final String amount; + final List outputs; BitcoinTransactionPriority priority; } diff --git a/lib/bitcoin/electrum_wallet.dart b/lib/bitcoin/electrum_wallet.dart index f86b04aa3..b2f5cf563 100644 --- a/lib/bitcoin/electrum_wallet.dart +++ b/lib/bitcoin/electrum_wallet.dart @@ -158,9 +158,8 @@ abstract class ElectrumWalletBase extends WalletBase[]; - final credentialsAmount = transactionCredentials.amount != null - ? stringDoubleToBitcoinAmount(transactionCredentials.amount) - : 0; + final outputs = transactionCredentials.outputs; + final hasMultiDestination = outputs.length > 1; var allInputsAmount = 0; if (unspentCoins.isEmpty) { @@ -174,28 +173,60 @@ abstract class ElectrumWalletBase extends WalletBase 0 && allInputsAmount < credentialsAmount)) { + if (inputs.isEmpty) { throw BitcoinTransactionNoInputsException(); } - final allAmountFee = - feeAmountForPriority(transactionCredentials.priority, inputs.length, 1); + final allAmountFee = feeAmountForPriority( + transactionCredentials.priority, inputs.length, outputs.length); final allAmount = allInputsAmount - allAmountFee; - final amount = transactionCredentials.amount == null || - allAmount - credentialsAmount < minAmount - ? allAmount - : credentialsAmount; - final fee = transactionCredentials.amount == null || amount == allAmount - ? allAmountFee - : calculateEstimatedFee(transactionCredentials.priority, amount); + var credentialsAmount = 0; + var amount = 0; + var fee = 0; + + if (hasMultiDestination) { + if (outputs.any((item) => item.sendAll + || item.formattedCryptoAmount <= 0)) { + throw BitcoinTransactionWrongBalanceException(currency); + } + + credentialsAmount = outputs.fold(0, (acc, value) => + acc + value.formattedCryptoAmount); + + if (allAmount - credentialsAmount < minAmount) { + throw BitcoinTransactionWrongBalanceException(currency); + } + + amount = credentialsAmount; + + fee = calculateEstimatedFee(transactionCredentials.priority, amount, + outputsCount: outputs.length + 1); + } else { + final output = outputs.first; + + credentialsAmount = !output.sendAll + ? output.formattedCryptoAmount + : 0; + + if (credentialsAmount > allAmount) { + throw BitcoinTransactionWrongBalanceException(currency); + } + + amount = output.sendAll || allAmount - credentialsAmount < minAmount + ? allAmount + : credentialsAmount; + + fee = output.sendAll || amount == allAmount + ? allAmountFee + : calculateEstimatedFee(transactionCredentials.priority, amount); + } if (fee == 0) { throw BitcoinTransactionWrongBalanceException(currency); } - final totalAmount = amount + fee; + final totalAmount = amount + fee; if (totalAmount > balance.confirmed || totalAmount > allInputsAmount) { throw BitcoinTransactionWrongBalanceException(currency); @@ -234,8 +265,8 @@ abstract class ElectrumWalletBase extends WalletBase(param1: item))); - getIt.registerFactory(() => SendViewModel( + getIt.registerFactory(() => SendTemplateViewModel( getIt.get().wallet, getIt.get().settingsStore, getIt.get(), + getIt.get() + )); + + getIt.registerFactory(() => SendViewModel( + getIt.get().wallet, + getIt.get().settingsStore, + getIt.get(), getIt.get(), _transactionDescriptionBox)); @@ -327,7 +335,8 @@ Future setup( () => SendPage(sendViewModel: getIt.get())); getIt.registerFactory( - () => SendTemplatePage(sendViewModel: getIt.get())); + () => SendTemplatePage( + sendTemplateViewModel: getIt.get())); getIt.registerFactory(() => WalletListViewModel( _walletInfoSource, diff --git a/lib/monero/monero_transaction_creation_credentials.dart b/lib/monero/monero_transaction_creation_credentials.dart index 75d3eaa79..1a9430619 100644 --- a/lib/monero/monero_transaction_creation_credentials.dart +++ b/lib/monero/monero_transaction_creation_credentials.dart @@ -1,13 +1,11 @@ import 'package:cake_wallet/entities/transaction_creation_credentials.dart'; import 'package:cake_wallet/entities/monero_transaction_priority.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; class MoneroTransactionCreationCredentials extends TransactionCreationCredentials { - MoneroTransactionCreationCredentials( - {this.address, this.paymentId, this.priority, this.amount}); + MoneroTransactionCreationCredentials({this.outputs, this.priority}); - final String address; - final String paymentId; - final String amount; + final List outputs; final MoneroTransactionPriority priority; } diff --git a/lib/monero/monero_wallet.dart b/lib/monero/monero_wallet.dart index 36f2e4bc8..a76b03ba8 100644 --- a/lib/monero/monero_wallet.dart +++ b/lib/monero/monero_wallet.dart @@ -5,6 +5,7 @@ import 'package:cake_wallet/monero/monero_transaction_creation_exception.dart'; import 'package:cake_wallet/monero/monero_transaction_info.dart'; import 'package:cake_wallet/monero/monero_wallet_addresses.dart'; import 'package:cake_wallet/monero/monero_wallet_utils.dart'; +import 'package:cw_monero/structs/pending_transaction.dart'; import 'package:flutter/foundation.dart'; import 'package:mobx/mobx.dart'; import 'package:cw_monero/transaction_history.dart' @@ -12,6 +13,7 @@ import 'package:cw_monero/transaction_history.dart' import 'package:cw_monero/wallet.dart'; import 'package:cw_monero/wallet.dart' as monero_wallet; import 'package:cw_monero/transaction_history.dart' as transaction_history; +import 'package:cw_monero/monero_output.dart'; import 'package:cake_wallet/monero/monero_transaction_creation_credentials.dart'; import 'package:cake_wallet/monero/pending_monero_transaction.dart'; import 'package:cake_wallet/monero/monero_wallet_keys.dart'; @@ -149,31 +151,66 @@ abstract class MoneroWalletBase extends WalletBase createTransaction(Object credentials) async { final _credentials = credentials as MoneroTransactionCreationCredentials; - final amount = _credentials.amount != null - ? moneroParseAmount(amount: _credentials.amount) - : null; + final outputs = _credentials.outputs; + final hasMultiDestination = outputs.length > 1; final unlockedBalance = - monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account.id); + monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account.id); - if ((amount != null && unlockedBalance < amount) || - (amount == null && unlockedBalance <= 0)) { - final formattedBalance = moneroAmountToString(amount: unlockedBalance); - - throw MoneroTransactionCreationException( - 'Incorrect unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${_credentials.amount}.'); - } + PendingTransactionDescription pendingTransactionDescription; if (!(syncStatus is SyncedSyncStatus)) { throw MoneroTransactionCreationException('The wallet is not synced.'); } - final pendingTransactionDescription = - await transaction_history.createTransaction( - address: _credentials.address, - paymentId: _credentials.paymentId, - amount: _credentials.amount, - priorityRaw: _credentials.priority.serialize(), - accountIndex: walletAddresses.account.id); + if (hasMultiDestination) { + if (outputs.any((item) => item.sendAll + || item.formattedCryptoAmount <= 0)) { + throw MoneroTransactionCreationException('Wrong balance. Not enough XMR on your balance.'); + } + + final int totalAmount = outputs.fold(0, (acc, value) => + acc + value.formattedCryptoAmount); + + if (unlockedBalance < totalAmount) { + throw MoneroTransactionCreationException('Wrong balance. Not enough XMR on your balance.'); + } + + final moneroOutputs = outputs.map((output) => + MoneroOutput( + address: output.address, + amount: output.cryptoAmount.replaceAll(',', '.'))) + .toList(); + + pendingTransactionDescription = + await transaction_history.createTransactionMultDest( + outputs: moneroOutputs, + priorityRaw: _credentials.priority.serialize(), + accountIndex: walletAddresses.account.id); + } else { + final output = outputs.first; + final address = output.address; + final amount = output.sendAll + ? null + : output.cryptoAmount.replaceAll(',', '.'); + final formattedAmount = output.sendAll + ? null + : output.formattedCryptoAmount; + + if ((formattedAmount != null && unlockedBalance < formattedAmount) || + (formattedAmount == null && unlockedBalance <= 0)) { + final formattedBalance = moneroAmountToString(amount: unlockedBalance); + + throw MoneroTransactionCreationException( + 'Incorrect unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${output.cryptoAmount}.'); + } + + pendingTransactionDescription = + await transaction_history.createTransaction( + address: address, + amount: amount, + priorityRaw: _credentials.priority.serialize(), + accountIndex: walletAddresses.account.id); + } return PendingMoneroTransaction(pendingTransactionDescription); } diff --git a/lib/src/screens/exchange/exchange_page.dart b/lib/src/screens/exchange/exchange_page.dart index 8811b677f..3f44f62d7 100644 --- a/lib/src/screens/exchange/exchange_page.dart +++ b/lib/src/screens/exchange/exchange_page.dart @@ -786,22 +786,7 @@ class ExchangePage extends BasePage { BuildContext context, String domain, String ticker) async { final parsedAddress = await parseAddressFromDomain(domain, ticker); - switch (parsedAddress.parseFrom) { - case ParseFrom.unstoppableDomains: - showAddressAlert( - context, - S.of(context).address_detected, - S.of(context).address_from_domain(parsedAddress.name)); - break; - case ParseFrom.openAlias: - showAddressAlert( - context, - S.of(context).openalias_alert_title, - S.of(context).openalias_alert_content(parsedAddress.name)); - break; - case ParseFrom.notParsed: - break; - } + showAddressAlert(context, parsedAddress); return parsedAddress.address; } diff --git a/lib/src/screens/exchange/widgets/exchange_card.dart b/lib/src/screens/exchange/widgets/exchange_card.dart index 8b93f1db8..23f235c65 100644 --- a/lib/src/screens/exchange/widgets/exchange_card.dart +++ b/lib/src/screens/exchange/widgets/exchange_card.dart @@ -165,11 +165,9 @@ class ExchangeCardState extends State { textAlign: TextAlign.left, keyboardType: TextInputType.numberWithOptions( signed: false, decimal: true), - // inputFormatters: [ - // LengthLimitingTextInputFormatter(15), - // BlacklistingTextInputFormatter( - // RegExp('[\\-|\\ |\\,]')) - // ], + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')) + ], hintText: '0.0000', borderColor: widget.borderColor, textStyle: TextStyle( diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart index 0a3b10e18..c20b60e3c 100644 --- a/lib/src/screens/exchange_trade/exchange_trade_page.dart +++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart @@ -385,9 +385,8 @@ class ExchangeTradeState extends State { .pendingTransactionFiatAmount + ' ' + widget.exchangeTradeViewModel.sendViewModel.fiat.title, - recipientTitle: S.of(context).recipient_address, - recipientAddress: - widget.exchangeTradeViewModel.sendViewModel.address); + outputs: widget.exchangeTradeViewModel.sendViewModel + .outputs); }); }); } diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 45401b553..c355e099b 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -1,19 +1,13 @@ import 'dart:ui'; -import 'package:cake_wallet/entities/parsed_address.dart'; -import 'package:cake_wallet/entities/transaction_priority.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/src/screens/send/widgets/parse_address_from_domain_alert.dart'; +import 'package:cake_wallet/src/screens/send/widgets/send_card.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; -import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; -import 'package:cake_wallet/src/widgets/picker.dart'; import 'package:cake_wallet/src/widgets/template_tile.dart'; -import 'package:cake_wallet/view_model/settings/settings_view_model.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:mobx/mobx.dart'; -import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:cake_wallet/core/execution_state.dart'; @@ -23,42 +17,18 @@ import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; import 'package:cake_wallet/src/widgets/trail_button.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; -import 'package:cake_wallet/src/widgets/address_text_field.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/screens/send/widgets/confirm_sending_alert.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; class SendPage extends BasePage { - SendPage({@required this.sendViewModel}) - : _addressController = TextEditingController(), - _cryptoAmountController = TextEditingController(), - _fiatAmountController = TextEditingController(), - _noteController = TextEditingController(), - _formKey = GlobalKey(), - _cryptoAmountFocus = FocusNode(), - _fiatAmountFocus = FocusNode(), - _addressFocusNode = FocusNode() { - _addressFocusNode.addListener(() { - if (!_addressFocusNode.hasFocus && _addressController.text.isNotEmpty) { - applyOpenaliasOrUnstoppableDomains(_addressFocusNode.context); - } - }); - } - - static const prefixIconWidth = 34.0; - static const prefixIconHeight = 34.0; + SendPage({@required this.sendViewModel}) :_formKey = GlobalKey(); final SendViewModel sendViewModel; - final TextEditingController _addressController; - final TextEditingController _cryptoAmountController; - final TextEditingController _fiatAmountController; - final TextEditingController _noteController; final GlobalKey _formKey; - final FocusNode _cryptoAmountFocus; - final FocusNode _fiatAmountFocus; - final FocusNode _addressFocusNode; + final controller = PageController(initialPage: 0); bool _effectsInstalled = false; @@ -78,534 +48,274 @@ class SendPage extends BasePage { AppBarStyle get appBarStyle => AppBarStyle.transparent; @override - Widget trailing(context) => TrailButton( - caption: S.of(context).clear, - onPressed: () { - _formKey.currentState.reset(); - sendViewModel.reset(); - }); + Widget trailing(context) => Observer(builder: (_) { + return sendViewModel.isBatchSending + ? TrailButton( + caption: S.of(context).remove, + onPressed: () { + var pageToJump = controller.page.round() - 1; + pageToJump = pageToJump > 0 ? pageToJump : 0; + final output = _defineCurrentOutput(); + sendViewModel.removeOutput(output); + controller.jumpToPage(pageToJump); + }) + : TrailButton( + caption: S.of(context).clear, + onPressed: () { + final output = _defineCurrentOutput(); + _formKey.currentState.reset(); + output.reset(); + }); + }); @override Widget body(BuildContext context) { _setEffects(context); - return KeyboardActions( - config: KeyboardActionsConfig( - keyboardActionsPlatform: KeyboardActionsPlatform.IOS, - keyboardBarColor: Theme.of(context).accentTextTheme.body2 - .backgroundColor, - nextFocus: false, - actions: [ - KeyboardActionsItem( - focusNode: _cryptoAmountFocus, - toolbarButtons: [(_) => KeyboardDoneButton()], + return Form( + key: _formKey, + child: ScrollableWithBottomSection( + contentPadding: EdgeInsets.only(bottom: 24), + content: Column( + children: [ + Container( + height: sendViewModel.isElectrumWallet ? 490 : 465, + child: Observer( + builder: (_) { + return PageView.builder( + scrollDirection: Axis.horizontal, + controller: controller, + itemCount: sendViewModel.outputs.length, + itemBuilder: (context, index) { + final output = sendViewModel.outputs[index]; + + return SendCard( + key: output.key, + output: output, + sendViewModel: sendViewModel, + ); + } + ); + }, + ) ), - KeyboardActionsItem( - focusNode: _fiatAmountFocus, - toolbarButtons: [(_) => KeyboardDoneButton()], - ) - ]), - child: Container( - height: 0, - color: Theme.of(context).backgroundColor, - child: ScrollableWithBottomSection( - contentPadding: EdgeInsets.only(bottom: 24), - content: Column( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24)), - gradient: LinearGradient(colors: [ - Theme.of(context).primaryTextTheme.subhead.color, - Theme.of(context) - .primaryTextTheme - .subhead - .decorationColor, - ], begin: Alignment.topLeft, end: Alignment.bottomRight), - ), - child: Form( - key: _formKey, - child: Column(children: [ - Padding( - padding: EdgeInsets.fromLTRB(24, 100, 24, 32), - child: Column( - children: [ - AddressTextField( - focusNode: _addressFocusNode, - controller: _addressController, - onURIScanned: (uri) { - var address = ''; - var amount = ''; + Padding( + padding: EdgeInsets.only(top: 10, left: 24, right: 24, bottom: 10), + child: Container( + height: 10, + child: Observer(builder: (_) { + final count = sendViewModel.outputs.length; - if (uri != null) { - address = uri.path; - amount = uri.queryParameters['tx_amount'] ?? - uri.queryParameters['amount']; - } else { - address = uri.toString(); - } - - _addressController.text = address; - _cryptoAmountController.text = amount; - }, - options: [ - AddressTextFieldOption.paste, - AddressTextFieldOption.qrCode, - AddressTextFieldOption.addressBook - ], - buttonColor: Theme.of(context) + return count > 1 + ? SmoothPageIndicator( + controller: controller, + count: count, + effect: ScrollingDotsEffect( + spacing: 6.0, + radius: 6.0, + dotWidth: 6.0, + dotHeight: 6.0, + dotColor: Theme.of(context) .primaryTextTheme - .display1 - .color, - borderColor: Theme.of(context) + .display2 + .backgroundColor, + activeDotColor: Theme.of(context) .primaryTextTheme - .headline - .color, - textStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white), - hintStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor), - validator: sendViewModel.addressValidator, - onPushPasteButton: (context) { - applyOpenaliasOrUnstoppableDomains(context); - }, - ), - Observer( - builder: (_) => Padding( - padding: const EdgeInsets.only(top: 20), - child: Stack( - children: [ - BaseTextFormField( - focusNode: _cryptoAmountFocus, - controller: _cryptoAmountController, - keyboardType: - TextInputType.numberWithOptions( - signed: false, decimal: true), - prefixIcon: Padding( - padding: EdgeInsets.only(top: 9), - child: Text( - sendViewModel.currency.title + - ':', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - )), - ), - suffixIcon: SizedBox( - width: prefixIconWidth, - ), - hintText: '0.0000', - borderColor: Theme.of(context) - .primaryTextTheme - .headline - .color, - textStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white), - placeholderTextStyle: TextStyle( - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor, - fontWeight: FontWeight.w500, - fontSize: 14), - validator: sendViewModel.sendAll - ? sendViewModel.allAmountValidator - : sendViewModel - .amountValidator), - Positioned( - top: 2, - right: 0, - child: Container( - width: prefixIconWidth, - height: prefixIconHeight, - child: InkWell( - onTap: () async => - sendViewModel.setSendAll(), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context) - .primaryTextTheme - .display1 - .color, - borderRadius: - BorderRadius.all( - Radius.circular(6))), - child: Center( - child: Text( - S.of(context).all, - textAlign: - TextAlign.center, - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.bold, - color: - Theme.of(context) - .primaryTextTheme - .display1 - .decorationColor))), - ))))]) - )), - Observer( - builder: (_) => Padding( - padding: EdgeInsets.only(top: 10), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - S.of(context).available_balance + - ':', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor), - )), - Text( - sendViewModel.balance, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor), - ) - ], - ), - )), - Padding( - padding: const EdgeInsets.only(top: 20), - child: BaseTextFormField( - focusNode: _fiatAmountFocus, - controller: _fiatAmountController, - keyboardType: - TextInputType.numberWithOptions( - signed: false, decimal: true), - prefixIcon: Padding( - padding: EdgeInsets.only(top: 9), - child: - Text(sendViewModel.fiat.title + ':', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - )), - ), - hintText: '0.00', - borderColor: Theme.of(context) - .primaryTextTheme - .headline - .color, - textStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white), - placeholderTextStyle: TextStyle( - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor, - fontWeight: FontWeight.w500, - fontSize: 14), - )), - Padding( - padding: EdgeInsets.only(top: 20), - child: BaseTextFormField( - controller: _noteController, - keyboardType: TextInputType.multiline, - maxLines: null, - borderColor: Theme.of(context) - .primaryTextTheme - .headline - .color, - textStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white), - hintText: S.of(context).note_optional, - placeholderTextStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor), - ), - ), - Observer( - builder: (_) => GestureDetector( - onTap: () => - _setTransactionPriority(context), - child: Container( - padding: EdgeInsets.only(top: 24), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - S - .of(context) - .send_estimated_fee, - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w500, - //color: Theme.of(context).primaryTextTheme.display2.color, - color: Colors.white)), - Container( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - sendViewModel - .estimatedFee - .toString() + - ' ' + - sendViewModel - .currency.title, - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w600, - //color: Theme.of(context).primaryTextTheme.display2.color, - color: - Colors.white)), - Padding( - padding: - EdgeInsets.only(top: 5), - child: Text( - sendViewModel - .estimatedFeeFiatAmount - + ' ' + - sendViewModel - .fiat.title, - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w600, - color: Theme - .of(context) - .primaryTextTheme - .headline - .decorationColor)) - ), - ], - ), - Padding( - padding: EdgeInsets.only( - top: 2, - left: 5), - child: Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white, - ), - ) - ], - ), - ) - ], - ), - ), - )), - if (sendViewModel.isElectrumWallet) Padding( - padding: EdgeInsets.only(top: 6), - child: GestureDetector( - onTap: () => Navigator.of(context) - .pushNamed(Routes.unspentCoinsList), - child: Container( - color: Colors.transparent, - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - S.of(context).coin_control, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white)), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white, - ) - ], - ) - ) - ) - ) - ], - ), - ) - ]), - ), - ), - Padding( - padding: EdgeInsets.only(top: 30, left: 24, bottom: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - S.of(context).send_templates, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, + .display3 + .backgroundColor + ) + ) + : Offstage(); + }) + ) + ), + Padding( + padding: EdgeInsets.only(left: 24, bottom: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + S.of(context).send_templates, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .primaryTextTheme + .display4 + .color), + ) + ], + ), + ), + Container( + height: 40, + width: double.infinity, + padding: EdgeInsets.only(left: 24), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context) + .pushNamed(Routes.sendTemplate), + child: Container( + padding: EdgeInsets.only(left: 1, right: 10), + child: DottedBorder( + borderType: BorderType.RRect, + dashPattern: [6, 4], color: Theme.of(context) .primaryTextTheme - .display4 - .color), - ) - ], - ), - ), - Container( - height: 40, - width: double.infinity, - padding: EdgeInsets.only(left: 24), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.of(context) - .pushNamed(Routes.sendTemplate), - child: Container( - padding: EdgeInsets.only(left: 1, right: 10), - child: DottedBorder( - borderType: BorderType.RRect, - dashPattern: [6, 4], - color: Theme.of(context) - .primaryTextTheme - .display2 - .decorationColor, - strokeWidth: 2, - radius: Radius.circular(20), - child: Container( - height: 34, - width: 75, - padding: - EdgeInsets.only(left: 10, right: 10), - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: - BorderRadius.all(Radius.circular(20)), - color: Colors.transparent, - ), - child: Text( - S.of(context).send_new, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .primaryTextTheme - .display3 - .color), - ), - )), - ), - ), - Observer(builder: (_) { - final templates = sendViewModel.templates; - final itemCount = templates.length; - - return ListView.builder( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: itemCount, - itemBuilder: (context, index) { - final template = templates[index]; - - return TemplateTile( - key: UniqueKey(), - to: template.name, - amount: template.amount, - from: template.cryptoCurrency, - onTap: () { - _addressController.text = - template.address; - _cryptoAmountController.text = - template.amount; - applyOpenaliasOrUnstoppableDomains(context); - }, - onRemove: () { - showPopUp( - context: context, - builder: (dialogContext) { - return AlertWithTwoActions( - alertTitle: - S.of(context).template, - alertContent: S - .of(context) - .confirm_delete_template, - rightButtonText: - S.of(context).delete, - leftButtonText: - S.of(context).cancel, - actionRightButton: () { - Navigator.of(dialogContext) - .pop(); - sendViewModel.removeTemplate( - template: template); - sendViewModel - .updateTemplate(); - }, - actionLeftButton: () => - Navigator.of(dialogContext) - .pop()); - }); - }, - ); - }); - }) - ], + .display2 + .decorationColor, + strokeWidth: 2, + radius: Radius.circular(20), + child: Container( + height: 34, + width: 75, + padding: + EdgeInsets.only(left: 10, right: 10), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: + BorderRadius.all(Radius.circular(20)), + color: Colors.transparent, + ), + child: Text( + S.of(context).send_new, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .primaryTextTheme + .display3 + .color), + ), + )), + ), ), - ), - ) - ], + Observer(builder: (_) { + final templates = sendViewModel.templates; + final itemCount = templates.length; + + return ListView.builder( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: itemCount, + itemBuilder: (context, index) { + final template = templates[index]; + + return TemplateTile( + key: UniqueKey(), + to: template.name, + amount: template.amount, + from: template.cryptoCurrency, + onTap: () async { + final output = _defineCurrentOutput(); + output.address = + template.address; + output.setCryptoAmount(template.amount); + final parsedAddress = await output + .applyOpenaliasOrUnstoppableDomains(); + showAddressAlert(context, parsedAddress); + }, + onRemove: () { + showPopUp( + context: context, + builder: (dialogContext) { + return AlertWithTwoActions( + alertTitle: + S.of(context).template, + alertContent: S + .of(context) + .confirm_delete_template, + rightButtonText: + S.of(context).delete, + leftButtonText: + S.of(context).cancel, + actionRightButton: () { + Navigator.of(dialogContext) + .pop(); + sendViewModel + .sendTemplateViewModel + .removeTemplate( + template: template); + }, + actionLeftButton: () => + Navigator.of(dialogContext) + .pop()); + }); + }, + ); + }); + }) + ], + ), + ), + ) + ], + ), + bottomSectionPadding: + EdgeInsets.only(left: 24, right: 24, bottom: 24), + bottomSection: Column( + children: [ + Padding( + padding: EdgeInsets.only(bottom: 12), + child: PrimaryButton( + onPressed: () { + sendViewModel.addOutput(); + Future.delayed(const Duration(milliseconds: 250), () { + controller.jumpToPage(sendViewModel.outputs.length - 1); + }); + }, + text: S.of(context).add_receiver, + color: Colors.transparent, + textColor: Theme.of(context) + .accentTextTheme + .display2 + .decorationColor, + isDottedBorder: true, + borderColor: Theme.of(context) + .primaryTextTheme + .display2 + .decorationColor, + ) ), - bottomSectionPadding: - EdgeInsets.only(left: 24, right: 24, bottom: 24), - bottomSection: Observer(builder: (_) { + Observer(builder: (_) { return LoadingPrimaryButton( - onPressed: () async { - if (_formKey.currentState.validate()) { - await sendViewModel.createTransaction(); + onPressed: () async { + if (!_formKey.currentState.validate()) { + if (sendViewModel.outputs.length > 1) { + showErrorValidationAlert(context); } - }, - text: S.of(context).send, - color: Theme.of(context).accentTextTheme.body2.color, - textColor: Colors.white, - isLoading: sendViewModel.state is IsExecutingState || - sendViewModel.state is TransactionCommitting, - isDisabled: !sendViewModel.isReadyForSend, - ); - })), - )); + + return; + } + + final notValidItems = sendViewModel.outputs + .where((item) => + item.address.isEmpty || item.cryptoAmount.isEmpty) + .toList(); + + if (notValidItems?.isNotEmpty ?? false) { + showErrorValidationAlert(context); + return; + } + + await sendViewModel.createTransaction(); + }, + text: S.of(context).send, + color: Theme.of(context).accentTextTheme.body2.color, + textColor: Colors.white, + isLoading: sendViewModel.state is IsExecutingState || + sendViewModel.state is TransactionCommitting, + isDisabled: !sendViewModel.isReadyForSend, + ); + }, + ) + ], + )), + ); } void _setEffects(BuildContext context) { @@ -613,78 +323,6 @@ class SendPage extends BasePage { return; } - _cryptoAmountController.addListener(() { - final amount = _cryptoAmountController.text; - - if (sendViewModel.sendAll && amount != S.current.all) { - sendViewModel.sendAll = false; - } - - if (amount != sendViewModel.cryptoAmount) { - sendViewModel.setCryptoAmount(amount); - } - }); - - _fiatAmountController.addListener(() { - final amount = _fiatAmountController.text; - - if (amount != sendViewModel.fiatAmount) { - sendViewModel.sendAll = false; - sendViewModel.setFiatAmount(amount); - } - }); - - _noteController.addListener(() { - final note = _noteController.text ?? ''; - - if (note != sendViewModel.note) { - sendViewModel.note = note; - } - }); - - reaction((_) => sendViewModel.sendAll, (bool all) { - if (all) { - _cryptoAmountController.text = S.current.all; - _fiatAmountController.text = null; - } - }); - - reaction((_) => sendViewModel.fiatAmount, (String amount) { - if (amount != _fiatAmountController.text) { - _fiatAmountController.text = amount; - } - }); - - reaction((_) => sendViewModel.cryptoAmount, (String amount) { - if (sendViewModel.sendAll && amount != S.current.all) { - sendViewModel.sendAll = false; - } - - if (amount != _cryptoAmountController.text) { - _cryptoAmountController.text = amount; - } - }); - - reaction((_) => sendViewModel.address, (String address) { - if (address != _addressController.text) { - _addressController.text = address; - } - }); - - _addressController.addListener(() { - final address = _addressController.text; - - if (sendViewModel.address != address) { - sendViewModel.address = address; - } - }); - - reaction((_) => sendViewModel.note, (String note) { - if (note != _noteController.text) { - _noteController.text = note; - } - }); - reaction((_) => sendViewModel.state, (ExecutionState state) { if (state is FailureState) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -709,15 +347,14 @@ class SendPage extends BasePage { alertTitle: S.of(context).confirm_sending, amount: S.of(context).send_amount, amountValue: - sendViewModel.pendingTransaction.amountFormatted, + sendViewModel.pendingTransaction.amountFormatted, fiatAmountValue: sendViewModel.pendingTransactionFiatAmount + ' ' + sendViewModel.fiat.title, fee: S.of(context).send_fee, feeValue: sendViewModel.pendingTransaction.feeFormatted, feeFiatAmount: sendViewModel.pendingTransactionFeeFiatAmount + ' ' + sendViewModel.fiat.title, - recipientTitle: S.of(context).recipient_address, - recipientAddress: sendViewModel.address, + outputs: sendViewModel.outputs, rightButtonText: S.of(context).ok, leftButtonText: S.of(context).cancel, actionRightButton: () { @@ -755,8 +392,7 @@ class SendPage extends BasePage { if (state is TransactionCommitted) { WidgetsBinding.instance.addPostFrameCallback((_) { - _addressController.text = ''; - _cryptoAmountController.text = ''; + sendViewModel.clearOutputs(); }); } }); @@ -764,45 +400,21 @@ class SendPage extends BasePage { _effectsInstalled = true; } - Future _setTransactionPriority(BuildContext context) async { - final items = priorityForWalletType(sendViewModel.walletType); - final selectedItem = items.indexOf(sendViewModel.transactionPriority); - final isShowScrollThumb = items.length > 3; - - await showPopUp( - builder: (_) => Picker( - items: items, - displayItem: sendViewModel.displayFeeRate, - selectedAtIndex: selectedItem, - title: S.of(context).please_select, - mainAxisAlignment: MainAxisAlignment.center, - onItemSelected: (TransactionPriority priority) => - sendViewModel.setTransactionPriority(priority), - ), - context: context); + Output _defineCurrentOutput() { + final itemCount = controller.page.round(); + return sendViewModel.outputs[itemCount]; } - void applyOpenaliasOrUnstoppableDomains(BuildContext context) async { - final domain = _addressController.text; - final ticker = sendViewModel.currency.title.toLowerCase(); - final parsedAddress = await parseAddressFromDomain(domain, ticker); - _addressController.text = parsedAddress.address; - - switch (parsedAddress.parseFrom) { - case ParseFrom.unstoppableDomains: - showAddressAlert( - context, - S.of(context).address_detected, - S.of(context).address_from_domain(parsedAddress.name)); - break; - case ParseFrom.openAlias: - showAddressAlert( - context, - S.of(context).openalias_alert_title, - S.of(context).openalias_alert_content(parsedAddress.name)); - break; - case ParseFrom.notParsed: - break; - } + void showErrorValidationAlert(BuildContext context) async { + await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: 'Please, check receiver forms', + buttonText: S.of(context).ok, + buttonAction: () => + Navigator.of(context).pop()); + }); } } diff --git a/lib/src/screens/send/send_template_page.dart b/lib/src/screens/send/send_template_page.dart index 3880bd2fb..86eebc7bf 100644 --- a/lib/src/screens/send/send_template_page.dart +++ b/lib/src/screens/send/send_template_page.dart @@ -2,11 +2,10 @@ import 'package:mobx/mobx.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/view_model/send/send_view_model.dart'; +import 'package:cake_wallet/view_model/send/send_template_view_model.dart'; import 'package:cake_wallet/src/widgets/address_text_field.dart'; import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; @@ -14,9 +13,11 @@ import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; class SendTemplatePage extends BasePage { - SendTemplatePage({@required this.sendViewModel}); + SendTemplatePage({@required this.sendTemplateViewModel}) { + sendTemplateViewModel.output.reset(); + } - final SendViewModel sendViewModel; + final SendTemplateViewModel sendTemplateViewModel; final _addressController = TextEditingController(); final _cryptoAmountController = TextEditingController(); final _fiatAmountController = TextEditingController(); @@ -100,7 +101,7 @@ class SendTemplatePage extends BasePage { .decorationColor, fontWeight: FontWeight.w500, fontSize: 14), - validator: sendViewModel.templateValidator, + validator: sendTemplateViewModel.templateValidator, ), Padding( padding: EdgeInsets.only(top: 20), @@ -145,49 +146,47 @@ class SendTemplatePage extends BasePage { .primaryTextTheme .headline .decorationColor), - //validator: sendViewModel.addressValidator, ), ), - Observer(builder: (_) { - return Padding( - padding: const EdgeInsets.only(top: 20), - child: BaseTextFormField( - focusNode: _cryptoAmountFocus, - controller: _cryptoAmountController, - keyboardType: TextInputType.numberWithOptions( - signed: false, decimal: true), - inputFormatters: [ - BlacklistingTextInputFormatter( - RegExp('[\\-|\\ ]')) - ], - prefixIcon: Padding( - padding: EdgeInsets.only(top: 9), - child: - Text(sendViewModel.currency.title + ':', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - )), - ), - hintText: '0.0000', - borderColor: Theme.of(context) - .primaryTextTheme - .headline - .color, - textStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white), - placeholderTextStyle: TextStyle( - color: Theme.of(context) - .primaryTextTheme - .headline - .decorationColor, - fontWeight: FontWeight.w500, - fontSize: 14), - validator: sendViewModel.amountValidator)); - }), + Padding( + padding: const EdgeInsets.only(top: 20), + child: BaseTextFormField( + focusNode: _cryptoAmountFocus, + controller: _cryptoAmountController, + keyboardType: TextInputType.numberWithOptions( + signed: false, decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')) + ], + prefixIcon: Padding( + padding: EdgeInsets.only(top: 9), + child: + Text(sendTemplateViewModel + .currency.title + ':', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + )), + ), + hintText: '0.0000', + borderColor: Theme.of(context) + .primaryTextTheme + .headline + .color, + textStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white), + placeholderTextStyle: TextStyle( + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor, + fontWeight: FontWeight.w500, + fontSize: 14), + validator: sendTemplateViewModel + .amountValidator)), Padding( padding: const EdgeInsets.only(top: 20), child: BaseTextFormField( @@ -196,12 +195,12 @@ class SendTemplatePage extends BasePage { keyboardType: TextInputType.numberWithOptions( signed: false, decimal: true), inputFormatters: [ - BlacklistingTextInputFormatter( - RegExp('[\\-|\\ ]')) + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')) ], prefixIcon: Padding( padding: EdgeInsets.only(top: 9), - child: Text(sendViewModel.fiat.title + ':', + child: Text(sendTemplateViewModel + .fiat.title + ':', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -236,12 +235,11 @@ class SendTemplatePage extends BasePage { bottomSection: PrimaryButton( onPressed: () { if (_formKey.currentState.validate()) { - sendViewModel.addTemplate( + sendTemplateViewModel.addTemplate( name: _nameController.text, address: _addressController.text, - cryptoCurrency: sendViewModel.currency.title, + cryptoCurrency: sendTemplateViewModel.currency.title, amount: _cryptoAmountController.text); - sendViewModel.updateTemplate(); Navigator.of(context).pop(); } }, @@ -258,19 +256,21 @@ class SendTemplatePage extends BasePage { return; } - reaction((_) => sendViewModel.fiatAmount, (String amount) { + final output = sendTemplateViewModel.output; + + reaction((_) => output.fiatAmount, (String amount) { if (amount != _fiatAmountController.text) { _fiatAmountController.text = amount; } }); - reaction((_) => sendViewModel.cryptoAmount, (String amount) { + reaction((_) => output.cryptoAmount, (String amount) { if (amount != _cryptoAmountController.text) { _cryptoAmountController.text = amount; } }); - reaction((_) => sendViewModel.address, (String address) { + reaction((_) => output.address, (String address) { if (address != _addressController.text) { _addressController.text = address; } @@ -279,24 +279,24 @@ class SendTemplatePage extends BasePage { _cryptoAmountController.addListener(() { final amount = _cryptoAmountController.text; - if (amount != sendViewModel.cryptoAmount) { - sendViewModel.setCryptoAmount(amount); + if (amount != output.cryptoAmount) { + output.setCryptoAmount(amount); } }); _fiatAmountController.addListener(() { final amount = _fiatAmountController.text; - if (amount != sendViewModel.fiatAmount) { - sendViewModel.setFiatAmount(amount); + if (amount != output.fiatAmount) { + output.setFiatAmount(amount); } }); _addressController.addListener(() { final address = _addressController.text; - if (sendViewModel.address != address) { - sendViewModel.address = address; + if (output.address != address) { + output.address = address; } }); diff --git a/lib/src/screens/send/widgets/confirm_sending_alert.dart b/lib/src/screens/send/widgets/confirm_sending_alert.dart index d1c916fc3..009340b2e 100644 --- a/lib/src/screens/send/widgets/confirm_sending_alert.dart +++ b/lib/src/screens/send/widgets/confirm_sending_alert.dart @@ -1,6 +1,9 @@ import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/src/widgets/base_alert_dialog.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/cake_scrollbar.dart'; class ConfirmSendingAlert extends BaseAlertDialog { ConfirmSendingAlert({ @@ -11,14 +14,12 @@ class ConfirmSendingAlert extends BaseAlertDialog { @required this.fee, @required this.feeValue, @required this.feeFiatAmount, - @required this.recipientTitle, - @required this.recipientAddress, + @required this.outputs, @required this.leftButtonText, @required this.rightButtonText, @required this.actionLeftButton, @required this.actionRightButton, - this.alertBarrierDismissible = true - }); + this.alertBarrierDismissible = true}); final String alertTitle; final String amount; @@ -27,8 +28,7 @@ class ConfirmSendingAlert extends BaseAlertDialog { final String fee; final String feeValue; final String feeFiatAmount; - final String recipientTitle; - final String recipientAddress; + final List outputs; final String leftButtonText; final String rightButtonText; final VoidCallback actionLeftButton; @@ -57,128 +57,290 @@ class ConfirmSendingAlert extends BaseAlertDialog { bool get barrierDismissible => alertBarrierDismissible; @override - Widget content(BuildContext context) { - return Column( - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - amount, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.normal, - fontFamily: 'Lato', - color: Theme.of(context).primaryTextTheme.title.color, - decoration: TextDecoration.none, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - amountValue, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Lato', - color: Theme.of(context).primaryTextTheme.title.color, - decoration: TextDecoration.none, - ), - ), - Text( - fiatAmountValue, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'Lato', - color: PaletteDark.pigeonBlue, - decoration: TextDecoration.none, - ), + Widget content(BuildContext context) => ConfirmSendingAlertContent( + amount: amount, + amountValue: amountValue, + fiatAmountValue: fiatAmountValue, + fee: fee, + feeValue: feeValue, + feeFiatAmount: feeFiatAmount, + outputs: outputs + ); +} + +class ConfirmSendingAlertContent extends StatefulWidget { + ConfirmSendingAlertContent({ + @required this.amount, + @required this.amountValue, + @required this.fiatAmountValue, + @required this.fee, + @required this.feeValue, + @required this.feeFiatAmount, + @required this.outputs}); + + final String amount; + final String amountValue; + final String fiatAmountValue; + final String fee; + final String feeValue; + final String feeFiatAmount; + final List outputs; + + @override + ConfirmSendingAlertContentState createState() => ConfirmSendingAlertContentState( + amount: amount, + amountValue: amountValue, + fiatAmountValue: fiatAmountValue, + fee: fee, + feeValue: feeValue, + feeFiatAmount: feeFiatAmount, + outputs: outputs + ); +} + +class ConfirmSendingAlertContentState extends State { + ConfirmSendingAlertContentState({ + @required this.amount, + @required this.amountValue, + @required this.fiatAmountValue, + @required this.fee, + @required this.feeValue, + @required this.feeFiatAmount, + @required this.outputs}) { + + itemCount = outputs.length; + recipientTitle = itemCount > 1 + ? S.current.transaction_details_recipient_address + : S.current.recipient_address; + } + + final String amount; + final String amountValue; + final String fiatAmountValue; + final String fee; + final String feeValue; + final String feeFiatAmount; + final List outputs; + + final double backgroundHeight = 160; + final double thumbHeight = 72; + ScrollController controller = ScrollController(); + double fromTop = 0; + String recipientTitle; + int itemCount; + + @override + Widget build(BuildContext context) { + controller.addListener(() { + fromTop = controller.hasClients + ? (controller.offset / controller.position.maxScrollExtent * + (backgroundHeight - thumbHeight)) + : 0; + setState(() {}); + }); + + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Container( + height: 200, + child: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + amount, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + fontFamily: 'Lato', + color: Theme.of(context) + .primaryTextTheme + .title + .color, + decoration: TextDecoration.none, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amountValue, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: Theme.of(context) + .primaryTextTheme + .title + .color, + decoration: TextDecoration.none, + ), + ), + Text( + fiatAmountValue, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: PaletteDark.pigeonBlue, + decoration: TextDecoration.none, + ), + ) + ], + ) + ], + ), + Padding( + padding: EdgeInsets.only(top: 16), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fee, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + fontFamily: 'Lato', + color: Theme.of(context) + .primaryTextTheme + .title + .color, + decoration: TextDecoration.none, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + feeValue, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: Theme.of(context) + .primaryTextTheme + .title + .color, + decoration: TextDecoration.none, + ), + ), + Text( + feeFiatAmount, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: PaletteDark.pigeonBlue, + decoration: TextDecoration.none, + ), + ) + ], + ) + ], + ) + ), + Padding( + padding: EdgeInsets.only(top: 16), + child: Column( + children: [ + Text( + '$recipientTitle:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + fontFamily: 'Lato', + color: Theme.of(context) + .primaryTextTheme + .title + .color, + decoration: TextDecoration.none, + ), + ), + itemCount > 1 + ? ListView.builder( + padding: EdgeInsets.only(top: 0), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: itemCount, + itemBuilder: (context, index) { + final item = outputs[index]; + final _address = item.address; + final _amount = + item.cryptoAmount.replaceAll(',', '.'); + + return Column( + children: [ + Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + _address, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: PaletteDark.pigeonBlue, + decoration: TextDecoration.none, + ), + ) + ), + Padding( + padding: EdgeInsets.only(top: 8), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _amount, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: PaletteDark.pigeonBlue, + decoration: TextDecoration.none, + ), + ) + ], + ) + ) + ], + ); + }) + : Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + outputs.first.address, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Lato', + color: PaletteDark.pigeonBlue, + decoration: TextDecoration.none, + ), + ), + ) + ], + ), + ) + ], ) - ], ) - ], ), - Padding( - padding: EdgeInsets.only(top: 16), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - fee, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.normal, - fontFamily: 'Lato', - color: Theme.of(context).primaryTextTheme.title.color, - decoration: TextDecoration.none, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - feeValue, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Lato', - color: Theme.of(context).primaryTextTheme.title.color, - decoration: TextDecoration.none, - ), - ), - Text( - feeFiatAmount, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'Lato', - color: PaletteDark.pigeonBlue, - decoration: TextDecoration.none, - ), - ) - ], - ) - ], + if (itemCount > 1) CakeScrollbar( + backgroundHeight: backgroundHeight, + thumbHeight: thumbHeight, + fromTop: fromTop, + rightOffset: -15 ) - ), - Padding( - padding: EdgeInsets.fromLTRB(0, 16, 0, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '$recipientTitle:', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.normal, - fontFamily: 'Lato', - color: Theme.of(context).primaryTextTheme.title.color, - decoration: TextDecoration.none, - ), - ), - Padding( - padding: EdgeInsets.only(top: 8), - child: Text( - recipientAddress, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'Lato', - color: PaletteDark.pigeonBlue, - decoration: TextDecoration.none, - ), - ) - ) - ], - ), - ) - ], + ] ); } } \ No newline at end of file diff --git a/lib/src/screens/send/widgets/parse_address_from_domain_alert.dart b/lib/src/screens/send/widgets/parse_address_from_domain_alert.dart index 760091611..526393109 100644 --- a/lib/src/screens/send/widgets/parse_address_from_domain_alert.dart +++ b/lib/src/screens/send/widgets/parse_address_from_domain_alert.dart @@ -1,9 +1,26 @@ +import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/generated/i18n.dart'; -void showAddressAlert(BuildContext context, String title, String content) async { +void showAddressAlert(BuildContext context, ParsedAddress parsedAddress) async { + var title = ''; + var content = ''; + + switch (parsedAddress.parseFrom) { + case ParseFrom.unstoppableDomains: + title = S.of(context).address_detected; + content = S.of(context).address_from_domain(parsedAddress.name); + break; + case ParseFrom.openAlias: + title = S.of(context).openalias_alert_title; + content = S.of(context).openalias_alert_content(parsedAddress.name); + break; + case ParseFrom.notParsed: + return; + } + await showPopUp( context: context, builder: (BuildContext context) { diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart new file mode 100644 index 000000000..b33f29724 --- /dev/null +++ b/lib/src/screens/send/widgets/send_card.dart @@ -0,0 +1,561 @@ +import 'dart:ui'; +import 'package:cake_wallet/entities/transaction_priority.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/send/widgets/parse_address_from_domain_alert.dart'; +import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; +import 'package:cake_wallet/src/widgets/picker.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cake_wallet/view_model/settings/settings_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; +import 'package:keyboard_actions/keyboard_actions.dart'; +import 'package:cake_wallet/view_model/send/send_view_model.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/src/widgets/address_text_field.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; + +class SendCard extends StatefulWidget { + SendCard({Key key, @required this.output, @required this.sendViewModel}) : super(key: key); + + final Output output; + final SendViewModel sendViewModel; + + @override + SendCardState createState() => SendCardState( + output: output, + sendViewModel: sendViewModel + ); +} + +class SendCardState extends State + with AutomaticKeepAliveClientMixin { + SendCardState({@required this.output, @required this.sendViewModel}) + : addressController = TextEditingController(), + cryptoAmountController = TextEditingController(), + fiatAmountController = TextEditingController(), + noteController = TextEditingController(), + cryptoAmountFocus = FocusNode(), + fiatAmountFocus = FocusNode(), + addressFocusNode = FocusNode(); + + static const prefixIconWidth = 34.0; + static const prefixIconHeight = 34.0; + + final Output output; + final SendViewModel sendViewModel; + + final TextEditingController addressController; + final TextEditingController cryptoAmountController; + final TextEditingController fiatAmountController; + final TextEditingController noteController; + final FocusNode cryptoAmountFocus; + final FocusNode fiatAmountFocus; + final FocusNode addressFocusNode; + + bool _effectsInstalled = false; + + @override + Widget build(BuildContext context) { + super.build(context); + _setEffects(context); + + return Stack( + children: [ + KeyboardActions( + config: KeyboardActionsConfig( + keyboardActionsPlatform: KeyboardActionsPlatform.IOS, + keyboardBarColor: Theme.of(context).accentTextTheme.body2 + .backgroundColor, + nextFocus: false, + actions: [ + KeyboardActionsItem( + focusNode: cryptoAmountFocus, + toolbarButtons: [(_) => KeyboardDoneButton()], + ), + KeyboardActionsItem( + focusNode: fiatAmountFocus, + toolbarButtons: [(_) => KeyboardDoneButton()], + ) + ]), + child: Container( + height: 0, + color: Colors.transparent, + )), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24)), + gradient: LinearGradient(colors: [ + Theme.of(context).primaryTextTheme.subhead.color, + Theme.of(context) + .primaryTextTheme + .subhead + .decorationColor, + ], begin: Alignment.topLeft, end: Alignment.bottomRight), + ), + child: Padding( + padding: EdgeInsets.fromLTRB(24, 100, 24, 32), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AddressTextField( + focusNode: addressFocusNode, + controller: addressController, + onURIScanned: (uri) { + var address = ''; + var amount = ''; + + if (uri != null) { + address = uri.path; + amount = uri.queryParameters['tx_amount'] ?? + uri.queryParameters['amount']; + } else { + address = uri.toString(); + } + + addressController.text = address; + cryptoAmountController.text = amount; + }, + options: [ + AddressTextFieldOption.paste, + AddressTextFieldOption.qrCode, + AddressTextFieldOption.addressBook + ], + buttonColor: Theme.of(context) + .primaryTextTheme + .display1 + .color, + borderColor: Theme.of(context) + .primaryTextTheme + .headline + .color, + textStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white), + hintStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor), + onPushPasteButton: (context) async { + final parsedAddress = + await output.applyOpenaliasOrUnstoppableDomains(); + showAddressAlert(context, parsedAddress); + }, + validator: sendViewModel.addressValidator, + ), + Observer( + builder: (_) => Padding( + padding: const EdgeInsets.only(top: 20), + child: Stack( + children: [ + BaseTextFormField( + focusNode: cryptoAmountFocus, + controller: cryptoAmountController, + keyboardType: + TextInputType.numberWithOptions( + signed: false, decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')) + ], + prefixIcon: Padding( + padding: EdgeInsets.only(top: 9), + child: Text( + sendViewModel.currency.title + + ':', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + )), + ), + suffixIcon: SizedBox( + width: prefixIconWidth, + ), + hintText: '0.0000', + borderColor: Theme.of(context) + .primaryTextTheme + .headline + .color, + textStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white), + placeholderTextStyle: TextStyle( + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor, + fontWeight: FontWeight.w500, + fontSize: 14), + validator: output.sendAll + ? sendViewModel.allAmountValidator + : sendViewModel + .amountValidator), + if (!sendViewModel.isBatchSending) Positioned( + top: 2, + right: 0, + child: Container( + width: prefixIconWidth, + height: prefixIconHeight, + child: InkWell( + onTap: () async => + output.setSendAll(), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .primaryTextTheme + .display1 + .color, + borderRadius: + BorderRadius.all( + Radius.circular(6))), + child: Center( + child: Text( + S.of(context).all, + textAlign: + TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.bold, + color: + Theme.of(context) + .primaryTextTheme + .display1 + .decorationColor))), + ))))]) + )), + Observer( + builder: (_) => Padding( + padding: EdgeInsets.only(top: 10), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + S.of(context).available_balance + + ':', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor), + )), + Text( + sendViewModel.balance, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor), + ) + ], + ), + )), + Padding( + padding: const EdgeInsets.only(top: 20), + child: BaseTextFormField( + focusNode: fiatAmountFocus, + controller: fiatAmountController, + keyboardType: + TextInputType.numberWithOptions( + signed: false, decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')) + ], + prefixIcon: Padding( + padding: EdgeInsets.only(top: 9), + child: + Text(sendViewModel.fiat.title + ':', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + )), + ), + hintText: '0.00', + borderColor: Theme.of(context) + .primaryTextTheme + .headline + .color, + textStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white), + placeholderTextStyle: TextStyle( + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor, + fontWeight: FontWeight.w500, + fontSize: 14), + )), + Padding( + padding: EdgeInsets.only(top: 20), + child: BaseTextFormField( + controller: noteController, + keyboardType: TextInputType.multiline, + maxLines: null, + borderColor: Theme.of(context) + .primaryTextTheme + .headline + .color, + textStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white), + hintText: S.of(context).note_optional, + placeholderTextStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .primaryTextTheme + .headline + .decorationColor), + ), + ), + Observer( + builder: (_) => GestureDetector( + onTap: () => + _setTransactionPriority(context), + child: Container( + padding: EdgeInsets.only(top: 24), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S + .of(context) + .send_estimated_fee, + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w500, + //color: Theme.of(context).primaryTextTheme.display2.color, + color: Colors.white)), + Container( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + output + .estimatedFee + .toString() + + ' ' + + sendViewModel + .currency.title, + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w600, + //color: Theme.of(context).primaryTextTheme.display2.color, + color: + Colors.white)), + Padding( + padding: + EdgeInsets.only(top: 5), + child: Text( + output + .estimatedFeeFiatAmount + + ' ' + + sendViewModel + .fiat.title, + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w600, + color: Theme + .of(context) + .primaryTextTheme + .headline + .decorationColor)) + ), + ], + ), + Padding( + padding: EdgeInsets.only( + top: 2, + left: 5), + child: Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white, + ), + ) + ], + ), + ) + ], + ), + ), + )), + if (sendViewModel.isElectrumWallet) Padding( + padding: EdgeInsets.only(top: 6), + child: GestureDetector( + onTap: () => Navigator.of(context) + .pushNamed(Routes.unspentCoinsList), + child: Container( + color: Colors.transparent, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).coin_control, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white)), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white, + ) + ], + ) + ) + ) + ) + ], + ) + ), + ), + ) + ], + ); + } + + void _setEffects(BuildContext context) { + addressController.text = output.address; + cryptoAmountController.text = output.cryptoAmount; + fiatAmountController.text = output.fiatAmount; + noteController.text = output.note; + + if (_effectsInstalled) { + return; + } + + cryptoAmountController.addListener(() { + final amount = cryptoAmountController.text; + + if (output.sendAll && amount != S.current.all) { + output.sendAll = false; + } + + if (amount != output.cryptoAmount) { + output.setCryptoAmount(amount); + } + }); + + fiatAmountController.addListener(() { + final amount = fiatAmountController.text; + + if (amount != output.fiatAmount) { + output.sendAll = false; + output.setFiatAmount(amount); + } + }); + + noteController.addListener(() { + final note = noteController.text ?? ''; + + if (note != output.note) { + output.note = note; + } + }); + + reaction((_) => output.sendAll, (bool all) { + if (all) { + cryptoAmountController.text = S.current.all; + fiatAmountController.text = null; + } + }); + + reaction((_) => output.fiatAmount, (String amount) { + if (amount != fiatAmountController.text) { + fiatAmountController.text = amount; + } + }); + + reaction((_) => output.cryptoAmount, (String amount) { + if (output.sendAll && amount != S.current.all) { + output.sendAll = false; + } + + if (amount != cryptoAmountController.text) { + cryptoAmountController.text = amount; + } + }); + + reaction((_) => output.address, (String address) { + if (address != addressController.text) { + addressController.text = address; + } + }); + + addressController.addListener(() { + final address = addressController.text; + + if (output.address != address) { + output.address = address; + } + }); + + reaction((_) => output.note, (String note) { + if (note != noteController.text) { + noteController.text = note; + } + }); + + addressFocusNode.addListener(() async { + if (!addressFocusNode.hasFocus && addressController.text.isNotEmpty) { + final parsedAddress = await output.applyOpenaliasOrUnstoppableDomains(); + showAddressAlert(context, parsedAddress); + } + }); + + _effectsInstalled = true; + } + + Future _setTransactionPriority(BuildContext context) async { + final items = priorityForWalletType(sendViewModel.walletType); + final selectedItem = items.indexOf(sendViewModel.transactionPriority); + + await showPopUp( + builder: (_) => Picker( + items: items, + displayItem: sendViewModel.displayFeeRate, + selectedAtIndex: selectedItem, + title: S.of(context).please_select, + mainAxisAlignment: MainAxisAlignment.center, + onItemSelected: (TransactionPriority priority) => + sendViewModel.setTransactionPriority(priority), + ), + context: context); + } + + @override + bool get wantKeepAlive => true; +} \ No newline at end of file diff --git a/lib/src/widgets/cake_scrollbar.dart b/lib/src/widgets/cake_scrollbar.dart index 90b571c9e..6ccf391c6 100644 --- a/lib/src/widgets/cake_scrollbar.dart +++ b/lib/src/widgets/cake_scrollbar.dart @@ -4,17 +4,19 @@ class CakeScrollbar extends StatelessWidget { CakeScrollbar({ @required this.backgroundHeight, @required this.thumbHeight, - @required this.fromTop + @required this.fromTop, + this.rightOffset = 6 }); final double backgroundHeight; final double thumbHeight; final double fromTop; + final double rightOffset; @override Widget build(BuildContext context) { return Positioned( - right: 6, + right: rightOffset, child: Container( height: backgroundHeight, width: 6, diff --git a/lib/src/widgets/primary_button.dart b/lib/src/widgets/primary_button.dart index 929bcd895..1b12d5d3c 100644 --- a/lib/src/widgets/primary_button.dart +++ b/lib/src/widgets/primary_button.dart @@ -1,3 +1,4 @@ +import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -8,18 +9,22 @@ class PrimaryButton extends StatelessWidget { @required this.color, @required this.textColor, this.isDisabled = false, + this.isDottedBorder = false, + this.borderColor = Colors.black, this.onDisabledPressed}); final VoidCallback onPressed; final VoidCallback onDisabledPressed; final Color color; final Color textColor; + final Color borderColor; final String text; final bool isDisabled; + final bool isDottedBorder; @override Widget build(BuildContext context) { - return ButtonTheme( + final content = ButtonTheme( minWidth: double.infinity, height: 52.0, child: FlatButton( @@ -27,6 +32,8 @@ class PrimaryButton extends StatelessWidget { ? (onDisabledPressed != null ? onDisabledPressed : null) : onPressed, color: isDisabled ? color.withOpacity(0.5) : color, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, disabledColor: color.withOpacity(0.5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(26.0), @@ -40,6 +47,16 @@ class PrimaryButton extends StatelessWidget { ? textColor.withOpacity(0.5) : textColor)), )); + + return isDottedBorder + ? DottedBorder( + borderType: BorderType.RRect, + dashPattern: [6, 4], + color: borderColor, + strokeWidth: 2, + radius: Radius.circular(26), + child: content) + : content; } } diff --git a/lib/themes/bright_theme.dart b/lib/themes/bright_theme.dart index 73c33aba3..5adb5d2f1 100644 --- a/lib/themes/bright_theme.dart +++ b/lib/themes/bright_theme.dart @@ -105,10 +105,12 @@ class BrightTheme extends ThemeBase { ), display2: TextStyle( color: Colors.white.withOpacity(0.5), // estimated fee (send page) + backgroundColor: PaletteDark.darkCyanBlue.withOpacity(0.67), // dot color for indicator on send page decorationColor: Palette.shadowWhite // template dotted border (send page) ), display3: TextStyle( color: Palette.darkBlueCraiola, // template new text (send page) + backgroundColor: PaletteDark.darkNightBlue, // active dot color for indicator on send page decorationColor: Palette.shadowWhite // template background color (send page) ), display4: TextStyle( diff --git a/lib/themes/dark_theme.dart b/lib/themes/dark_theme.dart index 428100eba..646afc964 100644 --- a/lib/themes/dark_theme.dart +++ b/lib/themes/dark_theme.dart @@ -104,10 +104,12 @@ class DarkTheme extends ThemeBase { ), display2: TextStyle( color: Colors.white, // estimated fee (send page) + backgroundColor: PaletteDark.cyanBlue, // dot color for indicator on send page decorationColor: PaletteDark.darkCyanBlue // template dotted border (send page) ), display3: TextStyle( color: PaletteDark.darkCyanBlue, // template new text (send page) + backgroundColor: Colors.white, // active dot color for indicator on send page decorationColor: PaletteDark.darkVioletBlue // template background color (send page) ), display4: TextStyle( diff --git a/lib/themes/light_theme.dart b/lib/themes/light_theme.dart index fa12a90b6..763872efe 100644 --- a/lib/themes/light_theme.dart +++ b/lib/themes/light_theme.dart @@ -105,10 +105,12 @@ class LightTheme extends ThemeBase { ), display2: TextStyle( color: Colors.white.withOpacity(0.5), // estimated fee (send page) + backgroundColor: PaletteDark.darkCyanBlue.withOpacity(0.67), // dot color for indicator on send page decorationColor: Palette.moderateLavender // template dotted border (send page) ), display3: TextStyle( color: Palette.darkBlueCraiola, // template new text (send page) + backgroundColor: PaletteDark.darkNightBlue, // active dot color for indicator on send page decorationColor: Palette.blueAlice // template background color (send page) ), display4: TextStyle( diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index 7882c3dca..5445823a2 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -79,8 +79,11 @@ abstract class ExchangeTradeViewModelBase with Store { return; } - sendViewModel.address = trade.inputAddress; - sendViewModel.setCryptoAmount(trade.amount); + sendViewModel.clearOutputs(); + final output = sendViewModel.outputs.first; + + output.address = trade.inputAddress; + output.setCryptoAmount(trade.amount); await sendViewModel.createTransaction(); } diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart new file mode 100644 index 000000000..e47956d7c --- /dev/null +++ b/lib/view_model/send/output.dart @@ -0,0 +1,206 @@ +import 'package:cake_wallet/bitcoin/bitcoin_amount_format.dart'; +import 'package:cake_wallet/bitcoin/electrum_wallet.dart'; +import 'package:cake_wallet/entities/calculate_fiat_amount_raw.dart'; +import 'package:cake_wallet/entities/parse_address_from_domain.dart'; +import 'package:cake_wallet/entities/parsed_address.dart'; +import 'package:cake_wallet/monero/monero_amount_format.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/core/wallet_base.dart'; +import 'package:cake_wallet/monero/monero_wallet.dart'; +import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; +import 'package:cake_wallet/entities/wallet_type.dart'; +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/generated/i18n.dart'; + +part 'output.g.dart'; + +const String cryptoNumberPattern = '0.0'; + +class Output = OutputBase with _$Output; + +abstract class OutputBase with Store { + OutputBase(this._wallet, this._settingsStore, this._fiatConversationStore) + :_cryptoNumberFormat = NumberFormat(cryptoNumberPattern) { + reset(); + _setCryptoNumMaximumFractionDigits(); + key = UniqueKey(); + } + + Key key; + + @observable + String fiatAmount; + + @observable + String cryptoAmount; + + @observable + String address; + + @observable + String note; + + @observable + bool sendAll; + + @computed + int get formattedCryptoAmount { + int amount = 0; + + try { + if (cryptoAmount?.isNotEmpty ?? false) { + final _cryptoAmount = cryptoAmount.replaceAll(',', '.'); + int _amount = 0; + switch (walletType) { + case WalletType.monero: + _amount = moneroParseAmount(amount: _cryptoAmount); + break; + case WalletType.bitcoin: + _amount = stringDoubleToBitcoinAmount(_cryptoAmount); + break; + case WalletType.litecoin: + _amount = stringDoubleToBitcoinAmount(_cryptoAmount); + break; + default: + break; + } + + if (_amount > 0) { + amount = _amount; + } + } + } catch(e) { + amount = 0; + } + + return amount; + } + + @computed + double get estimatedFee { + try { + final fee = _wallet.calculateEstimatedFee( + _settingsStore.priority[_wallet.type], formattedCryptoAmount); + + if (_wallet is ElectrumWallet) { + return bitcoinAmountToDouble(amount: fee); + } + + if (_wallet is MoneroWallet) { + return moneroAmountToDouble(amount: fee); + } + } catch (e) { + print(e.toString()); + } + + return 0; + } + + @computed + String get estimatedFeeFiatAmount { + try { + final fiat = calculateFiatAmountRaw( + price: _fiatConversationStore.prices[_wallet.currency], + cryptoAmount: estimatedFee); + return fiat; + } catch (_) { + return '0.00'; + } + } + + WalletType get walletType => _wallet.type; + final WalletBase _wallet; + final SettingsStore _settingsStore; + final FiatConversionStore _fiatConversationStore; + final NumberFormat _cryptoNumberFormat; + + @action + void setSendAll() => sendAll = true; + + @action + void reset() { + sendAll = false; + cryptoAmount = ''; + fiatAmount = ''; + address = ''; + note = ''; + } + + @action + void setCryptoAmount(String amount) { + if (amount.toUpperCase() != S.current.all) { + sendAll = false; + } + + cryptoAmount = amount; + _updateFiatAmount(); + } + + @action + void setFiatAmount(String amount) { + fiatAmount = amount; + _updateCryptoAmount(); + } + + @action + void _updateFiatAmount() { + try { + final fiat = calculateFiatAmount( + price: _fiatConversationStore.prices[_wallet.currency], + cryptoAmount: cryptoAmount.replaceAll(',', '.')); + if (fiatAmount != fiat) { + fiatAmount = fiat; + } + } catch (_) { + fiatAmount = ''; + } + } + + @action + void _updateCryptoAmount() { + try { + final crypto = double.parse(fiatAmount.replaceAll(',', '.')) / + _fiatConversationStore.prices[_wallet.currency]; + final cryptoAmountTmp = _cryptoNumberFormat.format(crypto); + + if (cryptoAmount != cryptoAmountTmp) { + cryptoAmount = cryptoAmountTmp; + } + } catch (e) { + cryptoAmount = ''; + } + } + + void _setCryptoNumMaximumFractionDigits() { + var maximumFractionDigits = 0; + + switch (_wallet.type) { + case WalletType.monero: + maximumFractionDigits = 12; + break; + case WalletType.bitcoin: + maximumFractionDigits = 8; + break; + case WalletType.litecoin: + maximumFractionDigits = 8; + break; + default: + break; + } + + _cryptoNumberFormat.maximumFractionDigits = maximumFractionDigits; + } + + Future applyOpenaliasOrUnstoppableDomains() async { + final domain = address; + final ticker = _wallet.currency.title.toLowerCase(); + final parsedAddress = await parseAddressFromDomain(domain, ticker); + + address = parsedAddress.address; + + return parsedAddress; + } +} \ No newline at end of file diff --git a/lib/view_model/send/send_template_view_model.dart b/lib/view_model/send/send_template_view_model.dart new file mode 100644 index 000000000..9ddd8f5f6 --- /dev/null +++ b/lib/view_model/send/send_template_view_model.dart @@ -0,0 +1,66 @@ +import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/entities/template.dart'; +import 'package:cake_wallet/store/templates/send_template_store.dart'; +import 'package:cake_wallet/core/template_validator.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/core/amount_validator.dart'; +import 'package:cake_wallet/core/validator.dart'; +import 'package:cake_wallet/core/wallet_base.dart'; +import 'package:cake_wallet/entities/crypto_currency.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; + +part 'send_template_view_model.g.dart'; + +class SendTemplateViewModel = SendTemplateViewModelBase + with _$SendTemplateViewModel; + +abstract class SendTemplateViewModelBase with Store { + SendTemplateViewModelBase(this._wallet, this._settingsStore, + this._sendTemplateStore, this._fiatConversationStore) { + + output = Output(_wallet, _settingsStore, _fiatConversationStore); + } + + Output output; + + Validator get amountValidator => AmountValidator(type: _wallet.type); + + Validator get addressValidator => AddressValidator(type: _wallet.currency); + + Validator get templateValidator => TemplateValidator(); + + CryptoCurrency get currency => _wallet.currency; + + FiatCurrency get fiat => _settingsStore.fiatCurrency; + + @computed + ObservableList