mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-03 13:39:38 +00:00
Turns the dummy-data AMM UI into a real client of the on-chain LEZ wallet.
Adds a hand-written ui_qml C++ backend (src/AmmUi*) over the core
logos_execution_zone module: create/open a local wallet, create and list
public/private accounts, and a navbar Connect / Connected + account-selector
+ Disconnect flow. Onboarding is password-only (no path picking) with a
per-app wallet at ~/.lee/amm-wallet (override: AMM_WALLET_HOME_DIR);
standalone gets its own wallet, Basecamp shares accounts via adopt-on-start.
Requires Nix with flakes; macOS also needs `sandbox = false` (the default).
The logos_execution_zone input is pinned to a module rev whose LEZ (lssa)
already includes the macOS Metal-build fix, so no `--override-input` is
needed — plain `nix run .` works:
cd apps/amm
nix run .
- create_new now returns the new wallet's BIP39 mnemonic (not an int status);
the app currently discards it, so the wallet can't yet be recovered. Surfacing
it in onboarding (+ restore_storage) is a follow-up.
- The wallet password is currently a no-op upstream (storage.rs: "TODO: use
password for storage encryption"); storage.json is plaintext. So Disconnect
is a UI-level lock and reconnect does not (cannot yet) re-prompt for it.
- wallet-ffi requires explicit config/storage paths; a *_default() FFI would
let the app drop its path handling.
- Bundled network config: connects to whatever WalletConfig::default() points
at; real testnet endpoints still TBD.
483 lines
20 KiB
QML
483 lines
20 KiB
QML
import QtQuick
|
|
import QtQml
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
import Logos.Theme
|
|
import Logos.Controls
|
|
|
|
// Header wallet control (Uniswap-style), with two states:
|
|
// - not connected → a "Connect" button that opens the create-wallet modal
|
|
// - connected → a single button showing the active account address;
|
|
// clicking it opens a popup (top-right, just under the
|
|
// button) holding the account selector, create-account and
|
|
// disconnect actions.
|
|
// The selected account address is exposed via selectedAddress for the
|
|
// trade/liquidity flows to use as the "from" account.
|
|
Item {
|
|
id: root
|
|
|
|
// Backend replica (logos.module("amm_ui")) and its account model.
|
|
property var backend: null
|
|
property var accountModel: null
|
|
|
|
readonly property bool connected: backend !== null && backend.isWalletOpen
|
|
|
|
// Index of the active account. selectedAddress/selectedName are derived from
|
|
// the model mirror below so they stay valid while the popup (and its list)
|
|
// is closed.
|
|
property int selectedIndex: 0
|
|
|
|
// Non-visual mirror of the account model: realizes every row regardless of
|
|
// popup visibility, so the active account is addressable by index at all
|
|
// times (a ListView only realizes rows while it is shown).
|
|
Instantiator {
|
|
id: accounts
|
|
model: root.accountModel
|
|
delegate: QtObject {
|
|
readonly property string address: model.address ?? ""
|
|
readonly property string name: model.name ?? ""
|
|
readonly property string balance: model.balance ?? ""
|
|
readonly property bool isPublic: model.isPublic ?? false
|
|
}
|
|
}
|
|
|
|
function entryAt(i) {
|
|
return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null
|
|
}
|
|
|
|
readonly property string selectedAddress: {
|
|
const e = root.entryAt(root.selectedIndex)
|
|
return e ? e.address : ""
|
|
}
|
|
readonly property string selectedName: {
|
|
const e = root.entryAt(root.selectedIndex)
|
|
return e ? e.name : ""
|
|
}
|
|
readonly property string selectedBalance: {
|
|
const e = root.entryAt(root.selectedIndex)
|
|
return e ? e.balance : ""
|
|
}
|
|
readonly property bool selectedIsPublic: {
|
|
const e = root.entryAt(root.selectedIndex)
|
|
return e ? e.isPublic : false
|
|
}
|
|
|
|
// Keep the selection within bounds as accounts are added/removed.
|
|
function clampSelection() {
|
|
if (accounts.count === 0) { root.selectedIndex = 0; return }
|
|
if (root.selectedIndex < 0) root.selectedIndex = 0
|
|
else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1
|
|
}
|
|
Connections {
|
|
target: root.accountModel
|
|
ignoreUnknownSignals: true
|
|
function onModelReset() { root.clampSelection() }
|
|
function onRowsInserted() { root.clampSelection() }
|
|
function onRowsRemoved() { root.clampSelection() }
|
|
}
|
|
|
|
// 0x123456…cdef style truncation for the connected button label.
|
|
function truncated(addr) {
|
|
if (!addr) return ""
|
|
return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr
|
|
}
|
|
|
|
// Copy on the QML/view side. Routing this through the backend would call
|
|
// QGuiApplication::clipboard() in the (headless) module host process, which
|
|
// has no clipboard — that call tears the backend down, dropping the wallet
|
|
// connection. A hidden TextEdit copies via the GUI process that owns it.
|
|
TextEdit { id: clipboardProxy; visible: false }
|
|
function copyToClipboard(text) {
|
|
if (!text) return
|
|
clipboardProxy.text = text
|
|
clipboardProxy.selectAll()
|
|
clipboardProxy.copy()
|
|
clipboardProxy.deselect()
|
|
clipboardProxy.text = ""
|
|
}
|
|
|
|
implicitWidth: root.connected ? connectedButton.width : connectButton.width
|
|
implicitHeight: 40
|
|
|
|
// ── Disconnected: Connect ────────────────────────────────────────────
|
|
LogosButton {
|
|
id: connectButton
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: 40
|
|
visible: !root.connected
|
|
enabled: root.backend !== null
|
|
text: qsTr("Connect")
|
|
onClicked: {
|
|
// Re-open an existing wallet; only show the create modal on first run.
|
|
if (root.backend && root.backend.walletExists)
|
|
logos.watch(root.backend.openExisting(),
|
|
function(ok) { if (!ok) console.warn("openExisting failed") },
|
|
function(error) { console.warn("openExisting error:", error) })
|
|
else
|
|
createWalletDialog.open()
|
|
}
|
|
}
|
|
|
|
// ── Connected: address pill that toggles the wallet menu ─────────────
|
|
Rectangle {
|
|
id: connectedButton
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.connected
|
|
implicitHeight: 40
|
|
implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2
|
|
radius: height / 2
|
|
// Keep an opaque dark fill in both states: the navbar is white, and the
|
|
// active "muted" fill is translucent gray, which renders light over white
|
|
// and makes the white label unreadable. Signal "open" with an accent
|
|
// border instead.
|
|
color: Theme.palette.backgroundSecondary
|
|
border.width: 1
|
|
border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent"
|
|
|
|
RowLayout {
|
|
id: connectedRow
|
|
anchors.centerIn: parent
|
|
spacing: Theme.spacing.small
|
|
|
|
Rectangle {
|
|
Layout.preferredWidth: 8
|
|
Layout.preferredHeight: 8
|
|
radius: 4
|
|
color: "#39c06a"
|
|
}
|
|
LogosText {
|
|
text: root.truncated(root.selectedAddress) || qsTr("Connected")
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
color: Theme.palette.text
|
|
}
|
|
LogosText {
|
|
text: walletMenu.opened ? "▴" : "▾"
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
color: Theme.palette.textSecondary
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
// CloseOnPressOutside already dismisses the popup on this same press
|
|
// (the button is outside it), so `opened` is false by the time this
|
|
// fires. Without the recency guard the dismissing click would just
|
|
// reopen it. If it just closed, leave it closed.
|
|
onClicked: {
|
|
if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200)
|
|
walletMenu.close()
|
|
else
|
|
walletMenu.open()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Wallet menu popup (top-right, under the connected button) ─────────
|
|
Popup {
|
|
id: walletMenu
|
|
parent: connectedButton
|
|
y: connectedButton.height + Theme.spacing.small
|
|
x: connectedButton.width - width // right-align under the button
|
|
width: 360
|
|
padding: Theme.spacing.medium
|
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
|
|
|
// Timestamp of the last dismissal, used by the toggle button to tell a
|
|
// genuine "open" click from the press that just closed the popup.
|
|
property real lastClosedMs: 0
|
|
onClosed: {
|
|
walletMenu.lastClosedMs = Date.now()
|
|
// Always reopen on the main (selected-account) view.
|
|
if (viewStack.depth > 1)
|
|
viewStack.pop(null, StackView.Immediate)
|
|
}
|
|
|
|
background: Rectangle {
|
|
color: Theme.palette.backgroundTertiary
|
|
border.width: 1
|
|
border.color: Theme.palette.backgroundElevated
|
|
radius: Theme.spacing.radiusLarge
|
|
}
|
|
|
|
// Two stacked views: the main view (active account + actions) and the
|
|
// accounts view (full list + create). The popup height follows the
|
|
// active view's natural height, animated so the resize isn't abrupt.
|
|
contentItem: StackView {
|
|
id: viewStack
|
|
clip: true
|
|
implicitWidth: walletMenu.availableWidth
|
|
implicitHeight: currentItem ? currentItem.implicitHeight : 0
|
|
initialItem: mainView
|
|
|
|
Behavior on implicitHeight {
|
|
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
|
}
|
|
|
|
pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
|
|
pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
|
|
popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
|
|
popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
|
|
}
|
|
|
|
// ── Main view: account icon + power icon, then the active account ──
|
|
Component {
|
|
id: mainView
|
|
|
|
ColumnLayout {
|
|
spacing: Theme.spacing.medium
|
|
|
|
// Top-right actions: open the account list / disconnect.
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.spacing.small
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
WalletIconButton {
|
|
iconSource: Qt.resolvedUrl("icons/account.svg")
|
|
onClicked: viewStack.push(accountsView)
|
|
}
|
|
WalletIconButton {
|
|
iconSource: Qt.resolvedUrl("icons/settings.svg")
|
|
onClicked: viewStack.push(settingsView)
|
|
}
|
|
WalletIconButton {
|
|
iconSource: Qt.resolvedUrl("icons/power.svg")
|
|
onClicked: {
|
|
walletMenu.close()
|
|
if (root.backend) root.backend.disconnectWallet()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Active account card.
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2
|
|
radius: Theme.spacing.radiusLarge
|
|
color: Theme.palette.backgroundMuted
|
|
|
|
ColumnLayout {
|
|
id: cardColumn
|
|
anchors.fill: parent
|
|
anchors.margins: Theme.spacing.medium
|
|
spacing: Theme.spacing.small
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.spacing.small
|
|
|
|
LogosText {
|
|
text: root.selectedName
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
font.bold: true
|
|
}
|
|
Rectangle {
|
|
Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2
|
|
Layout.preferredHeight: tagLabel.implicitHeight + 4
|
|
radius: 4
|
|
color: Theme.palette.backgroundSecondary
|
|
LogosText {
|
|
id: tagLabel
|
|
anchors.centerIn: parent
|
|
text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private")
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
color: Theme.palette.textSecondary
|
|
}
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
LogosText {
|
|
text: root.selectedBalance.length > 0 ? root.selectedBalance : "—"
|
|
font.bold: true
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
LogosText {
|
|
Layout.fillWidth: true
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: root.selectedAddress
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
color: Theme.palette.textMuted
|
|
elide: Text.ElideMiddle
|
|
}
|
|
LogosCopyButton {
|
|
Layout.preferredHeight: 40
|
|
Layout.preferredWidth: 40
|
|
visible: root.selectedAddress.length > 0
|
|
onCopyText: root.copyToClipboard(root.selectedAddress)
|
|
icon.color: Theme.palette.textMuted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Accounts view: back + full list + create ──────────────────────
|
|
Component {
|
|
id: accountsView
|
|
|
|
ColumnLayout {
|
|
spacing: Theme.spacing.medium
|
|
|
|
// Header: back to the main view + title.
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.spacing.small
|
|
|
|
WalletIconButton {
|
|
iconSource: Qt.resolvedUrl("icons/back.svg")
|
|
onClicked: viewStack.pop()
|
|
}
|
|
LogosText {
|
|
Layout.fillWidth: true
|
|
text: qsTr("Accounts")
|
|
font.bold: true
|
|
color: Theme.palette.text
|
|
}
|
|
}
|
|
|
|
// Account list: tap a row to make it the active account, then
|
|
// return to the main view so the selection is reflected.
|
|
ListView {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.min(contentHeight, 260)
|
|
clip: true
|
|
model: root.accountModel
|
|
spacing: Theme.spacing.small
|
|
ScrollIndicator.vertical: ScrollIndicator { }
|
|
|
|
delegate: AccountDelegate {
|
|
width: ListView.view.width
|
|
highlighted: index === root.selectedIndex
|
|
onClicked: {
|
|
root.selectedIndex = index
|
|
viewStack.pop()
|
|
}
|
|
onCopyRequested: (text) => root.copyToClipboard(text)
|
|
}
|
|
}
|
|
|
|
LogosButton {
|
|
Layout.fillWidth: true
|
|
height: 40
|
|
text: qsTr("Add")
|
|
// Leave the wallet menu open behind the (modal) dialog.
|
|
onClicked: createAccountDialog.open()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Settings view: back + editable network (sequencer) ────────────
|
|
Component {
|
|
id: settingsView
|
|
|
|
ColumnLayout {
|
|
spacing: Theme.spacing.medium
|
|
|
|
// Header: back to the main view + title.
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.spacing.small
|
|
|
|
WalletIconButton {
|
|
iconSource: Qt.resolvedUrl("icons/back.svg")
|
|
onClicked: viewStack.pop()
|
|
}
|
|
LogosText {
|
|
Layout.fillWidth: true
|
|
text: qsTr("Settings")
|
|
font.bold: true
|
|
color: Theme.palette.text
|
|
}
|
|
}
|
|
|
|
LogosText {
|
|
text: qsTr("Network (sequencer URL)")
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
color: Theme.palette.textSecondary
|
|
}
|
|
|
|
LogosTextField {
|
|
id: seqField
|
|
Layout.fillWidth: true
|
|
placeholderText: "http://127.0.0.1:3040"
|
|
// Initialize from the live value without binding, so typing
|
|
// isn't clobbered when sequencerAddr updates after a save.
|
|
Component.onCompleted: text = root.backend ? root.backend.sequencerAddr : ""
|
|
}
|
|
|
|
LogosText {
|
|
id: seqStatus
|
|
Layout.fillWidth: true
|
|
visible: text.length > 0
|
|
wrapMode: Text.WordWrap
|
|
font.pixelSize: Theme.typography.secondaryText
|
|
property bool ok: false
|
|
color: ok ? Theme.palette.success : Theme.palette.error
|
|
}
|
|
|
|
LogosButton {
|
|
Layout.fillWidth: true
|
|
height: 40
|
|
text: qsTr("Save")
|
|
onClicked: {
|
|
if (!root.backend) return
|
|
seqStatus.text = ""
|
|
logos.watch(root.backend.changeSequencerAddr(seqField.text),
|
|
function(ok) {
|
|
seqStatus.ok = ok
|
|
seqStatus.text = ok ? qsTr("Network updated.")
|
|
: qsTr("Failed to update network.")
|
|
},
|
|
function(error) {
|
|
seqStatus.ok = false
|
|
seqStatus.text = qsTr("Error: %1").arg(error)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Dialogs ──────────────────────────────────────────────────────────
|
|
CreateWalletDialog {
|
|
id: createWalletDialog
|
|
walletHome: root.backend ? root.backend.walletHome : ""
|
|
onCreateWallet: function(password) {
|
|
if (!root.backend) return
|
|
logos.watch(root.backend.createNewDefault(password),
|
|
function(ok) {
|
|
if (ok) createWalletDialog.close()
|
|
else createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.")
|
|
},
|
|
function(error) {
|
|
createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error)
|
|
})
|
|
}
|
|
}
|
|
|
|
CreateAccountDialog {
|
|
id: createAccountDialog
|
|
onCreatePublicRequested: {
|
|
if (!root.backend) return
|
|
logos.watch(root.backend.createAccountPublic(),
|
|
function(_id) { /* model updates via NOTIFY after refresh */ },
|
|
function(error) { console.warn("createAccountPublic failed:", error) })
|
|
}
|
|
onCreatePrivateRequested: {
|
|
if (!root.backend) return
|
|
logos.watch(root.backend.createAccountPrivate(),
|
|
function(_id) { /* model updates via NOTIFY after refresh */ },
|
|
function(error) { console.warn("createAccountPrivate failed:", error) })
|
|
}
|
|
}
|
|
}
|