diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 29c329028f..f371a5b5aa 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -42,6 +42,7 @@ QT_MOC_CPP = \ qml/models/moc_nodemodel.cpp \ qml/models/moc_options_model.cpp \ qml/models/moc_peerlistsortproxy.cpp \ + qml/models/moc_walletlistmodel.cpp \ qml/moc_appmode.cpp \ qml/moc_walletcontroller.cpp \ qt/moc_addressbookpage.cpp \ @@ -122,6 +123,7 @@ BITCOIN_QT_H = \ qml/models/nodemodel.h \ qml/models/options_model.h \ qml/models/peerlistsortproxy.h \ + qml/models/walletlistmodel.h \ qml/appmode.h \ qml/bitcoin.h \ qml/guiconstants.h \ @@ -311,6 +313,7 @@ BITCOIN_QML_BASE_CPP = \ qml/models/nodemodel.cpp \ qml/models/options_model.cpp \ qml/models/peerlistsortproxy.cpp \ + qml/models/walletlistmodel.cpp \ qml/imageprovider.cpp \ qml/util.cpp \ qml/walletcontroller.cpp @@ -339,6 +342,7 @@ QML_RES_ICONS = \ qml/res/icons/info.png \ qml/res/icons/network-dark.png \ qml/res/icons/network-light.png \ + qml/res/icons/plus.png \ qml/res/icons/shutdown.png \ qml/res/icons/singlesig-wallet.png \ qml/res/icons/storage-dark.png \ @@ -423,7 +427,9 @@ QML_RES_QML = \ qml/pages/wallet/CreateIntro.qml \ qml/pages/wallet/CreateName.qml \ qml/pages/wallet/CreatePassword.qml \ - qml/pages/wallet/DesktopWallets.qml + qml/pages/wallet/DesktopWallets.qml \ + qml/pages/wallet/WalletBadge.qml \ + qml/pages/wallet/WalletSelect.qml if TARGET_ANDROID BITCOIN_QT_H += qml/androidnotifier.h diff --git a/src/qml/bitcoin.cpp b/src/qml/bitcoin.cpp index 80c38e7f95..367512cbe1 100644 --- a/src/qml/bitcoin.cpp +++ b/src/qml/bitcoin.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -295,12 +296,15 @@ int QmlGuiMain(int argc, char* argv[]) assert(!network_style.isNull()); engine.addImageProvider(QStringLiteral("images"), new ImageProvider{network_style.data()}); + WalletListModel wallet_list_model{*node, nullptr}; + engine.rootContext()->setContextProperty("networkTrafficTower", &network_traffic_tower); engine.rootContext()->setContextProperty("nodeModel", &node_model); engine.rootContext()->setContextProperty("chainModel", &chain_model); engine.rootContext()->setContextProperty("peerTableModel", &peer_model); engine.rootContext()->setContextProperty("peerListModelProxy", &peer_model_sort_proxy); engine.rootContext()->setContextProperty("walletController", &wallet_controller); + engine.rootContext()->setContextProperty("walletListModel", &wallet_list_model); OptionsQmlModel options_model(*node, !need_onboarding.toBool()); engine.rootContext()->setContextProperty("optionsModel", &options_model); diff --git a/src/qml/bitcoin_qml.qrc b/src/qml/bitcoin_qml.qrc index 2f36fe8776..3d20c0109d 100644 --- a/src/qml/bitcoin_qml.qrc +++ b/src/qml/bitcoin_qml.qrc @@ -73,6 +73,8 @@ pages/wallet/CreateName.qml pages/wallet/CreatePassword.qml pages/wallet/DesktopWallets.qml + pages/wallet/WalletBadge.qml + pages/wallet/WalletSelect.qml res/icons/add-wallet-dark.png @@ -95,6 +97,7 @@ res/icons/info.png res/icons/network-dark.png res/icons/network-light.png + res/icons/plus.png res/icons/shutdown.png res/icons/singlesig-wallet.png res/icons/storage-dark.png diff --git a/src/qml/controls/Icon.qml b/src/qml/controls/Icon.qml index a051b2971a..599728ff7a 100644 --- a/src/qml/controls/Icon.qml +++ b/src/qml/controls/Icon.qml @@ -7,6 +7,8 @@ import QtQuick.Controls 2.15 Button { id: root + width: icon.width + height: icon.height required property color color required property url source property int size: 32 diff --git a/src/qml/imageprovider.cpp b/src/qml/imageprovider.cpp index 1804c37171..f00168acec 100644 --- a/src/qml/imageprovider.cpp +++ b/src/qml/imageprovider.cpp @@ -162,5 +162,9 @@ QPixmap ImageProvider::requestPixmap(const QString& id, QSize* size, const QSize return QIcon(":/icons/hidden").pixmap(requested_size); } + if (id == "plus") { + *size = requested_size; + return QIcon(":/icons/plus").pixmap(requested_size); + } return {}; } diff --git a/src/qml/models/walletlistmodel.cpp b/src/qml/models/walletlistmodel.cpp new file mode 100644 index 0000000000..ecf97b025a --- /dev/null +++ b/src/qml/models/walletlistmodel.cpp @@ -0,0 +1,81 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include + +WalletListModel::WalletListModel(interfaces::Node& node, QObject *parent) +: QAbstractListModel(parent) +, m_node(node) +{ + setSelectedWallet("Singlesig Wallet"); +} + +void WalletListModel::listWalletDir() +{ + QSet existing_names; + for (int i = 0; i < rowCount(); ++i) { + QModelIndex index = this->index(i, 0); + QString name = data(index, NameRole).toString(); + existing_names.insert(name); + } + + for (const std::string &name : m_node.walletLoader().listWalletDir()) { + QString qname = QString::fromStdString(name); + if (!existing_names.contains(qname)) { + addItem({ qname }); + } + } +} + +void WalletListModel::setSelectedWallet(QString wallet_name) +{ + if (m_selected_wallet != wallet_name) { + m_selected_wallet = wallet_name; + Q_EMIT selectedWalletChanged(); + } +} + +QString WalletListModel::selectedWallet() const +{ + return m_selected_wallet; +} + +int WalletListModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return m_items.size(); +} + +QVariant WalletListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return QVariant(); + + const auto &item = m_items[index.row()]; + switch (role) { + case Qt::DisplayRole: + case NameRole: + return item.name; + default: + return QVariant(); + } +} + +QHash WalletListModel::roleNames() const +{ + QHash roles; + roles[NameRole] = "name"; + return roles; +} + +void WalletListModel::addItem(const Item &item) +{ + beginInsertRows(QModelIndex(), rowCount(), rowCount()); + m_items.append(item); + endInsertRows(); +} diff --git a/src/qml/models/walletlistmodel.h b/src/qml/models/walletlistmodel.h new file mode 100644 index 0000000000..ae1451b21a --- /dev/null +++ b/src/qml/models/walletlistmodel.h @@ -0,0 +1,55 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_WALLETLISTMODEL_H +#define BITCOIN_QML_MODELS_WALLETLISTMODEL_H + +#include +#include +#include + +namespace interfaces { +class Node; +} + +class WalletListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(QString selectedWallet READ selectedWallet WRITE setSelectedWallet NOTIFY selectedWalletChanged) + +public: + WalletListModel(interfaces::Node& node, QObject *parent = nullptr); + ~WalletListModel() = default; + + enum Roles { + NameRole = Qt::UserRole + 1 + }; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void setSelectedWallet(QString wallet_name); + QString selectedWallet() const; + +public Q_SLOTS: + void listWalletDir(); + +Q_SIGNALS: + void selectedWalletChanged(); + +private: + struct Item { + QString name; + }; + + void addItem(const Item &item); + + QList m_items; + interfaces::Node& m_node; + QString m_selected_wallet; + +}; + +#endif // BITCOIN_QML_MODELS_WALLETLISTMODEL_H diff --git a/src/qml/pages/wallet/DesktopWallets.qml b/src/qml/pages/wallet/DesktopWallets.qml index 87b90e0166..59a7ac15e4 100644 --- a/src/qml/pages/wallet/DesktopWallets.qml +++ b/src/qml/pages/wallet/DesktopWallets.qml @@ -21,27 +21,26 @@ Page { header: NavigationBar2 { id: navBar - leftItem: RowLayout { - spacing: 5 - Icon { - source: "image://images/singlesig-wallet" - color: Theme.color.neutral8 - Layout.preferredWidth: 30 - Layout.preferredHeight: 30 - Layout.leftMargin: 10 - } - Column { - spacing: 2 - CoreText { - text: "Singlesig Wallet" - color: Theme.color.neutral7 - bold: true - } - CoreText { - text: " 0.00 167 599" - color: Theme.color.neutral7 + leftItem: WalletBadge { + implicitWidth: 154 + implicitHeight: 46 + text: walletListModel.selectedWallet + + MouseArea { + anchors.fill: parent + onClicked: { + walletListModel.listWalletDir() + walletSelect.opened ? walletSelect.close() : walletSelect.open() } } + + WalletSelect { + id: walletSelect + model: walletListModel + closePolicy: Popup.CloseOnPressOutside + x: 0 + y: parent.height + } } centerItem: RowLayout { NavigationTab { diff --git a/src/qml/pages/wallet/WalletBadge.qml b/src/qml/pages/wallet/WalletBadge.qml new file mode 100644 index 0000000000..fe28d58f47 --- /dev/null +++ b/src/qml/pages/wallet/WalletBadge.qml @@ -0,0 +1,166 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import org.bitcoincore.qt 1.0 + +import "../../controls" + +Button { + id: root + + function formatSatoshis(satoshis) { + var highlightColor = Theme.color.neutral9 + var zeroColor = Theme.color.neutral7 + + if (root.checked || root.hovered) { + highlightColor = zeroColor = Theme.color.orange + } + + // Convert satoshis to bitcoins + var bitcoins = satoshis / 100000000; + + // Format bitcoins to a fixed 8 decimal places string + var bitcoinStr = bitcoins.toFixed(8); + + // Split the bitcoin string into integer and fractional parts + var parts = bitcoinStr.split('.'); + + // Add spaces for every 3 digits in the integer part + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + + // Highlight the first significant digit and all following digits in the integer part + var significantFound = false; + parts[0] = parts[0].replace(/(\d)/g, function(match) { + if (!significantFound && match !== '0') { + significantFound = true; + } + if (significantFound) { + return '' + match + ''; + } + return match; + }); + + // Add spaces for every 3 digits in the decimal part + parts[1] = parts[1].replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + if (significantFound) { + parts[1] = '' + parts[1] + ''; + } else { + // Highlight the first significant digit and all following digits in the fractional part + significantFound = false; + parts[1] = parts[1].replace(/(\d)/g, function(match) { + if (!significantFound && match !== '0') { + significantFound = true; + } + if (significantFound) { + return '' + match + ''; + } + return match; + }); + } + + // Concatenate the parts back together + var formattedBitcoins = parts.join('.'); + + // Format the text with the Bitcoin symbol + var formattedText = ` ${formattedBitcoins}`; + + // Highlight zero in a different color if satoshis are zero + if (satoshis === 0) { + formattedText = `₿ 0.00`; + } + + return formattedText; + } + + property color bgActiveColor: Theme.color.neutral2 + property color textColor: Theme.color.neutral7 + property color textHoverColor: Theme.color.orange + property color textActiveColor: Theme.color.orange + property color iconColor: "transparent" + property string iconSource: "" + property bool showBalance: true + property bool showIcon: true + + checkable: true + hoverEnabled: AppMode.isDesktop + implicitHeight: 60 + implicitWidth: 220 + bottomPadding: 0 + topPadding: 0 + clip: true + + contentItem: RowLayout { + anchors.fill: parent + anchors.leftMargin: 5 + anchors.rightMargin: 5 + clip: true + spacing: 5 + Icon { + id: icon + visible: root.showIcon + source: "image://images/singlesig-wallet" + color: Theme.color.neutral8 + size: 30 + Layout.minimumWidth: 30 + Layout.preferredWidth: 30 + Layout.maximumWidth: 30 + } + ColumnLayout { + spacing: 2 + CoreText { + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + wrap: false + id: buttonText + font.pixelSize: 13 + text: root.text + color: root.textColor + bold: true + visible: root.text !== "" + } + CoreText { + id: balanceText + visible: root.showBalance + text: formatSatoshis(12300) + color: Theme.color.neutral7 + } + } + } + + background: Rectangle { + id: bg + height: root.height + width: root.width + radius: 5 + color: Theme.color.neutral3 + visible: root.hovered || root.checked + + FocusBorder { + visible: root.visualFocus + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + states: [ + State { + name: "CHECKED"; when: root.checked + PropertyChanges { target: buttonText; color: root.textActiveColor } + PropertyChanges { target: icon; color: root.textActiveColor } + PropertyChanges { target: balanceText; color: root.textActiveColor } + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: buttonText; color: root.textHoverColor } + PropertyChanges { target: icon; color: root.textHoverColor } + PropertyChanges { target: balanceText; color: root.textHoverColor } + } + ] +} diff --git a/src/qml/pages/wallet/WalletSelect.qml b/src/qml/pages/wallet/WalletSelect.qml new file mode 100644 index 0000000000..9f226ad0b0 --- /dev/null +++ b/src/qml/pages/wallet/WalletSelect.qml @@ -0,0 +1,115 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import "../../controls" + +Popup { + id: root + + property alias model: listView.model + implicitHeight: layout.height + arrow.height + implicitWidth: 250 + clip: true + + background: Item { + anchors.fill: parent + Rectangle { + id: tooltipBg + color: Theme.color.neutral0 + border.color: Theme.color.neutral4 + radius: 5 + border.width: 1 + width: parent.width + height: parent.height - arrow.height - 1 + anchors.top: arrow.bottom + anchors.horizontalCenter: root.horizontalCenter + anchors.topMargin: -1 + } + Image { + id: arrow + source: Theme.image.tooltipArrow + width: 22 + height: 10 + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.top: parent.top + } + } + + ButtonGroup { + id: buttonGroup + } + + ColumnLayout { + id: layout + width: 220 + anchors.topMargin: arrow.height + CoreText { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 220 + Layout.preferredHeight: 30 + id: label + text: qsTr("Wallets") + visible: listView.count > 0 + bold: true + color: Theme.color.neutral9 + font.pixelSize: 14 + topPadding: 10 + bottomPadding: 5 + } + + ListView { + Layout.preferredWidth: 220 + Layout.preferredHeight: Math.min(listView.count * 34, 300) + id: listView + interactive: true + spacing: 2 + model: walletListModel + + delegate: WalletBadge { + required property string name; + + width: 220 + height: 32 + text: name + ButtonGroup.group: buttonGroup + showBalance: false + showIcon: false + onClicked: { + walletListModel.selectedWallet = name + root.close() + } + } + } + + RowLayout { + id: addWallet + Layout.preferredWidth: addIcon.size + addText.width + Layout.preferredHeight: 45 + Layout.alignment: Qt.AlignHCenter + Icon { + id: addIcon + Layout.alignment: Qt.AlignHCenter + source: "image://images/plus" + color: Theme.color.neutral8 + size: 14 + topPadding: 5 + bottomPadding: 10 + } + CoreText { + id: addText + Layout.alignment: Qt.AlignHCenter + text: qsTr("Add Wallet") + color: Theme.color.neutral9 + font.pixelSize: 15 + topPadding: 5 + bottomPadding: 10 + } + } + } +} diff --git a/src/qml/res/icons/plus.png b/src/qml/res/icons/plus.png new file mode 100644 index 0000000000..6bbe9a4d92 Binary files /dev/null and b/src/qml/res/icons/plus.png differ diff --git a/src/qml/res/src/plus.svg b/src/qml/res/src/plus.svg new file mode 100644 index 0000000000..ee1e094b7f --- /dev/null +++ b/src/qml/res/src/plus.svg @@ -0,0 +1,3 @@ + + +