mirror of
https://github.com/cypherstack/stack_wallet.git
synced 2024-12-24 20:39:21 +00:00
1263 lines
53 KiB
Dart
1263 lines
53 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 '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/send_view_auto_fill_data.dart';
|
|
import 'package:stackwallet/pages/address_book_views/address_book_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/transaction_fee_selection_sheet.dart';
|
|
import 'package:stackwallet/pages/token_view/token_view.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/route_generator.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/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/wallets/isar/providers/wallet_info_provider.dart';
|
|
import 'package:stackwallet/wallets/models/tx_data.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/icon_widgets/addressbook_icon.dart';
|
|
import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart';
|
|
import 'package:stackwallet/widgets/icon_widgets/eth_token_icon.dart';
|
|
import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart';
|
|
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
|
|
import 'package:stackwallet/widgets/stack_dialog.dart';
|
|
import 'package:stackwallet/widgets/stack_text_field.dart';
|
|
import 'package:stackwallet/widgets/textfield_icon_button.dart';
|
|
|
|
class TokenSendView extends ConsumerStatefulWidget {
|
|
const TokenSendView({
|
|
Key? key,
|
|
required this.walletId,
|
|
required this.coin,
|
|
required this.tokenContract,
|
|
this.autoFillData,
|
|
this.clipboard = const ClipboardWrapper(),
|
|
this.barcodeScanner = const BarcodeScannerWrapper(),
|
|
}) : super(key: key);
|
|
|
|
static const String routeName = "/tokenSendView";
|
|
|
|
final String walletId;
|
|
final Coin coin;
|
|
final EthContract tokenContract;
|
|
final SendViewAutoFillData? autoFillData;
|
|
final ClipboardInterface clipboard;
|
|
final BarcodeScannerInterface barcodeScanner;
|
|
|
|
@override
|
|
ConsumerState<TokenSendView> createState() => _TokenSendViewState();
|
|
}
|
|
|
|
class _TokenSendViewState extends ConsumerState<TokenSendView> {
|
|
late final String walletId;
|
|
late final Coin coin;
|
|
late final EthContract tokenContract;
|
|
late final ClipboardInterface clipboard;
|
|
late final BarcodeScannerInterface scanner;
|
|
|
|
late TextEditingController sendToController;
|
|
late TextEditingController cryptoAmountController;
|
|
late TextEditingController baseAmountController;
|
|
late TextEditingController noteController;
|
|
late TextEditingController feeController;
|
|
|
|
late final SendViewAutoFillData? _data;
|
|
|
|
final _addressFocusNode = FocusNode();
|
|
final _noteFocusNode = FocusNode();
|
|
final _cryptoFocus = FocusNode();
|
|
final _baseFocus = FocusNode();
|
|
|
|
Amount? _amountToSend;
|
|
Amount? _cachedAmountToSend;
|
|
String? _address;
|
|
|
|
bool _addressToggleFlag = false;
|
|
|
|
bool _cryptoAmountChangeLock = false;
|
|
late VoidCallback onCryptoAmountChanged;
|
|
|
|
final updateFeesTimerDuration = const Duration(milliseconds: 500);
|
|
|
|
Timer? _cryptoAmountChangedFeeUpdateTimer;
|
|
Timer? _baseAmountChangedFeeUpdateTimer;
|
|
late Future<String> _calculateFeesFuture;
|
|
String cachedFees = "";
|
|
|
|
void _onTokenSendViewPasteAddressFieldButtonPressed() async {
|
|
final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain);
|
|
if (data?.text != null && data!.text!.isNotEmpty) {
|
|
String content = data.text!.trim();
|
|
if (content.contains("\n")) {
|
|
content = content.substring(0, content.indexOf("\n"));
|
|
}
|
|
sendToController.text = content.trim();
|
|
_address = content.trim();
|
|
|
|
_updatePreviewButtonState(_address, _amountToSend);
|
|
setState(() {
|
|
_addressToggleFlag = sendToController.text.isNotEmpty;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _onTokenSendViewScanQrButtonPressed() 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: tokenContract.decimals,
|
|
);
|
|
cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format(
|
|
amount,
|
|
withUnitName: false,
|
|
indicatePrecisionLoss: false,
|
|
);
|
|
_amountToSend = amount;
|
|
}
|
|
|
|
_updatePreviewButtonState(_address, _amountToSend);
|
|
setState(() {
|
|
_addressToggleFlag = sendToController.text.isNotEmpty;
|
|
});
|
|
|
|
// now check for non standard encoded basic address
|
|
} else if (ref
|
|
.read(pWallets)
|
|
.getWallet(walletId)
|
|
.cryptoCurrency
|
|
.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);
|
|
}
|
|
}
|
|
|
|
void _onFiatAmountFieldChanged(String baseAmountString) {
|
|
final baseAmount = Amount.tryParseFiatString(
|
|
baseAmountString,
|
|
locale: ref.read(localeServiceChangeNotifierProvider).locale,
|
|
);
|
|
if (baseAmount != null) {
|
|
final _price = ref
|
|
.read(priceAnd24hChangeNotifierProvider)
|
|
.getTokenPrice(tokenContract.address)
|
|
.item1;
|
|
|
|
if (_price == Decimal.zero) {
|
|
_amountToSend = Amount.zero;
|
|
} else {
|
|
_amountToSend = baseAmount <= Amount.zero
|
|
? Amount.zero
|
|
: Amount.fromDecimal(
|
|
(baseAmount.decimal / _price).toDecimal(
|
|
scaleOnInfinitePrecision: tokenContract.decimals),
|
|
fractionDigits: tokenContract.decimals);
|
|
}
|
|
if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) {
|
|
return;
|
|
}
|
|
_cachedAmountToSend = _amountToSend;
|
|
Logging.instance.log("it changed $_amountToSend $_cachedAmountToSend",
|
|
level: LogLevel.Info);
|
|
|
|
_cryptoAmountChangeLock = true;
|
|
cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format(
|
|
_amountToSend!,
|
|
withUnitName: false,
|
|
);
|
|
_cryptoAmountChangeLock = false;
|
|
} else {
|
|
_amountToSend = Amount.zero;
|
|
_cryptoAmountChangeLock = true;
|
|
cryptoAmountController.text = "";
|
|
_cryptoAmountChangeLock = false;
|
|
}
|
|
// setState(() {
|
|
// _calculateFeesFuture = calculateFees(
|
|
// Format.decimalAmountToSatoshis(
|
|
// _amountToSend!));
|
|
// });
|
|
_updatePreviewButtonState(_address, _amountToSend);
|
|
}
|
|
|
|
void _cryptoAmountChanged() async {
|
|
if (!_cryptoAmountChangeLock) {
|
|
final cryptoAmount = ref.read(pAmountFormatter(coin)).tryParse(
|
|
cryptoAmountController.text,
|
|
ethContract: tokenContract,
|
|
);
|
|
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)
|
|
.getTokenPrice(tokenContract.address)
|
|
.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();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void _baseAmountChanged() {
|
|
_baseAmountChangedFeeUpdateTimer?.cancel();
|
|
_baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () {
|
|
if (coin != Coin.epicCash && !_cryptoFocus.hasFocus) {
|
|
setState(() {
|
|
_calculateFeesFuture = calculateFees();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
String? _updateInvalidAddressText(String address) {
|
|
if (_data != null && _data!.contactLabel == address) {
|
|
return null;
|
|
}
|
|
if (address.isNotEmpty &&
|
|
!ref
|
|
.read(pWallets)
|
|
.getWallet(walletId)
|
|
.cryptoCurrency
|
|
.validateAddress(address)) {
|
|
return "Invalid address";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void _updatePreviewButtonState(String? address, Amount? amount) {
|
|
final isValidAddress = ref
|
|
.read(pWallets)
|
|
.getWallet(walletId)
|
|
.cryptoCurrency
|
|
.validateAddress(address ?? "");
|
|
ref.read(previewTxButtonStateProvider.state).state =
|
|
(isValidAddress && amount != null && amount > Amount.zero);
|
|
}
|
|
|
|
Future<String> calculateFees() async {
|
|
final wallet = ref.read(tokenServiceProvider)!;
|
|
final feeObject = await wallet.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;
|
|
}
|
|
|
|
final Amount fee = wallet.estimateFeeFor(feeRate);
|
|
cachedFees = ref.read(pAmountFormatter(coin)).format(
|
|
fee,
|
|
withUnitName: true,
|
|
indicatePrecisionLoss: false,
|
|
);
|
|
|
|
return cachedFees;
|
|
}
|
|
|
|
Future<void> _previewTransaction() async {
|
|
// wait for keyboard to disappear
|
|
FocusScope.of(context).unfocus();
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 100),
|
|
);
|
|
final wallet = ref.read(pWallets).getWallet(walletId);
|
|
final tokenWallet = ref.read(tokenServiceProvider)!;
|
|
|
|
final Amount amount = _amountToSend!;
|
|
|
|
// // 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: wallet.info.coin,
|
|
onCancel: () {
|
|
wasCancelled = true;
|
|
|
|
Navigator.of(context).pop();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
final time = Future<dynamic>.delayed(
|
|
const Duration(
|
|
milliseconds: 2500,
|
|
),
|
|
);
|
|
|
|
TxData txData;
|
|
Future<TxData> txDataFuture;
|
|
|
|
txDataFuture = tokenWallet.prepareSend(
|
|
txData: TxData(
|
|
recipients: [
|
|
(
|
|
address: _address!,
|
|
amount: amount,
|
|
)
|
|
],
|
|
feeRateType: ref.read(feeRateTypeStateProvider),
|
|
note: noteController.text,
|
|
),
|
|
);
|
|
|
|
final results = await Future.wait([
|
|
txDataFuture,
|
|
time,
|
|
]);
|
|
|
|
txData = results.first as TxData;
|
|
|
|
if (!wasCancelled && mounted) {
|
|
// pop building dialog
|
|
Navigator.of(context).pop();
|
|
|
|
unawaited(Navigator.of(context).push(
|
|
RouteGenerator.getRoute(
|
|
shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
|
|
builder: (_) => ConfirmTransactionView(
|
|
txData: txData,
|
|
walletId: walletId,
|
|
isTokenTx: true,
|
|
),
|
|
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();
|
|
},
|
|
),
|
|
);
|
|
},
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
ref.refresh(feeSheetSessionCacheProvider);
|
|
|
|
_calculateFeesFuture = calculateFees();
|
|
_data = widget.autoFillData;
|
|
walletId = widget.walletId;
|
|
coin = widget.coin;
|
|
tokenContract = widget.tokenContract;
|
|
clipboard = widget.clipboard;
|
|
scanner = widget.barcodeScanner;
|
|
|
|
sendToController = TextEditingController();
|
|
cryptoAmountController = TextEditingController();
|
|
baseAmountController = TextEditingController();
|
|
noteController = TextEditingController();
|
|
feeController = TextEditingController();
|
|
|
|
onCryptoAmountChanged = _cryptoAmountChanged;
|
|
cryptoAmountController.addListener(onCryptoAmountChanged);
|
|
baseAmountController.addListener(_baseAmountChanged);
|
|
|
|
if (_data != null) {
|
|
if (_data!.amount != null) {
|
|
cryptoAmountController.text = _data!.amount!.toString();
|
|
}
|
|
sendToController.text = _data!.contactLabel;
|
|
_address = _data!.address.trim();
|
|
_addressToggleFlag = true;
|
|
}
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_cryptoAmountChangedFeeUpdateTimer?.cancel();
|
|
_baseAmountChangedFeeUpdateTimer?.cancel();
|
|
|
|
cryptoAmountController.removeListener(onCryptoAmountChanged);
|
|
baseAmountController.removeListener(_baseAmountChanged);
|
|
|
|
sendToController.dispose();
|
|
cryptoAmountController.dispose();
|
|
baseAmountController.dispose();
|
|
noteController.dispose();
|
|
feeController.dispose();
|
|
|
|
_noteFocusNode.dispose();
|
|
_addressFocusNode.dispose();
|
|
_cryptoFocus.dispose();
|
|
_baseFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
debugPrint("BUILD: $runtimeType");
|
|
final String locale = ref.watch(
|
|
localeServiceChangeNotifierProvider.select((value) => value.locale));
|
|
|
|
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 ${tokenContract.symbol}",
|
|
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: [
|
|
EthTokenIcon(
|
|
contractAddress: tokenContract.address,
|
|
),
|
|
const SizedBox(
|
|
width: 6,
|
|
),
|
|
Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
ref.watch(pWalletName(walletId)),
|
|
style: STextStyles.titleBold12(context)
|
|
.copyWith(fontSize: 14),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
Text(
|
|
"Available balance",
|
|
style: STextStyles.label(context)
|
|
.copyWith(fontSize: 10),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: () {
|
|
cryptoAmountController.text = ref
|
|
.watch(pAmountFormatter(coin))
|
|
.format(
|
|
ref
|
|
.read(tokenServiceProvider)!
|
|
.balance
|
|
.spendable,
|
|
ethContract: tokenContract,
|
|
withUnitName: false,
|
|
indicatePrecisionLoss: true,
|
|
);
|
|
},
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
ref
|
|
.watch(pAmountFormatter(coin))
|
|
.format(
|
|
ref.watch(
|
|
tokenServiceProvider.select(
|
|
(value) => value!
|
|
.balance.spendable,
|
|
),
|
|
),
|
|
ethContract: ref.watch(
|
|
tokenServiceProvider.select(
|
|
(value) =>
|
|
value!.tokenContract,
|
|
),
|
|
),
|
|
),
|
|
style:
|
|
STextStyles.titleBold12(context)
|
|
.copyWith(
|
|
fontSize: 10,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
Text(
|
|
"${(ref.watch(tokenServiceProvider.select((value) => value!.balance.spendable.decimal)) * ref.watch(priceAnd24hChangeNotifierProvider.select((value) => value.getTokenPrice(tokenContract.address).item1))).toAmount(
|
|
fractionDigits: 2,
|
|
).fiatString(
|
|
locale: locale,
|
|
)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}",
|
|
style: STextStyles.subtitle(context)
|
|
.copyWith(
|
|
fontSize: 8,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(
|
|
height: 16,
|
|
),
|
|
Text(
|
|
"Send to",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
Constants.size.circularBorderRadius,
|
|
),
|
|
child: TextField(
|
|
key: const Key("tokenSendViewAddressFieldKey"),
|
|
controller: sendToController,
|
|
readOnly: false,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
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 ${tokenContract.symbol} address",
|
|
_addressFocusNode,
|
|
context,
|
|
).copyWith(
|
|
contentPadding: const EdgeInsets.only(
|
|
left: 16,
|
|
top: 6,
|
|
bottom: 8,
|
|
right: 5,
|
|
),
|
|
suffixIcon: Padding(
|
|
padding: sendToController.text.isEmpty
|
|
? const EdgeInsets.only(right: 8)
|
|
: const EdgeInsets.only(right: 0),
|
|
child: UnconstrainedBox(
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_addressToggleFlag
|
|
? TextFieldIconButton(
|
|
key: const Key(
|
|
"tokenSendViewClearAddressFieldButtonKey"),
|
|
onTap: () {
|
|
sendToController.text = "";
|
|
_address = "";
|
|
_updatePreviewButtonState(
|
|
_address, _amountToSend);
|
|
setState(() {
|
|
_addressToggleFlag = false;
|
|
});
|
|
},
|
|
child: const XIcon(),
|
|
)
|
|
: TextFieldIconButton(
|
|
key: const Key(
|
|
"tokenSendViewPasteAddressFieldButtonKey"),
|
|
onTap:
|
|
_onTokenSendViewPasteAddressFieldButtonPressed,
|
|
child: sendToController
|
|
.text.isEmpty
|
|
? const ClipboardIcon()
|
|
: const XIcon(),
|
|
),
|
|
if (sendToController.text.isEmpty)
|
|
TextFieldIconButton(
|
|
key: const Key(
|
|
"sendViewAddressBookButtonKey"),
|
|
onTap: () {
|
|
Navigator.of(context).pushNamed(
|
|
AddressBookView.routeName,
|
|
arguments: widget.coin,
|
|
);
|
|
},
|
|
child: const AddressBookIcon(),
|
|
),
|
|
if (sendToController.text.isEmpty)
|
|
TextFieldIconButton(
|
|
key: const Key(
|
|
"sendViewScanQrButtonKey"),
|
|
onTap:
|
|
_onTokenSendViewScanQrButtonPressed,
|
|
child: const QrCodeIcon(),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Builder(
|
|
builder: (_) {
|
|
final error = _updateInvalidAddressText(
|
|
_address ?? "",
|
|
);
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
"Amount",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
// CustomTextButton(
|
|
// text: "Send all ${tokenContract.symbol}",
|
|
// onTap: () async {
|
|
// cryptoAmountController.text = ref
|
|
// .read(tokenServiceProvider)!
|
|
// .balance
|
|
// .getSpendable()
|
|
// .toStringAsFixed(tokenContract.decimals);
|
|
//
|
|
// _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: tokenContract.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})$')
|
|
// .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(
|
|
tokenContract.symbol,
|
|
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})$')
|
|
// .hasMatch(newValue.text)
|
|
// ? newValue
|
|
// : oldValue),
|
|
],
|
|
onChanged: _onFiatAmountFieldChanged,
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(
|
|
height: 12,
|
|
),
|
|
Text(
|
|
"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)
|
|
Text(
|
|
"Transaction fee (estimated)",
|
|
style: STextStyles.smallMed12(context),
|
|
textAlign: TextAlign.left,
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
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: () {
|
|
showModalBottomSheet<dynamic>(
|
|
backgroundColor: Colors.transparent,
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(20),
|
|
),
|
|
),
|
|
builder: (_) =>
|
|
TransactionFeeSelectionSheet(
|
|
walletId: walletId,
|
|
isToken: true,
|
|
amount: (Decimal.tryParse(
|
|
cryptoAmountController
|
|
.text) ??
|
|
Decimal.zero)
|
|
.toAmount(
|
|
fractionDigits:
|
|
tokenContract.decimals,
|
|
),
|
|
updateChosen: (String fee) {
|
|
setState(() {
|
|
_calculateFeesFuture =
|
|
Future(() => fee);
|
|
});
|
|
},
|
|
),
|
|
);
|
|
},
|
|
child: 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) {
|
|
return Text(
|
|
"~${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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|