mirror of
https://github.com/cypherstack/stack_wallet.git
synced 2024-11-18 18:21:08 +00:00
2194 lines
104 KiB
Dart
2194 lines
104 KiB
Dart
/*
|
|
* This file is part of Stack Wallet.
|
|
*
|
|
* Copyright (c) 2023 Cypher Stack
|
|
* All Rights Reserved.
|
|
* The code is distributed under GPLv3 license, see LICENSE file for details.
|
|
* Generated by Cypher Stack on 2023-05-26
|
|
*
|
|
*/
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:bip47/bip47.dart';
|
|
import 'package:cw_core/monero_transaction_priority.dart';
|
|
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/isar/models/isar_models.dart';
|
|
import 'package:stackwallet/models/paynym/paynym_account_lite.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/coin_control/coin_control_view.dart';
|
|
import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart';
|
|
import 'package:stackwallet/pages/send_view/sub_widgets/building_transaction_dialog.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/services/mixins/paynym_wallet_interface.dart';
|
|
import 'package:stackwallet/themes/coin_icon_provider.dart';
|
|
import 'package:stackwallet/themes/stack_colors.dart';
|
|
import 'package:stackwallet/utilities/address_utils.dart';
|
|
import 'package:stackwallet/utilities/amount/amount.dart';
|
|
import 'package:stackwallet/utilities/amount/amount_formatter.dart';
|
|
import 'package:stackwallet/utilities/amount/amount_input_formatter.dart';
|
|
import 'package:stackwallet/utilities/amount/amount_unit.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/logger.dart';
|
|
import 'package:stackwallet/utilities/prefs.dart';
|
|
import 'package:stackwallet/utilities/text_styles.dart';
|
|
import 'package:stackwallet/utilities/util.dart';
|
|
import 'package:stackwallet/widgets/animated_text.dart';
|
|
import 'package:stackwallet/widgets/background.dart';
|
|
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
|
|
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
|
|
import 'package:stackwallet/widgets/fee_slider.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/rounded_white_container.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 'package:tuple/tuple.dart';
|
|
|
|
class SendView extends ConsumerStatefulWidget {
|
|
const SendView({
|
|
Key? key,
|
|
required this.walletId,
|
|
required this.coin,
|
|
this.autoFillData,
|
|
this.clipboard = const ClipboardWrapper(),
|
|
this.barcodeScanner = const BarcodeScannerWrapper(),
|
|
this.accountLite,
|
|
}) : super(key: key);
|
|
|
|
static const String routeName = "/sendView";
|
|
|
|
final String walletId;
|
|
final Coin coin;
|
|
final SendViewAutoFillData? autoFillData;
|
|
final ClipboardInterface clipboard;
|
|
final BarcodeScannerInterface barcodeScanner;
|
|
final PaynymAccountLite? accountLite;
|
|
|
|
@override
|
|
ConsumerState<SendView> createState() => _SendViewState();
|
|
}
|
|
|
|
class _SendViewState extends ConsumerState<SendView> {
|
|
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 onChainNoteController;
|
|
late TextEditingController feeController;
|
|
|
|
late final SendViewAutoFillData? _data;
|
|
|
|
final _addressFocusNode = FocusNode();
|
|
final _noteFocusNode = FocusNode();
|
|
final _onChainNoteFocusNode = FocusNode();
|
|
final _cryptoFocus = FocusNode();
|
|
final _baseFocus = FocusNode();
|
|
|
|
Amount? _amountToSend;
|
|
Amount? _cachedAmountToSend;
|
|
String? _address;
|
|
|
|
String? _privateBalanceString;
|
|
String? _publicBalanceString;
|
|
|
|
bool _addressToggleFlag = false;
|
|
|
|
bool _cryptoAmountChangeLock = false;
|
|
late VoidCallback onCryptoAmountChanged;
|
|
|
|
Amount? _cachedBalance;
|
|
|
|
Set<UTXO> selectedUTXOs = {};
|
|
|
|
void _cryptoAmountChanged() async {
|
|
if (!_cryptoAmountChangeLock) {
|
|
final cryptoAmount = ref.read(pAmountFormatter(coin)).tryParse(
|
|
cryptoAmountController.text,
|
|
);
|
|
if (cryptoAmount != null) {
|
|
_amountToSend = 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) {
|
|
baseAmountController.text = (_amountToSend!.decimal * price)
|
|
.toAmount(
|
|
fractionDigits: 2,
|
|
)
|
|
.fiatString(
|
|
locale: ref.read(localeServiceChangeNotifierProvider).locale,
|
|
);
|
|
}
|
|
} else {
|
|
_amountToSend = null;
|
|
baseAmountController.text = "";
|
|
}
|
|
|
|
_updatePreviewButtonState(_address, _amountToSend);
|
|
|
|
_cryptoAmountChangedFeeUpdateTimer?.cancel();
|
|
_cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () {
|
|
if (coin != Coin.epicCash && !_baseFocus.hasFocus) {
|
|
setState(() {
|
|
_calculateFeesFuture = calculateFees(
|
|
_amountToSend == null
|
|
? 0.toAmountAsRaw(fractionDigits: coin.decimals)
|
|
: _amountToSend!,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
final updateFeesTimerDuration = const Duration(milliseconds: 500);
|
|
|
|
Timer? _cryptoAmountChangedFeeUpdateTimer;
|
|
Timer? _baseAmountChangedFeeUpdateTimer;
|
|
|
|
void _baseAmountChanged() {
|
|
_baseAmountChangedFeeUpdateTimer?.cancel();
|
|
_baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () {
|
|
if (coin != Coin.epicCash && !_cryptoFocus.hasFocus) {
|
|
setState(() {
|
|
_calculateFeesFuture = calculateFees(
|
|
_amountToSend == null
|
|
? 0.toAmountAsRaw(fractionDigits: coin.decimals)
|
|
: _amountToSend!,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
late Amount _currentFee;
|
|
|
|
void _setCurrentFee(String fee, bool shouldSetState) {
|
|
fee = fee.trim();
|
|
|
|
if (fee.startsWith("~")) {
|
|
fee = fee.substring(1);
|
|
}
|
|
if (fee.contains(" ")) {
|
|
fee = fee.split(" ").first;
|
|
}
|
|
|
|
final value = fee.contains(",")
|
|
? Decimal.parse(fee.replaceFirst(",", "."))
|
|
.toAmount(fractionDigits: coin.decimals)
|
|
: Decimal.parse(fee).toAmount(fractionDigits: coin.decimals);
|
|
|
|
if (shouldSetState) {
|
|
setState(() => _currentFee = value);
|
|
} else {
|
|
_currentFee = value;
|
|
}
|
|
}
|
|
|
|
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, Amount? amount) {
|
|
if (isPaynymSend) {
|
|
ref.read(previewTxButtonStateProvider.state).state =
|
|
(amount != null && amount > Amount.zero);
|
|
} else {
|
|
final isValidAddress = ref
|
|
.read(walletsChangeNotifierProvider)
|
|
.getManager(walletId)
|
|
.validateAddress(address ?? "");
|
|
ref.read(previewTxButtonStateProvider.state).state =
|
|
(isValidAddress && amount != null && amount > Amount.zero);
|
|
}
|
|
}
|
|
|
|
late Future<String> _calculateFeesFuture;
|
|
|
|
Map<Amount, String> cachedFees = {};
|
|
Map<Amount, String> cachedFiroPrivateFees = {};
|
|
Map<Amount, String> cachedFiroPublicFees = {};
|
|
|
|
Future<String> calculateFees(Amount amount) async {
|
|
if (amount <= Amount.zero) {
|
|
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;
|
|
default:
|
|
feeRate = -1;
|
|
}
|
|
|
|
Amount fee;
|
|
if (coin == Coin.monero) {
|
|
MoneroTransactionPriority specialMoneroId;
|
|
switch (ref.read(feeRateTypeStateProvider.state).state) {
|
|
case FeeRateType.fast:
|
|
specialMoneroId = MoneroTransactionPriority.fast;
|
|
break;
|
|
case FeeRateType.average:
|
|
specialMoneroId = MoneroTransactionPriority.regular;
|
|
break;
|
|
case FeeRateType.slow:
|
|
specialMoneroId = MoneroTransactionPriority.slow;
|
|
break;
|
|
default:
|
|
throw ArgumentError("custom fee not available for monero");
|
|
}
|
|
|
|
fee = await manager.estimateFeeFor(amount, specialMoneroId.raw!);
|
|
cachedFees[amount] = ref.read(pAmountFormatter(coin)).format(
|
|
fee,
|
|
withUnitName: true,
|
|
indicatePrecisionLoss: false,
|
|
);
|
|
|
|
return cachedFees[amount]!;
|
|
} else if (coin == Coin.firo || coin == Coin.firoTestNet) {
|
|
if (ref.read(publicPrivateBalanceStateProvider.state).state ==
|
|
"Private") {
|
|
fee = await manager.estimateFeeFor(amount, feeRate);
|
|
|
|
cachedFiroPrivateFees[amount] = ref.read(pAmountFormatter(coin)).format(
|
|
fee,
|
|
withUnitName: true,
|
|
indicatePrecisionLoss: false,
|
|
);
|
|
|
|
return cachedFiroPrivateFees[amount]!;
|
|
} else {
|
|
fee = await (manager.wallet as FiroWallet)
|
|
.estimateFeeForPublic(amount, feeRate);
|
|
|
|
cachedFiroPublicFees[amount] = ref.read(pAmountFormatter(coin)).format(
|
|
fee,
|
|
withUnitName: true,
|
|
indicatePrecisionLoss: false,
|
|
);
|
|
|
|
return cachedFiroPublicFees[amount]!;
|
|
}
|
|
} else {
|
|
fee = await manager.estimateFeeFor(amount, feeRate);
|
|
cachedFees[amount] = ref.read(pAmountFormatter(coin)).format(
|
|
fee,
|
|
withUnitName: true,
|
|
indicatePrecisionLoss: false,
|
|
);
|
|
|
|
return cachedFees[amount]!;
|
|
}
|
|
}
|
|
|
|
Future<String?> _firoBalanceFuture(
|
|
ChangeNotifierProvider<Manager> provider, String locale) async {
|
|
final wallet = ref.read(provider).wallet as FiroWallet?;
|
|
|
|
if (wallet != null) {
|
|
Amount? balance;
|
|
if (ref.read(publicPrivateBalanceStateProvider.state).state ==
|
|
"Private") {
|
|
balance = wallet.availablePrivateBalance();
|
|
} else {
|
|
balance = wallet.availablePublicBalance();
|
|
}
|
|
|
|
return ref.read(pAmountFormatter(coin)).format(
|
|
balance,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<void> _previewTransaction() async {
|
|
// wait for keyboard to disappear
|
|
FocusScope.of(context).unfocus();
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 100),
|
|
);
|
|
final manager =
|
|
ref.read(walletsChangeNotifierProvider).getManager(walletId);
|
|
|
|
final Amount amount = _amountToSend!;
|
|
final Amount availableBalance;
|
|
if ((coin == Coin.firo || coin == Coin.firoTestNet)) {
|
|
if (ref.read(publicPrivateBalanceStateProvider.state).state ==
|
|
"Private") {
|
|
availableBalance =
|
|
(manager.wallet as FiroWallet).availablePrivateBalance();
|
|
} else {
|
|
availableBalance =
|
|
(manager.wallet as FiroWallet).availablePublicBalance();
|
|
}
|
|
} else {
|
|
availableBalance = manager.balance.spendable;
|
|
}
|
|
|
|
final coinControlEnabled =
|
|
ref.read(prefsChangeNotifierProvider).enableCoinControl;
|
|
|
|
if (coin != Coin.ethereum &&
|
|
!(manager.hasCoinControlSupport && coinControlEnabled) ||
|
|
(manager.hasCoinControlSupport &&
|
|
coinControlEnabled &&
|
|
selectedUTXOs.isEmpty)) {
|
|
// confirm send all
|
|
if (amount == availableBalance) {
|
|
bool? shouldSendAll;
|
|
if (mounted) {
|
|
shouldSendAll = await showDialog<bool>(
|
|
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<StackColors>()!
|
|
.getSecondaryEnabledButtonStyle(context),
|
|
child: Text(
|
|
"Cancel",
|
|
style: STextStyles.button(context).copyWith(
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.accentColorDark),
|
|
),
|
|
onPressed: () {
|
|
Navigator.of(context).pop(false);
|
|
},
|
|
),
|
|
rightButton: TextButton(
|
|
style: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.getPrimaryEnabledButtonStyle(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;
|
|
|
|
if (mounted) {
|
|
unawaited(
|
|
showDialog<void>(
|
|
context: context,
|
|
useSafeArea: false,
|
|
barrierDismissible: false,
|
|
builder: (context) {
|
|
return BuildingTransactionDialog(
|
|
coin: manager.coin,
|
|
onCancel: () {
|
|
wasCancelled = true;
|
|
|
|
Navigator.of(context).pop();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
final time = Future<dynamic>.delayed(
|
|
const Duration(
|
|
milliseconds: 2500,
|
|
),
|
|
);
|
|
|
|
Map<String, dynamic> txData;
|
|
Future<Map<String, dynamic>> txDataFuture;
|
|
|
|
if (isPaynymSend) {
|
|
final wallet = manager.wallet as PaynymWalletInterface;
|
|
final paymentCode = PaymentCode.fromPaymentCode(
|
|
widget.accountLite!.code,
|
|
networkType: wallet.networkType,
|
|
);
|
|
final feeRate = ref.read(feeRateTypeStateProvider);
|
|
txDataFuture = wallet.preparePaymentCodeSend(
|
|
paymentCode: paymentCode,
|
|
isSegwit: widget.accountLite!.segwit,
|
|
amount: amount,
|
|
args: {
|
|
"satsPerVByte": isCustomFee ? customFeeRate : null,
|
|
"feeRate": feeRate,
|
|
"UTXOs": (manager.hasCoinControlSupport &&
|
|
coinControlEnabled &&
|
|
selectedUTXOs.isNotEmpty)
|
|
? selectedUTXOs
|
|
: null,
|
|
},
|
|
);
|
|
} else if ((coin == Coin.firo || coin == Coin.firoTestNet) &&
|
|
ref.read(publicPrivateBalanceStateProvider.state).state !=
|
|
"Private") {
|
|
txDataFuture = (manager.wallet as FiroWallet).prepareSendPublic(
|
|
address: _address!,
|
|
amount: amount,
|
|
args: {
|
|
"feeRate": ref.read(feeRateTypeStateProvider),
|
|
"satsPerVByte": isCustomFee ? customFeeRate : null,
|
|
},
|
|
);
|
|
} else {
|
|
txDataFuture = manager.prepareSend(
|
|
address: _address!,
|
|
amount: amount,
|
|
args: {
|
|
"feeRate": ref.read(feeRateTypeStateProvider),
|
|
"satsPerVByte": isCustomFee ? customFeeRate : null,
|
|
"UTXOs": (manager.hasCoinControlSupport &&
|
|
coinControlEnabled &&
|
|
selectedUTXOs.isNotEmpty)
|
|
? selectedUTXOs
|
|
: null,
|
|
},
|
|
);
|
|
}
|
|
|
|
final results = await Future.wait([
|
|
txDataFuture,
|
|
time,
|
|
]);
|
|
|
|
txData = results.first as Map<String, dynamic>;
|
|
|
|
if (!wasCancelled && mounted) {
|
|
// pop building dialog
|
|
Navigator.of(context).pop();
|
|
txData["note"] = noteController.text;
|
|
txData["onChainNote"] = onChainNoteController.text;
|
|
if (isPaynymSend) {
|
|
txData["paynymAccountLite"] = widget.accountLite!;
|
|
} else {
|
|
txData["address"] = _address;
|
|
}
|
|
|
|
unawaited(Navigator.of(context).push(
|
|
RouteGenerator.getRoute(
|
|
shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
|
|
builder: (_) => ConfirmTransactionView(
|
|
transactionInfo: txData,
|
|
walletId: walletId,
|
|
isPaynymTransaction: isPaynymSend,
|
|
),
|
|
settings: const RouteSettings(
|
|
name: ConfirmTransactionView.routeName,
|
|
),
|
|
),
|
|
));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
// pop building dialog
|
|
Navigator.of(context).pop();
|
|
|
|
unawaited(showDialog<dynamic>(
|
|
context: context,
|
|
useSafeArea: false,
|
|
barrierDismissible: true,
|
|
builder: (context) {
|
|
return StackDialog(
|
|
title: "Transaction failed",
|
|
message: e.toString(),
|
|
rightButton: TextButton(
|
|
style: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.getSecondaryEnabledButtonStyle(context),
|
|
child: Text(
|
|
"Ok",
|
|
style: STextStyles.button(context).copyWith(
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.accentColorDark),
|
|
),
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
);
|
|
},
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
bool get isPaynymSend => widget.accountLite != null;
|
|
|
|
bool isCustomFee = false;
|
|
|
|
int customFeeRate = 1;
|
|
|
|
@override
|
|
void initState() {
|
|
coin = widget.coin;
|
|
ref.refresh(feeSheetSessionCacheProvider);
|
|
_currentFee = 0.toAmountAsRaw(fractionDigits: coin.decimals);
|
|
|
|
_calculateFeesFuture =
|
|
calculateFees(0.toAmountAsRaw(fractionDigits: coin.decimals));
|
|
_data = widget.autoFillData;
|
|
walletId = widget.walletId;
|
|
clipboard = widget.clipboard;
|
|
scanner = widget.barcodeScanner;
|
|
|
|
sendToController = TextEditingController();
|
|
cryptoAmountController = TextEditingController();
|
|
baseAmountController = TextEditingController();
|
|
noteController = TextEditingController();
|
|
onChainNoteController = TextEditingController();
|
|
feeController = TextEditingController();
|
|
|
|
onCryptoAmountChanged = _cryptoAmountChanged;
|
|
cryptoAmountController.addListener(onCryptoAmountChanged);
|
|
baseAmountController.addListener(_baseAmountChanged);
|
|
|
|
if (_data != null) {
|
|
if (_data!.amount != null) {
|
|
final amount = Amount.fromDecimal(
|
|
_data!.amount!,
|
|
fractionDigits: coin.decimals,
|
|
);
|
|
|
|
cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format(
|
|
amount,
|
|
withUnitName: false,
|
|
);
|
|
}
|
|
sendToController.text = _data!.contactLabel;
|
|
_address = _data!.address.trim();
|
|
_addressToggleFlag = true;
|
|
}
|
|
|
|
if (isPaynymSend) {
|
|
sendToController.text = widget.accountLite!.nymName;
|
|
noteController.text = "PayNym send";
|
|
}
|
|
|
|
// if (coin != Coin.epicCash) {
|
|
// _cryptoFocus.addListener(() {
|
|
// if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) {
|
|
// if (_amountToSend == null) {
|
|
// setState(() {
|
|
// _calculateFeesFuture = calculateFees(0);
|
|
// });
|
|
// } else {
|
|
// setState(() {
|
|
// _calculateFeesFuture = calculateFees(
|
|
// Format.decimalAmountToSatoshis(_amountToSend!, coin));
|
|
// });
|
|
// }
|
|
// }
|
|
// });
|
|
|
|
// _baseFocus.addListener(() {
|
|
// if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) {
|
|
// if (_amountToSend == null) {
|
|
// setState(() {
|
|
// _calculateFeesFuture = calculateFees(0);
|
|
// });
|
|
// } else {
|
|
// setState(() {
|
|
// _calculateFeesFuture = calculateFees(
|
|
// Format.decimalAmountToSatoshis(_amountToSend!, coin));
|
|
// });
|
|
// }
|
|
// }
|
|
// });
|
|
// }
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_cryptoAmountChangedFeeUpdateTimer?.cancel();
|
|
_baseAmountChangedFeeUpdateTimer?.cancel();
|
|
|
|
cryptoAmountController.removeListener(onCryptoAmountChanged);
|
|
baseAmountController.removeListener(_baseAmountChanged);
|
|
|
|
sendToController.dispose();
|
|
cryptoAmountController.dispose();
|
|
baseAmountController.dispose();
|
|
noteController.dispose();
|
|
onChainNoteController.dispose();
|
|
feeController.dispose();
|
|
|
|
_noteFocusNode.dispose();
|
|
_onChainNoteFocusNode.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));
|
|
|
|
final showCoinControl = ref.watch(
|
|
walletsChangeNotifierProvider.select(
|
|
(value) => value.getManager(walletId).hasCoinControlSupport,
|
|
),
|
|
) &&
|
|
ref.watch(
|
|
prefsChangeNotifierProvider.select(
|
|
(value) => value.enableCoinControl,
|
|
),
|
|
);
|
|
|
|
if (coin == Coin.firo || coin == Coin.firoTestNet) {
|
|
ref.listen(publicPrivateBalanceStateProvider, (previous, next) {
|
|
if (_amountToSend == null) {
|
|
setState(() {
|
|
_calculateFeesFuture =
|
|
calculateFees(0.toAmountAsRaw(fractionDigits: coin.decimals));
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_calculateFeesFuture = calculateFees(
|
|
_amountToSend!,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address)
|
|
if (coin == Coin.epicCash) {
|
|
sendToController.addListener(() {
|
|
_address = sendToController.text.trim();
|
|
|
|
if (_address != null && _address!.isNotEmpty) {
|
|
_address = _address!.trim();
|
|
if (_address!.contains("\n")) {
|
|
_address = _address!.substring(0, _address!.indexOf("\n"));
|
|
}
|
|
|
|
sendToController.text = formatAddress(_address!);
|
|
}
|
|
});
|
|
}
|
|
|
|
return Background(
|
|
child: Scaffold(
|
|
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
|
|
appBar: AppBar(
|
|
leading: AppBarBackButton(
|
|
onPressed: () async {
|
|
if (FocusScope.of(context).hasFocus) {
|
|
FocusScope.of(context).unfocus();
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
}
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
),
|
|
title: Text(
|
|
"Send ${coin.ticker}",
|
|
style: STextStyles.navBarTitle(context),
|
|
),
|
|
),
|
|
body: LayoutBuilder(
|
|
builder: (builderContext, constraints) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 12,
|
|
top: 12,
|
|
right: 12,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
// subtract top and bottom padding set in parent
|
|
minHeight: constraints.maxHeight - 24,
|
|
),
|
|
child: IntrinsicHeight(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.popupBG,
|
|
borderRadius: BorderRadius.circular(
|
|
Constants.size.circularBorderRadius,
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Row(
|
|
children: [
|
|
SvgPicture.file(
|
|
File(
|
|
ref.watch(
|
|
coinIconProvider(coin),
|
|
),
|
|
),
|
|
width: 22,
|
|
height: 22,
|
|
),
|
|
const SizedBox(
|
|
width: 6,
|
|
),
|
|
Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
ref.watch(provider.select(
|
|
(value) => value.walletName)),
|
|
style: STextStyles.titleBold12(context)
|
|
.copyWith(fontSize: 14),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
// const SizedBox(
|
|
// height: 2,
|
|
// ),
|
|
if (coin == Coin.firo ||
|
|
coin == Coin.firoTestNet)
|
|
Text(
|
|
"${ref.watch(publicPrivateBalanceStateProvider.state).state} balance",
|
|
style: STextStyles.label(context)
|
|
.copyWith(fontSize: 10),
|
|
),
|
|
if (coin != Coin.firo &&
|
|
coin != Coin.firoTestNet)
|
|
Text(
|
|
"Available balance",
|
|
style: STextStyles.label(context)
|
|
.copyWith(fontSize: 10),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
FutureBuilder(
|
|
// TODO redo this widget now that its not actually a future
|
|
future: (coin != Coin.firo &&
|
|
coin != Coin.firoTestNet)
|
|
? Future(() => ref.watch(
|
|
provider.select((value) =>
|
|
value.balance.spendable)))
|
|
: ref.watch(publicPrivateBalanceStateProvider.state).state ==
|
|
"Private"
|
|
? Future(() => (ref
|
|
.watch(provider)
|
|
.wallet as FiroWallet)
|
|
.availablePrivateBalance())
|
|
: Future(() => (ref
|
|
.watch(provider)
|
|
.wallet as FiroWallet)
|
|
.availablePublicBalance()),
|
|
builder:
|
|
(_, AsyncSnapshot<Amount> snapshot) {
|
|
if (snapshot.connectionState ==
|
|
ConnectionState.done &&
|
|
snapshot.hasData) {
|
|
_cachedBalance = snapshot.data!;
|
|
}
|
|
|
|
if (_cachedBalance != null) {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
cryptoAmountController.text = ref
|
|
.read(pAmountFormatter(coin))
|
|
.format(
|
|
_cachedBalance!,
|
|
withUnitName: false,
|
|
);
|
|
},
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
ref
|
|
.watch(pAmountFormatter(
|
|
coin))
|
|
.format(_cachedBalance!),
|
|
style:
|
|
STextStyles.titleBold12(
|
|
context)
|
|
.copyWith(
|
|
fontSize: 10,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
Text(
|
|
"${(_cachedBalance!.decimal * ref.watch(priceAnd24hChangeNotifierProvider.select((value) => value.getPrice(coin).item1))).toAmount(
|
|
fractionDigits: 2,
|
|
).fiatString(
|
|
locale: locale,
|
|
)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}",
|
|
style: STextStyles.subtitle(
|
|
context)
|
|
.copyWith(
|
|
fontSize: 8,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
return Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.end,
|
|
children: [
|
|
AnimatedText(
|
|
stringsToLoopThrough: const [
|
|
"Loading balance ",
|
|
"Loading balance. ",
|
|
"Loading balance.. ",
|
|
"Loading balance...",
|
|
],
|
|
style: STextStyles.itemSubtitle(
|
|
context)
|
|
.copyWith(
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(
|
|
height: 2,
|
|
),
|
|
AnimatedText(
|
|
stringsToLoopThrough: const [
|
|
"Loading balance ",
|
|
"Loading balance. ",
|
|
"Loading balance.. ",
|
|
"Loading balance...",
|
|
],
|
|
style: STextStyles.itemSubtitle(
|
|
context)
|
|
.copyWith(
|
|
fontSize: 8,
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(
|
|
height: 16,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
isPaynymSend
|
|
? "Send to PayNym address"
|
|
: "Send to",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
// if (coin == Coin.monero)
|
|
// CustomTextButton(
|
|
// text: "Use OpenAlias",
|
|
// onTap: () async {
|
|
// await showModalBottomSheet(
|
|
// context: context,
|
|
// builder: (context) =>
|
|
// OpenAliasBottomSheet(
|
|
// onSelected: (address) {
|
|
// sendToController.text = address;
|
|
// },
|
|
// ),
|
|
// );
|
|
// },
|
|
// ),
|
|
],
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
if (isPaynymSend)
|
|
TextField(
|
|
key: const Key("sendViewPaynymAddressFieldKey"),
|
|
controller: sendToController,
|
|
enabled: false,
|
|
readOnly: true,
|
|
style: STextStyles.fieldLabel(context),
|
|
),
|
|
if (!isPaynymSend)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
Constants.size.circularBorderRadius,
|
|
),
|
|
child: TextField(
|
|
key: const Key("sendViewAddressFieldKey"),
|
|
controller: sendToController,
|
|
readOnly: false,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
// inputFormatters: <TextInputFormatter>[
|
|
// FilteringTextInputFormatter.allow(
|
|
// RegExp("[a-zA-Z0-9]{34}")),
|
|
// ],
|
|
toolbarOptions: const ToolbarOptions(
|
|
copy: false,
|
|
cut: false,
|
|
paste: true,
|
|
selectAll: false,
|
|
),
|
|
onChanged: (newValue) {
|
|
_address = newValue.trim();
|
|
_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(
|
|
semanticsLabel:
|
|
"Clear Button. Clears The Address Field Input.",
|
|
key: const Key(
|
|
"sendViewClearAddressFieldButtonKey"),
|
|
onTap: () {
|
|
sendToController.text = "";
|
|
_address = "";
|
|
_updatePreviewButtonState(
|
|
_address,
|
|
_amountToSend);
|
|
setState(() {
|
|
_addressToggleFlag =
|
|
false;
|
|
});
|
|
},
|
|
child: const XIcon(),
|
|
)
|
|
: TextFieldIconButton(
|
|
semanticsLabel:
|
|
"Paste Button. Pastes From Clipboard To Address Field Input.",
|
|
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"));
|
|
}
|
|
|
|
if (coin ==
|
|
Coin.epicCash) {
|
|
// strip http:// and https:// if content contains @
|
|
content = formatAddress(
|
|
content);
|
|
}
|
|
sendToController.text =
|
|
content.trim();
|
|
_address = content.trim();
|
|
|
|
_updatePreviewButtonState(
|
|
_address,
|
|
_amountToSend);
|
|
setState(() {
|
|
_addressToggleFlag =
|
|
sendToController
|
|
.text
|
|
.isNotEmpty;
|
|
});
|
|
}
|
|
},
|
|
child: sendToController
|
|
.text.isEmpty
|
|
? const ClipboardIcon()
|
|
: const XIcon(),
|
|
),
|
|
if (sendToController.text.isEmpty)
|
|
TextFieldIconButton(
|
|
semanticsLabel:
|
|
"Address Book Button. Opens Address Book For Address Field.",
|
|
key: const Key(
|
|
"sendViewAddressBookButtonKey"),
|
|
onTap: () {
|
|
Navigator.of(context).pushNamed(
|
|
AddressBookView.routeName,
|
|
arguments: widget.coin,
|
|
);
|
|
},
|
|
child: const AddressBookIcon(),
|
|
),
|
|
if (sendToController.text.isEmpty)
|
|
TextFieldIconButton(
|
|
semanticsLabel:
|
|
"Scan QR Button. Opens Camera For Scanning QR Code.",
|
|
key: const Key(
|
|
"sendViewScanQrButtonKey"),
|
|
onTap: () async {
|
|
try {
|
|
// ref
|
|
// .read(
|
|
// shouldShowLockscreenOnResumeStateProvider
|
|
// .state)
|
|
// .state = false;
|
|
if (FocusScope.of(context)
|
|
.hasFocus) {
|
|
FocusScope.of(context)
|
|
.unfocus();
|
|
await Future<void>.delayed(
|
|
const Duration(
|
|
milliseconds: 75));
|
|
}
|
|
|
|
final qrResult =
|
|
await scanner.scan();
|
|
|
|
// Future<void>.delayed(
|
|
// const Duration(seconds: 2),
|
|
// () => ref
|
|
// .read(
|
|
// shouldShowLockscreenOnResumeStateProvider
|
|
// .state)
|
|
// .state = true,
|
|
// );
|
|
|
|
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"] ??
|
|
"")
|
|
.trim();
|
|
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 amount =
|
|
Decimal.parse(results[
|
|
"amount"]!)
|
|
.toAmount(
|
|
fractionDigits:
|
|
coin.decimals,
|
|
);
|
|
cryptoAmountController
|
|
.text =
|
|
ref
|
|
.read(
|
|
pAmountFormatter(
|
|
coin))
|
|
.format(
|
|
amount,
|
|
withUnitName:
|
|
false,
|
|
);
|
|
_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
|
|
.trim();
|
|
sendToController.text =
|
|
_address ?? "";
|
|
|
|
_updatePreviewButtonState(
|
|
_address,
|
|
_amountToSend);
|
|
setState(() {
|
|
_addressToggleFlag =
|
|
sendToController
|
|
.text.isNotEmpty;
|
|
});
|
|
}
|
|
} on PlatformException catch (e, s) {
|
|
// ref
|
|
// .read(
|
|
// shouldShowLockscreenOnResumeStateProvider
|
|
// .state)
|
|
// .state = true;
|
|
// 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<StackColors>()!
|
|
.textError,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
if (coin == Coin.firo)
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
if (coin == Coin.firo)
|
|
Text(
|
|
"Send from",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
if (coin == Coin.firo)
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
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<StackColors>()!
|
|
.highlight,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
Constants.size.circularBorderRadius,
|
|
),
|
|
),
|
|
onPressed: () {
|
|
showModalBottomSheet<dynamic>(
|
|
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<String?>
|
|
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",
|
|
style: STextStyles
|
|
.itemSubtitle(context),
|
|
);
|
|
} else if (ref
|
|
.read(
|
|
publicPrivateBalanceStateProvider
|
|
.state)
|
|
.state ==
|
|
"Public" &&
|
|
_publicBalanceString !=
|
|
null) {
|
|
return Text(
|
|
"$_publicBalanceString",
|
|
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<StackColors>()!
|
|
.textSubtitle2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
"Amount",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
if (coin != Coin.ethereum)
|
|
CustomTextButton(
|
|
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 = ref
|
|
.read(pAmountFormatter(coin))
|
|
.format(
|
|
firoWallet
|
|
.availablePrivateBalance(),
|
|
withUnitName: false,
|
|
);
|
|
} else {
|
|
cryptoAmountController.text = ref
|
|
.read(pAmountFormatter(coin))
|
|
.format(
|
|
firoWallet
|
|
.availablePublicBalance(),
|
|
withUnitName: false,
|
|
);
|
|
}
|
|
} else {
|
|
cryptoAmountController.text = ref
|
|
.read(pAmountFormatter(coin))
|
|
.format(
|
|
ref
|
|
.read(provider)
|
|
.balance
|
|
.spendable,
|
|
withUnitName: false,
|
|
);
|
|
}
|
|
_cryptoAmountChanged();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
TextField(
|
|
autocorrect: Util.isDesktop ? false : true,
|
|
enableSuggestions: Util.isDesktop ? false : true,
|
|
style: STextStyles.smallMed14(context).copyWith(
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.textDark,
|
|
),
|
|
key:
|
|
const Key("amountInputFieldCryptoTextFieldKey"),
|
|
controller: cryptoAmountController,
|
|
focusNode: _cryptoFocus,
|
|
keyboardType: Util.isDesktop
|
|
? null
|
|
: const TextInputType.numberWithOptions(
|
|
signed: false,
|
|
decimal: true,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
inputFormatters: [
|
|
AmountInputFormatter(
|
|
decimals: coin.decimals,
|
|
unit: ref.watch(pAmountUnit(coin)),
|
|
locale: locale,
|
|
),
|
|
|
|
// 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})$')
|
|
// // RegExp(r'^\d{1,3}([,\.]\d+)?|[,\.\d]+$')
|
|
// getAmountRegex(locale, coin.decimals)
|
|
// .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(
|
|
ref
|
|
.watch(pAmountUnit(coin))
|
|
.unitForCoin(coin),
|
|
style: STextStyles.smallMed14(context)
|
|
.copyWith(
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.accentColorDark),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (Prefs.instance.externalCalls)
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
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<StackColors>()!
|
|
.textDark,
|
|
),
|
|
key:
|
|
const Key("amountInputFieldFiatTextFieldKey"),
|
|
controller: baseAmountController,
|
|
focusNode: _baseFocus,
|
|
keyboardType: Util.isDesktop
|
|
? null
|
|
: const TextInputType.numberWithOptions(
|
|
signed: false,
|
|
decimal: true,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
inputFormatters: [
|
|
AmountInputFormatter(
|
|
decimals: 2,
|
|
locale: locale,
|
|
),
|
|
// 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})$')
|
|
// getAmountRegex(locale, 2)
|
|
// .hasMatch(newValue.text)
|
|
// ? newValue
|
|
// : oldValue),
|
|
],
|
|
onChanged: (baseAmountString) {
|
|
final baseAmount = Amount.tryParseFiatString(
|
|
baseAmountString,
|
|
locale: locale,
|
|
);
|
|
if (baseAmount != null) {
|
|
final Decimal _price = ref
|
|
.read(priceAnd24hChangeNotifierProvider)
|
|
.getPrice(coin)
|
|
.item1;
|
|
|
|
if (_price == Decimal.zero) {
|
|
_amountToSend = 0.toAmountAsRaw(
|
|
fractionDigits: coin.decimals);
|
|
} else {
|
|
_amountToSend = baseAmount <= Amount.zero
|
|
? 0.toAmountAsRaw(
|
|
fractionDigits: coin.decimals)
|
|
: (baseAmount.decimal / _price)
|
|
.toDecimal(
|
|
scaleOnInfinitePrecision:
|
|
coin.decimals,
|
|
)
|
|
.toAmount(
|
|
fractionDigits: coin.decimals);
|
|
}
|
|
if (_cachedAmountToSend != null &&
|
|
_cachedAmountToSend == _amountToSend) {
|
|
return;
|
|
}
|
|
_cachedAmountToSend = _amountToSend;
|
|
Logging.instance.log(
|
|
"it changed $_amountToSend $_cachedAmountToSend",
|
|
level: LogLevel.Info);
|
|
|
|
final amountString =
|
|
ref.read(pAmountFormatter(coin)).format(
|
|
_amountToSend!,
|
|
withUnitName: false,
|
|
);
|
|
|
|
_cryptoAmountChangeLock = true;
|
|
cryptoAmountController.text = amountString;
|
|
_cryptoAmountChangeLock = false;
|
|
} else {
|
|
_amountToSend = 0.toAmountAsRaw(
|
|
fractionDigits: coin.decimals);
|
|
_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<StackColors>()!
|
|
.accentColorDark),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (showCoinControl)
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
if (showCoinControl)
|
|
RoundedWhiteContainer(
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
"Coin control",
|
|
style:
|
|
STextStyles.w500_14(context).copyWith(
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.textSubtitle1,
|
|
),
|
|
),
|
|
CustomTextButton(
|
|
text: selectedUTXOs.isEmpty
|
|
? "Select coins"
|
|
: "Selected coins (${selectedUTXOs.length})",
|
|
onTap: () async {
|
|
if (FocusScope.of(context).hasFocus) {
|
|
FocusScope.of(context).unfocus();
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 100),
|
|
);
|
|
}
|
|
|
|
if (mounted) {
|
|
final spendable = ref
|
|
.read(walletsChangeNotifierProvider)
|
|
.getManager(widget.walletId)
|
|
.balance
|
|
.spendable;
|
|
|
|
Amount? amount;
|
|
if (_amountToSend != null) {
|
|
amount = _amountToSend!;
|
|
|
|
if (spendable == amount) {
|
|
// this is now a send all
|
|
} else {
|
|
amount += _currentFee;
|
|
}
|
|
}
|
|
|
|
final result =
|
|
await Navigator.of(context)
|
|
.pushNamed(
|
|
CoinControlView.routeName,
|
|
arguments: Tuple4(
|
|
walletId,
|
|
CoinControlViewType.use,
|
|
amount,
|
|
selectedUTXOs,
|
|
),
|
|
);
|
|
|
|
if (result is Set<UTXO>) {
|
|
setState(() {
|
|
selectedUTXOs = result;
|
|
});
|
|
}
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
if (coin == Coin.epicCash)
|
|
Text(
|
|
"On chain Note (optional)",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
if (coin == Coin.epicCash)
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
if (coin == Coin.epicCash)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
Constants.size.circularBorderRadius,
|
|
),
|
|
child: TextField(
|
|
autocorrect: Util.isDesktop ? false : true,
|
|
enableSuggestions: Util.isDesktop ? false : true,
|
|
maxLength: 256,
|
|
controller: onChainNoteController,
|
|
focusNode: _onChainNoteFocusNode,
|
|
style: STextStyles.field(context),
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: standardInputDecoration(
|
|
"Type something...",
|
|
_onChainNoteFocusNode,
|
|
context,
|
|
).copyWith(
|
|
suffixIcon: onChainNoteController.text.isNotEmpty
|
|
? Padding(
|
|
padding:
|
|
const EdgeInsets.only(right: 0),
|
|
child: UnconstrainedBox(
|
|
child: Row(
|
|
children: [
|
|
TextFieldIconButton(
|
|
child: const XIcon(),
|
|
onTap: () async {
|
|
setState(() {
|
|
onChainNoteController.text = "";
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
if (coin == Coin.epicCash)
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
Text(
|
|
(coin == Coin.epicCash) ? "Local Note (optional)"
|
|
: "Note (optional)",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
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: 12,
|
|
),
|
|
if (coin != Coin.epicCash &&
|
|
coin != Coin.nano &&
|
|
coin != Coin.banano)
|
|
Text(
|
|
"Transaction fee (estimated)",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
if (coin != Coin.epicCash &&
|
|
coin != Coin.nano &&
|
|
coin != Coin.banano)
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
if (coin != Coin.epicCash &&
|
|
coin != Coin.nano &&
|
|
coin != Coin.banano)
|
|
Stack(
|
|
children: [
|
|
TextField(
|
|
autocorrect: Util.isDesktop ? false : true,
|
|
enableSuggestions:
|
|
Util.isDesktop ? false : true,
|
|
controller: feeController,
|
|
readOnly: true,
|
|
textInputAction: TextInputAction.none,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
),
|
|
child: RawMaterialButton(
|
|
splashColor: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.highlight,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
Constants.size.circularBorderRadius,
|
|
),
|
|
),
|
|
onPressed: (coin == Coin.firo ||
|
|
coin == Coin.firoTestNet) &&
|
|
ref
|
|
.watch(
|
|
publicPrivateBalanceStateProvider
|
|
.state)
|
|
.state ==
|
|
"Private"
|
|
? null
|
|
: () {
|
|
showModalBottomSheet<dynamic>(
|
|
backgroundColor:
|
|
Colors.transparent,
|
|
context: context,
|
|
shape:
|
|
const RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.vertical(
|
|
top: Radius.circular(20),
|
|
),
|
|
),
|
|
builder: (_) =>
|
|
TransactionFeeSelectionSheet(
|
|
walletId: walletId,
|
|
amount: (Decimal.tryParse(
|
|
cryptoAmountController
|
|
.text) ??
|
|
_amountToSend
|
|
?.decimal ??
|
|
Decimal.zero)
|
|
.toAmount(
|
|
fractionDigits: coin.decimals,
|
|
),
|
|
updateChosen: (String fee) {
|
|
if (fee == "custom") {
|
|
if (!isCustomFee) {
|
|
setState(() {
|
|
isCustomFee = true;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
_setCurrentFee(
|
|
fee,
|
|
true,
|
|
);
|
|
setState(() {
|
|
_calculateFeesFuture =
|
|
Future(() => fee);
|
|
if (isCustomFee) {
|
|
isCustomFee = false;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
);
|
|
},
|
|
child: ((coin == Coin.firo ||
|
|
coin == Coin.firoTestNet) &&
|
|
ref
|
|
.watch(
|
|
publicPrivateBalanceStateProvider
|
|
.state)
|
|
.state ==
|
|
"Private")
|
|
? Row(
|
|
children: [
|
|
FutureBuilder(
|
|
future: _calculateFeesFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState ==
|
|
ConnectionState
|
|
.done &&
|
|
snapshot.hasData) {
|
|
_setCurrentFee(
|
|
snapshot.data!,
|
|
false,
|
|
);
|
|
return Text(
|
|
"~${snapshot.data!}",
|
|
style: STextStyles
|
|
.itemSubtitle(
|
|
context),
|
|
);
|
|
} else {
|
|
return AnimatedText(
|
|
stringsToLoopThrough: const [
|
|
"Calculating",
|
|
"Calculating.",
|
|
"Calculating..",
|
|
"Calculating...",
|
|
],
|
|
style: STextStyles
|
|
.itemSubtitle(
|
|
context),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
)
|
|
: Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
ref
|
|
.watch(
|
|
feeRateTypeStateProvider
|
|
.state)
|
|
.state
|
|
.prettyName,
|
|
style: STextStyles
|
|
.itemSubtitle12(
|
|
context),
|
|
),
|
|
const SizedBox(
|
|
width: 10,
|
|
),
|
|
FutureBuilder(
|
|
future:
|
|
_calculateFeesFuture,
|
|
builder:
|
|
(context, snapshot) {
|
|
if (snapshot.connectionState ==
|
|
ConnectionState
|
|
.done &&
|
|
snapshot.hasData) {
|
|
_setCurrentFee(
|
|
snapshot.data!,
|
|
false,
|
|
);
|
|
return Text(
|
|
isCustomFee
|
|
? ""
|
|
: "~${snapshot.data!}",
|
|
style: STextStyles
|
|
.itemSubtitle(
|
|
context),
|
|
);
|
|
} else {
|
|
return AnimatedText(
|
|
stringsToLoopThrough: const [
|
|
"Calculating",
|
|
"Calculating.",
|
|
"Calculating..",
|
|
"Calculating...",
|
|
],
|
|
style: STextStyles
|
|
.itemSubtitle(
|
|
context),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
SvgPicture.asset(
|
|
Assets.svg.chevronDown,
|
|
width: 8,
|
|
height: 4,
|
|
color: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.textSubtitle2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
if (isCustomFee)
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
bottom: 12,
|
|
top: 16,
|
|
),
|
|
child: FeeSlider(
|
|
coin: coin,
|
|
onSatVByteChanged: (rate) {
|
|
customFeeRate = rate;
|
|
},
|
|
),
|
|
),
|
|
const Spacer(),
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
TextButton(
|
|
onPressed: ref
|
|
.watch(previewTxButtonStateProvider.state)
|
|
.state
|
|
? _previewTransaction
|
|
: null,
|
|
style: ref
|
|
.watch(previewTxButtonStateProvider.state)
|
|
.state
|
|
? Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.getPrimaryEnabledButtonStyle(context)
|
|
: Theme.of(context)
|
|
.extension<StackColors>()!
|
|
.getPrimaryDisabledButtonStyle(context),
|
|
child: Text(
|
|
"Preview",
|
|
style: STextStyles.button(context),
|
|
),
|
|
),
|
|
const SizedBox(
|
|
height: 4,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String formatAddress(String epicAddress) {
|
|
// strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address)
|
|
if ((epicAddress.startsWith("http://") ||
|
|
epicAddress.startsWith("https://")) &&
|
|
epicAddress.contains("@")) {
|
|
epicAddress = epicAddress.replaceAll("http://", "");
|
|
epicAddress = epicAddress.replaceAll("https://", "");
|
|
}
|
|
// strip mailto: prefix
|
|
if (epicAddress.startsWith("mailto:")) {
|
|
epicAddress = epicAddress.replaceAll("mailto:", "");
|
|
}
|
|
// strip / suffix if the address contains an @ symbol (and is thus an epicbox address)
|
|
if (epicAddress.endsWith("/") && epicAddress.contains("@")) {
|
|
epicAddress = epicAddress.substring(0, epicAddress.length - 1);
|
|
}
|
|
return epicAddress;
|
|
}
|