From 8010d86210bef1c2d95c235bba9b112d0e3a2e1a Mon Sep 17 00:00:00 2001 From: Roxanne Skelly Date: Mon, 21 Jan 2019 13:39:16 -0800 Subject: [PATCH] QML Marketplace support Support QML UI for the Marketplace as some devices do not handle web on 3d surfaces. Checkpoint code --- .../hifi/commerce/marketplace/Marketplace.qml | 336 ++++++++++++++++++ interface/src/Application.cpp | 9 + interface/src/commerce/QmlMarketplace.cpp | 131 +++++++ interface/src/commerce/QmlMarketplace.h | 68 ++++ scripts/system/marketplaces/marketplace.js | 117 +----- scripts/system/marketplaces/marketplaces.js | 8 +- 6 files changed, 565 insertions(+), 104 deletions(-) create mode 100644 interface/resources/qml/hifi/commerce/marketplace/Marketplace.qml create mode 100644 interface/src/commerce/QmlMarketplace.cpp create mode 100644 interface/src/commerce/QmlMarketplace.h diff --git a/interface/resources/qml/hifi/commerce/marketplace/Marketplace.qml b/interface/resources/qml/hifi/commerce/marketplace/Marketplace.qml new file mode 100644 index 0000000000..fb8b410e3f --- /dev/null +++ b/interface/resources/qml/hifi/commerce/marketplace/Marketplace.qml @@ -0,0 +1,336 @@ +// +// Marketplace.qml +// qml/hifi/commerce/marketplace +// +// Marketplace +// +// Created by Roxanne Skelly on 2019-01-18 +// Copyright 2019 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +import Hifi 1.0 as Hifi +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtGraphicalEffects 1.0 +import stylesUit 1.0 +import controlsUit 1.0 as HifiControlsUit +import "../../../controls" as HifiControls +import "../common" as HifiCommerceCommon +import "qrc:////qml//hifi//models" as HifiModels // Absolute path so the same code works everywhere. +import "../common/sendAsset" +import "../.." as HifiCommon + +Rectangle { + HifiConstants { id: hifi; } + + id: root; + + property string activeView: "initialize"; + property bool keyboardRaised: false; + property int category_index: -1; + property alias categoryChoices: categoriesModel; + + anchors.fill: (typeof parent === undefined) ? undefined : parent; + + Component.onDestruction: { + KeyboardScriptingInterface.raised = false; + } + + Connections { + target: Marketplace; + + onGetMarketplaceCategoriesResult: { + if (result.status !== 'success') { + console.log("Failed to get Marketplace Categories", result.data.message); + } else { + + } + } + } + + HifiCommerceCommon.CommerceLightbox { + id: lightboxPopup; + visible: false; + anchors.fill: parent; + } + + // + // HEADER BAR START + // + Item { + id: header; + visible: true; + width: parent.width; + anchors.left: parent.left; + anchors.top: parent.top; + anchors.right: parent.right; + + Item { + id: titleBarContainer; + visible: true; + // Size + width: parent.width; + height: 50; + // Anchors + anchors.left: parent.left; + anchors.top: parent.top; + + // Wallet icon + Image { + id: walletIcon; + source: "../../../../images/hifi-logo-blackish.svg"; + height: 20 + width: walletIcon.height; + anchors.left: parent.left; + anchors.leftMargin: 8; + anchors.verticalCenter: parent.verticalCenter; + visible: true; + } + + // Title Bar text + RalewaySemiBold { + id: titleBarText; + text: "Marketplace"; + // Text size + size: hifi.fontSizes.overlayTitle; + // Anchors + anchors.top: parent.top; + anchors.left: walletIcon.right; + anchors.leftMargin: 6; + anchors.bottom: parent.bottom; + width: paintedWidth; + // Style + color: hifi.colors.black; + // Alignment + verticalAlignment: Text.AlignVCenter; + } + } + + Item { + id: searchBarContainer; + visible: true; + // Size + width: parent.width; + anchors.top: titleBarContainer.bottom; + height: 50; + + + Rectangle { + id: categoriesButton; + anchors.left: parent.left; + anchors.leftMargin: 10; + anchors.verticalCenter: parent.verticalCenter; + height: 34; + width: categoriesText.width + 25; + color: "white"; + radius: 4; + border.width: 1; + border.color: hifi.colors.lightGray; + + + // Categories Text + RalewayRegular { + id: categoriesText; + text: "Categories"; + // Text size + size: 18; + // Style + color: hifi.colors.baseGray; + elide: Text.ElideRight; + horizontalAlignment: Text.AlignHCenter; + verticalAlignment: Text.AlignVCenter; + width: Math.min(textMetrics.width + 25, 110); + // Anchors + anchors.centerIn: parent; + rightPadding: 10; + } + HiFiGlyphs { + id: categoriesDropdownIcon; + text: hifi.glyphs.caratDn; + // Size + size: 34; + // Anchors + anchors.right: parent.right; + anchors.rightMargin: -8; + anchors.verticalCenter: parent.verticalCenter; + horizontalAlignment: Text.AlignHCenter; + // Style + color: hifi.colors.baseGray; + } + + MouseArea { + anchors.fill: parent; + hoverEnabled: enabled; + onClicked: { + categoriesDropdown.visible = !categoriesDropdown.visible; + } + onEntered: categoriesText.color = hifi.colors.baseGrayShadow; + onExited: categoriesText.color = hifi.colors.baseGray; + } + + Component.onCompleted: { + console.log("Getting Marketplace Categories"); + console.log(JSON.stringify(Marketplace)); + Marketplace.getMarketplaceItems(); + } + } + + Rectangle { + id: categoriesContainer; + visible: true; + height: 50 * categoriesModel.count; + width: parent.width; + anchors.top: categoriesButton.bottom; + anchors.left: categoriesButton.left; + color: hifi.colors.white; + + ListModel { + id: categoriesModel; + } + + ListView { + id: dropdownListView; + interactive: false; + anchors.fill: parent; + model: categoriesModel; + delegate: Item { + width: parent.width; + height: 50; + Rectangle { + id: dropDownButton; + color: hifi.colors.white; + width: parent.width; + height: 50; + visible: true; + + RalewaySemiBold { + id: dropDownButtonText; + text: model.displayName; + anchors.fill: parent; + anchors.topMargin: 2; + anchors.leftMargin: 12; + color: hifi.colors.baseGray; + horizontalAlignment: Text.AlignLeft; + verticalAlignment: Text.AlignVCenter; + size: 18; + } + + MouseArea { + anchors.fill: parent; + hoverEnabled: true; + propagateComposedEvents: false; + onEntered: { + dropDownButton.color = hifi.colors.blueHighlight; + } + onExited: { + dropDownButton.color = hifi.colors.white; + } + onClicked: { + root.category_index = index; + dropdownContainer.visible = false; + } + } + } + Rectangle { + height: 2; + width: parent.width; + color: hifi.colors.lightGray; + visible: model.separator + } + } + } + } + + // or + RalewayRegular { + id: orText; + text: "or"; + // Text size + size: 18; + // Style + color: hifi.colors.baseGray; + elide: Text.ElideRight; + horizontalAlignment: Text.AlignHCenter; + verticalAlignment: Text.AlignVCenter; + width: Math.min(textMetrics.width + 25, 110); + // Anchors + anchors.left: categoriesButton.right; + rightPadding: 10; + leftPadding: 10; + anchors.verticalCenter: parent.verticalCenter; + } + HifiControlsUit.TextField { + id: searchField; + anchors.verticalCenter: parent.verticalCenter; + anchors.right: parent.right; + anchors.left: orText.right; + anchors.rightMargin: 10; + height: 34; + isSearchField: true; + colorScheme: hifi.colorSchemes.faintGray; + + + font.family: "Fira Sans" + font.pixelSize: hifi.fontSizes.textFieldInput; + + placeholderText: "Search Marketplace"; + + TextMetrics { + id: primaryFilterTextMetrics; + font.family: "FiraSans Regular"; + font.pixelSize: hifi.fontSizes.textFieldInput; + font.capitalization: Font.AllUppercase; + text: root.primaryFilter_displayName; + } + + // workaround for https://bugreports.qt.io/browse/QTBUG-49297 + Keys.onPressed: { + switch (event.key) { + case Qt.Key_Return: + case Qt.Key_Enter: + event.accepted = true; + + // emit accepted signal manually + if (acceptableInput) { + root.accepted(); + root.forceActiveFocus(); + } + break; + case Qt.Key_Backspace: + if (textField.text === "") { + primaryFilter_index = -1; + } + break; + } + } + + onAccepted: { + root.forceActiveFocus(); + } + + onActiveFocusChanged: { + if (!activeFocus) { + dropdownContainer.visible = false; + } + } + + } + } + } + // + // HEADER BAR END + // + DropShadow { + anchors.fill: header; + horizontalOffset: 0; + verticalOffset: 4; + radius: 4.0; + samples: 9 + color: Qt.rgba(0, 0, 0, 0.25); + source: header; + visible: header.visible; + } +} diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 7ed05611ee..1ec9c93e12 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -232,6 +232,7 @@ #include "commerce/Ledger.h" #include "commerce/Wallet.h" #include "commerce/QmlCommerce.h" +#include "commerce/QmlMarketplace.h" #include "ResourceRequestObserver.h" #include "webbrowser/WebBrowserSuggestionsEngine.h" @@ -2913,6 +2914,14 @@ void Application::initializeUi() { QUrl{ "hifi/dialogs/security/SecurityImageModel.qml" }, QUrl{ "hifi/dialogs/security/SecurityImageSelection.qml" }, }, commerceCallback); + + QmlContextCallback marketplaceCallback = [](QQmlContext* context) { + context->setContextProperty("Marketplace", new QmlMarketplace()); + }; + OffscreenQmlSurface::addWhitelistContextHandler({ + QUrl{ "hifi/commerce/marketplace/Marketplace.qml" }, + }, marketplaceCallback); + QmlContextCallback ttsCallback = [](QQmlContext* context) { context->setContextProperty("TextToSpeech", DependencyManager::get().data()); }; diff --git a/interface/src/commerce/QmlMarketplace.cpp b/interface/src/commerce/QmlMarketplace.cpp new file mode 100644 index 0000000000..99d3bb1ae6 --- /dev/null +++ b/interface/src/commerce/QmlMarketplace.cpp @@ -0,0 +1,131 @@ +// +// QmlMarketplace.cpp +// interface/src/commerce +// +// Guard for safe use of Marketplace by authorized QML. +// +// Created by Roxanne Skelly on 1/18/19. +// Copyright 2019 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + + +#include "QmlMarketplace.h" +#include "CommerceLogging.h" +#include "Application.h" +#include "DependencyManager.h" +#include +#include +#include +#include +#include "scripting/HMDScriptingInterface.h" + +#define ApiHandler(NAME) void QmlMarketplace::NAME##Success(QNetworkReply* reply) { emit NAME##Result(apiResponse(#NAME, reply)); } +#define FailHandler(NAME) void QmlMarketplace::NAME##Failure(QNetworkReply* reply) { emit NAME##Result(failResponse(#NAME, reply)); } +#define Handler(NAME) ApiHandler(NAME) FailHandler(NAME) +Handler(getMarketplaceItems) +Handler(getMarketplaceItem) +Handler(marketplaceItemLike) +Handler(getMarketplaceCategories) + +QmlMarketplace::QmlMarketplace() { +} + +void QmlMarketplace::openMarketplace(const QString& marketplaceItemId) { + auto tablet = dynamic_cast( + DependencyManager::get()->getTablet("com.highfidelity.interface.tablet.system")); + tablet->loadQMLSource("hifi/commerce/marketplace/Marketplace.qml"); + DependencyManager::get()->openTablet(); + if (!marketplaceItemId.isEmpty()) { + tablet->sendToQml(QVariantMap({ { "method", "marketplace_openItem" }, { "itemId", marketplaceItemId } })); + } +} + +void QmlMarketplace::getMarketplaceItems( + const QString& q, + const QString& view, + const QString& category, + const QString& adminFilter, + const QString& adminFilterCost, + const QString& sort, + const bool isFree, + const int& page, + const int& perPage) { + + QString endpoint = "items"; + QJsonObject request; + request["q"] = q; + request["view"] = view; + request["category"] = category; + request["adminFilter"] = adminFilter; + request["adminFilterCost"] = adminFilterCost; + request["sort"] = sort; + request["isFree"] = isFree; + request["page"] = page; + request["perPage"] = perPage; + send(endpoint, "getMarketplaceItemsSuccess", "getMarketplaceItemsFailure", QNetworkAccessManager::GetOperation, AccountManagerAuth::Optional, request); +} + +void QmlMarketplace::getMarketplaceItem(const QString& marketplaceItemId) { + QString endpoint = QString("items/") + marketplaceItemId; + QJsonObject request; + send(endpoint, "getMarketplaceItemSuccess", "getMarketplaceItemFailure", QNetworkAccessManager::GetOperation, AccountManagerAuth::Optional, request); +} + +void QmlMarketplace::marketplaceItemLike(const QString& marketplaceItemId, const bool like) { + QString endpoint = QString("items/") + marketplaceItemId + "/like"; + QJsonObject request; + send(endpoint, "marketplaceItemLikeSuccess", "marketplaceItemLikeFailure", like ? QNetworkAccessManager::PutOperation : QNetworkAccessManager::DeleteOperation, AccountManagerAuth::Required, request); +} + +void QmlMarketplace::getMarketplaceCategories() { + QString endpoint = "categories"; + QJsonObject request; + send(endpoint, "getMarketplaceCategoriesSuccess", "getMarketplaceCategoriesFailure", QNetworkAccessManager::GetOperation, AccountManagerAuth::None, request); +} + + +void QmlMarketplace::send(const QString& endpoint, const QString& success, const QString& fail, QNetworkAccessManager::Operation method, AccountManagerAuth::Type authType, QJsonObject request) { + auto accountManager = DependencyManager::get(); + const QString URL = "/api/v1/marketplace/"; + JSONCallbackParameters callbackParams(this, success, fail); +#if defined(DEV_BUILD) // Don't expose user's personal data in the wild. But during development this can be handy. + qCInfo(commerce) << "Sending" << QJsonDocument(request).toJson(QJsonDocument::Compact); +#endif + accountManager->sendRequest(URL + endpoint, + authType, + method, + callbackParams, + QJsonDocument(request).toJson()); +} + +QJsonObject QmlMarketplace::apiResponse(const QString& label, QNetworkReply* reply) { + QByteArray response = reply->readAll(); + QJsonObject data = QJsonDocument::fromJson(response).object(); +#if defined(DEV_BUILD) // Don't expose user's personal data in the wild. But during development this can be handy. + qInfo(commerce) << label << "response" << QJsonDocument(data).toJson(QJsonDocument::Compact); +#endif + return data; +} + +// Non-200 responses are not json: +QJsonObject QmlMarketplace::failResponse(const QString& label, QNetworkReply* reply) { + QString response = reply->readAll(); + qWarning(commerce) << "FAILED" << label << response; + + // tempResult will be NULL if the response isn't valid JSON. + QJsonDocument tempResult = QJsonDocument::fromJson(response.toLocal8Bit()); + if (tempResult.isNull()) { + QJsonObject result + { + { "status", "fail" }, + { "message", response } + }; + return result; + } + else { + return tempResult.object(); + } +} \ No newline at end of file diff --git a/interface/src/commerce/QmlMarketplace.h b/interface/src/commerce/QmlMarketplace.h new file mode 100644 index 0000000000..95a1aa3911 --- /dev/null +++ b/interface/src/commerce/QmlMarketplace.h @@ -0,0 +1,68 @@ +// +// QmlMarketplace.h +// interface/src/commerce +// +// Guard for safe use of Marketplace by authorized QML. +// +// Created by Roxanne Skelly on 1/18/19. +// Copyright 2019 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#pragma once +#ifndef hifi_QmlMarketplace_h +#define hifi_QmlMarketplace_h + +#include + +#include +#include +#include "AccountManager.h" + +class QmlMarketplace : public QObject { + Q_OBJECT + +public: + QmlMarketplace(); + +public slots: + void getMarketplaceItemsSuccess(QNetworkReply* reply); + void getMarketplaceItemsFailure(QNetworkReply* reply); + void getMarketplaceItemSuccess(QNetworkReply* reply); + void getMarketplaceItemFailure(QNetworkReply* reply); + void getMarketplaceCategoriesSuccess(QNetworkReply* reply); + void getMarketplaceCategoriesFailure(QNetworkReply* reply); + void marketplaceItemLikeSuccess(QNetworkReply* reply); + void marketplaceItemLikeFailure(QNetworkReply* reply); + +protected: + Q_INVOKABLE void openMarketplace(const QString& marketplaceItemId = QString()); + Q_INVOKABLE void getMarketplaceItems( + const QString& q = QString(), + const QString& view = QString(), + const QString& category = QString(), + const QString& adminFilter = QString("published"), + const QString& adminFilterCost = QString(), + const QString& sort = QString(), + const bool isFree = false, + const int& page = 1, + const int& perPage = 20); + Q_INVOKABLE void getMarketplaceItem(const QString& marketplaceItemId); + Q_INVOKABLE void marketplaceItemLike(const QString& marketplaceItemId, const bool like = true); + Q_INVOKABLE void getMarketplaceCategories(); + +signals: + void getMarketplaceItemsResult(QJsonObject result); + void getMarketplaceItemResult(QJsonObject result); + void getMarketplaceCategoriesResult(QJsonObject result); + void marketplaceItemLikeResult(QJsonObject result); + +private: + void send(const QString& endpoint, const QString& success, const QString& fail, QNetworkAccessManager::Operation method, AccountManagerAuth::Type authType, QJsonObject request); + QJsonObject apiResponse(const QString& label, QNetworkReply* reply); + QJsonObject failResponse(const QString& label, QNetworkReply* reply); +}; + +#endif // hifi_QmlMarketplace_h diff --git a/scripts/system/marketplaces/marketplace.js b/scripts/system/marketplaces/marketplace.js index d3e5c96739..70680acd1d 100644 --- a/scripts/system/marketplaces/marketplace.js +++ b/scripts/system/marketplaces/marketplace.js @@ -1,8 +1,8 @@ // // marketplace.js // -// Created by Eric Levin on 8 Jan 2016 -// Copyright 2016 High Fidelity, Inc. +// Created by Roxanne Skelly on 1/18/2019 +// Copyright 2019 High Fidelity, Inc. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html @@ -10,108 +10,27 @@ (function() { // BEGIN LOCAL_SCOPE -/* global WebTablet */ -Script.include("../libraries/WebTablet.js"); +var AppUi = Script.require('appUi'); -var toolIconUrl = Script.resolvePath("../assets/images/tools/"); +var BUTTON_NAME = "MARKET"; +var MARKETPLACE_QML_SOURCE = "hifi/commerce/marketplace/Marketplace.qml"; +var ui; +function startup() { -var MARKETPLACE_URL = Account.metaverseServerURL + "/marketplace"; -var marketplaceWindow = new OverlayWebWindow({ - title: "Marketplace", - source: "about:blank", - width: 900, - height: 700, - visible: false -}); - -var toolHeight = 50; -var toolWidth = 50; -var TOOLBAR_MARGIN_Y = 0; -var marketplaceVisible = false; -var marketplaceWebTablet; - -// We persist avatarEntity data in the .ini file, and reconsistitute it on restart. -// To keep things consistent, we pickle the tablet data in Settings, and kill any existing such on restart and domain change. -var persistenceKey = "io.highfidelity.lastDomainTablet"; - -function shouldShowWebTablet() { - var rightPose = Controller.getPoseValue(Controller.Standard.RightHand); - var leftPose = Controller.getPoseValue(Controller.Standard.LeftHand); - var hasHydra = !!Controller.Hardware.Hydra; - return HMD.active && (leftPose.valid || rightPose.valid || hasHydra); + ui = new AppUi({ + buttonName: BUTTON_NAME, + sortOrder: 10, + home: MARKETPLACE_QML_SOURCE + }); } -function showMarketplace(marketplaceID) { - var url = MARKETPLACE_URL; - if (marketplaceID) { - url = url + "/items/" + marketplaceID; - } - tablet.gotoWebScreen(url); - marketplaceVisible = true; - UserActivityLogger.openedMarketplace(); +function shutdown() { } -function hideTablet(tablet) { - if (!tablet) { - return; - } - updateButtonState(false); - tablet.unregister(); - tablet.destroy(); - marketplaceWebTablet = null; - Settings.setValue(persistenceKey, ""); -} -function clearOldTablet() { // If there was a tablet from previous domain or session, kill it and let it be recreated - -} -function hideMarketplace() { - if (marketplaceWindow.visible) { - marketplaceWindow.setVisible(false); - marketplaceWindow.setURL("about:blank"); - } else if (marketplaceWebTablet) { - - } - marketplaceVisible = false; -} - -function toggleMarketplace() { - if (marketplaceVisible) { - hideMarketplace(); - } else { - showMarketplace(); - } -} - -var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); - -var browseExamplesButton = tablet.addButton({ - icon: "icons/tablet-icons/market-i.svg", - text: "MARKET" -}); - -function updateButtonState(visible) { - -} -function onMarketplaceWindowVisibilityChanged() { - updateButtonState(marketplaceWindow.visible); - marketplaceVisible = marketplaceWindow.visible; -} - -function onClick() { - toggleMarketplace(); -} - -browseExamplesButton.clicked.connect(onClick); -marketplaceWindow.visibleChanged.connect(onMarketplaceWindowVisibilityChanged); - -clearOldTablet(); // Run once at startup, in case there's anything laying around from a crash. -// We could also optionally do something like Window.domainChanged.connect(function () {Script.setTimeout(clearOldTablet, 2000)}), -// but the HUD version stays around, so lets do the same. - -Script.scriptEnding.connect(function () { - browseExamplesButton.clicked.disconnect(onClick); - tablet.removeButton(browseExamplesButton); - marketplaceWindow.visibleChanged.disconnect(onMarketplaceWindowVisibilityChanged); -}); +// +// Run the functions. +// +startup(); +Script.scriptEnding.connect(shutdown); }()); // END LOCAL_SCOPE diff --git a/scripts/system/marketplaces/marketplaces.js b/scripts/system/marketplaces/marketplaces.js index e4891a9bae..db3b2e2107 100644 --- a/scripts/system/marketplaces/marketplaces.js +++ b/scripts/system/marketplaces/marketplaces.js @@ -769,16 +769,14 @@ var onTabletScreenChanged = function onTabletScreenChanged(type, url) { var BUTTON_NAME = "MARKET"; -var MARKETPLACE_URL = METAVERSE_SERVER_URL + "/marketplace"; -// Append "?" if necessary to signal injected script that it's the initial page. -var MARKETPLACE_URL_INITIAL = MARKETPLACE_URL + (MARKETPLACE_URL.indexOf("?") > -1 ? "" : "?"); +var MARKETPLACE_QML_SOURCE = "hifi/commerce/marketplace/Marketplace.qml"; + var ui; function startup() { ui = new AppUi({ buttonName: BUTTON_NAME, sortOrder: 9, - inject: MARKETPLACES_INJECT_SCRIPT_URL, - home: MARKETPLACE_URL_INITIAL, + home: MARKETPLACE_QML_SOURCE, onScreenChanged: onTabletScreenChanged, onMessage: onQmlMessageReceived });