lez-programs/apps/amm/qml/components/liquidity/TokenSelectorModal.qml
Ricardo Guilherme Schmidt 6de8a5fe9a
feat(amm-ui): complete new position liquidity flow
Implement one create-pool and add-liquidity workflow. Add searchable token resolution, direct opening-price and deposit editing, optimistic pool activation, and base58 transaction IDs.

Isolate network, wallet, AMM client, and runtime boundaries. Stabilize quote, submission, refresh, and pool-probe state while consolidating liquidity tests and removing obsolete liquidity paths.
2026-07-16 15:46:50 +02:00

490 lines
17 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Popup {
id: root
required property var theme
property var tokens: []
property string searchText: ""
property string title: qsTr("Select a token")
property string searchPlaceholder: qsTr("Search tokens")
property string popularTitle: qsTr("Popular tokens")
property string listTitle: qsTr("Tokens by 24H volume")
property bool allowCustomEntry: false
property var disabledReasonForCode: function(code) {
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
}
property var detailForToken: function(token) {
return token && token.balance !== undefined
? qsTr("Balance %1").arg(token.balance) : ""
}
readonly property var rows: root.filteredTokens()
readonly property var popularTokens: root.selectableTokens().slice(0, 5)
readonly property bool compact: root.height < 360
readonly property bool showPopular: root.popularTokens.length > 0 && root.height >= 440
signal tokenSelected(var token)
signal tokenEntered(string value)
parent: Overlay.overlay
x: parent ? Math.round((parent.width - width) / 2) : 0
y: parent ? Math.round((parent.height - height) / 2) : 0
width: parent && parent.width > 32
? Math.max(0, Math.min(480, parent.width - 32)) : 280
height: parent && parent.height > 32
? Math.max(0, Math.min(600, parent.height - 32)) : 360
padding: root.compact ? 8 : 20
modal: true
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: {
root.searchText = ""
searchField.text = ""
Qt.callLater(searchField.forceActiveFocus)
}
Overlay.modal: Rectangle {
color: Qt.rgba(0, 0, 0, 0.4)
}
background: Rectangle {
radius: 24
color: root.theme.colors.cardBg
border.color: root.theme.colors.border
border.width: 1
Behavior on color {
ColorAnimation { duration: 180 }
}
}
contentItem: ColumnLayout {
spacing: root.compact ? 8 : 16
RowLayout {
Layout.fillWidth: true
Text {
Layout.fillWidth: true
text: root.title
color: root.theme.colors.textPrimary
font.pixelSize: 18
font.weight: Font.Bold
}
Button {
id: closeButton
Layout.preferredWidth: 32
Layout.preferredHeight: 32
text: "\u00D7"
hoverEnabled: true
Accessible.name: qsTr("Close token selector")
ToolTip.visible: hovered
ToolTip.text: Accessible.name
onClicked: root.close()
contentItem: Text {
text: closeButton.text
color: root.theme.colors.textSecondary
font.pixelSize: 16
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: 16
color: closeButton.hovered || closeButton.activeFocus
? root.theme.colors.panelHoverBg
: root.theme.colors.panelBg
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: root.compact ? 40 : 48
radius: 16
color: root.theme.colors.inputBg
border.color: searchField.activeFocus
? root.theme.colors.ctaBg
: root.theme.colors.border
border.width: 1
RowLayout {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 8
Text {
text: "\u2315"
color: root.theme.colors.textSecondary
font.pixelSize: 20
}
TextField {
id: searchField
objectName: "tokenSearchField"
Layout.fillWidth: true
color: root.theme.colors.textPrimary
placeholderText: root.searchPlaceholder
placeholderTextColor: root.theme.colors.textPlaceholder
selectionColor: root.theme.colors.selection
selectedTextColor: root.theme.colors.textPrimary
font.pixelSize: 15
selectByMouse: true
background: null
onTextEdited: root.searchText = text
onAccepted: root.acceptInput(text)
}
}
}
Text {
Layout.fillWidth: true
visible: root.showPopular
text: root.popularTitle
color: root.theme.colors.textSecondary
font.pixelSize: 13
}
Flow {
Layout.fillWidth: true
visible: root.showPopular
spacing: 8
Repeater {
model: root.popularTokens
delegate: AmmTokenSelectButton {
required property var modelData
theme: root.theme
hasToken: true
tokenColor: root.tokenColor(modelData)
tokenLetter: root.tokenLetter(modelData)
showIndicator: false
text: root.tokenSymbol(modelData).length > 0
? root.tokenSymbol(modelData) : root.tokenName(modelData)
maximumTextWidth: 84
width: Math.min(140, implicitWidth)
onClicked: root.chooseToken(modelData)
}
}
}
Text {
Layout.fillWidth: true
visible: !root.compact
text: root.listTitle
color: root.theme.colors.textSecondary
font.pixelSize: 13
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ListView {
id: tokenList
objectName: "tokenList"
anchors.fill: parent
clip: true
spacing: 2
model: root.rows
boundsBehavior: Flickable.StopAtBounds
ScrollIndicator.vertical: ScrollIndicator { }
delegate: Item {
id: tokenRow
required property var modelData
readonly property bool selectable: root.isSelectable(modelData)
readonly property string disabledReason: root.disabledReasonForCode(
modelData.code
|| modelData.status)
readonly property bool pointerHovered: rowHover.containsMouse
readonly property bool disabledReasonVisible: (pointerHovered || activeFocus)
&& !selectable
width: ListView.view.width
height: 56
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: root.tokenName(modelData)
Accessible.description: selectable
? root.detailForToken(modelData)
: disabledReason
Accessible.onPressAction: tokenRow.activate()
Keys.onReturnPressed: function(event) {
tokenRow.activate()
event.accepted = true
}
Keys.onEnterPressed: function(event) {
tokenRow.activate()
event.accepted = true
}
Keys.onSpacePressed: function(event) {
tokenRow.activate()
event.accepted = true
}
Rectangle {
anchors.fill: parent
radius: 12
color: tokenRow.pointerHovered || tokenRow.activeFocus
? root.theme.colors.panelBg : "transparent"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 12
Rectangle {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
radius: 18
color: root.tokenColor(tokenRow.modelData)
Text {
anchors.centerIn: parent
text: root.tokenLetter(tokenRow.modelData)
color: "#FFFFFF"
font.pixelSize: 14
font.weight: Font.Bold
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
text: root.tokenName(tokenRow.modelData)
color: tokenRow.selectable
? root.theme.colors.textPrimary
: root.theme.colors.textPlaceholder
font.pixelSize: 15
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
visible: text.length > 0
text: root.tokenSymbol(tokenRow.modelData)
color: root.theme.colors.textSecondary
font.pixelSize: 12
}
Text {
Layout.fillWidth: true
text: root.shortAddress(root.tokenAddress(tokenRow.modelData))
color: root.theme.colors.textPlaceholder
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
}
}
Text {
Layout.maximumWidth: 118
text: root.detailForToken(tokenRow.modelData)
color: root.theme.colors.textSecondary
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
}
}
MouseArea {
id: rowHover
anchors.fill: parent
hoverEnabled: true
cursorShape: tokenRow.selectable
? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: tokenRow.activate()
}
ToolTip.visible: tokenRow.disabledReasonVisible
ToolTip.text: tokenRow.disabledReason
function activate() {
if (tokenRow.selectable)
root.chooseToken(tokenRow.modelData)
}
}
}
Button {
id: customEntryButton
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
visible: root.rows.length === 0
&& root.allowCustomEntry
&& root.searchText.trim().length > 0
implicitHeight: 56
text: qsTr("Use TokenDefinition address")
onClicked: root.acceptInput(root.searchText)
contentItem: Column {
leftPadding: 8
spacing: 2
Text {
width: parent.width - 16
text: qsTr("Use TokenDefinition address")
color: root.theme.colors.textPrimary
font.pixelSize: 14
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width - 16
text: root.searchText
color: root.theme.colors.textSecondary
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
}
background: Rectangle {
radius: 12
color: customEntryButton.hovered || customEntryButton.activeFocus
? root.theme.colors.panelBg : "transparent"
}
}
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 16
visible: root.rows.length === 0
&& (!root.allowCustomEntry
|| root.searchText.trim().length === 0)
text: qsTr("No tokens found")
color: root.theme.colors.textSecondary
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
}
}
}
function filteredTokens() {
var needle = root.searchText.trim().toLowerCase()
if (needle.length === 0)
return root.tokens
var result = []
for (var i = 0; i < root.tokens.length; ++i) {
var token = root.tokens[i]
if (root.tokenName(token).toLowerCase().indexOf(needle) >= 0
|| root.tokenSymbol(token).toLowerCase().indexOf(needle) >= 0
|| root.tokenAddress(token).toLowerCase().indexOf(needle) >= 0)
result.push(token)
}
return result
}
function selectableTokens() {
var result = []
for (var i = 0; i < root.tokens.length; ++i) {
if (root.isSelectable(root.tokens[i]))
result.push(root.tokens[i])
}
return result
}
function acceptInput(value) {
var entered = String(value || "").trim()
if (entered.length === 0)
return
var lowered = entered.toLowerCase()
for (var i = 0; i < root.tokens.length; ++i) {
var token = root.tokens[i]
if (root.tokenAddress(token).toLowerCase() === lowered
|| root.tokenName(token).toLowerCase() === lowered
|| root.tokenSymbol(token).toLowerCase() === lowered) {
if (root.isSelectable(token))
root.chooseToken(token)
else {
root.tokenEntered(root.tokenAddress(token))
root.close()
}
return
}
}
if (root.rows.length === 1 && root.isSelectable(root.rows[0])) {
root.chooseToken(root.rows[0])
return
}
if (root.allowCustomEntry) {
root.tokenEntered(entered)
root.close()
}
}
function chooseToken(token) {
if (!root.isSelectable(token))
return
root.tokenSelected(token)
root.close()
}
function isSelectable(token) {
return token && token.selectable !== false
}
function tokenName(token) {
if (!token)
return qsTr("Unknown token")
return String(token.name || token.symbol || qsTr("Unknown token"))
}
function tokenSymbol(token) {
return token ? String(token.symbol || "") : ""
}
function tokenAddress(token) {
return token ? String(token.definitionId || token.address || "") : ""
}
function tokenColor(token) {
return token && token.color ? token.color : root.theme.colors.noTokenCircle
}
function tokenLetter(token) {
if (token && token.letter)
return String(token.letter)
var label = root.tokenSymbol(token) || root.tokenName(token)
return label.length > 0 ? label.charAt(0).toUpperCase() : ""
}
function shortAddress(value) {
var text = String(value || "")
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
}
}