feat(tx-comps): Send transaction modal

Fixes #669.

Composes all tx components to create a send transaction modal for the wallet.

1. Add a reusable TransactionStackView component that wraps a StackView component to show the screens of the Send Tx modal and handles back/forward/reset functionality.
2. Add a reusable TransactionStackGroup which holds tx components and allows modal header and button text to be defined and handles validation for all child components.
3. Add an isValid property to all tx comps for pristine validation state.
4. Reset all components in modal once modal is closed. This consists of a `resetInternal` function that each component should implement to reinstate its original starting state, and a `reset` function that parent components can use to reinstate the overridden properties.
5. Tx error handling to display either a StatusGo error message in the dialog, or wrong password in the TransactionSigner.
6. Fix ReceiveModal to allow for pre-selected account based on current wallet account.
7. Add focused border colour to Input component.
8. Fix issue with last TransactionStackGroup input not being able to obtain focus.
9. Fix fiatBalance not appearing on initial load on AccountSelector.
10. Fix selected asset updated properly when assets changed in the AssetSelector component.
11. AccountSelector is pre-populated with selected wallet account. Supporting work on the components has been done to suppor this.
12. Changing accounts in the "from account" updates the asset balances in the AssetAndAmountInput component.
13. Move validation from ContactSelector to the Select component.

1. Test sending of tokens. This requires that tokens contracts are setup on testnet. Right now, they are set up for contract addresses on mainnet.
2. Loading state once transaction is sent. Button in modal needs to move to a loading state and the "toast" messages in the wallet need to appear informaing user of tx progress.
3. Need to clarify (and implement?) support of ENS names in the AddressInput. It appears that ENS names could be resolved. This would be a long operation and require some kind of UI loading indication.
4. Wallet balances need to be updated on every block, so for example, sending funds between accounts I should be able to see the balance updated in real time.

1. Sending to a contact currently doesn't work because the ContactSelector component selects the Contact's whipser key, instead of his/her wallet address. May need to figure out how this is done in status-react. As it stands, attempting to send to a contact will crash the app.
2. Sending *from* an imported account does not work, with an error from StatusGo `cannot locate account for address: 0x123...`
This commit is contained in:
emizzle 2020-08-20 14:45:29 +10:00 committed by Iuri Matias
parent f6b1f31326
commit 1e020a203c
36 changed files with 896 additions and 386 deletions

View File

@ -3,6 +3,7 @@ import ../../status/[status, wallet, threads]
import ../../status/wallet/collectibles as status_collectibles
import ../../status/libstatus/wallet as status_wallet
import ../../status/libstatus/tokens
import ../../status/libstatus/types
import ../../status/libstatus/utils
import views/[asset_list, account_list, account_item, transaction_list, collectibles_list]
@ -48,7 +49,7 @@ QtObject:
result.standardGasPrice = "0"
result.fastGasPrice = "0"
result.fastestGasPrice = "0"
result.defaultGasLimit = "22000"
result.defaultGasLimit = "21000"
result.signingPhrase = ""
result.setup
@ -231,8 +232,14 @@ QtObject:
read = getAccountList
notify = accountListChanged
proc onSendTransaction*(self: WalletView, from_value: string, to: string, assetAddress: string, value: string, password: string): string {.slot.} =
return self.status.wallet.sendTransaction(from_value, to, assetAddress, value, password)
proc sendTransaction*(self: WalletView, from_addr: string, to: string, assetAddress: string, value: string, gas: string, gasPrice: string, password: string): string {.slot.} =
let resultJson = %*{}
try:
resultJson{"result"} = %self.status.wallet.sendTransaction(from_addr, to, assetAddress, value, gas, gasPrice, password)
except StatusGoException as e:
resultJson{"error"} = %e.msg
finally:
result = $resultJson
proc getDefaultAccount*(self: WalletView): string {.slot.} =
self.currentAccount.address
@ -257,7 +264,12 @@ QtObject:
proc toggleAsset*(self: WalletView, symbol: string, checked: bool, address: string, name: string, decimals: int, color: string) {.slot.} =
self.status.wallet.toggleAsset(symbol, checked, address, name, decimals, color)
for account in self.status.wallet.accounts:
if account.address == self.currentAccount.address:
self.currentAccount.setAccountItem(account)
else:
self.accounts.updateAssetsInList(account.address, account.assetList)
self.accountListChanged()
self.currentAccountChanged()
proc updateView*(self: WalletView) =
self.totalFiatBalanceChanged()
@ -362,7 +374,7 @@ QtObject:
"collectibleType": status_collectibles.ETHERMON,
"collectiblesOrError": status_collectibles.getEthermons(address)
})
of STICKER:
of status_collectibles.STICKER:
spawnAndSend(self, "setCollectiblesResult") do:
$(%*{
"address": address,

View File

@ -1,4 +1,4 @@
import NimQml, std/wrapnils
import NimQml, std/wrapnils, strformat
from ../../../status/wallet import WalletAccount
import ./asset_list
@ -40,6 +40,10 @@ QtObject:
QtProperty[string] balance:
read = balance
proc fiatBalance*(self: AccountItemView): string {.slot.} = result = fmt"{?.self.account.realFiatBalance:>.2f}"
QtProperty[string] fiatBalance:
read = fiatBalance
proc path*(self: AccountItemView): string {.slot.} = result = ?.self.account.path
QtProperty[string] path:
read = path

View File

@ -8,6 +8,13 @@ type SignalSubscriber* = ref object of RootObj
type Signal* = ref object of RootObj
signalType* {.serializedFieldName("type").}: SignalType
type StatusGoErrorDetail* = object
message*: string
code*: int
type StatusGoErrorExtended* = object
error*: StatusGoErrorDetail
type StatusGoError* = object
error*: string

View File

@ -154,7 +154,7 @@ proc registerUsername*(username:string, address: EthAddress, pubKey: string, pas
let
register = Register(label: label, account: address, x: x, y: y)
registerAbiEncoded = ensUsernamesContract.methods["register"].encodeAbi(register)
approveAndCallObj = ApproveAndCall(to: ensUsernamesContract.address, value: price, data: DynamicBytes[132].fromHex(registerAbiEncoded))
approveAndCallObj = ApproveAndCall(to: ensUsernamesContract.address, value: price, data: DynamicBytes[100].fromHex(registerAbiEncoded))
approveAndCallAbiEncoded = sntContract.methods["approveAndCall"].encodeAbi(approveAndCallObj)
let payload = %* {

View File

@ -40,7 +40,7 @@ type
ApproveAndCall* = object
to*: EthAddress
value*: Stuint[256]
data*: DynamicBytes[132]
data*: DynamicBytes[100]
Transfer* = object
to*: EthAddress

View File

@ -134,7 +134,7 @@ proc buyPack*(packId: Stuint[256], address: EthAddress, price: Stuint[256], pass
buyToken = BuyToken(packId: packId, address: address, price: price)
buyTxAbiEncoded = stickerMktContract.methods["buyToken"].encodeAbi(buyToken)
let
approveAndCallObj = ApproveAndCall(to: stickerMktContract.address, value: price, data: DynamicBytes[132].fromHex(buyTxAbiEncoded))
approveAndCallObj = ApproveAndCall(to: stickerMktContract.address, value: price, data: DynamicBytes[100].fromHex(buyTxAbiEncoded))
approveAndCallAbiEncoded = sntContract.methods["approveAndCall"].encodeAbi(approveAndCallObj)
let payload = %* {
"from": $address,

View File

@ -1,5 +1,5 @@
import eventemitter, json
import eth/common/eth_types, stew/byteutils, json_serialization, stint
import eventemitter, json, options, typetraits, strutils
import eth/common/eth_types, stew/byteutils, json_serialization, stint, faststreams/textio
import accounts/constants
type SignalType* {.pure.} = enum
@ -174,3 +174,56 @@ type
name*: string
etherscanLink* {.serializedFieldName("etherscan-link").}: string
config*: NodeConfig
# TODO: Remove this when nim-web3 is added as a dependency
Quantity* = distinct uint64
# TODO: Remove this when nim-web3 is added as a dependency
EthSend* = object
source*: EthAddress # the address the transaction is send from.
to*: Option[EthAddress] # (optional when creating new contract) the address the transaction is directed to.
gas*: Option[Quantity] # (optional, default: 90000) integer of the gas provided for the transaction execution. It will return unused gas.
gasPrice*: Option[int] # (optional, default: To-Be-Determined) integer of the gasPrice used for each paid gas.
value*: Option[Uint256] # (optional) integer of the value sent with this transaction.
data*: string # the compiled code of a contract OR the hash of the invoked method signature and encoded parameters. For details see Ethereum Contract ABI.
nonce*: Option[int] # (optional) integer of a nonce. This allows to overwrite your own pending transactions that use the same nonce
# TODO: Remove this when nim-web3 is added as a dependency
template stripLeadingZeros(value: string): string =
var cidx = 0
# ignore the last character so we retain '0' on zero value
while cidx < value.len - 1 and value[cidx] == '0':
cidx.inc
value[cidx .. ^1]
# TODO: Remove this when nim-web3 is added as a dependency
proc encodeQuantity*(value: SomeUnsignedInt): string =
var hValue = value.toHex.stripLeadingZeros
result = "0x" & hValue
# TODO: Remove this when nim-web3 is added as a dependency
proc `%`*(v: EthAddress): JsonNode =
result = %("0x" & array[20, byte](v).toHex)
# TODO: Remove this when nim-web3 is added as a dependency
proc `%`*(v: Quantity): JsonNode =
result = %encodeQuantity(v.uint64)
# TODO: Remove this when nim-web3 is added as a dependency
proc `%`*(n: Int256|UInt256): JsonNode = %("0x" & n.toHex)
# TODO: Remove this when nim-web3 is added as a dependency
proc `%`*(x: EthSend): JsonNode =
result = newJobject()
result["from"] = %x.source
if x.to.isSome:
result["to"] = %x.to.unsafeGet
if x.gas.isSome:
result["gas"] = %x.gas.unsafeGet
if x.gasPrice.isSome:
result["gasPrice"] = %("0x" & x.gasPrice.unsafeGet.toHex.stripLeadingZeros)
if x.value.isSome:
result["value"] = %("0x" & x.value.unsafeGet.toHex)
result["data"] = %x.data
if x.nonce.isSome:
result["nonce"] = %x.nonce.unsafeGet

View File

@ -37,6 +37,24 @@ proc handleRPCErrors*(response: string) =
if (parsedReponse.hasKey("error")):
raise newException(ValueError, parsedReponse["error"]["message"].str)
proc toStUInt*[bits: static[int]](flt: float, T: typedesc[StUint[bits]]): T =
var stringValue = fmt"{flt:<.0f}"
stringValue.removeSuffix('.')
result = parse($stringValue, StUint[bits])
proc toUInt256*(flt: float): UInt256 =
toStUInt(flt, StUInt[256])
proc toUInt64*(flt: float): StUInt[64] =
toStUInt(flt, StUInt[64])
proc eth2Wei*(eth: float, decimals: int = 18): UInt256 =
let weiValue = eth * parseFloat(alignLeft("1", decimals + 1, '0'))
weiValue.toUInt256
proc gwei2Wei*(gwei: float): UInt256 =
eth2Wei(gwei, 9)
proc wei2Eth*(input: Stuint[256]): string =
var one_eth = fromHex(Stuint[256], "DE0B6B3A7640000")

View File

@ -1,9 +1,11 @@
import json, httpclient, json, strformat, stint, strutils, sequtils, chronicles, parseutils, tables
import json, options
import stint, chronicles, json_serialization
import nim_status, core, types, utils
import ../wallet/account
from ./accounts/constants import ZERO_ADDRESS
import ./contracts as contractMethods
from eth/common/utils as eth_utils import parseAddress
import eth/common/eth_types
import ./types
import ../../signals/types as signal_types
proc getWalletAccounts*(): seq[WalletAccount] =
try:
@ -60,45 +62,17 @@ proc getTransfersByAddress*(address: string): seq[types.Transaction] =
let msg = getCurrentExceptionMsg()
error "Failed getting wallet account transactions", msg
proc sendTransaction*(tx: EthSend, password: string): string =
let response = core.sendTransaction($(%tx), password)
proc sendTransaction*(from_address: string, to: string, assetAddress: string, value: string, password: string): string =
try:
var args: JsonNode
if (assetAddress == ZERO_ADDRESS or assetAddress == ""):
var weiValue = value.parseFloat() * float(1000000000000000000)
var stringValue = fmt"{weiValue:<.0f}"
stringValue.removeSuffix('.')
var hexValue = parseBiggestInt(stringValue).toHex()
hexValue.removePrefix('0')
args = %* {
"value": fmt"0x{hexValue}",
"from": from_address,
"to": to
}
else:
var bigIntValue = u256(value)
# TODO get decimals from the token
bigIntValue = bigIntValue * u256(1000000000000000000)
# Just using mainnet SNT to get the method ABI
let
contract = getContract("snt")
transfer = Transfer(to: parseAddress(to), value: bigIntValue)
transferEncoded = contract.methods["transfer"].encodeAbi(transfer)
args = %* {
"from": from_address,
"to": assetAddress,
"data": transferEncoded
}
let response = sendTransaction($args, password)
let parsedResponse = parseJson(response)
result = parsedResponse["result"].getStr
except:
let err = Json.decode(response, StatusGoErrorExtended)
raise newException(StatusGoException, "Error sending transaction: " & err.error.message)
trace "Transaction sent succesfully", hash=result
except Exception as e:
error "Error submitting transaction", msg=e.msg
result = e.msg
proc getBalance*(address: string): string =
let payload = %* [address, "latest"]

View File

@ -1,12 +1,14 @@
import eventemitter, json, strformat, strutils, chronicles, sequtils, httpclient
import json_serialization
import eventemitter, json, strformat, strutils, chronicles, sequtils, httpclient, tables
import json_serialization, stint
from eth/common/utils import parseAddress
import libstatus/accounts as status_accounts
import libstatus/tokens as status_tokens
import libstatus/settings as status_settings
import libstatus/wallet as status_wallet
import libstatus/accounts/constants as constants
from libstatus/types import GeneratedAccount, DerivedAccount, Transaction, Setting, GasPricePrediction
import libstatus/contracts as contracts
from libstatus/types import GeneratedAccount, DerivedAccount, Transaction, Setting, GasPricePrediction, EthSend, Quantity, `%`, StatusGoException
from libstatus/utils as libstatus_utils import eth2Wei, gwei2Wei, first, toUInt64
import wallet/balance_manager
import wallet/account
import wallet/collectibles
@ -48,8 +50,36 @@ proc initEvents*(self: WalletModel) =
proc delete*(self: WalletModel) =
discard
proc sendTransaction*(self: WalletModel, from_value: string, to: string, assetAddress: string, value: string, password: string): string =
status_wallet.sendTransaction(from_value, to, assetAddress, value, password)
proc sendTransaction*(self: WalletModel, source, to, assetAddress, value, gas, gasPrice, password: string): string =
var
weiValue = eth2Wei(parseFloat(value), 18) # ETH
data = ""
toAddr = parseAddress(to)
let gasPriceInWei = gwei2Wei(parseFloat(gasPrice))
# TODO: this code needs to be tested with testnet assets (to be implemented in
# a future PR
if assetAddress != ZERO_ADDRESS and not assetAddress.isEmptyOrWhitespace:
let
token = self.tokens.first("address", assetAddress)
contract = getContract("snt")
transfer = Transfer(to: toAddr, value: weiValue)
weiValue = eth2Wei(parseFloat(value), token["decimals"].getInt)
data = contract.methods["transfer"].encodeAbi(transfer)
toAddr = parseAddress(assetAddress)
let tx = EthSend(
source: parseAddress(source),
to: toAddr.some,
gas: (if gas.isEmptyOrWhitespace: Quantity.none else: Quantity(cast[uint64](parseFloat(gas).toUInt64)).some),
gasPrice: (if gasPrice.isEmptyOrWhitespace: int.none else: gwei2Wei(parseFloat(gasPrice)).truncate(int).some),
value: weiValue.some,
data: data
)
try:
result = status_wallet.sendTransaction(tx, password)
except StatusGoException as e:
raise
proc getDefaultCurrency*(self: WalletModel): string =
# TODO: this should come from a model? It is going to be used too in the

View File

@ -4,7 +4,7 @@ import "../../../imports"
import "../../../shared"
ModalPopup {
property string address: ""
property alias selectedAccount: accountSelector.selectedAccount
id: popup
//% "Receive"
@ -27,7 +27,6 @@ ModalPopup {
id: qrCodeImage
asynchronous: true
fillMode: Image.PreserveAspectFit
source: profileModel.qrCode(accountSelector.selectedAccount.address)
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
height: parent.height - Style.current.padding
@ -49,12 +48,18 @@ ModalPopup {
width: 240
dropdownWidth: parent.width - (Style.current.padding * 2)
dropdownAlignment: Select.MenuAlignment.Center
onSelectedAccountChanged: {
if (selectedAccount.address) {
qrCodeImage.source = profileModel.qrCode(selectedAccount.address)
txtWalletAddress.text = selectedAccount.address
}
}
}
Input {
id: txtWalletAddress
//% "Wallet address"
label: qsTrId("wallet-address")
text: accountSelector.selectedAccount.address
anchors.top: accountSelector.bottom
anchors.topMargin: Style.current.padding
copyToClipboard: true

View File

@ -1,27 +1,187 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.13
import QtQuick.Dialogs 1.3
import "../../../imports"
import "../../../shared"
import "../../../shared/status"
import "./components"
ModalPopup {
id: popup
id: root
//% "Send"
title: qsTrId("command-button-send")
height: 700
height: 504
onOpened: {
sendModalContent.amountInput.selectedAmount = ""
sendModalContent.passwordInput.text = ""
sendModalContent.amountInput.forceActiveFocus(Qt.MouseFocusReason)
property MessageDialog sendingError: MessageDialog {
id: sendingError
title: qsTr("Error sending the transaction")
icon: StandardIcon.Critical
standardButtons: StandardButton.Ok
}
property MessageDialog sendingSuccess: MessageDialog {
id: sendingSuccess
//% "Success sending the transaction"
title: qsTrId("success-sending-the-transaction")
icon: StandardIcon.NoIcon
standardButtons: StandardButton.Ok
onAccepted: {
root.close()
}
}
SendModalContent {
id: sendModalContent
closePopup: function () {
popup.close()
onClosed: {
stack.reset()
}
function sendTransaction() {
let responseStr = walletModel.sendTransaction(selectFromAccount.selectedAccount.address,
selectRecipient.selectedRecipient.address,
txtAmount.selectedAsset.address,
txtAmount.selectedAmount,
gasSelector.selectedGasLimit,
gasSelector.selectedGasPrice,
transactionSigner.enteredPassword)
let response = JSON.parse(responseStr)
if (response.error) {
if (response.error.includes("could not decrypt key with given password")){
transactionSigner.validationError = qsTr("Wrong password")
return
}
sendingError.text = response.error
return sendingError.open()
}
sendingSuccess.text = qsTr("Transaction sent to the blockchain. You can watch the progress on Etherscan: %2/%1").arg(response.result).arg(walletModel.etherscanLink)
sendingSuccess.open()
}
TransactionStackView {
id: stack
anchors.fill: parent
anchors.leftMargin: Style.current.padding
anchors.rightMargin: Style.current.padding
onGroupActivated: {
root.title = group.headerText
btnNext.label = group.footerText
}
TransactionFormGroup {
id: group1
headerText: qsTr("Send")
footerText: qsTr("Continue")
AccountSelector {
id: selectFromAccount
accounts: walletModel.accounts
selectedAccount: walletModel.currentAccount
currency: walletModel.defaultCurrency
width: stack.width
label: qsTr("From account")
reset: function() {
accounts = Qt.binding(function() { return walletModel.accounts })
selectedAccount = Qt.binding(function() { return walletModel.currentAccount })
}
}
SeparatorWithIcon {
id: separator
anchors.top: selectFromAccount.bottom
anchors.topMargin: 19
}
RecipientSelector {
id: selectRecipient
accounts: walletModel.accounts
contacts: profileModel.addedContacts
label: qsTr("Recipient")
anchors.top: separator.bottom
anchors.topMargin: 10
width: stack.width
reset: function() {
accounts = Qt.binding(function() { return walletModel.accounts })
contacts = Qt.binding(function() { return profileModel.addedContacts })
selectedRecipient = {}
}
}
}
TransactionFormGroup {
id: group2
headerText: qsTr("Send")
footerText: qsTr("Preview")
AssetAndAmountInput {
id: txtAmount
selectedAccount: selectFromAccount.selectedAccount
defaultCurrency: walletModel.defaultCurrency
getFiatValue: walletModel.getFiatValue
getCryptoValue: walletModel.getCryptoValue
width: stack.width
reset: function() {
selectedAccount = Qt.binding(function() { return selectFromAccount.selectedAccount })
}
}
GasSelector {
id: gasSelector
anchors.top: txtAmount.bottom
anchors.topMargin: Style.current.bigPadding
slowestGasPrice: parseFloat(walletModel.safeLowGasPrice)
fastestGasPrice: parseFloat(walletModel.fastestGasPrice)
getGasEthValue: walletModel.getGasEthValue
getFiatValue: walletModel.getFiatValue
defaultCurrency: walletModel.defaultCurrency
width: stack.width
reset: function() {
slowestGasPrice = Qt.binding(function(){ return parseFloat(walletModel.safeLowGasPrice) })
fastestGasPrice = Qt.binding(function(){ return parseFloat(walletModel.fastestGasPrice) })
}
}
}
TransactionFormGroup {
id: group3
headerText: qsTr("Transaction preview")
footerText: qsTr("Sign with password")
TransactionPreview {
id: pvwTransaction
width: stack.width
fromAccount: selectFromAccount.selectedAccount
gas: {
const value = walletModel.getGasEthValue(gasSelector.selectedGasPrice, gasSelector.selectedGasLimit)
const fiatValue = walletModel.getFiatValue(value, "ETH", walletModel.defaultCurrency)
return { value, "symbol": "ETH", fiatValue }
}
toAccount: selectRecipient.selectedRecipient
asset: txtAmount.selectedAsset
amount: { "value": txtAmount.selectedAmount, "fiatValue": txtAmount.selectedFiatAmount }
currency: walletModel.defaultCurrency
reset: function() {
fromAccount = Qt.binding(function() { return selectFromAccount.selectedAccount })
toAccount = Qt.binding(function() { return selectRecipient.selectedRecipient })
asset = Qt.binding(function() { return txtAmount.selectedAsset })
amount = Qt.binding(function() { return { "value": txtAmount.selectedAmount, "fiatValue": txtAmount.selectedFiatAmount } })
const value = walletModel.getGasEthValue(gasSelector.selectedGasPrice, gasSelector.selectedGasLimit)
const fiatValue = walletModel.getFiatValue(value, "ETH", walletModel.defaultCurrency)
gas = Qt.binding(function() {
const value = walletModel.getGasEthValue(gasSelector.selectedGasPrice, gasSelector.selectedGasLimit)
const fiatValue = walletModel.getFiatValue(value, "ETH", walletModel.defaultCurrency)
return { value, "symbol": "ETH", fiatValue }
})
}
}
}
TransactionFormGroup {
id: group4
headerText: qsTr("Sign with password")
footerText: qsTr("Send %1 %2").arg(txtAmount.selectedAmount).arg(!!txtAmount.selectedAsset ? txtAmount.selectedAsset.symbol : "")
TransactionSigner {
id: transactionSigner
width: stack.width
signingPhrase: walletModel.signingPhrase
reset: function() {
signingPhrase = Qt.binding(function() { return walletModel.signingPhrase })
}
}
}
}
@ -32,39 +192,46 @@ ModalPopup {
StyledButton {
id: btnBack
anchors.left: parent.left
//% "Back"
label: qsTrId("back")
visible: !btnPreview.visible
width: 44
height: 44
visible: !stack.isFirstGroup
label: ""
background: Rectangle {
anchors.fill: parent
border.width: 0
radius: width / 2
color: btnBack.disabled ? Style.current.grey :
btnBack.hovered ? Qt.darker(btnBack.btnColor, 1.1) : btnBack.btnColor
SVGImage {
width: 20.42
height: 15.75
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
source: "../../img/arrow-right.svg"
rotation: 180
}
}
onClicked: {
btnPreview.visible = true
sendModalContent.showInputs()
stack.back()
}
}
StyledButton {
id: btnPreview
id: btnNext
anchors.right: parent.right
//% "Preview"
label: qsTrId("preview")
label: qsTr("Next")
disabled: !stack.currentGroup.isValid
onClicked: {
if (!sendModalContent.validate()) {
return
const isValid = stack.currentGroup.validate()
if (stack.currentGroup.validate()) {
if (stack.isLastGroup) {
return root.sendTransaction()
}
visible = false
sendModalContent.showPreview()
stack.next()
}
}
StyledButton {
id: btnSend
anchors.right: parent.right
visible: !btnPreview.visible
//% "Send"
label: qsTrId("command-button-send") + " " + sendModalContent.amountInput.selectedAmount + " " + sendModalContent.amountInput.selectedAsset.symbol
onClicked: {
if (!sendModalContent.validatePassword()) {
return
}
sendModalContent.send()
}
}
}
}

View File

@ -74,7 +74,7 @@ Item {
ReceiveModal{
id: receiveModal
address: currentAccount.address
selectedAccount: currentAccount
}
SetCurrencyModal{

View File

@ -1,173 +0,0 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import QtQuick.Dialogs 1.3
import "../../../../imports"
import "../../../../shared"
Item {
id: sendModalContent
property var closePopup: function(){}
property alias amountInput: txtAmount
property alias passwordInput: transactionSigner.passwordInput
property string passwordValidationError: ""
function send() {
if (!validate() || !validatePassword()) {
return;
}
let result = walletModel.onSendTransaction(selectFromAccount.selectedAccount.address,
selectRecipient.selectedRecipient,
txtAmount.selectedAsset.address,
txtAmount.text,
transactionSigner.passwordInput.text)
if (!result.startsWith('0x')) {
// It's an error
sendingError.text = result
return sendingError.open()
}
//% "Transaction sent to the blockchain. You can watch the progress on Etherscan: %2/%1"
sendingSuccess.text = qsTrId("transaction-sent-to-the-blockchain--you-can-watch-the-progress-on-etherscan---2--1").arg(result).arg(walletModel.etherscanLink)
sendingSuccess.open()
}
function validatePassword() {
if (transactionSigner.passwordInput.text === "") {
//% "You need to enter a password"
passwordValidationError = qsTrId("you-need-to-enter-a-password")
} else if (transactionSigner.passwordInput.text.length < 4) {
//% "Password needs to be 4 characters or more"
passwordValidationError = qsTrId("password-needs-to-be-4-characters-or-more")
} else {
passwordValidationError = ""
}
return passwordValidationError === ""
}
function validate() {
const isRecipientValid = selectRecipient.validate()
const isAssetAndAmountValid = txtAmount.validate()
return isRecipientValid && isAssetAndAmountValid
}
function showPreview() {
pvwTransaction.visible = true
transactionSigner.visible = true
txtAmount.visible = selectFromAccount.visible = selectRecipient.visible = gasSelector.visible = false
}
function showInputs() {
pvwTransaction.visible = false
transactionSigner.visible = false
txtAmount.visible = selectFromAccount.visible = selectRecipient.visible = gasSelector.visible = true
}
anchors.left: parent.left
anchors.right: parent.right
MessageDialog {
id: sendingError
title: "Error sending the transaction"
icon: StandardIcon.Critical
standardButtons: StandardButton.Ok
onAccepted: {
sendModalContent.showInputs()
}
}
MessageDialog {
id: sendingSuccess
//% "Success sending the transaction"
title: qsTrId("success-sending-the-transaction")
icon: StandardIcon.NoIcon
standardButtons: StandardButton.Ok
onAccepted: {
closePopup()
sendModalContent.showInputs()
}
}
AssetAndAmountInput {
id: txtAmount
selectedAccount: walletModel.currentAccount
defaultCurrency: walletModel.defaultCurrency
anchors.top: parent.top
getFiatValue: walletModel.getFiatValue
getCryptoValue: walletModel.getCryptoValue
}
AccountSelector {
id: selectFromAccount
accounts: walletModel.accounts
currency: walletModel.defaultCurrency
anchors.top: txtAmount.bottom
anchors.topMargin: Style.current.padding
anchors.left: parent.left
anchors.right: parent.right
//% "From account"
label: qsTrId("from-account")
onSelectedAccountChanged: {
txtAmount.selectedAccount = selectFromAccount.selectedAccount
}
}
GasSelector {
id: gasSelector
anchors.top: selectFromAccount.bottom
anchors.topMargin: Style.current.bigPadding
slowestGasPrice: walletModel.safeLowGasPrice
fastestGasPrice: walletModel.fastestGasPrice
getGasEthValue: walletModel.getGasEthValue
getFiatValue: walletModel.getFiatValue
defaultCurrency: walletModel.defaultCurrency
}
RecipientSelector {
id: selectRecipient
accounts: walletModel.accounts
contacts: profileModel.addedContacts
//% "Recipient"
label: qsTrId("recipient")
anchors.top: gasSelector.bottom
anchors.topMargin: Style.current.padding
anchors.left: parent.left
anchors.right: parent.right
}
TransactionPreview {
id: pvwTransaction
visible: false
anchors.left: parent.left
anchors.right: parent.right
fromAccount: selectFromAccount.selectedAccount
gas: {
const value = walletModel.getGasEthValue(gasSelector.selectedGasPrice, gasSelector.selectedGasLimit)
const fiatValue = walletModel.getFiatValue(value, "ETH", walletModel.defaultCurrency)
return { value, "symbol": "ETH", fiatValue }
}
toAccount: selectRecipient.selectedRecipient
asset: txtAmount.selectedAsset
amount: { "value": txtAmount.selectedAmount, "fiatValue": txtAmount.selectedFiatAmount }
currency: walletModel.defaultCurrency
}
TransactionSigner {
id: transactionSigner
visible: false
anchors.left: parent.left
anchors.right: parent.right
anchors.top: pvwTransaction.bottom
anchors.topMargin: Style.current.smallPadding
signingPhrase: walletModel.signingPhrase
validationError: sendModalContent.passwordValidationError
}
}
/*##^##
Designer {
D{i:0;autoSize:true;formeditorColor:"#ffffff";height:480;width:640}
}
##^##*/

View File

@ -32,6 +32,7 @@ Theme {
property color currentUserTextColor: white
property color secondaryBackground: "#23252F"
property color inputBackground: secondaryBackground
property color inputBorderFocus: blue
property color inputColor: darkGrey
property color modalBackground: background
property color backgroundHover: "#252528"

View File

@ -31,6 +31,7 @@ Theme {
property color currentUserTextColor: white
property color secondaryBackground: lightBlue
property color inputBackground: grey
property color inputBorderFocus: blue
property color inputColor: black
property color modalBackground: white2
property color backgroundHover: grey

View File

@ -331,6 +331,7 @@ DISTFILES += \
shared/AccountSelector.qml \
shared/AddButton.qml \
shared/Address.qml \
shared/FormGroup.qml \
shared/IconButton.qml \
shared/Input.qml \
shared/LabelValueRow.qml \
@ -344,6 +345,7 @@ DISTFILES += \
shared/SearchBox.qml \
shared/Select.qml \
shared/Separator.qml \
shared/SeparatorWithIcon.qml \
shared/SplitViewHandle.qml \
shared/StatusTabButton.qml \
shared/StyledButton.qml \
@ -355,6 +357,8 @@ DISTFILES += \
shared/SVGImage.qml \
shared/TextWithLabel.qml \
shared/TransactionPreview.qml \
shared/TransactionFormGroup.qml \
shared/TransactionStackView.qml \
shared/img/check.svg \
shared/img/close.svg \
shared/img/loading.png \

View File

@ -10,9 +10,7 @@ Item {
property string label: qsTrId("choose-account")
property bool showAccountDetails: true
property var accounts
property var selectedAccount: {
"address": "", "name": "", "iconColor": "", "fiatBalance": ""
}
property var selectedAccount
property string currency: "usd"
height: select.height +
(selectedAccountDetails.visible ? selectedAccountDetails.height : 0)
@ -22,10 +20,39 @@ Item {
property string showAssetBalance: ""
property int dropdownWidth: width
property alias dropdownAlignment: select.menuAlignment
property bool isValid: true
property var reset: function() {}
function resetInternal() {
accounts = undefined
selectedAccount = undefined
isValid = true
}
onSelectedAccountChanged: {
if (!selectedAccount) {
return
}
if (selectedAccount.iconColor) {
selectedIconImgOverlay.color = selectedAccount.iconColor
}
if (selectedAccount.name) {
selectedTextField.text = selectedAccount.name
}
if (selectedAccount.address) {
textSelectedAddress.text = selectedAccount.address + " • "
}
if (selectedAccount.fiatBalance) {
textSelectedAddressFiatBalance.text = selectedAccount.fiatBalance + " " + currency.toUpperCase()
}
if (selectedAccount.assets) {
rptAccounts.model = selectedAccount.assets
}
}
Repeater {
id: rptAccounts
visible: showAssetBalance !== ""
model: selectedAccount.assets
delegate: StyledText {
visible: symbol === root.showAssetBalance.toUpperCase()
anchors.bottom: select.top
@ -64,19 +91,19 @@ Item {
source: "../app/img/walletIcon.svg"
}
ColorOverlay {
id: selectedIconImgOverlay
anchors.fill: selectedIconImg
source: selectedIconImg
color: selectedAccount.iconColor
}
StyledText {
id: selectedTextField
text: selectedAccount.name
elide: Text.ElideRight
anchors.left: selectedIconImg.right
anchors.leftMargin: 8
anchors.right: parent.right
anchors.rightMargin: select.selectedItemRightMargin
anchors.verticalCenter: parent.verticalCenter
width: select.contentWidth - (Style.current.padding + selectedIconImg.width + anchors.leftMargin)
font.pixelSize: 15
verticalAlignment: Text.AlignVCenter
height: 22
@ -94,7 +121,6 @@ Item {
StyledText {
id: textSelectedAddress
text: selectedAccount.address + " • "
font.pixelSize: 12
elide: Text.ElideMiddle
height: 16
@ -102,7 +128,7 @@ Item {
color: Style.current.secondaryText
}
StyledText {
text: selectedAccount.fiatBalance + " " + root.currency.toUpperCase()
id: textSelectedAddressFiatBalance
font.pixelSize: 12
height: 16
color: Style.current.secondaryText
@ -117,7 +143,7 @@ Item {
property bool isLastItem: index === accounts.rowCount() - 1
Component.onCompleted: {
if (root.selectedAccount.address === "") {
if (!root.selectedAccount && isFirstItem) {
root.selectedAccount = { address, name, iconColor, assets, fiatBalance }
}
}

View File

@ -9,9 +9,17 @@ Item {
property string validationError: "Error"
property alias label: inpAddress.label
property string selectedAddress
property var isValid: false
height: inpAddress.height
function resetInternal() {
selectedAddress = ""
inpAddress.resetInternal()
metrics.text = ""
isValid = false
}
function isValidEns(inputValue) {
// TODO: Check if the entered value resolves to an address. Long operation.
// Issue tracked: https://github.com/status-im/nim-status-client/issues/718
@ -26,6 +34,7 @@ Item {
(inputValue && inputValue.startsWith("0x") && Utils.isValidAddress(inputValue)) ||
isValidEns(inputValue)
inpAddress.validationError = isValid ? "" : validationError
root.isValid = isValid
return isValid
}

View File

@ -9,8 +9,14 @@ Item {
property var sources: []
property string selectedSource: sources[0] || "Address"
property int dropdownWidth: 220
property var reset: function() {}
height: select.height
function resetInternal() {
sources = []
selectedSource = sources[0] || "Address"
}
Select {
id: select
anchors.left: parent.left

View File

@ -21,6 +21,18 @@ Item {
property var getFiatValue: function () {}
property var getCryptoValue: function () {}
property bool isDirty: false
property bool isValid: false
property var reset: function() {}
function resetInternal() {
selectAsset.resetInternal()
selectedAccount = undefined
txtFiatBalance.text = "0.00"
inputAmount.resetInternal()
txtBalanceDesc.color = Style.current.secondaryText
txtBalance.color = Qt.binding(function() { return txtBalance.hovered ? Style.current.textColor : Style.current.secondaryText })
isValid = false
}
id: root
@ -57,14 +69,19 @@ Item {
txtBalanceDesc.color = Style.current.secondaryText
txtBalance.color = Qt.binding(function() { return txtBalance.hovered ? Style.current.textColor : Style.current.secondaryText })
}
root.isValid = isValid
return isValid
}
onSelectedAccountChanged: {
if (!selectAsset.selectedAsset) {
return
selectAsset.assets = Qt.binding(function() {
if (selectedAccount) {
return selectedAccount.assets
}
txtBalance.text = Utils.stripTrailingZeros(selectAsset.selectedAsset.value)
})
txtBalance.text = Qt.binding(function() {
return selectAsset.selectedAsset ? Utils.stripTrailingZeros(selectAsset.selectedAsset.value) : ""
})
}
Item {
@ -91,9 +108,6 @@ Item {
font.weight: Font.Medium
font.pixelSize: 13
color: hovered ? Style.current.textColor : Style.current.secondaryText
onTextChanged: {
root.validate(true)
}
MouseArea {
cursorShape: Qt.PointingHandCursor
@ -106,7 +120,7 @@ Item {
txtBalance.hovered = true
}
onClicked: {
inputAmount.text = selectAsset.selectedAsset.value
inputAmount.text = Utils.stripTrailingZeros(selectAsset.selectedAsset.value)
txtFiatBalance.text = root.getFiatValue(inputAmount.text, selectAsset.selectedAsset.symbol, root.defaultCurrency)
}
}
@ -123,7 +137,6 @@ Item {
validationErrorAlignment: TextEdit.AlignRight
validationErrorTopMargin: 8
Keys.onReleased: {
root.isDirty = true
let amount = inputAmount.text.trim()
if (isNaN(amount)) {
@ -136,13 +149,13 @@ Item {
}
}
onTextChanged: {
root.isDirty = true
root.validate(true)
}
}
AssetSelector {
id: selectAsset
assets: root.selectedAccount.assets
width: 86
height: 28
anchors.top: inputAmount.top
@ -150,11 +163,15 @@ Item {
anchors.right: parent.right
anchors.rightMargin: Style.current.smallPadding
onSelectedAssetChanged: {
if (!selectAsset.selectedAsset) {
return
}
txtBalance.text = Utils.stripTrailingZeros(selectAsset.selectedAsset.value)
if (inputAmount.text === "" || isNaN(inputAmount.text)) {
return
}
txtFiatBalance.text = root.getFiatValue(inputAmount.text, selectAsset.selectedAsset.symbol, root.defaultCurrency)
root.validate(true)
}
}

View File

@ -11,6 +11,11 @@ Item {
width: 86
height: 24
function resetInternal() {
assets = undefined
selectedAsset = undefined
}
onSelectedAssetChanged: {
if (selectedAsset && selectedAsset.symbol) {
iconImg.source = "../app/img/tokens/" + selectedAsset.symbol.toUpperCase() + ".png"
@ -18,13 +23,26 @@ Item {
}
}
onAssetsChanged: {
if (!assets) {
return
}
selectedAsset = {
name: assets.rowData(0, "name"),
symbol: assets.rowData(0, "symbol"),
value: assets.rowData(0, "value"),
fiatBalanceDisplay: assets.rowData(0, "fiatBalanceDisplay"),
address: assets.rowData(0, "address"),
fiatBalance: assets.rowData(0, "fiatBalance")
}
}
Select {
id: select
model: root.assets
width: parent.width
bgColor: Style.current.transparent
bgColorHover: Style.current.secondaryHover
model: root.assets
caretRightMargin: 7
select.radius: 6
select.height: root.height
@ -62,11 +80,6 @@ Item {
property bool isFirstItem: index === 0
property bool isLastItem: index === assets.rowCount() - 1
Component.onCompleted: {
if (isFirstItem) {
root.selectedAsset = { address, name, value, symbol, fiatBalanceDisplay, fiatBalance }
}
}
width: parent.width
height: 72
SVGImage {

View File

@ -8,10 +8,20 @@ Item {
id: root
property var contacts
property var selectedContact
height: select.height + (validationErrorText.visible ? validationErrorText.height : 0)
height: select.height
property int dropdownWidth: width
property string validationError: validationErrorText.text
property alias validationErrorAlignment: validationErrorText.horizontalAlignment
//% "Please select a contact"
property string validationError: qsTrId("please-select-a-contact")
property alias validationErrorAlignment: select.validationErrorAlignment
property bool isValid: false
property var reset: function() {}
function resetInternal() {
contacts = undefined
selectedContact = undefined
select.validationError = ""
isValid = false
}
onContactsChanged: {
//% "Select a contact"
@ -19,16 +29,9 @@ Item {
}
function validate() {
const isValid = root.selectedContact && root.selectedContact.address
if (!isValid) {
select.select.border.color = Style.current.danger
select.select.border.width = 1
validationErrorText.visible = true
} else {
select.select.border.color = Style.current.transparent
select.select.border.width = 0
validationErrorText.visible = false
}
const isValid = !!(selectedContact && selectedContact.address)
select.validationError = !isValid ? validationError : ""
root.isValid = isValid
return isValid
}
@ -46,14 +49,14 @@ Item {
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
height: 32
width: !!selectedContact.identicon ? 32 : 0
visible: !!selectedContact.identicon
source: selectedContact.identicon ? selectedContact.identicon : ""
width: (!!selectedContact && !!selectedContact.identicon) ? 32 : 0
visible: !!selectedContact && !!selectedContact.identicon
source: (!!selectedContact && !!selectedContact.identicon) ? selectedContact.identicon : ""
}
StyledText {
id: selectedTextField
text: selectedContact.name
text: !!selectedContact ? selectedContact.name : ""
anchors.left: iconImg.right
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
@ -79,21 +82,6 @@ Item {
menu.delegate: menuItem
menu.width: dropdownWidth
}
TextEdit {
id: validationErrorText
visible: false
//% "Please select a contact"
text: qsTrId("please-select-a-contact")
anchors.top: select.bottom
anchors.topMargin: 8
selectByMouse: true
readOnly: true
font.pixelSize: 12
height: 16
color: Style.current.danger
width: parent.width
horizontalAlignment: TextEdit.AlignRight
}
Component {
id: menuItem

59
ui/shared/FormGroup.qml Normal file
View File

@ -0,0 +1,59 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import "../imports"
Rectangle {
id: root
property var isValid: true
property var validate: function() {
let isValid = true
for (let i=0; i<children.length; i++) {
const component = children[i]
if (component.hasOwnProperty("validate") && typeof component.validate === "function") {
isValid = component.validate()
}
}
root.isValid = isValid
return isValid
}
color: Style.current.background
function reset() {
for (let i=0; i<children.length; i++) {
const component = children[i]
try {
if (component.hasOwnProperty("resetInternal") && typeof component.resetInternal === "function") {
component.resetInternal()
}
if (component.hasOwnProperty("reset") && typeof component.reset === "function") {
component.reset()
}
} catch (e) {
console.warn("Error resetting component", i, ":", e.message)
continue
}
}
}
StackView.onActivated: {
// parent refers to the StackView
parent.groupActivated(this)
}
Component.onCompleted: {
for (let i=0; i<children.length; i++) {
const component = children[i]
if (component.hasOwnProperty("isValid")) {
component.isValidChanged.connect(updateGroupValidity)
root.isValid = root.isValid && component.isValid // set the initial state
}
}
}
function updateGroupValidity() {
let isValid = true
for (let i=0; i<children.length; i++) {
const component = children[i]
if (component.hasOwnProperty("isValid")) {
isValid = isValid && component.isValid
}
}
root.isValid = isValid
}
}

View File

@ -9,7 +9,6 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
height: sliderWrapper.height + Style.current.smallPadding + txtNetworkFee.height + buttonAdvanced.height
property string validationError: "Please enter a number"
property double slowestGasPrice: 0
property double fastestGasPrice: 100
property double stepSize: ((root.fastestGasPrice - root.slowestGasPrice) / 10).toFixed(1)
@ -18,11 +17,26 @@ Item {
property string defaultCurrency: "USD"
property alias selectedGasPrice: inputGasPrice.text
property alias selectedGasLimit: inputGasLimit.text
property string greaterThan0ErrorMessage: qsTr("Must be greater than 0")
//% "This needs to be a number"
property string invalidInputErrorMessage: qsTrId("this-needs-to-be-a-number")
property string noInputErrorMessage: qsTr("Please enter an amount")
property bool isValid: true
property var reset: function() {}
function defaultGasPrice() {
return ((50 * (root.fastestGasPrice - root.slowestGasPrice) / 100) + root.slowestGasPrice)
}
function resetInternal() {
slowestGasPrice = 0
fastestGasPrice = 100
inputGasLimit.text = "21000"
customNetworkFeeDialog.isValid = true
inputGasPrice.text = Qt.binding(defaultGasPrice)
gasSlider.value = Qt.binding(defaultGasPrice)
}
function updateGasEthValue() {
// causes error on application load without this null check
if (!inputGasPrice || !inputGasLimit) {
@ -35,10 +49,6 @@ Item {
labelGasPriceSummaryAdvanced.text = summary
}
function validate(value) {
return !isNaN(value)
}
StyledText {
id: txtNetworkFee
anchors.top: parent.top
@ -157,24 +167,63 @@ Item {
title: qsTrId("custom-network-fee")
height: 286
width: 400
property bool isValid: true
onIsValidChanged: {
root.isValid = isValid
}
function validate() {
// causes error on application load without a null check
if (!inputGasLimit || !inputGasPrice) {
return
}
inputGasLimit.validationError = ""
inputGasPrice.validationError = ""
const noInputLimit = inputGasLimit.text === ""
const noInputPrice = inputGasPrice.text === ""
if (noInputLimit) {
inputGasLimit.validationError = root.noInputErrorMessage
}
if (noInputPrice) {
inputGasPrice.validationError = root.noInputErrorMessage
}
if (isNaN(inputGasLimit.text)) {
inputGasLimit.validationError = invalidInputErrorMessage
}
if (isNaN(inputGasPrice.text)) {
inputGasPrice.validationError = invalidInputErrorMessage
}
let inputLimit = parseFloat(inputGasLimit.text || "0.00")
let inputPrice = parseFloat(inputGasPrice.text || "0.00")
if (inputLimit === 0.00) {
inputGasLimit.validationError = root.greaterThan0ErrorMessage
}
if (inputPrice === 0.00) {
inputGasPrice.validationError = root.greaterThan0ErrorMessage
}
const isValid = inputGasLimit.validationError === "" && inputGasPrice.validationError === ""
customNetworkFeeDialog.isValid = isValid
return isValid
}
Input {
id: inputGasLimit
//% "Gas limit"
label: qsTrId("gas-limit")
text: "22000"
text: "21000"
customHeight: 56
anchors.top: parent.top
anchors.left: parent.left
anchors.right: inputGasPrice.left
anchors.rightMargin: Style.current.padding
placeholderText: "21000"
validationErrorAlignment: TextEdit.AlignRight
validationErrorTopMargin: 8
onTextChanged: {
if (root.validate(inputGasLimit.text.trim())) {
inputGasLimit.validationError = ""
if (customNetworkFeeDialog.validate()) {
root.updateGasEthValue()
return
}
inputGasLimit.validationError = root.validationError
}
}
@ -188,13 +237,14 @@ Item {
width: 130
customHeight: 56
text: root.defaultGasPrice()
placeholderText: "21000"
onTextChanged: {
if (root.validate(inputGasPrice.text.trim())) {
inputGasPrice.validationError = ""
root.updateGasEthValue()
return
if (inputGasPrice.text.trim() === "") {
inputGasPrice.text = root.defaultGasPrice()
}
if (customNetworkFeeDialog.validate()) {
root.updateGasEthValue()
}
inputGasPrice.validationError = root.validationError
}
StyledText {
@ -223,12 +273,13 @@ Item {
id: applyButton
anchors.right: parent.right
anchors.rightMargin: Style.current.smallPadding
//% "Apply"
label: qsTrId("invalid-key-confirm")
disabled: !root.validate(inputGasLimit.text.trim()) || !root.validate(inputGasPrice.text.trim())
label: qsTr("Apply")
anchors.bottom: parent.bottom
disabled: !customNetworkFeeDialog.isValid
onClicked: {
if (customNetworkFeeDialog.validate()) {
root.updateGasEthValue()
}
customNetworkFeeDialog.close()
}
}

View File

@ -34,6 +34,11 @@ Item {
anchors.right: parent.right
anchors.left: parent.left
function resetInternal() {
inputValue.text = ""
validationError = ""
}
StyledText {
id: inputLabel
text: inputBox.label
@ -55,15 +60,22 @@ Item {
anchors.topMargin: inputBox.hasLabel ? inputBox.labelMargin : 0
anchors.right: parent.right
anchors.left: parent.left
border.width: !!validationError ? 1 : 0
border.color: Style.current.red
border.width: (!!validationError || inputValue.focus) ? 1 : 0
border.color: {
if (!!validationError) {
return Style.current.danger
}
if (inputValue.focus) {
return Style.current.inputBorderFocus
}
return Style.current.transparent
}
StyledTextField {
id: inputValue
visible: !inputBox.isTextArea && !inputBox.isSelect
placeholderText: inputBox.placeholderText
placeholderTextColor: inputBox.placeholderTextColor
text: inputBox.text
anchors.top: parent.top
anchors.topMargin: 0
anchors.bottom: parent.bottom

View File

@ -17,7 +17,6 @@ Item {
font.pixelSize: 15
height: parent.height
anchors.left: parent.left
anchors.leftMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
width: 105

View File

@ -50,7 +50,6 @@ Popup {
anchors.left: parent.left
font.bold: true
font.pixelSize: 17
anchors.leftMargin: 16
anchors.topMargin: Style.current.padding
anchors.bottomMargin: Style.current.padding
visible: !!title

View File

@ -18,6 +18,24 @@ Item {
height: (readOnly ? inpReadOnly.height : inpAddress.height) + txtLabel.height
//% "Invalid ethereum address"
readonly property string addressValidationError: qsTrId("invalid-ethereum-address")
property bool isValid: false
property var reset: function() {}
readonly property var sources: [
qsTr("Address"),
qsTr("Contact"),
qsTr("My account")
]
function resetInternal() {
inpAddress.resetInternal()
selContact.resetInternal()
selAccount.resetInternal()
selAddressSource.resetInternal()
selContact.reset()
selAccount.reset()
selAddressSource.reset()
isValid = false
}
enum Type {
Address,
@ -37,6 +55,7 @@ Item {
} else if (selAddressSource.selectedSource === "Contact") {
isValid = selContact.validate()
}
root.isValid = isValid
return isValid
}
@ -108,6 +127,11 @@ Item {
}
root.selectedRecipient = { address: selectedAddress, type: RecipientSelector.Type.Address }
}
onIsValidChanged: {
if (selAddressSource.selectedSource === "Address") {
root.isValid = isValid
}
}
}
ContactSelector {
@ -119,6 +143,9 @@ Item {
Layout.preferredWidth: selAddressSource.visible ? root.inputWidth : parent.width
Layout.alignment: Qt.AlignTop
Layout.fillWidth: true
reset: function() {
contacts = root.contacts
}
onSelectedContactChanged: {
if (root.readOnly) {
return
@ -128,6 +155,11 @@ Item {
root.selectedRecipient = { address, name, alias, isContact, identicon, ensVerified, type: RecipientSelector.Type.Contact }
}
}
onIsValidChanged: {
if (selAddressSource.selectedSource === "Contact") {
root.isValid = isValid
}
}
}
AccountSelector {
@ -140,8 +172,11 @@ Item {
Layout.preferredWidth: selAddressSource.visible ? root.inputWidth : parent.width
Layout.alignment: Qt.AlignTop
Layout.fillWidth: true
reset: function() {
accounts = root.accounts
}
onSelectedAccountChanged: {
if (root.readOnly) {
if (root.readOnly || !selectedAccount) {
return
}
const { address, name, iconColor, assets, fiatBalance } = selectedAccount
@ -151,11 +186,13 @@ Item {
AddressSourceSelector {
id: selAddressSource
visible: !root.readOnly
sources: ["Address", "Contact", "My account"]
sources: root.sources
width: sourceSelectWidth
Layout.preferredWidth: root.sourceSelectWidth
Layout.alignment: Qt.AlignTop
reset: function() {
sources = root.sources
}
onSelectedSourceChanged: {
if (root.readOnly) {
return
@ -167,6 +204,7 @@ Item {
selContact.visible = selAccount.visible = false
root.height = Qt.binding(function() { return inpAddress.height + txtLabel.height })
root.selectedRecipient = { address: inpAddress.selectedAddress, type: RecipientSelector.Type.Address }
root.isValid = inpAddress.isValid
break;
case "Contact":
selContact.visible = true
@ -176,6 +214,7 @@ Item {
address = selContact.selectedContact.address
name = selContact.selectedContact.name
root.selectedRecipient = { address, name, alias, isContact, identicon, ensVerified, type: RecipientSelector.Type.Contact }
root.isValid = selContact.isValid
break;
case "My account":
selAccount.visible = true
@ -185,6 +224,7 @@ Item {
address = selAccount.selectedAccount.address
name = selAccount.selectedAccount.name
root.selectedRecipient = { address, name, iconColor, assets, fiatBalance, type: RecipientSelector.Type.Account }
root.isValid = selAccount.isValid
break;
}
}

View File

@ -3,7 +3,7 @@ import QtQuick 2.13
// Source: https://forum.qt.io/topic/52161/properly-scaling-svg-images/6
Image {
sourceSize: Qt.size(hiddenImg.sourceSize.width * 2, hiddenImg.sourceSize.height * 2)
sourceSize: Qt.size(Math.max(hiddenImg.sourceSize.width * 2, 250), Math.max(hiddenImg.sourceSize.height * 2, 250))
Image {
id: hiddenImg
source: parent.source

View File

@ -19,16 +19,19 @@ Item {
property color bgColorHover: bgColor
property alias selectedItemView: selectedItemContainer.children
property int caretRightMargin: Style.current.padding
property int caretLeftMargin: 8
property int caretLeftMargin: Style.current.halfPadding
property alias select: inputRectangle
property int menuAlignment: Select.MenuAlignment.Right
property Item zeroItemsView: Item {}
property int contentWidth: inputRectangle.width - (caret.width + caretRightMargin + caretLeftMargin)
property int selectedItemRightMargin: caret.width + caretRightMargin + caretLeftMargin
property string validationError: ""
property alias validationErrorAlignment: validationErrorText.horizontalAlignment
property int validationErrorTopMargin: Style.current.halfPadding
anchors.left: parent.left
anchors.right: parent.right
id: root
height: inputRectangle.height + (hasLabel ? inputLabel.height + labelMargin : 0)
height: inputRectangle.height + (hasLabel ? inputLabel.height + labelMargin : 0) + (!!validationError ? (validationErrorText.height + validationErrorTopMargin) : 0)
StyledText {
id: inputLabel
@ -53,6 +56,8 @@ Item {
anchors.topMargin: root.hasLabel ? root.labelMargin : 0
anchors.right: parent.right
anchors.left: parent.left
border.width: !!validationError ? 1 : 0
border.color: Style.current.danger
Item {
id: selectedItemContainer
@ -134,6 +139,20 @@ Item {
}
}
}
TextEdit {
id: validationErrorText
visible: !!validationError
text: validationError
anchors.top: inputRectangle.bottom
anchors.topMargin: validationErrorTopMargin
selectByMouse: true
readOnly: true
font.pixelSize: 12
height: 16
color: Style.current.danger
width: parent.width
horizontalAlignment: TextEdit.AlignRight
}
MouseArea {
id: mouseArea
anchors.fill: inputRectangle

View File

@ -0,0 +1,46 @@
import QtQuick 2.13
import QtGraphicalEffects 1.13
import "../imports"
Item {
property int iconMargin: Style.current.padding
property alias icon: icon
readonly property int separatorWidth: (parent.width / 2) - (icon.height / 2) - iconMargin
width: parent.width
height: icon.height
Separator {
id: separatorLeft
width: separatorWidth
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.topMargin: undefined
}
SVGImage {
id: icon
height: 14
width: 18
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
source: "../app/img/arrow-right.svg"
rotation: 90
ColorOverlay {
anchors.fill: parent
source: parent
color: Style.current.textColor
antialiasing: true
}
}
Separator {
id: separatorRight
width: separatorWidth
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.topMargin: undefined
}
}

View File

@ -0,0 +1,9 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import "../imports"
FormGroup {
id: root
property string headerText
property string footerText
}

View File

@ -6,13 +6,22 @@ import "../imports"
Item {
id: root
property var fromAccount: ({})
property var toAccount: ({ type: "" })
property var asset: ({ name: "", symbol: "" })
property var amount: ({ value: "", fiatValue: "", currency: "" })
property var fromAccount
property var toAccount
property var asset
property var amount
property string currency: "USD"
property var gas: ({ value: "", symbol: "", fiatValue: "" })
property var gas
height: content.height
property var reset: function() {}
function resetInternal() {
fromAccount = undefined
toAccount = undefined
asset = undefined
amount = undefined
gas = undefined
}
Column {
id: content
@ -30,11 +39,11 @@ Item {
StyledText {
font.pixelSize: 15
height: 22
text: root.fromAccount.name
text: root.fromAccount ? root.fromAccount.name : ""
elide: Text.ElideRight
anchors.left: parent.left
anchors.right: imgFromWallet.left
anchors.rightMargin: 8
anchors.rightMargin: Style.current.halfPadding
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
@ -44,7 +53,6 @@ Item {
sourceSize.height: 18
sourceSize.width: 18
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
source: "../app/img/walletIcon.svg"
@ -52,22 +60,21 @@ Item {
ColorOverlay {
anchors.fill: imgFromWallet
source: imgFromWallet
color: fromAccount.iconColor
color: root.fromAccount ? root.fromAccount.iconColor : Style.current.blue
}
}
}
LabelValueRow {
id: itmTo
property var props: { "primaryText": "replace1", "secondaryText": "me1" }
//% "Recipient"
label: qsTrId("recipient")
states: [
State {
name: "Address"
when: root.toAccount.type === RecipientSelector.Type.Address
when: !!root.toAccount && root.toAccount.type === RecipientSelector.Type.Address
PropertyChanges {
target: txtToPrimary
text: root.toAccount.address
text: root.toAccount ? root.toAccount.address : ""
elide: Text.ElideMiddle
anchors.leftMargin: 190
}
@ -78,7 +85,7 @@ Item {
},
State {
name: "Contact"
when: root.toAccount.type === RecipientSelector.Type.Contact && !!root.toAccount.address
when: !!root.toAccount && root.toAccount.type === RecipientSelector.Type.Contact && !!root.toAccount.address
PropertyChanges {
target: metSecondary
text: root.toAccount.ensVerified ? root.toAccount.alias : root.toAccount.address
@ -101,14 +108,14 @@ Item {
},
State {
name: "Account"
when: root.toAccount.type === RecipientSelector.Type.Account && !!root.toAccount.address
when: !!root.toAccount && root.toAccount.type === RecipientSelector.Type.Account && !!root.toAccount.address
PropertyChanges {
target: metSecondary
text: root.toAccount.address
}
PropertyChanges {
target: txtToSecondary
anchors.rightMargin: Style.current.padding + imgToWallet.width + 8
anchors.rightMargin: Style.current.padding + imgToWallet.width + Style.current.halfPadding
text: metSecondary.elidedText
width: metSecondary.elidedWidth
}
@ -177,7 +184,6 @@ Item {
sourceSize.height: 18
sourceSize.width: 18
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
source: "../app/img/walletIcon.svg"
@ -192,7 +198,6 @@ Item {
id: idtToContact
visible: false
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
@ -213,7 +218,7 @@ Item {
text: (root.asset && root.asset.name) ? root.asset.name : ""
anchors.left: parent.left
anchors.right: txtAssetSymbol.left
anchors.rightMargin: 8
anchors.rightMargin: Style.current.halfPadding
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
@ -225,7 +230,7 @@ Item {
text: (root.asset && root.asset.symbol) ? root.asset.symbol : ""
color: Style.current.secondaryText
anchors.right: imgAsset.left
anchors.rightMargin: 8
anchors.rightMargin: Style.current.halfPadding
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
@ -235,7 +240,6 @@ Item {
sourceSize.height: 32
sourceSize.width: 32
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
source: "../app/img/tokens/" + ((root.asset && root.asset.symbol) ? root.asset.symbol : "ETH") + ".png"
@ -259,7 +263,7 @@ Item {
StyledText {
font.pixelSize: 15
height: 22
text: root.amount.value ? Utils.stripTrailingZeros(root.amount.value) : ""
text: (root.amount && root.amount.value) ? Utils.stripTrailingZeros(root.amount.value) : ""
anchors.left: parent.left
anchors.right: txtAmountSymbol.left
anchors.rightMargin: 5
@ -284,7 +288,7 @@ Item {
id: txtAmountFiat
font.pixelSize: 15
height: 22
text: "~" + (root.amount.fiatValue ? root.amount.fiatValue : "0.00")
text: "~" + (root.amount && root.amount.fiatValue ? root.amount.fiatValue : "0.00")
anchors.right: txtAmountCurrency.left
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
@ -298,7 +302,6 @@ Item {
text: root.currency.toUpperCase()
color: Style.current.secondaryText
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
@ -356,7 +359,6 @@ Item {
text: root.currency.toUpperCase()
color: Style.current.secondaryText
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter

View File

@ -7,9 +7,38 @@ Item {
id: root
height: signingPhraseItem.height + signingPhrase.height + txtPassword.height + Style.current.smallPadding + Style.current.bigPadding
property string signingPhrase: "not a real one"
property alias passwordInput: txtPassword
property string validationError: ""
property alias signingPhrase: signingPhrase.text
property string enteredPassword
property alias validationError: txtPassword.validationError
//% "You need to enter a password"
property string noInputErrorMessage: qsTrId("you-need-to-enter-a-password")
//% "Password needs to be 4 characters or more"
property string invalidInputErrorMessage: qsTrId("password-needs-to-be-4-characters-or-more")
property bool isValid: false
property var reset: function() {}
function resetInternal() {
signingPhrase.text = ""
enteredPassword = ""
txtPassword.resetInternal()
isValid = false
}
function forceActiveFocus(reason) {
txtPassword.forceActiveFocus(reason)
}
function validate() {
txtPassword.validationError = ""
const noInput = txtPassword.text === ""
if (noInput) {
txtPassword.validationError = noInputErrorMessage
} else if (txtPassword.text.length < 4) {
txtPassword.validationError = invalidInputErrorMessage
}
isValid = txtPassword.validationError === ""
return isValid
}
Item {
id: signingPhraseItem
@ -73,15 +102,23 @@ Item {
}
Input {
id: txtPassword
anchors.top: signingPhrase.bottom
anchors.topMargin: Style.current.bigPadding
id: txtPassword
focus: true
customHeight: 56
//% "Password"
label: qsTrId("password")
//% "Enter Password"
placeholderText: qsTrId("enter-password")
textField.echoMode: TextInput.Password
validationError: root.validationError
validationErrorAlignment: TextEdit.AlignRight
validationErrorTopMargin: 8
onTextChanged: {
if(root.validate()) {
root.enteredPassword = this.text
}
}
}
}

View File

@ -0,0 +1,75 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import "../imports"
StackView {
id: root
default property list<FormGroup> groups
property int currentIdx: 0
property bool isLastGroup: currentIdx === groups.length - 1
property bool isFirstGroup: currentIdx === 0
signal groupActivated(Item group)
property alias currentGroup: root.currentItem
property var next: function() {
if (groups && groups.length <= currentIdx + 1) {
return
}
const group = groups[++currentIdx]
this.push(group, StackView.Immediate)
}
property var back: function() {
if (currentIdx <= 0) {
return
}
this.pop()
currentIdx--
}
function reset() {
for (let i=0; i<groups.length; i++) {
groups[i].reset()
}
this.pop(null)
currentIdx = 0
}
initialItem: groups[currentIdx]
anchors.fill: parent
// The below transitions are pointless, but without them,
// the final input in the final TransactionFormGroup will
// not be able to receive focus! Seems like a Qt bug...
pushEnter: Transition {
PropertyAnimation {
property: "opacity"
from: 1
to:1
duration: 1
}
}
pushExit: Transition {
PropertyAnimation {
property: "opacity"
from: 1
to:1
duration: 1
}
}
popEnter: Transition {
PropertyAnimation {
property: "opacity"
from: 1
to:1
duration: 1
}
}
popExit: Transition {
PropertyAnimation {
property: "opacity"
from: 1
to:1
duration: 1
}
}
}