diff --git a/storybook/CMakeLists.txt b/storybook/CMakeLists.txt index 155e1afaa61..94a48bf6dc1 100644 --- a/storybook/CMakeLists.txt +++ b/storybook/CMakeLists.txt @@ -76,7 +76,7 @@ target_link_libraries( ${PROJECT_LIB} PUBLIC Qt5::Core Qt5::Gui Qt5::Quick Qt5::QuickControls2 Qt5::WebView) target_link_libraries( - ${PROJECT_NAME} PRIVATE ${PROJECT_LIB}) + ${PROJECT_NAME} PRIVATE ${PROJECT_LIB}) add_dependencies(${PROJECT_NAME} StatusQ) @@ -123,6 +123,7 @@ add_test(NAME QmlTests COMMAND QmlTests -platform offscreen) list(APPEND QML_DIRS "${CMAKE_SOURCE_DIR}/../ui/app") list(APPEND QML_DIRS "${CMAKE_SOURCE_DIR}/../ui/imports") +list(APPEND QML_DIRS "${CMAKE_SOURCE_DIR}/../ui/StatusQ/src") list(APPEND QML_DIRS "${CMAKE_SOURCE_DIR}/src") list(APPEND QML_DIRS "${CMAKE_SOURCE_DIR}/pages") list(APPEND QML_DIRS "${CMAKE_SOURCE_DIR}/stubs") diff --git a/storybook/pages/ManageTokensPanelPage.qml b/storybook/pages/ManageTokensPanelPage.qml new file mode 100644 index 00000000000..88600b1f4f2 --- /dev/null +++ b/storybook/pages/ManageTokensPanelPage.qml @@ -0,0 +1,87 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 + +import StatusQ.Core 0.1 + +import AppLayouts.Wallet.panels 1.0 + +import utils 1.0 + +import Storybook 1.0 +import Models 1.0 + +SplitView { + id: root + + Logs { id: logs } + + orientation: Qt.Horizontal + + ManageTokensModel { + id: assetsModel + } + + StatusScrollView { // wrapped in a ScrollView on purpose; to simulate SettingsContentBase.qml + SplitView.fillWidth: true + SplitView.preferredHeight: 500 + ManageTokensPanel { + id: showcasePanel + width: 500 + baseModel: ctrlEmptyModel.checked ? null : assetsModel + } + } + + LogsAndControlsPanel { + id: logsAndControlsPanel + + SplitView.minimumWidth: 150 + SplitView.preferredWidth: 250 + + logsView.logText: logs.logText + + ColumnLayout { + Label { + Layout.fillWidth: true + text: "Dirty: %1".arg(showcasePanel.dirty ? "true" : "false") + } + + Button { + text: "Save" + onClicked: showcasePanel.saveSettings() + } + + Button { + enabled: showcasePanel.dirty + text: "Revert" + onClicked: showcasePanel.revert() + } + + Button { + text: "Random data" + onClicked: { + assetsModel.clear() + assetsModel.randomizeData() + } + } + + Button { + text: "Clear settings" + onClicked: showcasePanel.clearSettings() + } + + Switch { + id: ctrlEmptyModel + text: "Empty model" + } + } + } +} + +// category: Panels + +// https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?type=design&node-id=18139-95033&mode=design&t=nqFScWLfusXBNQA5-0 +// https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?type=design&node-id=17674-273051&mode=design&t=nqFScWLfusXBNQA5-0 +// https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?type=design&node-id=17636-249780&mode=design&t=nqFScWLfusXBNQA5-0 +// https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?type=design&node-id=17674-276833&mode=design&t=nqFScWLfusXBNQA5-0 +// https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?type=design&node-id=17675-283206&mode=design&t=nqFScWLfusXBNQA5-0 diff --git a/storybook/src/Models/ManageTokensModel.qml b/storybook/src/Models/ManageTokensModel.qml new file mode 100644 index 00000000000..cb5bb7c556a --- /dev/null +++ b/storybook/src/Models/ManageTokensModel.qml @@ -0,0 +1,237 @@ +import QtQuick 2.15 +import QtQml.Models 2.15 + +import Models 1.0 + +ListModel { + function randomizeData() { + var data = [] + for (let i = 0; i < 100; i++) { + const communityId = i % 2 == 0 ? "" : "communityId%1".arg(Math.round(i)) + const enabledNetworkBalance = !!communityId ? Math.round(i) + : { + amount: 1, + symbol: "ZRX" + } + var obj = { + name: "Item %1".arg(i), + symbol: "SYM %1".arg(i), + enabledNetworkBalance: enabledNetworkBalance, + enabledNetworkCurrencyBalance: { + amount: 10.37, + symbol: "EUR", + displayDecimals: 2 + }, + communityId: communityId, + communityName: "COM %1".arg(i), + communityImage: "" + } + data.push(obj) + } + append(data) + } + + readonly property var data: [ + { + name: "0x", + symbol: "ZRX", + enabledNetworkBalance: { + amount: 1, + symbol: "ZRX" + }, + enabledNetworkCurrencyBalance: { + amount: 10.37, + symbol: "EUR", + displayDecimals: 2 + }, + communityId: "ddls", + communityName: "Doodles", + communityImage: ModelsData.collectibles.doodles // FIXME backend + }, + { + name: "Omg", + symbol: "OMG", + enabledNetworkBalance: { + amount: 2, + symbol: "OMG" + }, + enabledNetworkCurrencyBalance: { + amount: 13.37, + symbol: "EUR", + displayDecimals: 2 + }, + communityId: "sox", + communityName: "Socks", + communityImage: ModelsData.icons.socks + }, + { + name: "Decentraland", + symbol: "MANA", + enabledNetworkBalance: { + amount: 301, + symbol: "MANA" + }, + enabledNetworkCurrencyBalance: { + amount: 75.256, + symbol: "EUR", + displayDecimals: 2 + }, + changePct24hour: -2.1, + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "Ave Maria", + symbol: "AAVE", + enabledNetworkBalance: { + amount: 23.3, + symbol: "AAVE", + displayDecimals: 2 + }, + enabledNetworkCurrencyBalance: { + amount: 2.335, + symbol: "EUR", + displayDecimals: 2 + }, + changePct24hour: 4.56, + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "Polymorphism", + symbol: "POLY", + enabledNetworkBalance: { + amount: 3590, + symbol: "POLY" + }, + enabledNetworkCurrencyBalance: { + amount: 2.7, + symbol: "EUR", + displayDecimals: 2 + }, + changePct24hour: -11.6789, + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "Dai", + symbol: "DAI", + enabledNetworkBalance: { + amount: 634.22, + symbol: "DAI", + displayDecimals: 2 + }, + enabledNetworkCurrencyBalance: { + amount: 594.72, + symbol: "EUR", + displayDecimals: 2 + }, + changePct24hour: 0, + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "Makers' choice", + symbol: "MKR", + enabledNetworkBalance: { + amount: 1.3, + symbol: "MKR", + displayDecimals: 2 + }, + enabledNetworkCurrencyBalance: { + amount: 100.37, + symbol: "EUR", + displayDecimals: 2 + }, + changePct24hour: -1, + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "Ethereum", + symbol: "ETH", + enabledNetworkBalance: { + amount: 0.12345, + symbol: "ETH", + displayDecimals: 8, + stripTrailingZeroes: true + }, + enabledNetworkCurrencyBalance: { + amount: 182.72, + symbol: "EUR", + displayDecimals: 2 + }, + changePct24hour: -3.51, + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "GetOuttaHere", + symbol: "InvisibleSYM", + enabledNetworkBalance: {}, + enabledNetworkCurrencyBalance: {}, + changePct24hour: NaN, + communityId: "", + communityName: "", + communityImage: "" + }, + { + enabledNetworkBalance: ({ + displayDecimals: true, + stripTrailingZeroes: true, + amount: 324343.3, + symbol: "SNT" + }), + enabledNetworkCurrencyBalance: ({ + displayDecimals: 4, + stripTrailingZeroes: true, + amount: 2.333321323400, + symbol: "EUR" + }), + symbol: "SNT", + name: "Status", + communityId: "", + communityName: "", + communityImage: "" + }, + { + name: "Meth", + symbol: "MET", + enabledNetworkBalance: { + amount: 666, + symbol: "MET" + }, + enabledNetworkCurrencyBalance: { + amount: 1000.37, + symbol: "EUR", + displayDecimals: 2 + }, + communityId: "ddls", + communityName: "Doodles", + communityImage: ModelsData.collectibles.doodles + }, + { + name: "Ast", + symbol: "AST", + enabledNetworkBalance: { + amount: 1, + symbol: "AST" + }, + enabledNetworkCurrencyBalance: { + amount: 0.374, + symbol: "EUR", + displayDecimals: 2 + }, + communityId: "ast", + communityName: "Astafarians", + communityImage: ModelsData.icons.dribble + } + ] + Component.onCompleted: append(data) +} diff --git a/storybook/src/Models/qmldir b/storybook/src/Models/qmldir index 81d973c0c8b..79023fac7e5 100644 --- a/storybook/src/Models/qmldir +++ b/storybook/src/Models/qmldir @@ -9,6 +9,7 @@ FlatTokensModel 1.0 FlatTokensModel.qml IconModel 1.0 IconModel.qml LinkPreviewModel 1.0 LinkPreviewModel.qml MintedTokensModel 1.0 MintedTokensModel.qml +ManageTokensModel 1.0 ManageTokensModel.qml RecipientModel 1.0 RecipientModel.qml SourceOfTokensModel 1.0 SourceOfTokensModel.qml TokenHoldersModel 1.0 TokenHoldersModel.qml diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index c42422abfeb..eefb2adbe61 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -109,6 +109,12 @@ add_library(StatusQ SHARED src/statuswindow.cpp src/stringutilsinternal.cpp src/submodelproxymodel.cpp + + # wallet + src/wallet/managetokenscontroller.cpp + src/wallet/managetokenscontroller.h + src/wallet/managetokensmodel.cpp + src/wallet/managetokensmodel.h ) set_target_properties(StatusQ PROPERTIES diff --git a/ui/StatusQ/src/StatusQ/Components/StatusDraggableListItem.qml b/ui/StatusQ/src/StatusQ/Components/StatusDraggableListItem.qml index f51790b69ee..df0a5e15ec5 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusDraggableListItem.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusDraggableListItem.qml @@ -146,9 +146,14 @@ ItemDelegate { property int visualIndex /*! \qmlproperty bool StatusDraggableListItem::draggable - This property holds whether this item can be dragged (and whether the drag handle is displayed) + This property holds whether the drag handle is displayed */ property bool draggable + /*! + \qmlproperty bool StatusDraggableListItem::dragEnabled + This property holds whether this item can be dragged (and whether the drag handle is displayed) + */ + property bool dragEnabled: draggable /*! \qmlproperty bool StatusDraggableListItem::customizable This property holds whether this item can be customized @@ -200,6 +205,13 @@ ItemDelegate { */ property color bgColor: "transparent" + /*! + \qmlproperty color StatusDraggableListItem::assetBgColor + This property holds icon/image background color, if any + Defaults to "transparent" (ie no background) + */ + property color assetBgColor: "transparent" + Drag.dragType: Drag.Automatic Drag.hotSpot.x: dragHandler.mouseX Drag.hotSpot.y: dragHandler.mouseY @@ -209,7 +221,7 @@ ItemDelegate { \qmlproperty readonly bool StatusDraggableListItem::dragActive This property holds whether a drag is currently in progress */ - readonly property bool dragActive: draggable && dragHandler.drag.active + readonly property bool dragActive: dragHandler.drag.active onDragActiveChanged: { if (dragActive) Drag.start() @@ -234,19 +246,25 @@ ItemDelegate { ] background: Rectangle { - color: root.dragActive && !root.customizable ? Theme.palette.indirectColor2 : "transparent" + color: root.dragActive && !root.customizable ? Theme.palette.alphaColor(Theme.palette.baseColor2, 0.7) : root.bgColor border.width: root.customizable ? 0 : 1 border.color: Theme.palette.baseColor2 - radius: customizable ? 0 : 8 + radius: root.customizable ? 0 : 8 MouseArea { id: dragHandler anchors.fill: parent - drag.target: root.draggable ? root : null + drag.target: root.dragEnabled ? root : null drag.axis: root.dragAxis preventStealing: true // otherwise DND is broken inside a Flickable/ScrollView hoverEnabled: true - cursorShape: root.dragActive ? Qt.ClosedHandCursor : Qt.OpenHandCursor + cursorShape: { + if (!root.enabled) + return undefined + if (root.dragEnabled) + return root.dragActive ? Qt.ClosedHandCursor : Qt.OpenHandCursor + return Qt.PointingHandCursor + } acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: { root.clicked(mouse); @@ -255,8 +273,8 @@ ItemDelegate { } // inset to simulate spacing - topInset: 6 - bottomInset: 6 + topInset: 4 + bottomInset: 4 horizontalPadding: 12 verticalPadding: 16 @@ -273,10 +291,10 @@ ItemDelegate { Layout.preferredHeight: 20 icon: "justify" visible: root.draggable && !root.customizable + color: root.dragEnabled ? Theme.palette.baseColor1 : Theme.palette.baseColor2 } Loader { - Layout.leftMargin: root.spacing/2 asynchronous: true active: !!root.icon.name || !!root.icon.source visible: active @@ -293,7 +311,7 @@ ItemDelegate { visible: text elide: Text.ElideRight maximumLineCount: 1 - font.weight: root.highlighted ? Font.Medium : Font.Normal + font.weight: Font.Medium } Row { @@ -302,6 +320,8 @@ ItemDelegate { spacing: 8 StatusBaseText { + width: Math.min(parent.width - (secondaryTitleIconLoader.item ? parent.spacing + secondaryTitleIconLoader.item.width : 0), + implicitWidth) text: root.secondaryTitle color: Theme.palette.baseColor1 elide: Text.ElideRight @@ -309,6 +329,8 @@ ItemDelegate { } Loader { + id: secondaryTitleIconLoader + anchors.verticalCenter: parent.verticalCenter asynchronous: true active: !!root.secondaryTitleIcon visible: active @@ -349,13 +371,10 @@ ItemDelegate { id: imageComponent StatusRoundedImage { radius: root.bgRadius - color: root.bgColor + color: root.assetBgColor width: root.icon.width height: root.icon.height image.source: root.icon.source - image.sourceSize: Qt.size(width, height) - image.smooth: false - image.mipmap: true showLoadingIndicator: true image.fillMode: Image.PreserveAspectCrop } diff --git a/ui/StatusQ/src/StatusQ/Controls/StatusCheckBox.qml b/ui/StatusQ/src/StatusQ/Controls/StatusCheckBox.qml index b8a7ecf7616..18c62212454 100644 --- a/ui/StatusQ/src/StatusQ/Controls/StatusCheckBox.qml +++ b/ui/StatusQ/src/StatusQ/Controls/StatusCheckBox.qml @@ -74,7 +74,6 @@ CheckBox { verticalAlignment: Text.AlignVCenter wrapMode: Text.WordWrap width: parent.width - color: Theme.palette.directColor1 lineHeight: 1.2 leftPadding: root.leftSide? (!!root.text ? root.indicator.width + root.spacing : root.indicator.width) : 0 diff --git a/ui/StatusQ/src/StatusQ/Controls/StatusItemDelegate.qml b/ui/StatusQ/src/StatusQ/Controls/StatusItemDelegate.qml index 89eebbf50e6..90c104032f7 100644 --- a/ui/StatusQ/src/StatusQ/Controls/StatusItemDelegate.qml +++ b/ui/StatusQ/src/StatusQ/Controls/StatusItemDelegate.qml @@ -18,7 +18,10 @@ ItemDelegate { icon.width: 16 icon.height: 16 - contentItem: RowLayout { + font.family: Theme.palette.baseFont.name + font.pixelSize: 15 + + contentItem: RowLayout { spacing: root.spacing StatusIcon { diff --git a/ui/StatusQ/src/StatusQ/Controls/StatusSwitch.qml b/ui/StatusQ/src/StatusQ/Controls/StatusSwitch.qml index 11acd6b9f8a..5daef05b8c1 100644 --- a/ui/StatusQ/src/StatusQ/Controls/StatusSwitch.qml +++ b/ui/StatusQ/src/StatusQ/Controls/StatusSwitch.qml @@ -8,6 +8,8 @@ import StatusQ.Components 0.1 Switch { id: root + property color textColor: Theme.palette.directColor1 + background: MouseArea { cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: Qt.NoButton @@ -18,8 +20,9 @@ Switch { implicitWidth: 52 implicitHeight: 28 - x: root.leftPadding - y: parent.height / 2 - height / 2 + anchors.left: parent.left + anchors.leftMargin: root.leftPadding + anchors.verticalCenter: parent.verticalCenter Rectangle { anchors.fill: parent @@ -71,8 +74,9 @@ Switch { contentItem: StatusBaseText { text: root.text opacity: enabled ? 1.0 : 0.3 + color: root.textColor verticalAlignment: Text.AlignVCenter - leftPadding: !!root.text ? root.indicator.width + root.spacing - : root.indicator.width + leftPadding: root.mirrored ? 0 : !!root.text ? root.indicator.width + root.spacing : root.indicator.width + rightPadding: root.mirrored ? !!root.text ? root.indicator.width + root.spacing : root.indicator.width : 0 } } diff --git a/ui/StatusQ/src/StatusQ/Popups/StatusAction.qml b/ui/StatusQ/src/StatusQ/Popups/StatusAction.qml index b1c099fd0d1..460e9216e54 100644 --- a/ui/StatusQ/src/StatusQ/Popups/StatusAction.qml +++ b/ui/StatusQ/src/StatusQ/Popups/StatusAction.qml @@ -23,6 +23,7 @@ Action { imgIsIdenticon: false color: root.icon.color name: root.icon.name + hoverColor: Theme.palette.statusMenu.hoverBackgroundColor } property StatusFontSettings fontSettings: StatusFontSettings {} diff --git a/ui/StatusQ/src/StatusQ/Popups/StatusMenu.qml b/ui/StatusQ/src/StatusQ/Popups/StatusMenu.qml index 0bde374dac0..a283dd94d70 100644 --- a/ui/StatusQ/src/StatusQ/Popups/StatusMenu.qml +++ b/ui/StatusQ/src/StatusQ/Popups/StatusMenu.qml @@ -36,13 +36,23 @@ Menu { property real maxImplicitWidth: 640 readonly property color defaultIconColor: Theme.palette.primaryColor1 + property int type: StatusAction.Type.Normal + property StatusAssetSettings assetSettings: StatusAssetSettings { width: 18 height: 18 rotation: 0 isLetterIdenticon: false isImage: false - color: root.defaultIconColor + color: { + if (!root.enabled) + return Theme.palette.baseColor1 + if (root.type === StatusAction.Type.Danger) + return Theme.palette.dangerColor1 + if (root.type === StatusAction.Type.Success) + return Theme.palette.successColor1 + return Theme.palette.primaryColor1 + } } property StatusFontSettings fontSettings: StatusFontSettings {} @@ -57,8 +67,6 @@ Menu { property var openHandler property var closeHandler - signal menuItemClicked(int menuIndex) - function checkIfEmpty() { for (let i = 0; i < root.contentItem.count; ++i) { const menuItem = root.contentItem.itemAtIndex(i) @@ -98,7 +106,8 @@ Menu { visible: root.hideDisabledItems ? enabled : true height: visible ? implicitHeight : 0 onImplicitWidthChanged: { - d.maxDelegateImplWidth = Math.max(d.maxDelegateImplWidth, implicitWidth) + if (visible) + d.maxDelegateImplWidth = Math.max(d.maxDelegateImplWidth, implicitWidth) } } diff --git a/ui/StatusQ/src/StatusQ/Popups/StatusMenuItem.qml b/ui/StatusQ/src/StatusQ/Popups/StatusMenuItem.qml index 2d02aa72c23..9dc0f278c0c 100644 --- a/ui/StatusQ/src/StatusQ/Popups/StatusMenuItem.qml +++ b/ui/StatusQ/src/StatusQ/Popups/StatusMenuItem.qml @@ -23,8 +23,10 @@ MenuItem { readonly property bool subMenuOpened: isSubMenu && root.subMenu.opened readonly property bool hasAction: !!root.action readonly property bool isStatusAction: d.hasAction && (root.action instanceof StatusAction) - readonly property bool isStatusDangerAction: d.isStatusAction && root.action.type === StatusAction.Type.Danger - readonly property bool isStatusSuccessAction: d.isStatusAction && root.action.type === StatusAction.Type.Success + readonly property bool isStatusDangerAction: (d.isStatusAction && root.action.type === StatusAction.Type.Danger) || + (d.isStatusSubMenu && root.subMenu.type === StatusAction.Type.Danger) + readonly property bool isStatusSuccessAction: (d.isStatusAction && root.action.type === StatusAction.Type.Success) || + (d.isStatusSubMenu && root.subMenu.type === StatusAction.Type.Success) readonly property StatusAssetSettings originalAssetSettings: d.isStatusSubMenu && root.subMenu.assetSettings ? root.subMenu.assetSettings diff --git a/ui/StatusQ/src/StatusQ/Popups/StatusSearchPopupMenuItem.qml b/ui/StatusQ/src/StatusQ/Popups/StatusSearchPopupMenuItem.qml index 4539e6c2cbd..1e02a42bd00 100644 --- a/ui/StatusQ/src/StatusQ/Popups/StatusSearchPopupMenuItem.qml +++ b/ui/StatusQ/src/StatusQ/Popups/StatusSearchPopupMenuItem.qml @@ -1,10 +1,6 @@ import QtQuick 2.14 -import QtQuick.Controls 2.14 -import QtQuick.Layouts 1.12 -import StatusQ.Components 0.1 import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 -import StatusQ.Controls 0.1 StatusAction { id: root diff --git a/ui/StatusQ/src/assets/img/icons/arrow-bottom.svg b/ui/StatusQ/src/assets/img/icons/arrow-bottom.svg new file mode 100644 index 00000000000..4a0b3a81883 --- /dev/null +++ b/ui/StatusQ/src/assets/img/icons/arrow-bottom.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/StatusQ/src/assets/img/icons/arrow-top.svg b/ui/StatusQ/src/assets/img/icons/arrow-top.svg new file mode 100644 index 00000000000..d0a23f52b1a --- /dev/null +++ b/ui/StatusQ/src/assets/img/icons/arrow-top.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index 82a073ad2e2..494dcccdc39 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -15,6 +15,9 @@ #include "StatusQ/submodelproxymodel.h" +#include "wallet/managetokenscontroller.h" +#include "wallet/managetokensmodel.h" + class StatusQPlugin : public QQmlExtensionPlugin { Q_OBJECT @@ -28,6 +31,9 @@ class StatusQPlugin : public QQmlExtensionPlugin qmlRegisterType("StatusQ", 0, 1, "StatusSyntaxHighlighter"); qmlRegisterType("StatusQ", 0, 1, "RXValidator"); + qmlRegisterType("StatusQ.Models", 0, 1, "ManageTokensController"); + qmlRegisterType("StatusQ.Models", 0, 1, "ManageTokensModel"); + qmlRegisterType("StatusQ", 0, 1, "LeftJoinModel"); qmlRegisterType("StatusQ", 0, 1, "SubmodelProxyModel"); qmlRegisterType("StatusQ", 0, 1, "RoleRename"); diff --git a/ui/StatusQ/src/wallet/managetokenscontroller.cpp b/ui/StatusQ/src/wallet/managetokenscontroller.cpp new file mode 100644 index 00000000000..9691a630c9e --- /dev/null +++ b/ui/StatusQ/src/wallet/managetokenscontroller.cpp @@ -0,0 +1,393 @@ +#include "managetokenscontroller.h" + +#include + +ManageTokensController::ManageTokensController(QObject* parent) + : QObject(parent) + , m_regularTokensModel(new ManageTokensModel(this)) + , m_communityTokensModel(new ManageTokensModel(this)) + , m_communityTokenGroupsModel(new ManageTokensModel(this)) + , m_hiddenTokensModel(new ManageTokensModel(this)) +{ + for (auto model : m_allModels) { + connect(model, &ManageTokensModel::dirtyChanged, this, &ManageTokensController::dirtyChanged); + } + + connect(this, &ManageTokensController::sourceModelChanged, this, [this]() { + if (!m_sourceModel) { + m_modelConnectionsInitialized = false; + return; + } + if (m_modelConnectionsInitialized) + return; + connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, [this](const QModelIndex &parent, int first, int last) { +#ifdef QT_DEBUG + QElapsedTimer t; + t.start(); + qCInfo(manageTokens) << "!!! ADDING" << last-first+1 << "NEW TOKENS"; +#endif + for (int i = first; i <= last; i++) + addItem(i); + reloadCommunityIds(); + m_communityTokensModel->setCommunityIds(m_communityIds); + m_communityTokensModel->saveCustomSortOrder(); + rebuildCommunityTokenGroupsModel(); +#ifdef QT_DEBUG + qCInfo(manageTokens) << "!!! ADDING NEW SOURCE DATA TOOK" << t.nsecsElapsed()/1'000'000.f << "ms"; +#endif + }); + connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &ManageTokensController::parseSourceModel); + connect(m_sourceModel, &QAbstractItemModel::dataChanged, this, &ManageTokensController::parseSourceModel); // NB at this point we don't know in which submodel the item is + connect(m_communityTokensModel, &ManageTokensModel::rowsMoved, this, [this]() { + if (!m_arrangeByCommunity) + rebuildCommunityTokenGroupsModel(); + reloadCommunityIds(); + m_communityTokensModel->setCommunityIds(m_communityIds); + m_communityTokensModel->saveCustomSortOrder(); + }); + connect(m_communityTokenGroupsModel, &ManageTokensModel::rowsMoved, this, [this](const QModelIndex &parent, int start, int end, const QModelIndex &destination, int toRow) { + qCDebug(manageTokens) << "!!! GROUP MOVED FROM" << start << "TO" << toRow; + // FIXME swap toRow<->start instead of reloadCommunityIds()? + reloadCommunityIds(); + m_communityTokensModel->setCommunityIds(m_communityIds); + m_communityTokensModel->saveCustomSortOrder(); + }); + m_modelConnectionsInitialized = true; + }); +} + +void ManageTokensController::showHideRegularToken(int row, bool flag) +{ + if (flag) { // show + auto hiddenItem = m_hiddenTokensModel->takeItem(row); + if (hiddenItem) + m_regularTokensModel->addItem(*hiddenItem); + } else { // hide + auto shownItem = m_regularTokensModel->takeItem(row); + if (shownItem) + m_hiddenTokensModel->addItem(*shownItem, false /*prepend*/); + } +} + +void ManageTokensController::showHideCommunityToken(int row, bool flag) +{ + if (flag) { // show + auto hiddenItem = m_hiddenTokensModel->takeItem(row); + if (hiddenItem) { + m_communityTokensModel->addItem(*hiddenItem); + if (!m_communityIds.contains(hiddenItem->communityId)) + m_communityIds.append(hiddenItem->communityId); + } + } else { // hide + auto shownItem = m_communityTokensModel->takeItem(row); + if (shownItem) { + m_hiddenTokensModel->addItem(*shownItem, false /*prepend*/); + if (!m_communityTokensModel->hasCommunityIdToken(shownItem->communityId)) + m_communityIds.removeAll(shownItem->communityId); + } + } + m_communityTokensModel->setCommunityIds(m_communityIds); + m_communityTokensModel->saveCustomSortOrder(); + rebuildCommunityTokenGroupsModel(); +} + +void ManageTokensController::showHideGroup(const QString& groupId, bool flag) +{ + if (flag) { // show + const auto tokens = m_hiddenTokensModel->takeAllItems(groupId); + for (const auto& token: tokens) { + m_communityTokensModel->addItem(token); + } + m_communityIds.append(groupId); + } else { // hide + const auto tokens = m_communityTokensModel->takeAllItems(groupId); + for (const auto& token: tokens) { + m_hiddenTokensModel->addItem(token, false /*prepend*/); + } + m_communityIds.removeAll(groupId); + } + m_communityTokensModel->setCommunityIds(m_communityIds); + m_communityTokensModel->saveCustomSortOrder(); + rebuildCommunityTokenGroupsModel(); +} + +void ManageTokensController::saveSettings() +{ + Q_ASSERT(!m_settingsKey.isEmpty()); + + // gather the data to save + SerializedTokenData result; + for (auto model: {m_regularTokensModel, m_communityTokensModel}) + result.insert(model->save()); + result.insert(m_hiddenTokensModel->save(false)); + + // save to QSettings + m_settings.beginGroup(QStringLiteral("ManageTokens-%1").arg(m_settingsKey)); + m_settings.beginWriteArray(m_settingsKey); + SerializedTokenData::const_key_value_iterator it = result.constKeyValueBegin(); + for (auto i = 0; it != result.constKeyValueEnd() && i < result.size(); it++, i++) { + m_settings.setArrayIndex(i); + const auto tuple = it->second; + m_settings.setValue(QStringLiteral("symbol"), it->first); + m_settings.setValue(QStringLiteral("pos"), std::get<0>(tuple)); + m_settings.setValue(QStringLiteral("visible"), std::get<1>(tuple)); + m_settings.setValue(QStringLiteral("groupId"), std::get<2>(tuple)); + } + m_settings.endArray(); + m_settings.endGroup(); + m_settings.sync(); + + // unset dirty + for (auto model: m_allModels) + model->setDirty(false); +} + +void ManageTokensController::clearSettings() +{ + Q_ASSERT(!m_settingsKey.isEmpty()); + + // clear the relevant QSettings group + m_settings.beginGroup(QStringLiteral("ManageTokens-%1").arg(m_settingsKey)); + m_settings.remove(QString()); + m_settings.endGroup(); + m_settings.sync(); +} + +void ManageTokensController::loadSettings() +{ + Q_ASSERT(!m_settingsKey.isEmpty()); + + m_settingsData.clear(); + + // load from QSettings + m_settings.beginGroup(QStringLiteral("ManageTokens-%1").arg(m_settingsKey)); + const auto size = m_settings.beginReadArray(m_settingsKey); + for (auto i = 0; i < size; i++) { + m_settings.setArrayIndex(i); + const auto symbol = m_settings.value(QStringLiteral("symbol")).toString(); + if (symbol.isEmpty()) { + qCWarning(manageTokens) << Q_FUNC_INFO << "Missing symbol while reading tokens settings"; + continue; + } + const auto pos = m_settings.value(QStringLiteral("pos"), -1).toInt(); + const auto visible = m_settings.value(QStringLiteral("visible"), true).toBool(); + const auto groupId = m_settings.value(QStringLiteral("groupId")).toString(); + m_settingsData.insert(symbol, {pos, visible, groupId}); + } + m_settings.endArray(); + m_settings.endGroup(); +} + +void ManageTokensController::revert() +{ + loadSettings(); + parseSourceModel(); +} + +void ManageTokensController::classBegin() +{ + // empty on purpose +} + +void ManageTokensController::componentComplete() +{ + loadSettings(); +} + +void ManageTokensController::setSourceModel(QAbstractItemModel* newSourceModel) +{ + if(m_sourceModel == newSourceModel) return; + + if(!newSourceModel) { + disconnect(sourceModel()); + // clear all the models + for (auto model: m_allModels) + model->clear(); + m_communityIds.clear(); + m_sourceModel = newSourceModel; + emit sourceModelChanged(); + return; + } + + m_sourceModel = newSourceModel; + + connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &ManageTokensController::parseSourceModel); + + if (m_sourceModel && m_sourceModel->roleNames().isEmpty()) { // workaround for when a model has no roles and roles are added when the model is populated (ListModel) + // QTBUG-57971 + connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &ManageTokensController::parseSourceModel); + return; + } else { + parseSourceModel(); + } +} + +void ManageTokensController::parseSourceModel() +{ + if (!m_sourceModel) + return; + + disconnect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &ManageTokensController::parseSourceModel); + +#ifdef QT_DEBUG + QElapsedTimer t; + t.start(); +#endif + + // clear all the models + for (auto model: m_allModels) + model->clear(); + m_communityIds.clear(); + + // read and transform the original data + const auto newSize = m_sourceModel->rowCount(); + qCInfo(manageTokens) << "!!! PARSING" << newSize << "TOKENS"; + for (auto i = 0; i < newSize; i++) { + addItem(i); + } + + // build community groups model + rebuildCommunityTokenGroupsModel(); + reloadCommunityIds(); + m_communityTokensModel->setCommunityIds(m_communityIds); + + // (pre)sort + for (auto model: m_allModels) { + model->applySort(); + model->saveCustomSortOrder(); + model->setDirty(false); + } + +#ifdef QT_DEBUG + qCInfo(manageTokens) << "!!! PARSING SOURCE DATA TOOK" << t.nsecsElapsed()/1'000'000.f << "ms"; +#endif + + emit sourceModelChanged(); +} + +void ManageTokensController::addItem(int index) +{ + const auto sourceRoleNames = m_sourceModel->roleNames(); + + const auto dataForIndex = [&](const QModelIndex &idx, const QByteArray& rolename) -> QVariant { + const auto key = sourceRoleNames.key(rolename, -1); + if (key == -1) + return {}; + return idx.data(key); + }; + + const auto srcIndex = m_sourceModel->index(index, 0); + const auto symbol = dataForIndex(srcIndex, kSymbolRoleName).toString(); + const auto communityId = dataForIndex(srcIndex, kCommunityIdRoleName).toString(); + const auto communityName = dataForIndex(srcIndex, kCommunityNameRoleName).toString(); + const auto visible = m_settingsData.contains(symbol) ? std::get<1>(m_settingsData.value(symbol)) : true; + + TokenData token; + token.symbol = symbol; + token.name = dataForIndex(srcIndex, kNameRoleName).toString(); + token.image = dataForIndex(srcIndex, kTokenImageRoleName).toString(); + token.communityId = communityId; + token.communityName = !communityName.isEmpty() ? communityName : communityId; + token.communityImage = dataForIndex(srcIndex, kCommunityImageRoleName).toString(); + token.collectionUid = dataForIndex(srcIndex, kCollectionUidRoleName).toString(); + token.collectionName = dataForIndex(srcIndex, kCollectionNameRoleName).toString(); + token.balance = dataForIndex(srcIndex, kEnabledNetworkBalanceRoleName); + token.currencyBalance = dataForIndex(srcIndex, kEnabledNetworkCurrencyBalanceRoleName); + + token.customSortOrderNo = m_settingsData.contains(symbol) ? std::get<0>(m_settingsData.value(symbol)) + : (visible ? INT_MAX : 0); // append/prepend + + if (!visible) + m_hiddenTokensModel->addItem(token, /*append*/ false); + else if (!communityId.isEmpty()) + m_communityTokensModel->addItem(token); + else + m_regularTokensModel->addItem(token); +} + +bool ManageTokensController::dirty() const +{ + return std::any_of(m_allModels.cbegin(), m_allModels.cend(), [](auto model) { + return model->dirty(); + }); +} + +bool ManageTokensController::arrangeByCommunity() const +{ + return m_arrangeByCommunity; +} + +void ManageTokensController::setArrangeByCommunity(bool newArrangeByCommunity) +{ + if(m_arrangeByCommunity == newArrangeByCommunity) return; + m_arrangeByCommunity = newArrangeByCommunity; + if (!m_arrangeByCommunity) + m_communityTokensModel->applySort(); + else + rebuildCommunityTokenGroupsModel(); + emit arrangeByCommunityChanged(); +} + +void ManageTokensController::reloadCommunityIds() +{ + m_communityIds.clear(); + auto model = m_arrangeByCommunity ? m_communityTokenGroupsModel : m_communityTokensModel; + const auto count = model->count(); + for (int i = 0; i < count; i++) { + const auto& token = model->itemAt(i); + if (!m_communityIds.contains(token.communityId)) + m_communityIds.append(token.communityId); + } + qCDebug(manageTokens) << "!!! FOUND UNIQUE COMMUNITY GROUP IDs:" << m_communityIds; +} + +void ManageTokensController::rebuildCommunityTokenGroupsModel() +{ + QStringList communityIds; + QList result; + + const auto count = m_communityTokensModel->count(); + for (auto i = 0; i < count; i++) { + const auto& communityToken = m_communityTokensModel->itemAt(i); + const auto communityId = communityToken.communityId; + if (!communityIds.contains(communityId)) { // insert into groups + communityIds.append(communityId); + + TokenData tokenGroup; + tokenGroup.communityId = communityId; + tokenGroup.communityName = communityToken.communityName; + tokenGroup.communityImage = communityToken.communityImage; + tokenGroup.balance = 1; + result.append(tokenGroup); + } else { // update group's childCount + const auto tokenGroup = std::find_if(result.cbegin(), result.cend(), [communityId](const auto& item) { + return communityId == item.communityId; + }); + if (tokenGroup != result.cend()) { + const auto row = std::distance(result.cbegin(), tokenGroup); + TokenData updTokenGroup = result.takeAt(row); + updTokenGroup.balance = updTokenGroup.balance.toInt() + 1; + result.insert(row, updTokenGroup); + } + } + } + + m_communityTokenGroupsModel->clear(); + for (const auto& group: result) + m_communityTokenGroupsModel->addItem(group); + + qCDebug(manageTokens) << "!!! GROUPS MODEL REBUILT WITH GROUPS:" << communityIds; +} + +QString ManageTokensController::settingsKey() const +{ + return m_settingsKey; +} + +void ManageTokensController::setSettingsKey(const QString& newSettingsKey) +{ + if (m_settingsKey == newSettingsKey) + return; + m_settingsKey = newSettingsKey; + emit settingsKeyChanged(); +} diff --git a/ui/StatusQ/src/wallet/managetokenscontroller.h b/ui/StatusQ/src/wallet/managetokenscontroller.h new file mode 100644 index 00000000000..908baf2520b --- /dev/null +++ b/ui/StatusQ/src/wallet/managetokenscontroller.h @@ -0,0 +1,94 @@ +#include +#include +#include + +#include + +#include "managetokensmodel.h" + +class QAbstractItemModel; + +class ManageTokensController : public QObject, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + // input properties + Q_PROPERTY(QAbstractItemModel* sourceModel READ sourceModel WRITE setSourceModel NOTIFY sourceModelChanged FINAL REQUIRED) + Q_PROPERTY(QString settingsKey READ settingsKey WRITE setSettingsKey NOTIFY settingsKeyChanged FINAL REQUIRED) + Q_PROPERTY(bool arrangeByCommunity READ arrangeByCommunity WRITE setArrangeByCommunity NOTIFY arrangeByCommunityChanged FINAL) + + // output properties + Q_PROPERTY(QAbstractItemModel* regularTokensModel READ regularTokensModel CONSTANT FINAL) + // TODO regularTokenGroupsModel for grouped (collections of) collectibles? + Q_PROPERTY(QAbstractItemModel* communityTokensModel READ communityTokensModel CONSTANT FINAL) + Q_PROPERTY(QAbstractItemModel* communityTokenGroupsModel READ communityTokenGroupsModel CONSTANT FINAL) + Q_PROPERTY(QAbstractItemModel* hiddenTokensModel READ hiddenTokensModel CONSTANT FINAL) + Q_PROPERTY(bool dirty READ dirty NOTIFY dirtyChanged FINAL) + +public: + explicit ManageTokensController(QObject* parent = nullptr); + + Q_INVOKABLE void showHideRegularToken(int row, bool flag); + Q_INVOKABLE void showHideCommunityToken(int row, bool flag); + Q_INVOKABLE void showHideGroup(const QString& groupId, bool flag); + + Q_INVOKABLE void saveSettings(); + Q_INVOKABLE void clearSettings(); + Q_INVOKABLE void revert(); + + // TODO: to be used by SFPM on the main wallet page as an "expressionRole" + // bool lessThan(lhsSymbol, rhsSymbol) const; + // bool filterAcceptsRow(index or symbol?) const; + +protected: + void classBegin() override; + void componentComplete() override; + +signals: + void sourceModelChanged(); + void dirtyChanged(); + void arrangeByCommunityChanged(); + void settingsKeyChanged(); + +private: + QAbstractItemModel* m_sourceModel{nullptr}; + QAbstractItemModel* sourceModel() const { return m_sourceModel; } + void setSourceModel(QAbstractItemModel* newSourceModel); + void parseSourceModel(); + + void addItem(int index); + + ManageTokensModel* m_regularTokensModel{nullptr}; + QAbstractItemModel* regularTokensModel() const { return m_regularTokensModel; }; + + ManageTokensModel* m_communityTokensModel{nullptr}; + QAbstractItemModel* communityTokensModel() const { return m_communityTokensModel; }; + + ManageTokensModel* m_communityTokenGroupsModel{nullptr}; + QAbstractItemModel* communityTokenGroupsModel() const { return m_communityTokenGroupsModel; }; + + ManageTokensModel* m_hiddenTokensModel{nullptr}; + QAbstractItemModel* hiddenTokensModel() const { return m_hiddenTokensModel; }; + + bool dirty() const; + + bool m_arrangeByCommunity{false}; + bool arrangeByCommunity() const; + void setArrangeByCommunity(bool newArrangeByCommunity); + + QStringList m_communityIds; + void reloadCommunityIds(); + void rebuildCommunityTokenGroupsModel(); + + const std::array m_allModels {m_regularTokensModel, m_communityTokensModel, m_communityTokenGroupsModel, m_hiddenTokensModel}; + + QString m_settingsKey; + QString settingsKey() const; + void setSettingsKey(const QString& newSettingsKey); + QSettings m_settings; + void loadSettings(); + SerializedTokenData m_settingsData; // symbol -> {sortOrder, visible, groupId} + + bool m_modelConnectionsInitialized{false}; +}; diff --git a/ui/StatusQ/src/wallet/managetokensmodel.cpp b/ui/StatusQ/src/wallet/managetokensmodel.cpp new file mode 100644 index 00000000000..ea19f1f0ffa --- /dev/null +++ b/ui/StatusQ/src/wallet/managetokensmodel.cpp @@ -0,0 +1,195 @@ +#include "managetokensmodel.h" + +#include + +Q_LOGGING_CATEGORY(manageTokens, "status.models.manageTokens", QtInfoMsg) + +ManageTokensModel::ManageTokensModel(QObject* parent) + : QAbstractListModel(parent) +{ + connect(this, &QAbstractItemModel::rowsInserted, this, &ManageTokensModel::countChanged); + connect(this, &QAbstractItemModel::rowsRemoved, this, &ManageTokensModel::countChanged); + connect(this, &QAbstractItemModel::modelReset, this, &ManageTokensModel::countChanged); + connect(this, &QAbstractItemModel::layoutChanged, this, &ManageTokensModel::countChanged); +} + +void ManageTokensModel::moveItem(int fromRow, int toRow) +{ + qCDebug(manageTokens) << Q_FUNC_INFO << "from" << fromRow << "to" << toRow; + + if (toRow < 0 || toRow >= rowCount() || fromRow < 0 || fromRow >= rowCount()) + return; + + auto destRow = toRow; + if (toRow > fromRow) + destRow++; + + beginMoveRows({}, fromRow, fromRow, {}, destRow); + m_data.move(fromRow, toRow); + endMoveRows(); + setDirty(true); +} + +void ManageTokensModel::addItem(const TokenData& item, bool append) +{ + const auto destRow = append ? rowCount() : 0; + beginInsertRows({}, destRow, destRow); + append ? m_data.append(item) : m_data.prepend(item); + endInsertRows(); + setDirty(true); +} + +std::optional ManageTokensModel::takeItem(int row) +{ + if (row < 0 || row >= rowCount()) + return {}; + + beginRemoveRows({}, row, row); + auto res = m_data.takeAt(row); + endRemoveRows(); + setDirty(true); + return res; +} + +QList ManageTokensModel::takeAllItems(const QString& communityId) +{ + QList result; + QList indexesToRemove; + + for (int i = 0; i < m_data.count(); i++) { + const auto &token = m_data.at(i); + if (token.communityId == communityId) { + result.append(token); + indexesToRemove.append(i); + } + } + + QList::reverse_iterator its; + for(its = indexesToRemove.rbegin(); its != indexesToRemove.rend(); ++its) { + const auto row = *its; + beginRemoveRows({}, row, row); + m_data.removeAt(row); + endRemoveRows(); + } + + setDirty(true); + return result; +} + +void ManageTokensModel::clear() +{ + beginResetModel(); + m_data.clear(); + endResetModel(); + setDirty(false); +} + +SerializedTokenData ManageTokensModel::save(bool isVisible) +{ + saveCustomSortOrder(); + const auto size = count(); + SerializedTokenData result; + for (int i = 0; i < size; i++) { + const auto& token = itemAt(i); + const auto groupId = !token.communityId.isEmpty() ? token.communityId : token.collectionUid; + result.insert(token.symbol, {i, isVisible, groupId}); + } + setDirty(false); + return result; +} + +int ManageTokensModel::rowCount(const QModelIndex& parent) const +{ + return m_data.size(); +} + +QHash ManageTokensModel::roleNames() const +{ + static const QHash roles { + {SymbolRole, kSymbolRoleName}, + {NameRole, kNameRoleName}, + {CommunityIdRole, kCommunityIdRoleName}, + {CommunityNameRole, kCommunityNameRoleName}, + {CommunityImageRole, kCommunityImageRoleName}, + {CollectionUidRole, kCollectionUidRoleName}, + {CollectionNameRole, kCollectionNameRoleName}, + {BalanceRole, kEnabledNetworkBalanceRoleName}, + {CurrencyBalanceRole, kEnabledNetworkCurrencyBalanceRoleName}, + {CustomSortOrderNoRole, kCustomSortOrderNoRoleName}, + {TokenImageRole, kTokenImageRoleName}, + }; + + return roles; +} + +QVariant ManageTokensModel::data(const QModelIndex& index, int role) const +{ + if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid | QAbstractItemModel::CheckIndexOption::ParentIsInvalid)) + return {}; + + const auto& token = m_data.at(index.row()); + + switch(static_cast(role)) + { + case SymbolRole: return token.symbol; + case NameRole: return token.name; + case CommunityIdRole: return token.communityId; + case CommunityNameRole: return token.communityName; + case CommunityImageRole: return token.communityImage; + case CollectionUidRole: return token.collectionUid; + case CollectionNameRole: return token.collectionName; + case BalanceRole: return token.balance; + case CurrencyBalanceRole: return token.currencyBalance; + case CustomSortOrderNoRole: return token.customSortOrderNo; + case TokenImageRole: return token.image; + } + + return {}; +} + +bool ManageTokensModel::dirty() const +{ + return m_dirty; +} + +void ManageTokensModel::setDirty(bool flag) +{ + if (m_dirty == flag) return; + m_dirty = flag; + emit dirtyChanged(); +} + +void ManageTokensModel::saveCustomSortOrder() +{ + const auto count = rowCount(); + for (auto i = 0; i < count; i++) { + TokenData newToken{m_data.at(i)}; + if (newToken.communityId.isEmpty()) { + newToken.customSortOrderNo = i; + } else { + const auto communityIdx = m_communityIds.indexOf(newToken.communityId) + 1; + newToken.customSortOrderNo = i + (communityIdx * 100'000); + } + m_data[i] = newToken; + } + emit dataChanged(index(0, 0), index(count - 1, 0), {TokenDataRoles::CustomSortOrderNoRole}); +} + +void ManageTokensModel::applySort() +{ + emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); + + // clazy:exclude=clazy-detaching-member + std::stable_sort(m_data.begin(), m_data.end(), [this](const TokenData& lhs, const TokenData& rhs) { + return lhs.customSortOrderNo < rhs.customSortOrderNo; + }); + + emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); +} + +bool ManageTokensModel::hasCommunityIdToken(const QString& communityId) const +{ + return std::any_of(m_data.cbegin(), m_data.constEnd(), [communityId](const auto& token) { + return token.communityId == communityId; + }); +} diff --git a/ui/StatusQ/src/wallet/managetokensmodel.h b/ui/StatusQ/src/wallet/managetokensmodel.h new file mode 100644 index 00000000000..53a1350af08 --- /dev/null +++ b/ui/StatusQ/src/wallet/managetokensmodel.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include + +#include + +Q_DECLARE_LOGGING_CATEGORY(manageTokens) + +namespace +{ +const auto kSymbolRoleName = QByteArrayLiteral("symbol"); +const auto kNameRoleName = QByteArrayLiteral("name"); +const auto kCommunityIdRoleName = QByteArrayLiteral("communityId"); +const auto kCommunityNameRoleName = QByteArrayLiteral("communityName"); +const auto kCommunityImageRoleName = QByteArrayLiteral("communityImage"); +const auto kCollectionUidRoleName = QByteArrayLiteral("collectionUid"); +const auto kCollectionNameRoleName = QByteArrayLiteral("collectionName"); +const auto kEnabledNetworkBalanceRoleName = QByteArrayLiteral("enabledNetworkBalance"); +const auto kEnabledNetworkCurrencyBalanceRoleName = QByteArrayLiteral("enabledNetworkCurrencyBalance"); +const auto kCustomSortOrderNoRoleName = QByteArrayLiteral("customSortOrderNo"); +const auto kTokenImageRoleName = QByteArrayLiteral("imageUrl"); +} // namespace + +struct TokenData { + QString symbol, name, communityId, communityName, communityImage, collectionUid, collectionName, image; + QVariant balance, currencyBalance; + int customSortOrderNo{-1}; +}; + +// symbol -> {sortOrder, visible, groupId} +using SerializedTokenData = QHash>; + +class ManageTokensModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged FINAL) + Q_PROPERTY(bool dirty READ dirty NOTIFY dirtyChanged FINAL) + +public: + enum TokenDataRoles { + SymbolRole = Qt::UserRole + 1, + NameRole, + CommunityIdRole, + CommunityNameRole, + CommunityImageRole, + CollectionUidRole, + CollectionNameRole, + BalanceRole, + CurrencyBalanceRole, + CustomSortOrderNoRole, + TokenImageRole, + }; + Q_ENUM(TokenDataRoles) + + explicit ManageTokensModel(QObject* parent = nullptr); + + Q_INVOKABLE void moveItem(int fromRow, int toRow); + + void addItem(const TokenData& item, bool append = true); + std::optional takeItem(int row); + QList takeAllItems(const QString& communityId); + void clear(); + + SerializedTokenData save(bool isVisible = true); + + bool dirty() const; + void setDirty(bool flag); + + void saveCustomSortOrder(); + void applySort(); + + int count() const { return rowCount(); } + const TokenData& itemAt(int row) const { return m_data.at(row); } + + void setCommunityIds(const QStringList& ids) { m_communityIds = ids; }; + bool hasCommunityIdToken(const QString& communityId) const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QHash roleNames() const override; + QVariant data(const QModelIndex &index, int role) const override; + +signals: + void countChanged(); + void dirtyChanged(); + +private: + QStringList m_communityIds; + + bool m_dirty{false}; + + QList m_data; +}; diff --git a/ui/app/AppLayouts/Profile/controls/CollectibleShowcaseDelegate.qml b/ui/app/AppLayouts/Profile/controls/CollectibleShowcaseDelegate.qml index 15cff77a389..2c4a7227bf5 100644 --- a/ui/app/AppLayouts/Profile/controls/CollectibleShowcaseDelegate.qml +++ b/ui/app/AppLayouts/Profile/controls/CollectibleShowcaseDelegate.qml @@ -9,5 +9,5 @@ ShowcaseDelegate { icon.source: hasImage ? showcaseObj.imageUrl : "" bgRadius: Style.current.radius - bgColor: !!showcaseObj && !!showcaseObj.backgroundColor ? showcaseObj.backgroundColor : "transparent" + assetBgColor: !!showcaseObj && !!showcaseObj.backgroundColor ? showcaseObj.backgroundColor : "transparent" } diff --git a/ui/app/AppLayouts/Profile/controls/ShowcaseDelegate.qml b/ui/app/AppLayouts/Profile/controls/ShowcaseDelegate.qml index eb0c4f5c509..79003c3f2da 100644 --- a/ui/app/AppLayouts/Profile/controls/ShowcaseDelegate.qml +++ b/ui/app/AppLayouts/Profile/controls/ShowcaseDelegate.qml @@ -90,7 +90,6 @@ StatusDraggableListItem { } } } - } ] } diff --git a/ui/app/AppLayouts/Wallet/controls/ManageTokenMenuButton.qml b/ui/app/AppLayouts/Wallet/controls/ManageTokenMenuButton.qml new file mode 100644 index 00000000000..9c8b1f49195 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/ManageTokenMenuButton.qml @@ -0,0 +1,127 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +import StatusQ.Core 0.1 +import StatusQ.Components 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Popups 0.1 + +StatusFlatButton { + id: root + + property int currentIndex + property int count + + property bool inHidden + property bool isGroup + property string groupId + property bool isCommunityAsset + + readonly property bool hideEnabled: model.symbol !== "ETH" + readonly property bool menuVisible: menuLoader.active + + signal moveRequested(int from, int to) + signal showHideRequested(int index, bool flag) + signal showHideGroupRequested(string groupId, bool flag) + + icon.name: "more" + horizontalPadding: 4 + verticalPadding: 4 + textColor: hovered || highlighted ? Theme.palette.directColor1 : Theme.palette.baseColor1 + highlighted: menuLoader.item && menuLoader.item.opened + + onClicked: { + menuLoader.active = true + menuLoader.item.popup(width - menuLoader.item.width, height) + } + + Loader { + id: menuLoader + active: false + sourceComponent: StatusMenu { + onClosed: menuLoader.active = false + + StatusAction { + enabled: !root.inHidden && root.currentIndex !== 0 + icon.name: "arrow-top" + text: qsTr("Move to top") + onTriggered: root.moveRequested(root.currentIndex, 0) + } + StatusAction { + enabled: !root.inHidden && root.currentIndex !== 0 + icon.name: "arrow-up" + text: qsTr("Move up") + onTriggered: root.moveRequested(root.currentIndex, root.currentIndex - 1) + } + StatusAction { + enabled: !root.inHidden && root.currentIndex < root.count - 1 + icon.name: "arrow-down" + text: qsTr("Move down") + onTriggered: root.moveRequested(root.currentIndex, root.currentIndex + 1) + } + StatusAction { + enabled: !root.inHidden && root.currentIndex < root.count - 1 + icon.name: "arrow-bottom" + text: qsTr("Move to bottom") + onTriggered: root.moveRequested(root.currentIndex, root.count - 1) + } + + StatusMenuSeparator { enabled: !root.inHidden && root.hideEnabled } + + // any token + StatusAction { + enabled: !root.inHidden && root.hideEnabled && !root.isGroup && !root.isCommunityAsset + type: StatusAction.Type.Danger + icon.name: "hide" + text: qsTr("Hide asset") + onTriggered: root.showHideRequested(root.currentIndex, false) + } + StatusAction { + enabled: root.inHidden + icon.name: "show" + text: qsTr("Show asset") + onTriggered: root.showHideRequested(root.currentIndex, true) + } + + // (hide) community tokens + StatusMenu { + id: communitySubmenu + enabled: !root.inHidden && root.isCommunityAsset + title: qsTr("Hide") + assetSettings.name: "hide" + type: StatusAction.Type.Danger + + StatusAction { + text: qsTr("This asset") + onTriggered: { + root.showHideRequested(root.currentIndex, false) + communitySubmenu.dismiss() + } + } + StatusAction { + text: qsTr("All assets from this community") + onTriggered: { + root.showHideGroupRequested(root.groupId, false) + communitySubmenu.dismiss() + } + } + } + + // token group + StatusAction { + enabled: !root.inHidden && root.isGroup + type: StatusAction.Type.Danger + icon.name: "hide" + text: qsTr("Hide all assets from this community") + onTriggered: root.showHideGroupRequested(root.groupId, false) + } + StatusAction { + enabled: root.inHidden && root.groupId + icon.name: "show" + text: qsTr("Show all assets from this community") + onTriggered: root.showHideGroupRequested(root.groupId, true) + } + } + } +} diff --git a/ui/app/AppLayouts/Wallet/controls/SortOrderComboBox.qml b/ui/app/AppLayouts/Wallet/controls/SortOrderComboBox.qml new file mode 100644 index 00000000000..9551d99f4b2 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/SortOrderComboBox.qml @@ -0,0 +1,272 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import QtGraphicalEffects 1.15 + +import StatusQ.Core 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Popups 0.1 + +import utils 1.0 + +ComboBox { + id: root + + property int sortOrder: Qt.DescendingOrder + readonly property string currentSortRoleName: d.currentSortRoleName + + model: d.predefinedSortModel + textRole: "text" + valueRole: "value" + displayText: !d.isCustomSortOrder ? "%1 %2".arg(currentText).arg(sortOrder === Qt.DescendingOrder ? "↓" : "↑") + : currentText + + Component.onCompleted: currentIndex = indexOfValue(SortOrderComboBox.TokenOrderCustom) + + enum TokenOrder { + TokenOrderNone = 0, + TokenOrderCustom, + TokenOrderValue, + TokenOrderBalance, + TokenOrder1WChange, + TokenOrderAlpha + } + + horizontalPadding: 12 + verticalPadding: 8 + spacing: 8 + + font.family: Theme.palette.baseFont.name + font.pixelSize: Style.current.additionalTextSize + + + QtObject { + id: d + + readonly property int defaultDelegateHeight: 34 + +// // models +// readonly property SortFilterProxyModel tokensModel: SortFilterProxyModel { +// sourceModel: root.baseModel +// proxyRoles: [ +// ExpressionRole { +// name: "currentBalance" +// expression: model.enabledNetworkBalance.amount +// }, +// ExpressionRole { +// name: "currentCurrencyBalance" +// expression: model.enabledNetworkCurrencyBalance.amount +// } +// ] +// sorters: RoleSorter { +// roleName: cmbTokenOrder.currentSortRoleName +// sortOrder: cmbTokenOrder.sortOrder +// enabled: !d.isCustomSortOrder +// } +// filters: ValueFilter { +// roleName: "visibleForNetworkWithPositiveBalance" +// value: true +// } +// } + + readonly property var predefinedSortModel: [ + { value: SortOrderComboBox.TokenOrderValue, text: qsTr("Token value"), icon: "token-sale", sortRoleName: "currentCurrencyBalance" }, // custom SFPM ExpressionRole + { value: SortOrderComboBox.TokenOrderBalance, text: qsTr("Token balance"), icon: "wallet", sortRoleName: "currentBalance" }, // custom SFPM ExpressionRole + { value: SortOrderComboBox.TokenOrder1WChange, text: qsTr("1W change"), icon: "time", sortRoleName: "changePct24hour" }, // FIXME changePct1Week role missing in backend!!! + { value: SortOrderComboBox.TokenOrderAlpha, text: qsTr("Alphabetic"), icon: "bold", sortRoleName: "name" }, + { value: SortOrderComboBox.TokenOrderNone, text: "---", icon: "", sortRoleName: "" }, + { value: SortOrderComboBox.TokenOrderCustom, text: qsTr("Custom order"), icon: "exchange", sortRoleName: "" } + ] + readonly property string currentSortRoleName: root.currentIndex !== -1 ? d.predefinedSortModel[root.currentIndex].sortRoleName : "" + readonly property bool isCustomSortOrder: root.currentValue === SortOrderComboBox.TokenOrderCustom + } + + background: Rectangle { + border.width: 1 + border.color: Theme.palette.directColor7 + radius: 8 + color: root.down ? Theme.palette.baseColor2 : "transparent" + HoverHandler { + cursorShape: root.enabled ? Qt.PointingHandCursor : undefined + } + } + + contentItem: StatusBaseText { + leftPadding: root.horizontalPadding + rightPadding: root.horizontalPadding + font.pixelSize: root.font.pixelSize + font.weight: Font.Medium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + text: root.displayText + color: Theme.palette.baseColor1 + } + + indicator: StatusIcon { + x: root.mirrored ? root.horizontalPadding : root.width - width - root.horizontalPadding + y: root.topPadding + (root.availableHeight - height) / 2 + width: 16 + height: width + icon: "chevron-down" + color: Theme.palette.baseColor1 + } + + popup: Popup { + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + y: root.height + 4 + + implicitWidth: root.width + margins: 8 + + padding: 1 + verticalPadding: 8 + + background: Rectangle { + color: Theme.palette.statusSelect.menuItemBackgroundColor + radius: 8 + border.color: Theme.palette.baseColor2 + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 0 + verticalOffset: 4 + radius: 12 + samples: 25 + spread: 0.2 + color: Theme.palette.dropShadow + } + } + + contentItem: ColumnLayout { + StatusBaseText { + Layout.fillWidth: true + Layout.preferredHeight: d.defaultDelegateHeight + text: qsTr("Sort by") + font.pixelSize: Style.current.tertiaryTextFontSize + leftPadding: Style.current.padding + verticalAlignment: Qt.AlignVCenter + color: Theme.palette.baseColor1 + } + StatusListView { + Layout.fillWidth: true + implicitWidth: contentWidth + implicitHeight: contentHeight + + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + } + } + } + + Component { + id: regularMenuComponent + RowLayout { + spacing: root.spacing + + StatusIcon { + visible: !!icon + icon: iconName + color: root.enabled ? Theme.palette.primaryColor1 : Theme.palette.baseColor1 + width: 16 + height: 16 + } + + StatusBaseText { + Layout.fillWidth: true + Layout.fillHeight: true + text: menuText + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + color: root.enabled ? Theme.palette.directColor1 : Theme.palette.baseColor1 + font.pixelSize: root.font.pixelSize + font.weight: root.currentIndex === menuIndex ? Font.DemiBold : Font.Normal + } + + Item { Layout.fillWidth: true } + + Row { + visible: !isCustomOrder + spacing: 4 + StatusFlatRoundButton { + radius: 6 + width: 24 + height: 24 + icon.name: "arrow-up" + icon.width: 18 + icon.height: 18 + opacity: root.highlightedIndex === menuIndex || highlighted // not "visible, we want the item to stay put + highlighted: root.currentIndex === menuIndex && root.sortOrder === Qt.AscendingOrder + onClicked: { + if (root.currentIndex !== menuIndex) + root.currentIndex = menuIndex + root.sortOrder = Qt.AscendingOrder + root.popup.close() + } + } + StatusFlatRoundButton { + radius: 6 + width: 24 + height: 24 + icon.name: "arrow-down" + icon.width: 18 + icon.height: 18 + opacity: root.highlightedIndex === menuIndex || highlighted // not "visible, we want the item to stay put + highlighted: root.currentIndex === menuIndex && root.sortOrder === Qt.DescendingOrder + onClicked: { + if (root.currentIndex !== menuIndex) + root.currentIndex = menuIndex + root.sortOrder = Qt.DescendingOrder + root.popup.close() + } + } + } + } + } + + Component { + id: separatorMenuComponent + StatusMenuSeparator {} + } + + delegate: ItemDelegate { + required property int index + required property var modelData + readonly property bool isSeparator: text === "---" + + id: menuDelegate + width: root.width + highlighted: root.highlightedIndex === index + enabled: !isSeparator + leftPadding: isSeparator ? 0 : 14 + rightPadding: isSeparator ? 0 : 8 + verticalPadding: isSeparator ? 2 : 5 + spacing: root.spacing + font: root.font + text: root.textRole ? (Array.isArray(root.model) ? modelData[root.textRole] : model[root.textRole]) + : modelData + icon.name: modelData["icon"] + icon.color: Theme.palette.primaryColor1 + background: Rectangle { + implicitHeight: parent.isSeparator ? 3 : d.defaultDelegateHeight + color: { + if (menuDelegate.index === root.currentIndex) + return Theme.palette.primaryColor3 + if (menuDelegate.highlighted) + return Theme.palette.statusMenu.hoverBackgroundColor + + return "transparent" + } + HoverHandler { + cursorShape: root.enabled ? Qt.PointingHandCursor : undefined + } + } + contentItem: Loader { + readonly property int menuIndex: menuDelegate.index + readonly property string menuText: menuDelegate.text + readonly property string iconName: menuDelegate.icon.name + readonly property bool isCustomOrder: !menuDelegate.modelData["sortRoleName"] + sourceComponent: menuDelegate.isSeparator ? separatorMenuComponent : regularMenuComponent + } + onClicked: root.currentIndex = index + } +} diff --git a/ui/app/AppLayouts/Wallet/controls/qmldir b/ui/app/AppLayouts/Wallet/controls/qmldir index bc56afce9d4..fdcd6f1c49d 100644 --- a/ui/app/AppLayouts/Wallet/controls/qmldir +++ b/ui/app/AppLayouts/Wallet/controls/qmldir @@ -4,3 +4,5 @@ AccountHeaderGradient 1.0 AccountHeaderGradient.qml StatusTxProgressBar 1.0 StatusTxProgressBar.qml StatusDateRangePicker 1.0 StatusDateRangePicker.qml ActivityFilterTagItem 1.0 ActivityFilterTagItem.qml +SortOrderComboBox 1.0 SortOrderComboBox.qml +ManageTokenMenuButton 1.0 ManageTokenMenuButton.qml diff --git a/ui/app/AppLayouts/Wallet/panels/ManageTokensPanel.qml b/ui/app/AppLayouts/Wallet/panels/ManageTokensPanel.qml new file mode 100644 index 00000000000..6557a353dec --- /dev/null +++ b/ui/app/AppLayouts/Wallet/panels/ManageTokensPanel.qml @@ -0,0 +1,378 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ.Core 0.1 +import StatusQ.Components 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Popups 0.1 +import StatusQ.Models 0.1 + +import utils 1.0 +import shared.controls 1.0 + +import AppLayouts.Wallet.controls 1.0 + +Control { + id: root + + required property var baseModel + + readonly property bool dirty: d.controller.dirty + + background: null + + function saveSettings() { + d.controller.saveSettings(); + } + + function revert() { + d.controller.revert(); + } + + function clearSettings() { + d.controller.clearSettings(); + } + + QtObject { + id: d + + property bool communityGroupsExpanded: true + + readonly property var controller: ManageTokensController { + sourceModel: root.baseModel + arrangeByCommunity: switchArrangeByCommunity.checked + settingsKey: "WalletAssets" + } + } + + component CommunityTag: InformationTag { + tagPrimaryLabel.font.weight: Font.Medium + customBackground: Component { + Rectangle { + color: Theme.palette.baseColor4 + radius: 20 + } + } + } + + component LocalTokenDelegate: DropArea { + id: delegateRoot + + property int visualIndex: index + property alias dragEnabled: delegate.dragEnabled + property alias bgColor: delegate.bgColor + property alias topInset: delegate.topInset + property alias bottomInset: delegate.bottomInset + property bool isGrouped + property bool isHidden + property int count + + ListView.onRemove: SequentialAnimation { + PropertyAction { target: delegateRoot; property: "ListView.delayRemove"; value: true } + NumberAnimation { target: delegateRoot; property: "scale"; to: 0; easing.type: Easing.InOutQuad } + PropertyAction { target: delegateRoot; property: "ListView.delayRemove"; value: false } + } + + width: ListView.view.width + height: visible ? delegate.height : 0 + + onEntered: function(drag) { + var from = drag.source.visualIndex + var to = delegate.visualIndex + if (to === from) + return + //console.warn("!!! DROP from/to", from, to) + ListView.view.model.moveItem(from, to) + drag.accept() + } + + StatusDraggableListItem { + id: delegate + + visualIndex: index + dragParent: root + Drag.keys: delegateRoot.keys + draggable: true + + width: delegateRoot.width + title: model.name// + " (%1 -> %2)".arg(index).arg(model.customSortOrderNo) + secondaryTitle: hovered || menuBtn.menuVisible ? "%1 · %2".arg(LocaleUtils.currencyAmountToLocaleString(model.enabledNetworkBalance)) + .arg(LocaleUtils.currencyAmountToLocaleString(model.enabledNetworkCurrencyBalance)) + : LocaleUtils.currencyAmountToLocaleString(model.enabledNetworkBalance) + hasImage: true + icon.source: model.imageUrl || Constants.tokenIcon(model.symbol) + icon.width: 32 + icon.height: 32 + spacing: 12 + + actions: [ + CommunityTag { + tagPrimaryLabel.text: model.communityName + visible: !!model.communityId && !delegateRoot.isGrouped + image.source: model.communityImage + }, + ManageTokenMenuButton { + id: menuBtn + currentIndex: visualIndex + count: delegateRoot.count + inHidden: delegateRoot.isHidden + groupId: model.communityId + isCommunityAsset: !!model.communityId + onMoveRequested: (from, to) => isCommunityAsset ? d.controller.communityTokensModel.moveItem(from, to) + : d.controller.regularTokensModel.moveItem(from, to) + onShowHideRequested: (index, flag) => isCommunityAsset ? d.controller.showHideCommunityToken(index, flag) + : d.controller.showHideRegularToken(index, flag) + onShowHideGroupRequested: (groupId, flag) => d.controller.showHideGroup(groupId, flag) + } + ] + } + } + + component LocalTokenGroupDelegate: DropArea { + id: communityDelegateRoot + + property int visualIndex: index + readonly property string communityId: model.communityId + readonly property int childCount: model.enabledNetworkBalance // NB using "balance" as "count" in m_communityTokenGroupsModel + + ListView.onRemove: SequentialAnimation { + PropertyAction { target: communityDelegateRoot; property: "ListView.delayRemove"; value: true } + NumberAnimation { target: communityDelegateRoot; property: "scale"; to: 0; easing.type: Easing.InOutQuad } + PropertyAction { target: communityDelegateRoot; property: "ListView.delayRemove"; value: false } + } + + keys: ["x-status-draggable-community-group-item"] + visible: childCount + width: ListView.view.width + height: visible ? groupedCommunityTokenDelegate.implicitHeight : 0 + + onEntered: function(drag) { + var from = drag.source.visualIndex + var to = groupedCommunityTokenDelegate.visualIndex + if (to === from) + return + //console.warn("!!! DROP GROUP from/to", from, to) + ListView.view.model.moveItem(from, to) + drag.accept() + } + + StatusDraggableListItem { + id: groupedCommunityTokenDelegate + width: parent.width + height: dragActive ? implicitHeight : parent.height + leftPadding: Style.current.halfPadding + rightPadding: Style.current.halfPadding + bottomPadding: Style.current.halfPadding + topPadding: 22 + draggable: true + spacing: 12 + bgColor: Theme.palette.baseColor4 + + visualIndex: index + dragParent: root + Drag.keys: communityDelegateRoot.keys + + contentItem: ColumnLayout { + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.bottomMargin: 14 + spacing: groupedCommunityTokenDelegate.spacing + + StatusIcon { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + icon: "justify" + color: Theme.palette.baseColor1 + } + + StatusRoundedImage { + radius: groupedCommunityTokenDelegate.bgRadius + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + image.source: model.communityImage + showLoadingIndicator: true + image.fillMode: Image.PreserveAspectCrop + } + + StatusBaseText { + text: model.communityName// + "(%1 -> %2)".arg(index).arg(model.customSortOrderNo) + elide: Text.ElideRight + maximumLineCount: 1 + font.weight: Font.Medium + } + + StatusBaseText { + Layout.leftMargin: -parent.spacing/2 + text: "· %1".arg(qsTr("%n asset(s)", "", communityDelegateRoot.childCount)) + elide: Text.ElideRight + color: Theme.palette.baseColor1 + maximumLineCount: 1 + visible: !d.communityGroupsExpanded + } + + Item { Layout.fillWidth: true } + + ManageTokenMenuButton { + currentIndex: visualIndex + count: d.controller.communityTokenGroupsModel.count + isGroup: true + groupId: model.communityId + onMoveRequested: (from, to) => d.controller.communityTokenGroupsModel.moveItem(from, to) + onShowHideGroupRequested: (groupId, flag) => d.controller.showHideGroup(groupId, flag) + } + } + + StatusListView { + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + model: d.controller.communityTokensModel + interactive: false + visible: d.communityGroupsExpanded + + displaced: Transition { + NumberAnimation { properties: "x,y"; easing.type: Easing.OutQuad } + } + + delegate: LocalTokenDelegate { + isGrouped: true + count: communityDelegateRoot.childCount + dragEnabled: count > 1 + keys: ["x-status-draggable-community-token-item-%1".arg(model.communityId)] + bgColor: Theme.palette.indirectColor4 + topInset: 2 // tighter "spacing" + bottomInset: 2 + visible: communityDelegateRoot.communityId === model.communityId + } + } + } + } + } + + contentItem: ColumnLayout { + spacing: Style.current.padding + + StatusListView { + Layout.fillWidth: true + model: d.controller.regularTokensModel + implicitHeight: contentHeight + interactive: false + + displaced: Transition { + NumberAnimation { properties: "x,y"; easing.type: Easing.OutQuad } + } + + delegate: LocalTokenDelegate { + count: d.controller.regularTokensModel.count + dragEnabled: count > 1 + keys: ["x-status-draggable-token-item"] + } + } + + RowLayout { + id: communityTokensHeader + Layout.fillWidth: true + Layout.topMargin: Style.current.padding + visible: d.controller.communityTokensModel.count + StatusBaseText { + color: Theme.palette.baseColor1 + text: qsTr("Community")// + " -> %1".arg(switchArrangeByCommunity.checked ? d.controller.communityTokenGroupsModel.count : d.controller.communityTokensModel.count) + } + Item { Layout.fillWidth: true } + StatusSwitch { + LayoutMirroring.enabled: true + LayoutMirroring.childrenInherit: true + id: switchArrangeByCommunity + textColor: Theme.palette.baseColor1 + text: qsTr("Arrange by community") + } + } + + StatusModalDivider { + Layout.fillWidth: true + Layout.topMargin: -Style.current.halfPadding + visible: communityTokensHeader.visible && switchArrangeByCommunity.checked + } + + StatusLinkText { + Layout.alignment: Qt.AlignTrailing + visible: communityTokensHeader.visible && switchArrangeByCommunity.checked + text: d.communityGroupsExpanded ? qsTr("Collapse all") : qsTr("Expand all") + normalColor: linkColor + font.weight: Font.Normal + onClicked: d.communityGroupsExpanded = !d.communityGroupsExpanded + } + + Loader { + Layout.fillWidth: true + active: d.controller.communityTokensModel.count + visible: active + sourceComponent: switchArrangeByCommunity.checked ? cmpCommunityTokenGroups : cmpCommunityTokens + } + + StatusBaseText { + Layout.fillWidth: true + Layout.topMargin: Style.current.padding + color: Theme.palette.baseColor1 + text: qsTr("Hidden")// + " -> %1".arg(d.controller.hiddenTokensModel.count) + visible: d.controller.hiddenTokensModel.count + } + + StatusListView { + Layout.fillWidth: true + model: d.controller.hiddenTokensModel + implicitHeight: contentHeight + interactive: false + + displaced: Transition { + NumberAnimation { properties: "x,y"; easing.type: Easing.OutQuad } + } + + delegate: LocalTokenDelegate { + dragEnabled: false + keys: ["x-status-draggable-none"] + isHidden: true + } + } + } + + Component { + id: cmpCommunityTokens + StatusListView { + model: d.controller.communityTokensModel + implicitHeight: contentHeight + interactive: false + + displaced: Transition { + NumberAnimation { properties: "x,y"; easing.type: Easing.OutQuad } + } + + delegate: LocalTokenDelegate { + count: d.controller.communityTokensModel.count + dragEnabled: count > 1 + keys: ["x-status-draggable-community-token-item"] + } + } + } + + Component { + id: cmpCommunityTokenGroups + StatusListView { + model: d.controller.communityTokenGroupsModel + implicitHeight: contentHeight + interactive: false + spacing: Style.current.halfPadding + + displaced: Transition { + NumberAnimation { properties: "x,y"; easing.type: Easing.OutQuad } + } + + delegate: LocalTokenGroupDelegate {} + } + } +} diff --git a/ui/app/AppLayouts/Wallet/panels/qmldir b/ui/app/AppLayouts/Wallet/panels/qmldir index 4f006f8f963..003160c8278 100644 --- a/ui/app/AppLayouts/Wallet/panels/qmldir +++ b/ui/app/AppLayouts/Wallet/panels/qmldir @@ -2,3 +2,4 @@ WalletHeader 1.0 WalletHeader.qml WalletTxProgressBlock 1.0 WalletTxProgressBlock.qml WalletNftPreview 1.0 WalletNftPreview.qml ActivityFilterPanel 1.0 ActivityFilterPanel.qml +ManageTokensPanel 1.0 ManageTokensPanel.qml diff --git a/ui/imports/shared/controls/InformationTag.qml b/ui/imports/shared/controls/InformationTag.qml index 467d45ba567..cfa2816244c 100644 --- a/ui/imports/shared/controls/InformationTag.qml +++ b/ui/imports/shared/controls/InformationTag.qml @@ -11,8 +11,8 @@ import utils 1.0 Control { id: root - property alias image : image - property alias iconAsset : iconAsset + property alias image: image + property alias iconAsset: iconAsset property alias tagPrimaryLabel: tagPrimaryLabel property alias tagSecondaryLabel: tagSecondaryLabel property alias middleLabel: middleLabel @@ -31,63 +31,54 @@ Control { QtObject { id: d - property var loadingComponent: Component { LoadingComponent {}} + property var loadingComponent: Component { LoadingComponent {} } } - horizontalPadding: Style.current.halfPadding - verticalPadding: 5 + horizontalPadding: 12 + verticalPadding: 8 + spacing: 4 background: Loader { sourceComponent: root.loading ? d.loadingComponent : root.customBackground } contentItem: RowLayout { - spacing: 4 + spacing: root.spacing visible: !root.loading // FIXME this could be StatusIcon but it can't load images from an arbitrary URL Image { id: image - Layout.alignment: Qt.AlignVCenter Layout.maximumWidth: visible ? 16 : 0 Layout.maximumHeight: visible ? 16 : 0 - visible: image.source !== "" + visible: !!source } StatusIcon { id: iconAsset - Layout.alignment: Qt.AlignVCenter Layout.maximumWidth: visible ? 16 : 0 Layout.maximumHeight: visible ? 16 : 0 - visible: iconAsset.icon !== "" + visible: !!icon } StatusBaseText { id: tagPrimaryLabel - Layout.alignment: Qt.AlignVCenter font.pixelSize: Style.current.tertiaryTextFontSize - font.weight: Font.Normal - color: Theme.palette.directColor1 visible: text !== "" } StatusBaseText { id: middleLabel - Layout.alignment: Qt.AlignVCenter font.pixelSize: Style.current.tertiaryTextFontSize - font.weight: Font.Normal color: Theme.palette.baseColor1 visible: text !== "" } StatusBaseText { id: tagSecondaryLabel - Layout.alignment: Qt.AlignVCenter Layout.maximumWidth: root.secondarylabelMaxWidth font.pixelSize: Style.current.tertiaryTextFontSize - font.weight: Font.Normal color: Theme.palette.baseColor1 visible: text !== "" elide: Text.ElideMiddle } Loader { id: rightComponent - Layout.alignment: Qt.AlignVCenter } } }