diff --git a/interface/resources/icons/checkmark-stroke.svg b/interface/resources/icons/checkmark-stroke.svg
new file mode 100644
index 0000000000..cc343c421b
--- /dev/null
+++ b/interface/resources/icons/checkmark-stroke.svg
@@ -0,0 +1,4 @@
+
diff --git a/interface/resources/icons/loader-snake-256-wf.gif b/interface/resources/icons/loader-snake-256-wf.gif
new file mode 100644
index 0000000000..c0d5eec1ef
Binary files /dev/null and b/interface/resources/icons/loader-snake-256-wf.gif differ
diff --git a/interface/resources/icons/loader-snake-256.gif b/interface/resources/icons/loader-snake-256.gif
new file mode 100644
index 0000000000..ebcbf54bd7
Binary files /dev/null and b/interface/resources/icons/loader-snake-256.gif differ
diff --git a/interface/resources/images/loader-snake-128.png b/interface/resources/images/loader-snake-128.png
new file mode 100644
index 0000000000..b8ee577664
Binary files /dev/null and b/interface/resources/images/loader-snake-128.png differ
diff --git a/interface/resources/qml/controlsUit/Button.qml b/interface/resources/qml/controlsUit/Button.qml
index c5c879a24c..3c5626e29e 100644
--- a/interface/resources/qml/controlsUit/Button.qml
+++ b/interface/resources/qml/controlsUit/Button.qml
@@ -32,6 +32,10 @@ Original.Button {
width: hifi.dimensions.buttonWidth
height: hifi.dimensions.controlLineHeight
+ property size implicitPadding: Qt.size(20, 16)
+ property int implicitWidth: buttonContentItem.implicitWidth + implicitPadding.width
+ property int implicitHeight: buttonContentItem.implicitHeight + implicitPadding.height
+
HifiConstants { id: hifi }
onHoveredChanged: {
@@ -94,6 +98,8 @@ Original.Button {
contentItem: Item {
id: buttonContentItem
+ implicitWidth: (buttonGlyph.visible ? buttonGlyph.implicitWidth : 0) + buttonText.implicitWidth
+ implicitHeight: buttonText.implicitHeight
TextMetrics {
id: buttonGlyphTextMetrics;
font: buttonGlyph.font;
diff --git a/interface/resources/qml/hifi/AvatarPackagerWindow.qml b/interface/resources/qml/hifi/AvatarPackagerWindow.qml
new file mode 100644
index 0000000000..82bcd3fa40
--- /dev/null
+++ b/interface/resources/qml/hifi/AvatarPackagerWindow.qml
@@ -0,0 +1,24 @@
+import QtQuick 2.6
+import "../stylesUit" 1.0
+import "../windows" as Windows
+import "avatarPackager" 1.0
+
+Windows.ScrollingWindow {
+ id: root
+ objectName: "AvatarPackager"
+ width: 480
+ height: 706
+ title: "Avatar Packager"
+ resizable: false
+ opacity: parent.opacity
+ destroyOnHidden: true
+ implicitWidth: 384; implicitHeight: 640
+ minSize: Qt.vector2d(480, 706)
+
+ HifiConstants { id: hifi }
+
+ AvatarPackagerApp {
+ height: pane.height
+ width: pane.width
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerApp.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerApp.qml
new file mode 100644
index 0000000000..b4293d5eee
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerApp.qml
@@ -0,0 +1,396 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+import QtQml.Models 2.1
+import QtGraphicalEffects 1.0
+import Hifi.AvatarPackager.AvatarProjectStatus 1.0
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+import "../../controls" 1.0
+import "../../dialogs"
+import "../avatarapp" 1.0 as AvatarApp
+
+Item {
+ id: windowContent
+
+ HifiConstants { id: hifi }
+
+ property alias desktopObject: avatarPackager.desktopObject
+
+ MouseArea {
+ anchors.fill: parent
+
+ onClicked: {
+ unfocusser.forceActiveFocus();
+ }
+ Item {
+ id: unfocusser
+ visible: false
+ }
+ }
+
+ InfoBox {
+ id: fileListPopup
+
+ title: "List of Files"
+
+ content: Rectangle {
+ id: fileList
+
+ color: "#404040"
+
+ anchors.fill: parent
+ anchors.topMargin: 10
+ anchors.bottomMargin: 10
+ anchors.leftMargin: 29
+ anchors.rightMargin: 29
+
+ clip: true
+
+ ListView {
+ anchors.fill: parent
+ model: AvatarPackagerCore.currentAvatarProject === null ? [] : AvatarPackagerCore.currentAvatarProject.projectFiles
+ delegate: Rectangle {
+ width: parent.width
+ height: fileText.implicitHeight + 8
+ color: "#404040"
+ RalewaySemiBold {
+ id: fileText
+ size: 16
+ elide: Text.ElideLeft
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: 16
+ anchors.rightMargin: 16
+ anchors.topMargin: 4
+ width: parent.width - 10
+ color: "white"
+ text: modelData
+ }
+ }
+ }
+ }
+ }
+
+ InfoBox {
+ id: errorPopup
+
+ property string errorMessage
+
+ boxWidth: 380
+ boxHeight: 293
+
+ content: RalewayRegular {
+
+ id: bodyMessage
+
+ anchors.fill: parent
+ anchors.bottomMargin: 10
+ anchors.leftMargin: 29
+ anchors.rightMargin: 29
+
+ size: 20
+ color: "white"
+ text: errorPopup.errorMessage
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+
+ function show(title, message) {
+ errorPopup.title = title;
+ errorMessage = message;
+ errorPopup.open();
+ }
+ }
+
+ Rectangle {
+ id: modalOverlay
+ anchors.fill: parent
+ z: 20
+ color: "#a15d5d5d"
+ visible: false
+
+ // This mouse area captures the cursor events while the modalOverlay is active
+ MouseArea {
+ anchors.fill: parent
+ propagateComposedEvents: false
+ hoverEnabled: true
+ }
+ }
+
+ AvatarApp.MessageBox {
+ id: popup
+ anchors.fill: parent
+ visible: false
+ closeOnClickOutside: true
+ }
+
+ Column {
+ id: avatarPackager
+ anchors.fill: parent
+ state: "main"
+ states: [
+ State {
+ name: AvatarPackagerState.main
+ PropertyChanges { target: avatarPackagerHeader; title: qsTr("Avatar Packager"); docsEnabled: true; backButtonVisible: false }
+ PropertyChanges { target: avatarPackagerMain; visible: true }
+ PropertyChanges { target: avatarPackagerFooter; content: avatarPackagerMain.footer }
+ },
+ State {
+ name: AvatarPackagerState.createProject
+ PropertyChanges { target: avatarPackagerHeader; title: qsTr("Create Project") }
+ PropertyChanges { target: createAvatarProject; visible: true }
+ PropertyChanges { target: avatarPackagerFooter; content: createAvatarProject.footer }
+ },
+ State {
+ name: AvatarPackagerState.project
+ PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name; canRename: true }
+ PropertyChanges { target: avatarProject; visible: true }
+ PropertyChanges { target: avatarPackagerFooter; content: avatarProject.footer }
+ },
+ State {
+ name: AvatarPackagerState.projectUpload
+ PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name; backButtonEnabled: false }
+ PropertyChanges { target: avatarUploader; visible: true }
+ PropertyChanges { target: avatarPackagerFooter; visible: false }
+ }
+ ]
+
+ property alias showModalOverlay: modalOverlay.visible
+
+ property var desktopObject: desktop
+
+ function openProject(path) {
+ let status = AvatarPackagerCore.openAvatarProject(path);
+ if (status !== AvatarProjectStatus.SUCCESS) {
+ displayErrorMessage(status);
+ return status;
+ }
+ avatarProject.reset();
+ avatarPackager.state = AvatarPackagerState.project;
+ return status;
+ }
+
+ function displayErrorMessage(status) {
+ if (status === AvatarProjectStatus.SUCCESS) {
+ return;
+ }
+ switch (status) {
+ case AvatarProjectStatus.ERROR_CREATE_PROJECT_NAME:
+ errorPopup.show("Project Folder Already Exists", "A folder with that name already exists at that location. Please choose a different project name or location.");
+ break;
+ case AvatarProjectStatus.ERROR_CREATE_CREATING_DIRECTORIES:
+ errorPopup.show("Project Folders Creation Error", "There was a problem creating the Avatar Project directory. Please check the project location and try again.");
+ break;
+ case AvatarProjectStatus.ERROR_CREATE_FIND_MODEL:
+ errorPopup.show("Cannot Find Model File", "There was a problem while trying to find the specified model file. Please verify that it exists at the specified location.");
+ break;
+ case AvatarProjectStatus.ERROR_CREATE_OPEN_MODEL:
+ errorPopup.show("Cannot Open Model File", "There was a problem while trying to open the specified model file.");
+ break;
+ case AvatarProjectStatus.ERROR_CREATE_READ_MODEL:
+ errorPopup.show("Error Read Model File", "There was a problem while trying to read the specified model file. Please check that the file is a valid FBX file and try again.");
+ break;
+ case AvatarProjectStatus.ERROR_CREATE_WRITE_FST:
+ errorPopup.show("Error Writing Project File", "There was a problem while trying to write the FST file.");
+ break;
+ case AvatarProjectStatus.ERROR_OPEN_INVALID_FILE_TYPE:
+ errorPopup.show("Invalid Project Path", "The avatar packager can only open FST files.");
+ break;
+ case AvatarProjectStatus.ERROR_OPEN_PROJECT_FOLDER:
+ errorPopup.show("Project Missing", "Project folder cannot be found. Please locate the folder and copy/move it to its original location.");
+ break;
+ case AvatarProjectStatus.ERROR_OPEN_FIND_FST:
+ errorPopup.show("File Missing", "We cannot find the project file (.fst) in the project folder. Please locate it and move it to the project folder.");
+ break;
+ case AvatarProjectStatus.ERROR_OPEN_OPEN_FST:
+ errorPopup.show("File Read Error", "We cannot read the project file (.fst).");
+ break;
+ case AvatarProjectStatus.ERROR_OPEN_FIND_MODEL:
+ errorPopup.show("File Missing", "We cannot find the avatar model file (.fbx) in the project folder. Please locate it and move it to the project folder.");
+ break;
+ default:
+ errorPopup.show("Error Message Missing", "Error message missing for status " + status);
+ }
+
+ }
+
+ function openDocs() {
+ Qt.openUrlExternally("https://docs.highfidelity.com/create/avatars/create-avatars#how-to-package-your-avatar");
+ }
+
+ AvatarPackagerHeader {
+ z: 100
+
+ id: avatarPackagerHeader
+ colorScheme: root.colorScheme
+ onBackButtonClicked: {
+ avatarPackager.state = AvatarPackagerState.main;
+ }
+ onDocsButtonClicked: {
+ avatarPackager.openDocs();
+ }
+ }
+
+ Item {
+ height: windowContent.height - avatarPackagerHeader.height - avatarPackagerFooter.height
+ width: windowContent.width
+
+ Rectangle {
+ anchors.fill: parent
+ color: "#404040"
+ }
+
+ AvatarProject {
+ id: avatarProject
+ colorScheme: root.colorScheme
+ anchors.fill: parent
+ }
+
+ AvatarProjectUpload {
+ id: avatarUploader
+ anchors.fill: parent
+ root: avatarProject
+ }
+
+ CreateAvatarProject {
+ id: createAvatarProject
+ colorScheme: root.colorScheme
+ anchors.fill: parent
+ }
+
+ Item {
+ id: avatarPackagerMain
+ visible: false
+ anchors.fill: parent
+
+ property var footer: Item {
+ anchors.fill: parent
+ anchors.rightMargin: 17
+ HifiControls.Button {
+ id: createProjectButton
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: openProjectButton.left
+ anchors.rightMargin: 22
+ height: 40
+ width: 134
+ text: qsTr("New Project")
+ colorScheme: root.colorScheme
+ onClicked: {
+ createAvatarProject.clearInputs();
+ avatarPackager.state = AvatarPackagerState.createProject;
+ }
+ }
+
+ HifiControls.Button {
+ id: openProjectButton
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ height: 40
+ width: 133
+ text: qsTr("Open Project")
+ color: hifi.buttons.blue
+ colorScheme: root.colorScheme
+ onClicked: {
+ avatarPackager.showModalOverlay = true;
+
+ let browser = avatarPackager.desktopObject.fileDialog({
+ selectDirectory: false,
+ dir: fileDialogHelper.pathToUrl(AvatarPackagerCore.AVATAR_PROJECTS_PATH),
+ filter: "Avatar Project FST Files (*.fst)",
+ title: "Open Project (.fst)",
+ });
+
+ browser.canceled.connect(function() {
+ avatarPackager.showModalOverlay = false;
+ });
+
+ browser.selectedFile.connect(function(fileUrl) {
+ let fstFilePath = fileDialogHelper.urlToPath(fileUrl);
+ avatarPackager.showModalOverlay = false;
+ avatarPackager.openProject(fstFilePath);
+ });
+ }
+ }
+ }
+
+ Flow {
+ visible: AvatarPackagerCore.recentProjects.length === 0
+ anchors {
+ fill: parent
+ topMargin: 18
+ leftMargin: 16
+ rightMargin: 16
+ }
+ RalewayRegular {
+ size: 20
+ color: "white"
+ text: "Use a custom avatar of your choice."
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+ RalewayRegular {
+ size: 20
+ color: "white"
+ text: "Visit our docs to learn more about using the packager."
+ linkColor: "#00B4EF"
+ width: parent.width
+ wrapMode: Text.WordWrap
+ onLinkActivated: {
+ avatarPackager.openDocs();
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+
+ visible: AvatarPackagerCore.recentProjects.length > 0
+
+ RalewayRegular {
+ id: recentProjectsText
+
+ color: 'white'
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.topMargin: 16
+ anchors.leftMargin: 16
+
+ size: 20
+
+ text: "Recent Projects"
+
+ onLinkActivated: fileListPopup.open()
+ }
+
+ Column {
+ anchors {
+ left: parent.left
+ right: parent.right
+ bottom: parent.bottom
+ top: recentProjectsText.bottom
+ topMargin: 16
+ leftMargin: 16
+ rightMargin: 16
+ }
+ spacing: 10
+
+ Repeater {
+ model: AvatarPackagerCore.recentProjects
+ AvatarProjectCard {
+ title: modelData.name
+ path: modelData.projectPath
+ onOpen: avatarPackager.openProject(modelData.path)
+ }
+ }
+ }
+ }
+ }
+ }
+ AvatarPackagerFooter {
+ id: avatarPackagerFooter
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerFooter.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerFooter.qml
new file mode 100644
index 0000000000..31e05672d2
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerFooter.qml
@@ -0,0 +1,41 @@
+import QtQuick 2.6
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+Rectangle {
+ id: avatarPackagerFooter
+
+ color: "#404040"
+ height: content === defaultContent ? 0 : 74
+ visible: content !== defaultContent
+ width: parent.width
+
+ property var content: Item { id: defaultContent }
+
+ children: [background, content]
+
+ property var background: Rectangle {
+ anchors.fill: parent
+ color: "#404040"
+
+ Rectangle {
+ id: topBorder1
+
+ anchors.top: parent.top
+
+ color: "#252525"
+ height: 1
+ width: parent.width
+ }
+ Rectangle {
+ id: topBorder2
+
+ anchors.top: topBorder1.bottom
+
+ color: "#575757"
+ height: 1
+ width: parent.width
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerHeader.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerHeader.qml
new file mode 100644
index 0000000000..25201bf81e
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerHeader.qml
@@ -0,0 +1,144 @@
+import QtQuick 2.6
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+import "../avatarapp" 1.0
+
+ShadowRectangle {
+ id: root
+
+ width: parent.width
+ height: 74
+ color: "#252525"
+
+ property string title: qsTr("Avatar Packager")
+ property alias docsEnabled: docs.visible
+ property bool backButtonVisible: true // If false, is not visible and does not take up space
+ property bool backButtonEnabled: true // If false, is not visible but does not affect space
+ property bool canRename: false
+ property int colorScheme
+
+ property color textColor: "white"
+ property color hoverTextColor: "gray"
+ property color pressedTextColor: "#6A6A6A"
+
+ signal backButtonClicked
+ signal docsButtonClicked
+
+ RalewayButton {
+ id: back
+
+ visible: backButtonEnabled && backButtonVisible
+
+ size: 28
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.leftMargin: 16
+
+ text: "◀"
+
+ onClicked: root.backButtonClicked()
+ }
+ Item {
+ id: titleArea
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: root.backButtonVisible ? back.right : parent.left
+ anchors.leftMargin: root.backButtonVisible ? 11 : 21
+ anchors.right: docs.left
+ states: [
+ State {
+ name: "renaming"
+ PropertyChanges { target: title; visible: false }
+ PropertyChanges { target: titleInputArea; visible: true }
+ }
+ ]
+
+ Item {
+ id: title
+ anchors.fill: parent
+
+ RalewaySemiBold {
+ id: titleNotRenameable
+
+ visible: !root.canRename
+
+ size: 28
+ anchors.fill: parent
+ text: root.title
+ color: "white"
+ }
+
+ RalewayButton {
+ id: titleRenameable
+
+ visible: root.canRename
+ enabled: root.canRename
+
+ size: 28
+ anchors.fill: parent
+ text: root.title
+
+ onClicked: {
+ if (!root.canRename || AvatarPackagerCore.currentAvatarProject === null) {
+ return;
+ }
+
+ titleArea.state = "renaming";
+ titleInput.text = AvatarPackagerCore.currentAvatarProject.name;
+ titleInput.selectAll();
+ titleInput.forceActiveFocus(Qt.MouseFocusReason);
+ }
+ }
+ }
+ Item {
+ id: titleInputArea
+ visible: false
+ anchors.fill: parent
+
+ HifiControls.TextField {
+ id: titleInput
+ anchors.fill: parent
+ text: ""
+ colorScheme: root.colorScheme
+ font.family: "Fira Sans"
+ font.pixelSize: 28
+ z: 200
+ onFocusChanged: {
+ if (titleArea.state === "renaming" && !focus) {
+ accepted();
+ }
+ }
+ Keys.onPressed: {
+ if (event.key === Qt.Key_Escape) {
+ titleArea.state = "";
+ }
+ }
+ onAccepted: {
+ if (acceptableInput) {
+ AvatarPackagerCore.currentAvatarProject.name = text;
+ }
+ titleArea.state = "";
+ }
+ }
+ }
+ }
+
+ RalewayButton {
+ id: docs
+ visible: false
+ size: 28
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ anchors.rightMargin: 16
+
+ text: qsTr("Docs")
+
+ onClicked: {
+ docsButtonClicked();
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerState.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerState.qml
new file mode 100644
index 0000000000..c81173a080
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerState.qml
@@ -0,0 +1,10 @@
+pragma Singleton
+import QtQuick 2.6
+
+Item {
+ id: singleton
+ readonly property string main: "main"
+ readonly property string project: "project"
+ readonly property string createProject: "createProject"
+ readonly property string projectUpload: "projectUpload"
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarProject.qml b/interface/resources/qml/hifi/avatarPackager/AvatarProject.qml
new file mode 100644
index 0000000000..85ef821a4a
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarProject.qml
@@ -0,0 +1,336 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+import QtGraphicalEffects 1.0
+
+import QtQuick.Controls 2.2 as Original
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+
+Item {
+ id: root
+
+ HifiConstants { id: hifi }
+
+ Style { id: style }
+
+ property int colorScheme
+ property var uploader: null
+
+ property bool hasSuccessfullyUploaded: true
+
+ visible: false
+ anchors.fill: parent
+ anchors.margins: 10
+
+ function reset() {
+ hasSuccessfullyUploaded = false;
+ uploader = null;
+ }
+
+ property var footer: Item {
+ anchors.fill: parent
+
+ Item {
+ id: uploadFooter
+
+ visible: !root.uploader || root.finished || root.uploader.state !== 4
+
+ anchors.fill: parent
+ anchors.rightMargin: 17
+
+ HifiControls.Button {
+ id: uploadButton
+
+ visible: AvatarPackagerCore.currentAvatarProject && !AvatarPackagerCore.currentAvatarProject.fst.hasMarketplaceID && !root.hasSuccessfullyUploaded
+ enabled: Account.loggedIn
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ text: qsTr("Upload")
+ color: hifi.buttons.blue
+ colorScheme: root.colorScheme
+ width: 133
+ height: 40
+ onClicked: {
+ uploadNew();
+ }
+ }
+ HifiControls.Button {
+ id: updateButton
+
+ visible: AvatarPackagerCore.currentAvatarProject && AvatarPackagerCore.currentAvatarProject.fst.hasMarketplaceID && !root.hasSuccessfullyUploaded
+ enabled: Account.loggedIn
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ text: qsTr("Update")
+ color: hifi.buttons.blue
+ colorScheme: root.colorScheme
+ width: 134
+ height: 40
+ onClicked: {
+ showConfirmUploadPopup(uploadNew, uploadUpdate);
+ }
+ }
+ Item {
+ anchors.fill: parent
+ visible: root.hasSuccessfullyUploaded
+
+ HifiControls.Button {
+ enabled: Account.loggedIn
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: viewInInventoryButton.left
+ anchors.rightMargin: 16
+
+ text: qsTr("Update")
+ color: hifi.buttons.white
+ colorScheme: root.colorScheme
+ width: 134
+ height: 40
+ onClicked: {
+ showConfirmUploadPopup(uploadNew, uploadUpdate);
+ }
+ }
+ HifiControls.Button {
+ id: viewInInventoryButton
+
+ enabled: Account.loggedIn
+
+ width: 168
+ height: 40
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ text: qsTr("View in Inventory")
+ color: hifi.buttons.blue
+ colorScheme: root.colorScheme
+
+ onClicked: AvatarPackagerCore.currentAvatarProject.openInInventory()
+ }
+ }
+ }
+
+ Rectangle {
+ id: uploadingItemFooter
+
+ anchors.fill: parent
+ anchors.topMargin: 1
+ visible: !!root.uploader && !root.finished && root.uploader.state === 4
+
+ color: "#00B4EF"
+
+ LoadingCircle {
+ id: runningImage
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: 16
+
+ width: 28
+ height: 28
+
+ white: true
+ }
+ RalewayRegular {
+ id: stepText
+
+ size: 20
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: runningImage.right
+ anchors.leftMargin: 16
+
+ text: "Adding item to Inventory"
+ color: "white"
+ }
+ }
+ }
+
+ function uploadNew() {
+ upload(false);
+ }
+ function uploadUpdate() {
+ upload(true);
+ }
+
+ Connections {
+ target: root.uploader
+ onStateChanged: {
+ root.hasSuccessfullyUploaded = newState >= 4;
+ }
+ }
+
+ function upload(updateExisting) {
+ root.uploader = AvatarPackagerCore.currentAvatarProject.upload(updateExisting);
+ console.log("uploader: "+ root.uploader);
+ root.uploader.send();
+ avatarPackager.state = AvatarPackagerState.projectUpload;
+ }
+
+ function showConfirmUploadPopup() {
+ popup.titleText = 'Overwrite Avatar';
+ popup.bodyText = 'You have previously uploaded the avatar file from this project.' +
+ ' This will overwrite that avatar and you won’t be able to access the older version.';
+
+ popup.button1text = 'CREATE NEW';
+ popup.button2text = 'OVERWRITE';
+
+ popup.onButton2Clicked = function() {
+ popup.close();
+ uploadUpdate();
+ };
+ popup.onButton1Clicked = function() {
+ popup.close();
+ showConfirmCreateNewPopup();
+ };
+
+ popup.open();
+ }
+
+ function showConfirmCreateNewPopup(confirmCallback) {
+ popup.titleText = 'Create New';
+ popup.bodyText = 'This will upload your current files with the same avatar name.' +
+ ' You will lose the ability to update the previously uploaded avatar. Are you sure you want to continue?';
+
+ popup.button1text = 'CANCEL';
+ popup.button2text = 'CONFIRM';
+
+ popup.onButton1Clicked = function() {
+ popup.close()
+ };
+ popup.onButton2Clicked = function() {
+ popup.close();
+ uploadNew();
+ };
+
+ popup.open();
+ }
+
+ RalewayRegular {
+ id: infoMessage
+
+ states: [
+ State {
+ when: root.hasSuccessfullyUploaded
+ name: "upload-success"
+ PropertyChanges {
+ target: infoMessage
+ text: "Your avatar has been successfully uploaded to our servers. Make changes to your avatar by editing and uploading the project files."
+ }
+ },
+ State {
+ name: "has-previous-success"
+ when: !!AvatarPackagerCore.currentAvatarProject && AvatarPackagerCore.currentAvatarProject.fst.hasMarketplaceID
+ PropertyChanges {
+ target: infoMessage
+ text: "Click \"Update\" to overwrite the hosted files and update the avatar in your inventory. You will have to “Wear” the avatar again to see changes."
+ }
+ }
+ ]
+
+ color: 'white'
+ size: 20
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ anchors.bottomMargin: 24
+
+ wrapMode: Text.Wrap
+
+ text: "You can upload your files to our servers to always access them, and to make your avatar visible to other users."
+ }
+
+ HifiControls.Button {
+ id: openFolderButton
+
+ visible: false
+ width: parent.width
+ anchors.top: infoMessage.bottom
+ anchors.topMargin: 10
+ text: qsTr("Open Project Folder")
+ colorScheme: root.colorScheme
+ height: 30
+ onClicked: {
+ fileDialogHelper.openDirectory(fileDialogHelper.pathToUrl(AvatarPackagerCore.currentAvatarProject.projectFolderPath));
+ }
+ }
+
+ RalewayRegular {
+ id: showFilesText
+
+ color: 'white'
+ linkColor: style.colors.blueHighlight
+
+ visible: AvatarPackagerCore.currentAvatarProject !== null
+
+ anchors.bottom: loginRequiredMessage.top
+ anchors.bottomMargin: 10
+
+ size: 20
+
+ text: AvatarPackagerCore.currentAvatarProject ? AvatarPackagerCore.currentAvatarProject.projectFiles.length + " files in project. See list" : ""
+
+ onLinkActivated: fileListPopup.open()
+ }
+
+ Rectangle {
+ id: loginRequiredMessage
+
+ visible: !Account.loggedIn
+ height: !Account.loggedIn ? loginRequiredTextRow.height + 20 : 0
+
+ anchors {
+ bottom: parent.bottom
+ left: parent.left
+ right: parent.right
+ }
+
+ color: "#FFD6AD"
+
+ border.color: "#F39622"
+ border.width: 2
+ radius: 2
+
+ Item {
+ id: loginRequiredTextRow
+
+ height: Math.max(loginWarningGlyph.implicitHeight, loginWarningText.implicitHeight)
+ anchors.fill: parent
+ anchors.margins: 10
+
+ HiFiGlyphs {
+ id: loginWarningGlyph
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ width: implicitWidth
+
+ size: 48
+ text: "+"
+ color: "black"
+ }
+ RalewayRegular {
+ id: loginWarningText
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: 16
+ anchors.left: loginWarningGlyph.right
+ anchors.right: parent.right
+
+ text: "Please login to upload your avatar to High Fidelity hosting."
+ size: 18
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarProjectCard.qml b/interface/resources/qml/hifi/avatarPackager/AvatarProjectCard.qml
new file mode 100644
index 0000000000..25222c814c
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarProjectCard.qml
@@ -0,0 +1,102 @@
+import QtQuick 2.0
+import QtGraphicalEffects 1.0
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+
+Item {
+ id: projectCard
+ height: 80
+ width: parent.width
+
+ property alias title: title.text
+ property alias path: path.text
+
+ property color textColor: "#E3E3E3"
+ property color hoverTextColor: "#121212"
+ property color pressedTextColor: "#121212"
+
+ property color backgroundColor: "#121212"
+ property color hoverBackgroundColor: "#E3E3E3"
+ property color pressedBackgroundColor: "#6A6A6A"
+
+ signal open
+
+ state: mouseArea.pressed ? "pressed" : (mouseArea.containsMouse ? "hover" : "normal")
+ states: [
+ State {
+ name: "normal"
+ PropertyChanges { target: background; color: backgroundColor }
+ PropertyChanges { target: title; color: textColor }
+ PropertyChanges { target: path; color: textColor }
+ },
+ State {
+ name: "hover"
+ PropertyChanges { target: background; color: hoverBackgroundColor }
+ PropertyChanges { target: title; color: hoverTextColor }
+ PropertyChanges { target: path; color: hoverTextColor }
+ },
+ State {
+ name: "pressed"
+ PropertyChanges { target: background; color: pressedBackgroundColor }
+ PropertyChanges { target: title; color: pressedTextColor }
+ PropertyChanges { target: path; color: pressedTextColor }
+ }
+ ]
+
+ Rectangle {
+ id: background
+ width: parent.width
+ height: parent.height
+ color: "#121212"
+ radius: 4
+
+ RalewayBold {
+ id: title
+ elide: "ElideRight"
+ anchors {
+ top: parent.top
+ topMargin: 13
+ left: parent.left
+ leftMargin: 16
+ right: parent.right
+ rightMargin: 16
+ }
+ text: "
"
+ size: 24
+ }
+
+ RalewayRegular {
+ id: path
+ anchors {
+ top: title.bottom
+ left: parent.left
+ leftMargin: 32
+ right: background.right
+ rightMargin: 16
+ }
+ elide: "ElideLeft"
+ horizontalAlignment: Text.AlignRight
+ text: ""
+ size: 20
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: open()
+ }
+ }
+
+ DropShadow {
+ id: shadow
+ anchors.fill: background
+ radius: 4
+ horizontalOffset: 0
+ verticalOffset: 4
+ color: Qt.rgba(0, 0, 0, 0.25)
+ source: background
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarProjectUpload.qml b/interface/resources/qml/hifi/avatarPackager/AvatarProjectUpload.qml
new file mode 100644
index 0000000000..68f465f514
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarProjectUpload.qml
@@ -0,0 +1,202 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+import QtGraphicalEffects 1.0
+
+import QtQuick.Controls 2.2 as Original
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+Item {
+ id: uploadingScreen
+
+ property var root: undefined
+ visible: false
+ anchors.fill: parent
+
+ Timer {
+ id: backToProjectTimer
+ interval: 2000
+ running: false
+ repeat: false
+ onTriggered: {
+ if (avatarPackager.state === AvatarPackagerState.projectUpload) {
+ avatarPackager.state = AvatarPackagerState.project;
+ }
+ }
+ }
+
+ function stateChangedCallback(newState) {
+ if (newState >= 4) {
+ root.uploader.stateChanged.disconnect(stateChangedCallback);
+ backToProjectTimer.start();
+ }
+ }
+
+ onVisibleChanged: {
+ if (visible) {
+ root.uploader.stateChanged.connect(stateChangedCallback);
+ root.uploader.finishedChanged.connect(function() {
+ if (root.uploader.error === 0) {
+ backToProjectTimer.start();
+ }
+ });
+ }
+ }
+
+ Item {
+ id: uploadStatus
+
+ anchors.fill: parent
+
+ Item {
+ id: statusItem
+
+ width: parent.width
+ height: 256
+
+ states: [
+ State {
+ name: "success"
+ when: root.uploader.state >= 4 && root.uploader.error === 0
+ PropertyChanges { target: uploadSpinner; visible: false }
+ PropertyChanges { target: errorIcon; visible: false }
+ PropertyChanges { target: successIcon; visible: true }
+ },
+ State {
+ name: "error"
+ when: root.uploader.finished && root.uploader.error !== 0
+ PropertyChanges { target: uploadSpinner; visible: false }
+ PropertyChanges { target: errorIcon; visible: true }
+ PropertyChanges { target: successIcon; visible: false }
+ PropertyChanges { target: errorFooter; visible: true }
+ PropertyChanges { target: errorMessage; visible: true }
+ }
+ ]
+
+ AnimatedImage {
+ id: uploadSpinner
+
+ visible: true
+
+ anchors {
+ horizontalCenter: parent.horizontalCenter
+ verticalCenter: parent.verticalCenter
+ }
+
+ width: 164
+ height: 164
+
+ source: "../../../icons/loader-snake-256.gif"
+ playing: true
+ }
+
+ HiFiGlyphs {
+ id: errorIcon
+
+ visible: false
+
+ anchors {
+ horizontalCenter: parent.horizontalCenter
+ verticalCenter: parent.verticalCenter
+ }
+
+ size: 315
+ text: "+"
+ color: "#EA4C5F"
+ }
+
+ Image {
+ id: successIcon
+
+ visible: false
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ width: 148
+ height: 148
+
+ source: "../../../icons/checkmark-stroke.svg"
+ }
+ }
+
+ Item {
+ id: statusRows
+
+ anchors.top: statusItem.bottom
+ anchors.left: parent.left
+ anchors.leftMargin: 12
+
+ AvatarUploadStatusItem {
+ id: statusCategories
+ uploader: root.uploader
+ text: "Retrieving categories"
+
+ uploaderState: 1
+ }
+ AvatarUploadStatusItem {
+ id: statusUploading
+ uploader: root.uploader
+ anchors.top: statusCategories.bottom
+ text: "Uploading data"
+
+ uploaderState: 2
+ }
+ AvatarUploadStatusItem {
+ id: statusResponse
+ uploader: root.uploader
+ anchors.top: statusUploading.bottom
+ text: "Waiting for response"
+
+ uploaderState: 3
+ }
+ }
+
+ RalewayRegular {
+ id: errorMessage
+
+ visible: false
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: errorFooter.top
+ anchors.leftMargin: 16
+ anchors.rightMargin: 16
+ anchors.bottomMargin: 32
+
+ size: 28
+ wrapMode: Text.Wrap
+ color: "white"
+ text: "We couldn't upload your avatar at this time. Please try again later."
+ }
+
+ AvatarPackagerFooter {
+ id: errorFooter
+
+ anchors.bottom: parent.bottom
+ visible: false
+
+ content: Item {
+ anchors.fill: parent
+ anchors.rightMargin: 17
+ HifiControls.Button {
+ id: backButton
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ text: qsTr("Back")
+ color: hifi.buttons.blue
+ colorScheme: root.colorScheme
+ width: 133
+ height: 40
+ onClicked: {
+ avatarPackager.state = AvatarPackagerState.project;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarUploadStatusItem.qml b/interface/resources/qml/hifi/avatarPackager/AvatarUploadStatusItem.qml
new file mode 100644
index 0000000000..70a0ea0672
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/AvatarUploadStatusItem.qml
@@ -0,0 +1,96 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+Item {
+ id: root
+
+ height: 48
+
+ property string text: "NO STEP TEXT"
+ property int uploaderState;
+ property var uploader;
+
+ states: [
+ State {
+ name: ""
+ when: root.uploader === null
+ },
+ State {
+ name: "success"
+ when: root.uploader.state > uploaderState
+ PropertyChanges { target: stepText; color: "white" }
+ PropertyChanges { target: successGlyph; visible: true }
+ },
+ State {
+ name: "fail"
+ when: root.uploader.error !== 0
+ PropertyChanges { target: stepText; color: "#EA4C5F" }
+ PropertyChanges { target: failGlyph; visible: true }
+ },
+ State {
+ name: "running"
+ when: root.uploader.state === uploaderState
+ PropertyChanges { target: stepText; color: "white" }
+ PropertyChanges { target: runningImage; visible: true; playing: true }
+ }
+ ]
+
+ Item {
+ id: statusItem
+
+ width: 48
+ height: parent.height
+
+ LoadingCircle {
+ id: runningImage
+
+ visible: false
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ width: 32
+ height: 32
+ }
+ Image {
+ id: successGlyph
+
+ visible: false
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ width: 30
+ height: 30
+
+ source: "../../../icons/checkmark-stroke.svg"
+ }
+ HiFiGlyphs {
+ id: failGlyph
+
+ visible: false
+
+ width: implicitWidth
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ size: 48
+ text: "+"
+ color: "#EA4C5F"
+ }
+ }
+ RalewayRegular {
+ id: stepText
+
+ anchors.left: statusItem.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ text: root.text
+ size: 28
+ color: "#777777"
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/ClickableArea.qml b/interface/resources/qml/hifi/avatarPackager/ClickableArea.qml
new file mode 100644
index 0000000000..0f7b201f72
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/ClickableArea.qml
@@ -0,0 +1,63 @@
+import QtQuick 2.6
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+import TabletScriptingInterface 1.0
+
+Item {
+ id: root
+
+ readonly property bool pressed: mouseArea.state == "pressed"
+ readonly property bool hovered: mouseArea.state == "hovering"
+
+ signal clicked()
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+
+ hoverEnabled: true
+
+ onClicked: {
+ root.focus = true
+ Tablet.playSound(TabletEnums.ButtonClick);
+ root.clicked();
+ }
+
+ property string lastState: ""
+
+ states: [
+ State {
+ name: ""
+ StateChangeScript {
+ script: {
+ mouseArea.lastState = mouseArea.state;
+ }
+ }
+ },
+ State {
+ name: "pressed"
+ when: mouseArea.containsMouse && mouseArea.pressed
+ StateChangeScript {
+ script: {
+ mouseArea.lastState = mouseArea.state;
+ }
+ }
+ },
+ State {
+ name: "hovering"
+ when: mouseArea.containsMouse
+ StateChangeScript {
+ script: {
+ if (mouseArea.lastState == "") {
+ Tablet.playSound(TabletEnums.ButtonHover);
+ }
+ mouseArea.lastState = mouseArea.state;
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/CreateAvatarProject.qml b/interface/resources/qml/hifi/avatarPackager/CreateAvatarProject.qml
new file mode 100644
index 0000000000..c299417c27
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/CreateAvatarProject.qml
@@ -0,0 +1,135 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+
+import Hifi.AvatarPackager.AvatarProjectStatus 1.0
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+Item {
+ id: root
+
+ HifiConstants { id: hifi }
+
+ property int colorScheme
+
+ property var footer: Item {
+ anchors.fill: parent
+ anchors.rightMargin: 17
+ HifiControls.Button {
+ id: createButton
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ height: 30
+ width: 133
+ text: qsTr("Create")
+ enabled: false
+ onClicked: {
+ let status = AvatarPackagerCore.createAvatarProject(projectLocation.text, name.text, avatarModel.text, textureFolder.text);
+ if (status !== AvatarProjectStatus.SUCCESS) {
+ avatarPackager.displayErrorMessage(status);
+ return;
+ }
+ avatarProject.reset();
+ avatarPackager.state = AvatarPackagerState.project;
+ }
+ }
+ }
+
+ visible: false
+ anchors.fill: parent
+ height: parent.height
+ width: parent.width
+
+ function clearInputs() {
+ name.text = projectLocation.text = avatarModel.text = textureFolder.text = "";
+ }
+
+ function checkErrors() {
+ let newErrorMessageText = "";
+
+ let projectName = name.text;
+ let projectFolder = projectLocation.text;
+
+ let hasProjectNameError = projectName !== "" && projectFolder !== "" && !AvatarPackagerCore.isValidNewProjectName(projectFolder, projectName);
+
+ if (hasProjectNameError) {
+ newErrorMessageText = "A folder with that name already exists at that location. Please choose a different project name or location.";
+ }
+
+ name.error = projectLocation.error = hasProjectNameError;
+ errorMessage.text = newErrorMessageText;
+ createButton.enabled = newErrorMessageText === "" && requiredFieldsFilledIn();
+ }
+
+ function requiredFieldsFilledIn() {
+ return name.text !== "" && projectLocation.text !== "" && avatarModel.text !== "";
+ }
+
+ RalewayRegular {
+ id: errorMessage
+ visible: text !== ""
+ text: ""
+ color: "#EA4C5F"
+ wrapMode: Text.WordWrap
+ size: 20
+ anchors {
+ left: parent.left
+ right: parent.right
+ }
+ }
+
+ Column {
+ id: createAvatarColumns
+ anchors.top: errorMessage.visible ? errorMessage.bottom : parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: 10
+
+ spacing: 17
+
+ property string defaultFileBrowserPath: fileDialogHelper.pathToUrl(AvatarPackagerCore.AVATAR_PROJECTS_PATH)
+
+ ProjectInputControl {
+ id: name
+ label: "Name"
+ colorScheme: root.colorScheme
+ onTextChanged: checkErrors()
+ }
+
+ ProjectInputControl {
+ id: projectLocation
+ label: "Specify Project Location"
+ colorScheme: root.colorScheme
+ browseEnabled: true
+ browseFolder: true
+ browseDir: text !== "" ? fileDialogHelper.pathToUrl(text) : createAvatarColumns.defaultFileBrowserPath
+ browseTitle: "Project Location"
+ onTextChanged: checkErrors()
+ }
+
+ ProjectInputControl {
+ id: avatarModel
+ label: "Specify Avatar Model (.fbx)"
+ colorScheme: root.colorScheme
+ browseEnabled: true
+ browseFolder: false
+ browseDir: text !== "" ? fileDialogHelper.pathToUrl(text) : createAvatarColumns.defaultFileBrowserPath
+ browseFilter: "Avatar Model File (*.fbx)"
+ browseTitle: "Open Avatar Model (.fbx)"
+ onTextChanged: checkErrors()
+ }
+
+ ProjectInputControl {
+ id: textureFolder
+ label: "Specify Texture Folder - Optional"
+ colorScheme: root.colorScheme
+ browseEnabled: true
+ browseFolder: true
+ browseDir: text !== "" ? fileDialogHelper.pathToUrl(text) : createAvatarColumns.defaultFileBrowserPath
+ browseTitle: "Texture Folder"
+ onTextChanged: checkErrors()
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/InfoBox.qml b/interface/resources/qml/hifi/avatarPackager/InfoBox.qml
new file mode 100644
index 0000000000..e33e427af0
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/InfoBox.qml
@@ -0,0 +1,120 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import stylesUit 1.0
+import controlsUit 1.0 as HifiControlsUit
+import "../../controls" as HifiControls
+
+Rectangle {
+ id: root
+ visible: false
+ color: Qt.rgba(.34, .34, .34, 0.6)
+ z: 999;
+
+ anchors.fill: parent
+
+ property alias title: titleText.text
+ property alias content: loader.sourceComponent
+
+ property bool closeOnClickOutside: false;
+
+ property alias boxWidth: mainContainer.width
+ property alias boxHeight: mainContainer.height
+
+ onVisibleChanged: {
+ if (visible) {
+ focus = true;
+ }
+ }
+
+ function open() {
+ visible = true;
+ }
+
+ function close() {
+ visible = false;
+ }
+
+ HifiConstants {
+ id: hifi
+ }
+
+ // This object is always used in a popup.
+ // This MouseArea is used to prevent a user from being
+ // able to click on a button/mouseArea underneath the popup.
+ MouseArea {
+ anchors.fill: parent;
+ propagateComposedEvents: false;
+ hoverEnabled: true;
+ onClicked: {
+ if (closeOnClickOutside) {
+ root.close()
+ }
+ }
+ }
+
+ Rectangle {
+ id: mainContainer
+
+ width: Math.max(parent.width * 0.8, 400)
+ height: parent.height * 0.6
+
+ MouseArea {
+ anchors.fill: parent
+ propagateComposedEvents: false
+ hoverEnabled: true
+ onClicked: function(ev) {
+ ev.accepted = true;
+ }
+ }
+
+ anchors.centerIn: parent
+
+ color: "#252525"
+
+ // TextStyle1
+ RalewaySemiBold {
+ id: titleText
+ size: 24
+ color: "white"
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: 30
+
+ text: "Title not defined"
+ }
+
+ Item {
+ anchors.topMargin: 10
+ anchors.top: titleText.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: button.top
+
+ Loader {
+ id: loader
+ anchors.fill: parent
+ }
+ }
+
+ Item {
+ id: button
+
+ height: 40
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 12
+
+ HifiControlsUit.Button {
+ anchors.centerIn: parent
+
+ text: "CLOSE"
+ onClicked: close()
+
+ color: hifi.buttons.noneBorderlessWhite;
+ colorScheme: hifi.colorSchemes.dark;
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/LoadingCircle.qml b/interface/resources/qml/hifi/avatarPackager/LoadingCircle.qml
new file mode 100644
index 0000000000..a1fac72ae4
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/LoadingCircle.qml
@@ -0,0 +1,16 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+import QtGraphicalEffects 1.0
+
+AnimatedImage {
+ id: root
+
+ width: 128
+ height: 128
+
+ property bool white: false
+
+ source: white ? "../../../icons/loader-snake-256-wf.gif" : "../../../icons/loader-snake-256.gif"
+ playing: true
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/ProjectInputControl.qml b/interface/resources/qml/hifi/avatarPackager/ProjectInputControl.qml
new file mode 100644
index 0000000000..f0a3aac8a7
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/ProjectInputControl.qml
@@ -0,0 +1,78 @@
+import QtQuick 2.6
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+Column {
+ id: control
+
+ anchors.left: parent.left
+ anchors.leftMargin: 21
+ anchors.right: parent.right
+ anchors.rightMargin: 16
+
+ height: 75
+
+ spacing: 4
+
+ property alias label: label.text
+ property alias browseEnabled: browseButton.visible
+ property bool browseFolder: false
+ property string browseFilter: "All Files (*.*)"
+ property string browseTitle: "Open file"
+ property string browseDir: ""
+ property alias text: input.text
+ property alias error: input.error
+
+ property int colorScheme
+
+ Row {
+ RalewaySemiBold {
+ id: label
+ size: 20
+ font.weight: Font.Medium
+ text: ""
+ color: "white"
+ }
+ }
+ Row {
+ width: control.width
+ spacing: 16
+ height: 40
+ HifiControls.TextField {
+ id: input
+ colorScheme: control.colorScheme
+ font.family: "Fira Sans"
+ font.pixelSize: 18
+ height: parent.height
+ width: browseButton.visible ? parent.width - browseButton.width - parent.spacing : parent.width
+ }
+
+ HifiControls.Button {
+ id: browseButton
+ visible: false
+ height: parent.height
+ width: 133
+ text: qsTr("Browse")
+ colorScheme: root.colorScheme
+ onClicked: {
+ avatarPackager.showModalOverlay = true;
+ let browser = avatarPackager.desktopObject.fileDialog({
+ selectDirectory: browseFolder,
+ dir: browseDir,
+ filter: browseFilter,
+ title: browseTitle,
+ });
+
+ browser.canceled.connect(function() {
+ avatarPackager.showModalOverlay = false;
+ });
+
+ browser.selectedFile.connect(function(fileUrl) {
+ input.text = fileDialogHelper.urlToPath(fileUrl);
+ avatarPackager.showModalOverlay = false;
+ });
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/RalewayButton.qml b/interface/resources/qml/hifi/avatarPackager/RalewayButton.qml
new file mode 100644
index 0000000000..86742ddccd
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/RalewayButton.qml
@@ -0,0 +1,26 @@
+import QtQuick 2.6
+
+import "../../controlsUit" 1.0 as HifiControls
+import "../../stylesUit" 1.0
+
+import TabletScriptingInterface 1.0
+
+RalewaySemiBold {
+ id: root
+
+ property color idleColor: "white"
+ property color hoverColor: "#AFAFAF"
+ property color pressedColor: "#575757"
+
+ color: clickable.hovered ? root.hoverColor : (clickable.pressed ? root.pressedColor : root.idleColor)
+
+ signal clicked()
+
+ ClickableArea {
+ id: clickable
+
+ anchors.fill: root
+
+ onClicked: root.clicked()
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/Style.qml b/interface/resources/qml/hifi/avatarPackager/Style.qml
new file mode 100644
index 0000000000..a1dcc8f0c1
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/Style.qml
@@ -0,0 +1,20 @@
+import QtQuick 2.5
+import QtQuick.Window 2.2
+
+import "../../stylesUit" 1.0
+
+QtObject {
+ readonly property QtObject colors: QtObject {
+ readonly property color lightGrayBackground: "#f2f2f2"
+ readonly property color black: "#000000"
+ readonly property color white: "#ffffff"
+ readonly property color blueHighlight: "#00b4ef"
+ readonly property color inputFieldBackground: "#d4d4d4"
+ readonly property color yellowishOrange: "#ffb017"
+ readonly property color blueAccent: "#0093c5"
+ readonly property color greenHighlight: "#1fc6a6"
+ readonly property color lightGray: "#afafaf"
+ readonly property color redHighlight: "#ea4c5f"
+ readonly property color orangeAccent: "#ff6309"
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarPackager/qmldir b/interface/resources/qml/hifi/avatarPackager/qmldir
new file mode 100644
index 0000000000..4204b6d89f
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarPackager/qmldir
@@ -0,0 +1,2 @@
+module AvatarPackager
+singleton AvatarPackagerState 1.0 AvatarPackagerState.qml
diff --git a/interface/resources/qml/hifi/avatarapp/MessageBox.qml b/interface/resources/qml/hifi/avatarapp/MessageBox.qml
index 1834364fe4..88f7f888cb 100644
--- a/interface/resources/qml/hifi/avatarapp/MessageBox.qml
+++ b/interface/resources/qml/hifi/avatarapp/MessageBox.qml
@@ -23,6 +23,8 @@ Rectangle {
property string button2color: hifi.buttons.blue;
property string button2text: ''
+ property bool closeOnClickOutside: false;
+
property var onButton2Clicked;
property var onButton1Clicked;
property var onLinkClicked;
@@ -56,6 +58,11 @@ Rectangle {
anchors.fill: parent;
propagateComposedEvents: false;
hoverEnabled: true;
+ onClicked: {
+ if (closeOnClickOutside) {
+ root.close()
+ }
+ }
}
Rectangle {
@@ -68,6 +75,15 @@ Rectangle {
console.debug('mainContainer: height = ', height)
}
+ MouseArea {
+ anchors.fill: parent;
+ propagateComposedEvents: false;
+ hoverEnabled: true;
+ onClicked: function(ev) {
+ ev.accepted = true;
+ }
+ }
+
anchors.centerIn: parent
color: "white"
diff --git a/interface/resources/qml/hifi/tablet/AvatarPackager.qml b/interface/resources/qml/hifi/tablet/AvatarPackager.qml
new file mode 100644
index 0000000000..c1c234dd73
--- /dev/null
+++ b/interface/resources/qml/hifi/tablet/AvatarPackager.qml
@@ -0,0 +1,15 @@
+import QtQuick 2.0
+import "../avatarPackager" 1.0
+
+Item {
+ id: root
+ width: 480
+ height: 706
+
+ AvatarPackagerApp {
+ width: parent.width
+ height: parent.height
+
+ desktopObject: tabletRoot
+ }
+}
diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp
index b6b4e8e2a1..2816dbcb04 100644
--- a/interface/src/Application.cpp
+++ b/interface/src/Application.cpp
@@ -158,6 +158,7 @@
#include "audio/AudioScope.h"
#include "avatar/AvatarManager.h"
#include "avatar/MyHead.h"
+#include "avatar/AvatarPackager.h"
#include "CrashRecoveryHandler.h"
#include "CrashHandler.h"
#include "devices/DdeFaceTracker.h"
@@ -922,6 +923,7 @@ bool setupEssentials(int& argc, char** argv, bool runningMarkerExisted) {
DependencyManager::set();
DependencyManager::set();
DependencyManager::set();
+ DependencyManager::set();
return previousSessionCrashed;
}
@@ -2617,6 +2619,7 @@ void Application::cleanupBeforeQuit() {
DependencyManager::destroy();
DependencyManager::destroy();
DependencyManager::destroy();
+ DependencyManager::destroy();
qCDebug(interfaceapp) << "Application::cleanupBeforeQuit() complete";
}
diff --git a/interface/src/Menu.cpp b/interface/src/Menu.cpp
index 140d2a7ccc..810e21daf5 100644
--- a/interface/src/Menu.cpp
+++ b/interface/src/Menu.cpp
@@ -35,6 +35,7 @@
#include "assets/ATPAssetMigrator.h"
#include "audio/AudioScope.h"
#include "avatar/AvatarManager.h"
+#include "avatar/AvatarPackager.h"
#include "AvatarBookmarks.h"
#include "devices/DdeFaceTracker.h"
#include "MainWindow.h"
@@ -144,9 +145,13 @@ Menu::Menu() {
assetServerAction->setEnabled(nodeList->getThisNodeCanWriteAssets());
}
- // Edit > Package Avatar as .fst...
- addActionToQMenuAndActionHash(editMenu, MenuOption::PackageModel, 0,
- qApp, SLOT(packageModel()));
+ // Edit > Avatar Packager
+#ifndef Q_OS_ANDROID
+ action = addActionToQMenuAndActionHash(editMenu, MenuOption::AvatarPackager);
+ connect(action, &QAction::triggered, [] {
+ DependencyManager::get()->open();
+ });
+#endif
// Edit > Reload All Content
addActionToQMenuAndActionHash(editMenu, MenuOption::ReloadContent, 0, qApp, SLOT(reloadResourceCaches()));
@@ -645,6 +650,8 @@ Menu::Menu() {
addCheckableActionToQMenuAndActionHash(avatarDebugMenu, MenuOption::ShowTrackedObjects, 0, false, qApp, SLOT(setShowTrackedObjects(bool)));
+ addActionToQMenuAndActionHash(avatarDebugMenu, MenuOption::PackageModel, 0, qApp, SLOT(packageModel()));
+
// Developer > Hands >>>
MenuWrapper* handOptionsMenu = developerMenu->addMenu("Hands");
addCheckableActionToQMenuAndActionHash(handOptionsMenu, MenuOption::DisplayHandTargets, 0, false,
diff --git a/interface/src/Menu.h b/interface/src/Menu.h
index 7168b7294e..3611faaf8f 100644
--- a/interface/src/Menu.h
+++ b/interface/src/Menu.h
@@ -46,6 +46,7 @@ namespace MenuOption {
const QString AutoMuteAudio = "Auto Mute Microphone";
const QString AvatarReceiveStats = "Show Receive Stats";
const QString AvatarBookmarks = "Avatar Bookmarks";
+ const QString AvatarPackager = "Avatar Packager";
const QString Back = "Back";
const QString BinaryEyelidControl = "Binary Eyelid Control";
const QString BookmarkAvatar = "Bookmark Avatar";
diff --git a/interface/src/avatar/AvatarPackager.cpp b/interface/src/avatar/AvatarPackager.cpp
new file mode 100644
index 0000000000..fa70eee374
--- /dev/null
+++ b/interface/src/avatar/AvatarPackager.cpp
@@ -0,0 +1,149 @@
+//
+// AvatarPackager.cpp
+//
+//
+// Created by Thijs Wenker on 12/6/2018
+// Copyright 2018 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 "AvatarPackager.h"
+
+#include "Application.h"
+
+#include
+#include
+#include
+
+#include
+#include "ModelSelector.h"
+#include
+
+#include
+#include "ui/TabletScriptingInterface.h"
+
+std::once_flag setupQMLTypesFlag;
+AvatarPackager::AvatarPackager() {
+ std::call_once(setupQMLTypesFlag, []() {
+ qmlRegisterType();
+ qmlRegisterType();
+ qRegisterMetaType();
+ qRegisterMetaType();
+ qRegisterMetaType();
+ qmlRegisterUncreatableMetaObject(
+ AvatarProjectStatus::staticMetaObject,
+ "Hifi.AvatarPackager.AvatarProjectStatus",
+ 1, 0,
+ "AvatarProjectStatus",
+ "Error: only enums"
+ );
+ });
+
+ recentProjectsFromVariantList(_recentProjectsSetting.get());
+
+ QDir defaultProjectsDir(AvatarProject::getDefaultProjectsPath());
+ defaultProjectsDir.mkpath(".");
+}
+
+bool AvatarPackager::open() {
+ const auto packageModelDialogCreated = [=](QQmlContext* context, QObject* newObject) {
+ context->setContextProperty("AvatarPackagerCore", this);
+ };
+
+ static const QString SYSTEM_TABLET = "com.highfidelity.interface.tablet.system";
+ auto tablet = dynamic_cast(DependencyManager::get()->getTablet(SYSTEM_TABLET));
+
+ if (tablet->getToolbarMode()) {
+ static const QUrl url{ "hifi/AvatarPackagerWindow.qml" };
+ DependencyManager::get()->show(url, "AvatarPackager", packageModelDialogCreated);
+ return true;
+ }
+
+ static const QUrl url{ "hifi/tablet/AvatarPackager.qml" };
+ if (!tablet->isPathLoaded(url)) {
+ tablet->getTabletSurface()->getSurfaceContext()->setContextProperty("AvatarPackagerCore", this);
+ tablet->pushOntoStack(url);
+ return true;
+ }
+
+ return false;
+}
+
+void AvatarPackager::addCurrentProjectToRecentProjects() {
+ const int MAX_RECENT_PROJECTS = 5;
+ const QString& fstPath = _currentAvatarProject->getFSTPath();
+ auto removeProjects = QVector();
+ for (const auto& project : _recentProjects) {
+ if (project.getProjectFSTPath() == fstPath) {
+ removeProjects.append(project);
+ }
+ }
+ for (const auto& removeProject : removeProjects) {
+ _recentProjects.removeOne(removeProject);
+ }
+
+ const auto newRecentProject = RecentAvatarProject(_currentAvatarProject->getProjectName(), fstPath);
+ _recentProjects.prepend(newRecentProject);
+
+ while (_recentProjects.size() > MAX_RECENT_PROJECTS) {
+ _recentProjects.pop_back();
+ }
+
+ _recentProjectsSetting.set(recentProjectsToVariantList(false));
+ emit recentProjectsChanged();
+}
+
+QVariantList AvatarPackager::recentProjectsToVariantList(bool includeProjectPaths) const {
+ QVariantList result;
+ for (const auto& project : _recentProjects) {
+ QVariantMap projectVariant;
+ projectVariant.insert("name", project.getProjectName());
+ projectVariant.insert("path", project.getProjectFSTPath());
+ if (includeProjectPaths) {
+ projectVariant.insert("projectPath", project.getProjectPath());
+ }
+ result.append(projectVariant);
+ }
+
+ return result;
+}
+void AvatarPackager::recentProjectsFromVariantList(QVariantList projectsVariant) {
+ _recentProjects.clear();
+ for (const auto& projectVariant : projectsVariant) {
+ auto map = projectVariant.toMap();
+ _recentProjects.append(RecentAvatarProject(map.value("name").toString(), map.value("path").toString()));
+ }
+}
+
+AvatarProjectStatus::AvatarProjectStatus AvatarPackager::openAvatarProject(const QString& avatarProjectFSTPath) {
+ AvatarProjectStatus::AvatarProjectStatus status;
+ setAvatarProject(AvatarProject::openAvatarProject(avatarProjectFSTPath, status));
+ return status;
+}
+
+AvatarProjectStatus::AvatarProjectStatus AvatarPackager::createAvatarProject(const QString& projectsFolder,
+ const QString& avatarProjectName,
+ const QString& avatarModelPath,
+ const QString& textureFolder) {
+ AvatarProjectStatus::AvatarProjectStatus status;
+ setAvatarProject(AvatarProject::createAvatarProject(projectsFolder, avatarProjectName, avatarModelPath, textureFolder, status));
+ return status;
+}
+
+void AvatarPackager::setAvatarProject(AvatarProject* avatarProject) {
+ if (avatarProject == _currentAvatarProject) {
+ return;
+ }
+ if (_currentAvatarProject) {
+ _currentAvatarProject->deleteLater();
+ }
+ _currentAvatarProject = avatarProject;
+ if (_currentAvatarProject) {
+ addCurrentProjectToRecentProjects();
+ connect(_currentAvatarProject, &AvatarProject::nameChanged, this, &AvatarPackager::addCurrentProjectToRecentProjects);
+ QQmlEngine::setObjectOwnership(_currentAvatarProject, QQmlEngine::CppOwnership);
+ }
+ emit avatarProjectChanged();
+}
diff --git a/interface/src/avatar/AvatarPackager.h b/interface/src/avatar/AvatarPackager.h
new file mode 100644
index 0000000000..ec954a60d7
--- /dev/null
+++ b/interface/src/avatar/AvatarPackager.h
@@ -0,0 +1,100 @@
+//
+// AvatarPackager.h
+//
+//
+// Created by Thijs Wenker on 12/6/2018
+// Copyright 2018 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_AvatarPackager_h
+#define hifi_AvatarPackager_h
+
+#include
+#include
+
+#include "FileDialogHelper.h"
+
+#include "avatar/AvatarProject.h"
+#include "SettingHandle.h"
+
+class RecentAvatarProject {
+public:
+ RecentAvatarProject() = default;
+
+
+ RecentAvatarProject(QString projectName, QString projectFSTPath) {
+ _projectName = projectName;
+ _projectFSTPath = projectFSTPath;
+ }
+ RecentAvatarProject(const RecentAvatarProject& other) {
+ _projectName = other._projectName;
+ _projectFSTPath = other._projectFSTPath;
+ }
+
+ QString getProjectName() const { return _projectName; }
+
+ QString getProjectFSTPath() const { return _projectFSTPath; }
+
+ QString getProjectPath() const {
+ return QFileInfo(_projectFSTPath).absoluteDir().absolutePath();
+ }
+
+ bool operator==(const RecentAvatarProject& other) const {
+ return _projectName == other._projectName && _projectFSTPath == other._projectFSTPath;
+ }
+
+private:
+ QString _projectName;
+ QString _projectFSTPath;
+
+};
+
+class AvatarPackager : public QObject, public Dependency {
+ Q_OBJECT
+ SINGLETON_DEPENDENCY
+ Q_PROPERTY(AvatarProject* currentAvatarProject READ getAvatarProject NOTIFY avatarProjectChanged)
+ Q_PROPERTY(QString AVATAR_PROJECTS_PATH READ getAvatarProjectsPath CONSTANT)
+ Q_PROPERTY(QVariantList recentProjects READ getRecentProjects NOTIFY recentProjectsChanged)
+public:
+ AvatarPackager();
+ bool open();
+
+ Q_INVOKABLE AvatarProjectStatus::AvatarProjectStatus createAvatarProject(const QString& projectsFolder,
+ const QString& avatarProjectName,
+ const QString& avatarModelPath,
+ const QString& textureFolder);
+
+ Q_INVOKABLE AvatarProjectStatus::AvatarProjectStatus openAvatarProject(const QString& avatarProjectFSTPath);
+ Q_INVOKABLE bool isValidNewProjectName(const QString& projectPath, const QString& projectName) const {
+ return AvatarProject::isValidNewProjectName(projectPath, projectName);
+ }
+
+signals:
+ void avatarProjectChanged();
+ void recentProjectsChanged();
+
+private:
+ Q_INVOKABLE AvatarProject* getAvatarProject() const { return _currentAvatarProject; };
+ Q_INVOKABLE QString getAvatarProjectsPath() const { return AvatarProject::getDefaultProjectsPath(); }
+ Q_INVOKABLE QVariantList getRecentProjects() const { return recentProjectsToVariantList(true); }
+
+ void setAvatarProject(AvatarProject* avatarProject);
+
+ void addCurrentProjectToRecentProjects();
+
+ AvatarProject* _currentAvatarProject { nullptr };
+ QVector _recentProjects;
+
+ QVariantList recentProjectsToVariantList(bool includeProjectPaths) const;
+
+ void recentProjectsFromVariantList(QVariantList projectsVariant);
+
+
+ Setting::Handle _recentProjectsSetting { "io.highfidelity.avatarPackager.recentProjects", QVariantList() };
+};
+
+#endif // hifi_AvatarPackager_h
diff --git a/interface/src/avatar/AvatarProject.cpp b/interface/src/avatar/AvatarProject.cpp
new file mode 100644
index 0000000000..728917e673
--- /dev/null
+++ b/interface/src/avatar/AvatarProject.cpp
@@ -0,0 +1,260 @@
+//
+// AvatarProject.cpp
+//
+//
+// Created by Thijs Wenker on 12/7/2018
+// Copyright 2018 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 "AvatarProject.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include "FBXSerializer.h"
+#include
+#include "scripting/HMDScriptingInterface.h"
+
+AvatarProject* AvatarProject::openAvatarProject(const QString& path, AvatarProjectStatus::AvatarProjectStatus& status) {
+ status = AvatarProjectStatus::NONE;
+
+ if (!path.toLower().endsWith(".fst")) {
+ status = AvatarProjectStatus::ERROR_OPEN_INVALID_FILE_TYPE;
+ return nullptr;
+ }
+
+ QFileInfo fstFileInfo{ path };
+ if (!fstFileInfo.absoluteDir().exists()) {
+ status = AvatarProjectStatus::ERROR_OPEN_PROJECT_FOLDER;
+ return nullptr;
+ }
+
+ if (!fstFileInfo.exists()) {
+ status = AvatarProjectStatus::ERROR_OPEN_FIND_FST;
+ return nullptr;
+ }
+
+ QFile file{ fstFileInfo.filePath() };
+ if (!file.open(QIODevice::ReadOnly)) {
+ status = AvatarProjectStatus::ERROR_OPEN_OPEN_FST;
+ return nullptr;
+ }
+
+ const auto project = new AvatarProject(path, file.readAll());
+
+ QFileInfo fbxFileInfo{ project->getFBXPath() };
+ if (!fbxFileInfo.exists()) {
+ project->deleteLater();
+ status = AvatarProjectStatus::ERROR_OPEN_FIND_MODEL;
+ return nullptr;
+ }
+
+ QQmlEngine::setObjectOwnership(project, QQmlEngine::CppOwnership);
+ status = AvatarProjectStatus::SUCCESS;
+ return project;
+}
+
+AvatarProject* AvatarProject::createAvatarProject(const QString& projectsFolder, const QString& avatarProjectName,
+ const QString& avatarModelPath, const QString& textureFolder,
+ AvatarProjectStatus::AvatarProjectStatus& status) {
+ status = AvatarProjectStatus::NONE;
+
+ if (!isValidNewProjectName(projectsFolder, avatarProjectName)) {
+ status = AvatarProjectStatus::ERROR_CREATE_PROJECT_NAME;
+ return nullptr;
+ }
+
+ QDir projectDir(projectsFolder + "/" + avatarProjectName);
+ if (!projectDir.mkpath(".")) {
+ status = AvatarProjectStatus::ERROR_CREATE_CREATING_DIRECTORIES;
+ return nullptr;
+ }
+
+ QDir projectTexturesDir(projectDir.path() + "/textures");
+ if (!projectTexturesDir.mkpath(".")) {
+ status = AvatarProjectStatus::ERROR_CREATE_CREATING_DIRECTORIES;
+ return nullptr;
+ }
+
+ QDir projectScriptsDir(projectDir.path() + "/scripts");
+ if (!projectScriptsDir.mkpath(".")) {
+ status = AvatarProjectStatus::ERROR_CREATE_CREATING_DIRECTORIES;
+ return nullptr;
+ }
+
+ const auto fileName = QFileInfo(avatarModelPath).fileName();
+ const auto newModelPath = projectDir.absoluteFilePath(fileName);
+ const auto newFSTPath = projectDir.absoluteFilePath("avatar.fst");
+ QFile::copy(avatarModelPath, newModelPath);
+
+ QFileInfo fbxInfo{ newModelPath };
+ if (!fbxInfo.exists() || !fbxInfo.isFile()) {
+ status = AvatarProjectStatus::ERROR_CREATE_FIND_MODEL;
+ return nullptr;
+ }
+
+ QFile fbx{ fbxInfo.filePath() };
+ if (!fbx.open(QIODevice::ReadOnly)) {
+ status = AvatarProjectStatus::ERROR_CREATE_OPEN_MODEL;
+ return nullptr;
+ }
+
+ std::shared_ptr hfmModel;
+
+ try {
+ const QByteArray fbxContents = fbx.readAll();
+ hfmModel = FBXSerializer().read(fbxContents, QVariantHash(), fbxInfo.filePath());
+ } catch (const QString& error) {
+ Q_UNUSED(error)
+ status = AvatarProjectStatus::ERROR_CREATE_READ_MODEL;
+ return nullptr;
+ }
+ QStringList textures{};
+
+ auto addTextureToList = [&textures](hfm::Texture texture) mutable {
+ if (!texture.filename.isEmpty() && texture.content.isEmpty() && !textures.contains(texture.filename)) {
+ textures << texture.filename;
+ }
+ };
+
+ foreach(const HFMMaterial material, hfmModel->materials) {
+ addTextureToList(material.normalTexture);
+ addTextureToList(material.albedoTexture);
+ addTextureToList(material.opacityTexture);
+ addTextureToList(material.glossTexture);
+ addTextureToList(material.roughnessTexture);
+ addTextureToList(material.specularTexture);
+ addTextureToList(material.metallicTexture);
+ addTextureToList(material.emissiveTexture);
+ addTextureToList(material.occlusionTexture);
+ addTextureToList(material.scatteringTexture);
+ addTextureToList(material.lightmapTexture);
+ }
+
+ QDir textureDir(textureFolder.isEmpty() ? fbxInfo.absoluteDir() : textureFolder);
+
+ for (const auto& texture : textures) {
+ QString sourcePath = textureDir.path() + "/" + texture;
+ QString targetPath = projectTexturesDir.path() + "/" + texture;
+
+ QFileInfo sourceTexturePath(sourcePath);
+ if (sourceTexturePath.exists()) {
+ QFile::copy(sourcePath, targetPath);
+ }
+ }
+
+ auto fst = FST::createFSTFromModel(newFSTPath, newModelPath, *hfmModel);
+ fst->setName(avatarProjectName);
+
+ if (!fst->write()) {
+ status = AvatarProjectStatus::ERROR_CREATE_WRITE_FST;
+ return nullptr;
+ }
+
+ status = AvatarProjectStatus::SUCCESS;
+ return new AvatarProject(fst);
+}
+
+QStringList AvatarProject::getScriptPaths(const QDir& scriptsDir) const {
+ QStringList result{};
+ constexpr auto flags = QDir::Files | QDir::NoSymLinks | QDir::NoDotAndDotDot | QDir::Hidden;
+ if (!scriptsDir.exists()) {
+ return result;
+ }
+
+ for (const auto& script : scriptsDir.entryInfoList({}, flags)) {
+ if (script.fileName().toLower().endsWith(".js")) {
+ result.push_back("scripts/" + script.fileName());
+ }
+ }
+
+ return result;
+}
+
+bool AvatarProject::isValidNewProjectName(const QString& projectPath, const QString& projectName) {
+ if (projectPath.trimmed().isEmpty() || projectName.trimmed().isEmpty()) {
+ return false;
+ }
+ QDir dir(projectPath + "/" + projectName);
+ return !dir.exists();
+}
+
+AvatarProject::AvatarProject(const QString& fstPath, const QByteArray& data) :
+ AvatarProject::AvatarProject(new FST(fstPath, FSTReader::readMapping(data))) {
+}
+AvatarProject::AvatarProject(FST* fst) {
+ _fst = fst;
+ auto fileInfo = QFileInfo(getFSTPath());
+ _directory = fileInfo.absoluteDir();
+
+ _fst->setScriptPaths(getScriptPaths(QDir(_directory.path() + "/scripts")));
+ _fst->write();
+
+ refreshProjectFiles();
+
+ _projectPath = fileInfo.absoluteDir().absolutePath();
+}
+
+void AvatarProject::appendDirectory(const QString& prefix, const QDir& dir) {
+ constexpr auto flags = QDir::Dirs | QDir::Files | QDir::NoSymLinks | QDir::NoDotAndDotDot | QDir::Hidden;
+ for (auto& entry : dir.entryInfoList({}, flags)) {
+ if (entry.isFile()) {
+ _projectFiles.append({ entry.absoluteFilePath(), prefix + entry.fileName() });
+ } else if (entry.isDir()) {
+ appendDirectory(prefix + entry.fileName() + "/", entry.absoluteFilePath());
+ }
+ }
+}
+
+void AvatarProject::refreshProjectFiles() {
+ _projectFiles.clear();
+ appendDirectory("", _directory);
+}
+
+QStringList AvatarProject::getProjectFiles() const {
+ QStringList paths;
+ for (auto& path : _projectFiles) {
+ paths.append(path.relativePath);
+ }
+ return paths;
+}
+
+MarketplaceItemUploader* AvatarProject::upload(bool updateExisting) {
+ QUuid itemID;
+ if (updateExisting) {
+ itemID = _fst->getMarketplaceID();
+ }
+ auto uploader = new MarketplaceItemUploader(getProjectName(), "", QFileInfo(getFSTPath()).fileName(),
+ itemID, _projectFiles);
+ connect(uploader, &MarketplaceItemUploader::completed, this, [this, uploader]() {
+ if (uploader->getError() == MarketplaceItemUploader::Error::None) {
+ _fst->setMarketplaceID(uploader->getMarketplaceID());
+ _fst->write();
+ }
+ });
+
+ return uploader;
+}
+
+void AvatarProject::openInInventory() const {
+ constexpr int TIME_TO_WAIT_FOR_INVENTORY_TO_OPEN_MS { 1000 };
+
+ auto tablet = dynamic_cast(
+ DependencyManager::get()->getTablet("com.highfidelity.interface.tablet.system"));
+ tablet->loadQMLSource("hifi/commerce/wallet/Wallet.qml");
+ DependencyManager::get()->openTablet();
+ tablet->getTabletRoot()->forceActiveFocus();
+ auto name = getProjectName();
+
+ // I'm not a fan of this, but it's the only current option.
+ QTimer::singleShot(TIME_TO_WAIT_FOR_INVENTORY_TO_OPEN_MS, [name, tablet]() {
+ tablet->sendToQml(QVariantMap({ { "method", "updatePurchases" }, { "filterText", name } }));
+ });
+}
diff --git a/interface/src/avatar/AvatarProject.h b/interface/src/avatar/AvatarProject.h
new file mode 100644
index 0000000000..1710282a3e
--- /dev/null
+++ b/interface/src/avatar/AvatarProject.h
@@ -0,0 +1,115 @@
+//
+// AvatarProject.h
+//
+//
+// Created by Thijs Wenker on 12/7/2018
+// Copyright 2018 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_AvatarProject_h
+#define hifi_AvatarProject_h
+
+#include "MarketplaceItemUploader.h"
+#include "ProjectFile.h"
+#include "FST.h"
+
+#include
+#include
+#include
+#include
+
+namespace AvatarProjectStatus {
+ Q_NAMESPACE
+ enum AvatarProjectStatus {
+ NONE,
+ SUCCESS,
+ ERROR_CREATE_PROJECT_NAME,
+ ERROR_CREATE_CREATING_DIRECTORIES,
+ ERROR_CREATE_FIND_MODEL,
+ ERROR_CREATE_OPEN_MODEL,
+ ERROR_CREATE_READ_MODEL,
+ ERROR_CREATE_WRITE_FST,
+ ERROR_OPEN_INVALID_FILE_TYPE,
+ ERROR_OPEN_PROJECT_FOLDER,
+ ERROR_OPEN_FIND_FST,
+ ERROR_OPEN_OPEN_FST,
+ ERROR_OPEN_FIND_MODEL
+ };
+ Q_ENUM_NS(AvatarProjectStatus)
+}
+
+
+class AvatarProject : public QObject {
+ Q_OBJECT
+ Q_PROPERTY(FST* fst READ getFST CONSTANT)
+
+ Q_PROPERTY(QStringList projectFiles READ getProjectFiles NOTIFY projectFilesChanged)
+
+ Q_PROPERTY(QString projectFolderPath READ getProjectPath CONSTANT)
+ Q_PROPERTY(QString projectFSTPath READ getFSTPath CONSTANT)
+ Q_PROPERTY(QString projectFBXPath READ getFBXPath CONSTANT)
+ Q_PROPERTY(QString name READ getProjectName WRITE setProjectName NOTIFY nameChanged)
+
+public:
+ Q_INVOKABLE MarketplaceItemUploader* upload(bool updateExisting);
+ Q_INVOKABLE void openInInventory() const;
+ Q_INVOKABLE QStringList getProjectFiles() const;
+
+ Q_INVOKABLE QString getProjectName() const { return _fst->getName(); }
+ Q_INVOKABLE void setProjectName(const QString& newProjectName) {
+ if (newProjectName.trimmed().length() > 0) {
+ _fst->setName(newProjectName);
+ _fst->write();
+ emit nameChanged();
+ }
+ }
+ Q_INVOKABLE QString getProjectPath() const { return _projectPath; }
+ Q_INVOKABLE QString getFSTPath() const { return _fst->getPath(); }
+ Q_INVOKABLE QString getFBXPath() const {
+ return QDir::cleanPath(QDir(_projectPath).absoluteFilePath(_fst->getModelPath()));
+ }
+
+ /**
+ * returns the AvatarProject or a nullptr on failure.
+ */
+ static AvatarProject* openAvatarProject(const QString& path, AvatarProjectStatus::AvatarProjectStatus& status);
+ static AvatarProject* createAvatarProject(const QString& projectsFolder,
+ const QString& avatarProjectName,
+ const QString& avatarModelPath,
+ const QString& textureFolder,
+ AvatarProjectStatus::AvatarProjectStatus& status);
+
+ static bool isValidNewProjectName(const QString& projectPath, const QString& projectName);
+
+ static QString getDefaultProjectsPath() {
+ return QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation) + "/High Fidelity Projects";
+ }
+
+signals:
+ void nameChanged();
+ void projectFilesChanged();
+
+private:
+ AvatarProject(const QString& fstPath, const QByteArray& data);
+ AvatarProject(FST* fst);
+
+ ~AvatarProject() { _fst->deleteLater(); }
+
+ FST* getFST() { return _fst; }
+
+ void refreshProjectFiles();
+ void appendDirectory(const QString& prefix, const QDir& dir);
+ QStringList getScriptPaths(const QDir& scriptsDir) const;
+
+ FST* _fst;
+
+ QDir _directory;
+ QList _projectFiles{};
+ QString _projectPath;
+};
+
+#endif // hifi_AvatarProject_h
diff --git a/interface/src/avatar/MarketplaceItemUploader.cpp b/interface/src/avatar/MarketplaceItemUploader.cpp
new file mode 100644
index 0000000000..53b37eba4f
--- /dev/null
+++ b/interface/src/avatar/MarketplaceItemUploader.cpp
@@ -0,0 +1,321 @@
+//
+// MarketplaceItemUploader.cpp
+//
+//
+// Created by Ryan Huffman on 12/10/2018
+// Copyright 2018 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 "MarketplaceItemUploader.h"
+
+#include
+#include
+
+#ifndef Q_OS_ANDROID
+#include
+#include
+#endif
+
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+MarketplaceItemUploader::MarketplaceItemUploader(QString title,
+ QString description,
+ QString rootFilename,
+ QUuid marketplaceID,
+ QList filePaths) :
+ _title(title),
+ _description(description),
+ _rootFilename(rootFilename),
+ _marketplaceID(marketplaceID),
+ _filePaths(filePaths) {
+}
+
+void MarketplaceItemUploader::setState(State newState) {
+ Q_ASSERT(_state != State::Complete);
+ Q_ASSERT(_error == Error::None);
+ Q_ASSERT(newState != _state);
+
+ _state = newState;
+ emit stateChanged(newState);
+ if (newState == State::Complete) {
+ emit completed();
+ emit finishedChanged();
+ }
+}
+
+void MarketplaceItemUploader::setError(Error error) {
+ Q_ASSERT(_state != State::Complete);
+ Q_ASSERT(_error == Error::None);
+
+ _error = error;
+ emit errorChanged(error);
+ emit finishedChanged();
+}
+
+void MarketplaceItemUploader::send() {
+ doGetCategories();
+}
+
+void MarketplaceItemUploader::doGetCategories() {
+ setState(State::GettingCategories);
+
+ static const QString path = "/api/v1/marketplace/categories";
+
+ auto accountManager = DependencyManager::get();
+ auto request = accountManager->createRequest(path, AccountManagerAuth::None);
+
+ qWarning() << "Request url is: " << request.url();
+
+ QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance();
+
+ QNetworkReply* reply = networkAccessManager.get(request);
+
+ connect(reply, &QNetworkReply::finished, this, [this, reply]() {
+ auto error = reply->error();
+ if (error == QNetworkReply::NoError) {
+ auto doc = QJsonDocument::fromJson(reply->readAll());
+ auto extractCategoryID = [&doc]() -> std::pair {
+ auto items = doc.object()["data"].toObject()["items"];
+ if (!items.isArray()) {
+ qWarning() << "Categories parse error: data.items is not an array";
+ return { false, 0 };
+ }
+
+ auto itemsArray = items.toArray();
+ for (const auto item : itemsArray) {
+ if (!item.isObject()) {
+ qWarning() << "Categories parse error: item is not an object";
+ return { false, 0 };
+ }
+
+ auto itemObject = item.toObject();
+ if (itemObject["name"].toString() == "Avatars") {
+ auto idValue = itemObject["id"];
+ if (!idValue.isDouble()) {
+ qWarning() << "Categories parse error: id is not a number";
+ return { false, 0 };
+ }
+ return { true, (int)idValue.toDouble() };
+ }
+ }
+
+ qWarning() << "Categories parse error: could not find a category for 'Avatar'";
+ return { false, 0 };
+ };
+
+ bool success;
+ std::tie(success, _categoryID) = extractCategoryID();
+ if (!success) {
+ qWarning() << "Failed to find marketplace category id";
+ setError(Error::Unknown);
+ } else {
+ qDebug() << "Marketplace Avatar category ID is" << _categoryID;
+ doUploadAvatar();
+ }
+ } else {
+ setError(Error::Unknown);
+ }
+ });
+}
+
+void MarketplaceItemUploader::doUploadAvatar() {
+#ifdef Q_OS_ANDROID
+ qWarning() << "Marketplace uploading is not supported on Android";
+ setError(Error::Unknown);
+ return;
+#else
+ QBuffer buffer{ &_fileData };
+ QuaZip zip{ &buffer };
+ if (!zip.open(QuaZip::Mode::mdAdd)) {
+ qWarning() << "Failed to open zip";
+ setError(Error::Unknown);
+ return;
+ }
+
+ for (auto& filePath : _filePaths) {
+ qWarning() << "Zipping: " << filePath.absolutePath << filePath.relativePath;
+ QFileInfo fileInfo{ filePath.absolutePath };
+
+ QuaZipFile zipFile{ &zip };
+ if (!zipFile.open(QIODevice::WriteOnly, QuaZipNewInfo(filePath.relativePath))) {
+ qWarning() << "Could not open zip file:" << zipFile.getZipError();
+ setError(Error::Unknown);
+ return;
+ }
+ QFile file{ filePath.absolutePath };
+ if (file.open(QIODevice::ReadOnly)) {
+ zipFile.write(file.readAll());
+ } else {
+ qWarning() << "Failed to open: " << filePath.absolutePath;
+ }
+ file.close();
+ zipFile.close();
+ if (zipFile.getZipError() != UNZ_OK) {
+ qWarning() << "Could not close zip file: " << zipFile.getZipError();
+ setState(State::Complete);
+ return;
+ }
+ }
+
+ zip.close();
+
+ qDebug() << "Finished zipping, size: " << (buffer.size() / (1000.0f)) << "KB";
+
+ QString path = "/api/v1/marketplace/items";
+ bool creating = true;
+ if (!_marketplaceID.isNull()) {
+ creating = false;
+ auto idWithBraces = _marketplaceID.toString();
+ auto idWithoutBraces = idWithBraces.mid(1, idWithBraces.length() - 2);
+ path += "/" + idWithoutBraces;
+ }
+ auto accountManager = DependencyManager::get();
+ auto request = accountManager->createRequest(path, AccountManagerAuth::Required);
+ request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json");
+
+ // TODO(huffman) add JSON escaping
+ auto escapeJson = [](QString str) -> QString { return str; };
+
+ QString jsonString = "{\"marketplace_item\":{";
+ jsonString += "\"title\":\"" + escapeJson(_title) + "\"";
+
+ // Items cannot have their description updated after they have been submitted.
+ if (creating) {
+ jsonString += ",\"description\":\"" + escapeJson(_description) + "\"";
+ }
+
+ jsonString += ",\"root_file_key\":\"" + escapeJson(_rootFilename) + "\"";
+ jsonString += ",\"category_ids\":[" + QStringLiteral("%1").arg(_categoryID) + "]";
+ jsonString += ",\"license\":0";
+ jsonString += ",\"files\":\"" + QString::fromLatin1(_fileData.toBase64()) + "\"}}";
+
+ QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance();
+
+ QNetworkReply* reply{ nullptr };
+ if (creating) {
+ reply = networkAccessManager.post(request, jsonString.toUtf8());
+ } else {
+ reply = networkAccessManager.put(request, jsonString.toUtf8());
+ }
+
+ connect(reply, &QNetworkReply::uploadProgress, this, [this](float bytesSent, float bytesTotal) {
+ if (_state == State::UploadingAvatar) {
+ emit uploadProgress(bytesSent, bytesTotal);
+ if (bytesSent >= bytesTotal) {
+ setState(State::WaitingForUploadResponse);
+ }
+ }
+ });
+
+ connect(reply, &QNetworkReply::finished, this, [this, reply]() {
+ _responseData = reply->readAll();
+
+ auto error = reply->error();
+ if (error == QNetworkReply::NoError) {
+ auto doc = QJsonDocument::fromJson(_responseData.toLatin1());
+ auto status = doc.object()["status"].toString();
+ if (status == "success") {
+ _marketplaceID = QUuid::fromString(doc["data"].toObject()["marketplace_id"].toString());
+ _itemVersion = doc["data"].toObject()["version"].toDouble();
+ setState(State::WaitingForInventory);
+ doWaitForInventory();
+ } else {
+ qWarning() << "Got error response while uploading avatar: " << _responseData;
+ setError(Error::Unknown);
+ }
+ } else {
+ qWarning() << "Got error while uploading avatar: " << reply->error() << reply->errorString() << _responseData;
+ setError(Error::Unknown);
+ }
+ });
+
+ setState(State::UploadingAvatar);
+#endif
+}
+
+void MarketplaceItemUploader::doWaitForInventory() {
+ static const QString path = "/api/v1/commerce/inventory";
+
+ auto accountManager = DependencyManager::get();
+ auto request = accountManager->createRequest(path, AccountManagerAuth::Required);
+
+ QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance();
+
+ QNetworkReply* reply = networkAccessManager.post(request, "");
+
+ _numRequestsForInventory++;
+
+ connect(reply, &QNetworkReply::finished, this, [this, reply]() {
+ auto data = reply->readAll();
+
+ bool success = false;
+
+ auto error = reply->error();
+ if (error == QNetworkReply::NoError) {
+ // Parse response data
+ auto doc = QJsonDocument::fromJson(data);
+ auto isAssetAvailable = [this, &doc]() -> bool {
+ if (!doc.isObject()) {
+ return false;
+ }
+ auto root = doc.object();
+ auto status = root["status"].toString();
+ if (status != "success") {
+ return false;
+ }
+ auto data = root["data"];
+ if (!data.isObject()) {
+ return false;
+ }
+ auto assets = data.toObject()["assets"];
+ if (!assets.isArray()) {
+ return false;
+ }
+ for (auto asset : assets.toArray()) {
+ auto assetObject = asset.toObject();
+ auto id = QUuid::fromString(assetObject["id"].toString());
+ if (id.isNull()) {
+ continue;
+ }
+ if (id == _marketplaceID) {
+ auto version = assetObject["version"];
+ auto valid = assetObject["valid"];
+ if (version.isDouble() && valid.isBool()) {
+ if ((int)version.toDouble() >= _itemVersion && valid.toBool()) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ };
+
+ success = isAssetAvailable();
+ }
+ if (success) {
+ qDebug() << "Found item in inventory";
+ setState(State::Complete);
+ } else {
+ constexpr int MAX_INVENTORY_REQUESTS { 8 };
+ constexpr int TIME_BETWEEN_INVENTORY_REQUESTS_MS { 5000 };
+ if (_numRequestsForInventory > MAX_INVENTORY_REQUESTS) {
+ qDebug() << "Failed to find item in inventory";
+ setError(Error::Unknown);
+ } else {
+ QTimer::singleShot(TIME_BETWEEN_INVENTORY_REQUESTS_MS, [this]() { doWaitForInventory(); });
+ }
+ }
+ });
+}
diff --git a/interface/src/avatar/MarketplaceItemUploader.h b/interface/src/avatar/MarketplaceItemUploader.h
new file mode 100644
index 0000000000..998413da88
--- /dev/null
+++ b/interface/src/avatar/MarketplaceItemUploader.h
@@ -0,0 +1,105 @@
+//
+// MarketplaceItemUploader.h
+//
+//
+// Created by Ryan Huffman on 12/10/2018
+// Copyright 2018 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_MarketplaceItemUploader_h
+#define hifi_MarketplaceItemUploader_h
+
+#include "ProjectFile.h"
+
+#include
+#include
+
+class QNetworkReply;
+
+class MarketplaceItemUploader : public QObject {
+ Q_OBJECT
+
+ Q_PROPERTY(bool finished READ getFinished NOTIFY finishedChanged)
+
+ Q_PROPERTY(bool complete READ getComplete NOTIFY stateChanged)
+ Q_PROPERTY(State state READ getState NOTIFY stateChanged)
+ Q_PROPERTY(Error error READ getError NOTIFY errorChanged)
+ Q_PROPERTY(QString responseData READ getResponseData)
+public:
+ enum class Error {
+ None,
+ Unknown,
+ };
+ Q_ENUM(Error);
+
+ enum class State {
+ Idle,
+ GettingCategories,
+ UploadingAvatar,
+ WaitingForUploadResponse,
+ WaitingForInventory,
+ Complete,
+ };
+ Q_ENUM(State);
+
+ MarketplaceItemUploader(QString title,
+ QString description,
+ QString rootFilename,
+ QUuid marketplaceID,
+ QList filePaths);
+
+ Q_INVOKABLE void send();
+
+ void setError(Error error);
+
+ QString getResponseData() const { return _responseData; }
+ void setState(State newState);
+ State getState() const { return _state; }
+ bool getComplete() const { return _state == State::Complete; }
+
+ QUuid getMarketplaceID() const { return _marketplaceID; }
+
+ Error getError() const { return _error; }
+ bool getFinished() const { return _state == State::Complete || _error != Error::None; }
+
+signals:
+ void uploadProgress(qint64 bytesSent, qint64 bytesTotal);
+ void completed();
+
+ void stateChanged(State newState);
+ void errorChanged(Error error);
+
+ // Triggered when the upload has finished, either succesfully completing, or stopping with an error
+ void finishedChanged();
+
+private:
+ void doGetCategories();
+ void doUploadAvatar();
+ void doWaitForInventory();
+
+ QNetworkReply* _reply;
+
+ State _state { State::Idle };
+ Error _error { Error::None };
+
+ QString _title;
+ QString _description;
+ QString _rootFilename;
+ QUuid _marketplaceID;
+ int _categoryID;
+ int _itemVersion;
+
+ QString _responseData;
+
+ int _numRequestsForInventory { 0 };
+
+ QString _rootFilePath;
+ QList _filePaths;
+ QByteArray _fileData;
+};
+
+#endif // hifi_MarketplaceItemUploader_h
diff --git a/libraries/avatars/src/ProjectFile.h b/libraries/avatars/src/ProjectFile.h
new file mode 100644
index 0000000000..4040eb1ce5
--- /dev/null
+++ b/libraries/avatars/src/ProjectFile.h
@@ -0,0 +1,13 @@
+#ifndef hifi_AvatarProjectFile_h
+#define hifi_AvatarProjectFile_h
+
+#include
+
+class ProjectFilePath {
+ Q_GADGET;
+public:
+ QString absolutePath;
+ QString relativePath;
+};
+
+#endif // hifi_AvatarProjectFile_h
diff --git a/libraries/fbx/src/FST.cpp b/libraries/fbx/src/FST.cpp
new file mode 100644
index 0000000000..7828037c74
--- /dev/null
+++ b/libraries/fbx/src/FST.cpp
@@ -0,0 +1,190 @@
+//
+// FST.cpp
+//
+// Created by Ryan Huffman on 12/11/15.
+// Copyright 2018 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 "FST.h"
+
+#include
+#include
+#include
+
+constexpr float DEFAULT_SCALE { 1.0f };
+
+FST::FST(QString fstPath, QVariantHash data) : _fstPath(std::move(fstPath)) {
+
+ auto setValueFromFSTData = [&data] (const QString& propertyID, auto &targetProperty) mutable {
+ if (data.contains(propertyID)) {
+ targetProperty = data[propertyID].toString();
+ data.remove(propertyID);
+ }
+ };
+ setValueFromFSTData(NAME_FIELD, _name);
+ setValueFromFSTData(FILENAME_FIELD, _modelPath);
+ setValueFromFSTData(MARKETPLACE_ID_FIELD, _marketplaceID);
+
+ if (data.contains(SCRIPT_FIELD)) {
+ QVariantList scripts = data.values(SCRIPT_FIELD);
+ for (const auto& script : scripts) {
+ _scriptPaths.push_back(script.toString());
+ }
+ data.remove(SCRIPT_FIELD);
+ }
+
+ _other = data;
+}
+
+FST* FST::createFSTFromModel(const QString& fstPath, const QString& modelFilePath, const hfm::Model& hfmModel) {
+ QVariantHash mapping;
+
+ // mixamo files - in the event that a mixamo file was edited by some other tool, it's likely the applicationName will
+ // be rewritten, so we detect the existence of several different blendshapes which indicate we're likely a mixamo file
+ bool likelyMixamoFile = hfmModel.applicationName == "mixamo.com" ||
+ (hfmModel.blendshapeChannelNames.contains("BrowsDown_Right") &&
+ hfmModel.blendshapeChannelNames.contains("MouthOpen") &&
+ hfmModel.blendshapeChannelNames.contains("Blink_Left") &&
+ hfmModel.blendshapeChannelNames.contains("Blink_Right") &&
+ hfmModel.blendshapeChannelNames.contains("Squint_Right"));
+
+ mapping.insert(NAME_FIELD, QFileInfo(fstPath).baseName());
+ mapping.insert(FILENAME_FIELD, QFileInfo(modelFilePath).fileName());
+ mapping.insert(TEXDIR_FIELD, "textures");
+
+ // mixamo/autodesk defaults
+ mapping.insert(SCALE_FIELD, DEFAULT_SCALE);
+ QVariantHash joints = mapping.value(JOINT_FIELD).toHash();
+ joints.insert("jointEyeLeft", hfmModel.jointIndices.contains("jointEyeLeft") ? "jointEyeLeft" :
+ (hfmModel.jointIndices.contains("EyeLeft") ? "EyeLeft" : "LeftEye"));
+
+ joints.insert("jointEyeRight", hfmModel.jointIndices.contains("jointEyeRight") ? "jointEyeRight" :
+ hfmModel.jointIndices.contains("EyeRight") ? "EyeRight" : "RightEye");
+
+ joints.insert("jointNeck", hfmModel.jointIndices.contains("jointNeck") ? "jointNeck" : "Neck");
+ joints.insert("jointRoot", "Hips");
+ joints.insert("jointLean", "Spine");
+ joints.insert("jointLeftHand", "LeftHand");
+ joints.insert("jointRightHand", "RightHand");
+
+ const char* topName = likelyMixamoFile ? "HeadTop_End" : "HeadEnd";
+ joints.insert("jointHead", hfmModel.jointIndices.contains(topName) ? topName : "Head");
+
+ mapping.insert(JOINT_FIELD, joints);
+
+ QVariantHash jointIndices;
+ for (int i = 0; i < hfmModel.joints.size(); i++) {
+ jointIndices.insert(hfmModel.joints.at(i).name, QString::number(i));
+ }
+ mapping.insert(JOINT_INDEX_FIELD, jointIndices);
+
+ mapping.insertMulti(FREE_JOINT_FIELD, "LeftArm");
+ mapping.insertMulti(FREE_JOINT_FIELD, "LeftForeArm");
+ mapping.insertMulti(FREE_JOINT_FIELD, "RightArm");
+ mapping.insertMulti(FREE_JOINT_FIELD, "RightForeArm");
+
+
+ // If there are no blendshape mappings, and we detect that this is likely a mixamo file,
+ // then we can add the default mixamo to "faceshift" mappings
+ if (likelyMixamoFile) {
+ QVariantHash blendshapes;
+ blendshapes.insertMulti("BrowsD_L", QVariantList() << "BrowsDown_Left" << 1.0);
+ blendshapes.insertMulti("BrowsD_R", QVariantList() << "BrowsDown_Right" << 1.0);
+ blendshapes.insertMulti("BrowsU_C", QVariantList() << "BrowsUp_Left" << 1.0);
+ blendshapes.insertMulti("BrowsU_C", QVariantList() << "BrowsUp_Right" << 1.0);
+ blendshapes.insertMulti("BrowsU_L", QVariantList() << "BrowsUp_Left" << 1.0);
+ blendshapes.insertMulti("BrowsU_R", QVariantList() << "BrowsUp_Right" << 1.0);
+ blendshapes.insertMulti("ChinLowerRaise", QVariantList() << "Jaw_Up" << 1.0);
+ blendshapes.insertMulti("ChinUpperRaise", QVariantList() << "UpperLipUp_Left" << 0.5);
+ blendshapes.insertMulti("ChinUpperRaise", QVariantList() << "UpperLipUp_Right" << 0.5);
+ blendshapes.insertMulti("EyeBlink_L", QVariantList() << "Blink_Left" << 1.0);
+ blendshapes.insertMulti("EyeBlink_R", QVariantList() << "Blink_Right" << 1.0);
+ blendshapes.insertMulti("EyeOpen_L", QVariantList() << "EyesWide_Left" << 1.0);
+ blendshapes.insertMulti("EyeOpen_R", QVariantList() << "EyesWide_Right" << 1.0);
+ blendshapes.insertMulti("EyeSquint_L", QVariantList() << "Squint_Left" << 1.0);
+ blendshapes.insertMulti("EyeSquint_R", QVariantList() << "Squint_Right" << 1.0);
+ blendshapes.insertMulti("JawFwd", QVariantList() << "JawForeward" << 1.0);
+ blendshapes.insertMulti("JawLeft", QVariantList() << "JawRotateY_Left" << 0.5);
+ blendshapes.insertMulti("JawOpen", QVariantList() << "MouthOpen" << 0.7);
+ blendshapes.insertMulti("JawRight", QVariantList() << "Jaw_Right" << 1.0);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "JawForeward" << 0.39);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "Jaw_Down" << 0.36);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthNarrow_Left" << 1.0);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthNarrow_Right" << 1.0);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthWhistle_NarrowAdjust_Left" << 0.5);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthWhistle_NarrowAdjust_Right" << 0.5);
+ blendshapes.insertMulti("LipsFunnel", QVariantList() << "TongueUp" << 1.0);
+ blendshapes.insertMulti("LipsLowerClose", QVariantList() << "LowerLipIn" << 1.0);
+ blendshapes.insertMulti("LipsLowerDown", QVariantList() << "LowerLipDown_Left" << 0.7);
+ blendshapes.insertMulti("LipsLowerDown", QVariantList() << "LowerLipDown_Right" << 0.7);
+ blendshapes.insertMulti("LipsLowerOpen", QVariantList() << "LowerLipOut" << 1.0);
+ blendshapes.insertMulti("LipsPucker", QVariantList() << "MouthNarrow_Left" << 1.0);
+ blendshapes.insertMulti("LipsPucker", QVariantList() << "MouthNarrow_Right" << 1.0);
+ blendshapes.insertMulti("LipsUpperClose", QVariantList() << "UpperLipIn" << 1.0);
+ blendshapes.insertMulti("LipsUpperOpen", QVariantList() << "UpperLipOut" << 1.0);
+ blendshapes.insertMulti("LipsUpperUp", QVariantList() << "UpperLipUp_Left" << 0.7);
+ blendshapes.insertMulti("LipsUpperUp", QVariantList() << "UpperLipUp_Right" << 0.7);
+ blendshapes.insertMulti("MouthDimple_L", QVariantList() << "Smile_Left" << 0.25);
+ blendshapes.insertMulti("MouthDimple_R", QVariantList() << "Smile_Right" << 0.25);
+ blendshapes.insertMulti("MouthFrown_L", QVariantList() << "Frown_Left" << 1.0);
+ blendshapes.insertMulti("MouthFrown_R", QVariantList() << "Frown_Right" << 1.0);
+ blendshapes.insertMulti("MouthLeft", QVariantList() << "Midmouth_Left" << 1.0);
+ blendshapes.insertMulti("MouthRight", QVariantList() << "Midmouth_Right" << 1.0);
+ blendshapes.insertMulti("MouthSmile_L", QVariantList() << "Smile_Left" << 1.0);
+ blendshapes.insertMulti("MouthSmile_R", QVariantList() << "Smile_Right" << 1.0);
+ blendshapes.insertMulti("Puff", QVariantList() << "CheekPuff_Left" << 1.0);
+ blendshapes.insertMulti("Puff", QVariantList() << "CheekPuff_Right" << 1.0);
+ blendshapes.insertMulti("Sneer", QVariantList() << "NoseScrunch_Left" << 0.75);
+ blendshapes.insertMulti("Sneer", QVariantList() << "NoseScrunch_Right" << 0.75);
+ blendshapes.insertMulti("Sneer", QVariantList() << "Squint_Left" << 0.5);
+ blendshapes.insertMulti("Sneer", QVariantList() << "Squint_Right" << 0.5);
+ mapping.insert(BLENDSHAPE_FIELD, blendshapes);
+ }
+ return new FST(fstPath, mapping);
+}
+
+QString FST::absoluteModelPath() const {
+ QFileInfo fileInfo{ _fstPath };
+ QDir dir{ fileInfo.absoluteDir() };
+ return dir.absoluteFilePath(_modelPath);
+}
+
+void FST::setName(const QString& name) {
+ _name = name;
+ emit nameChanged(name);
+}
+
+void FST::setModelPath(const QString& modelPath) {
+ _modelPath = modelPath;
+ emit modelPathChanged(modelPath);
+}
+
+QVariantHash FST::getMapping() const {
+ QVariantHash mapping;
+ mapping.unite(_other);
+ mapping.insert(NAME_FIELD, _name);
+ mapping.insert(FILENAME_FIELD, _modelPath);
+ mapping.insert(MARKETPLACE_ID_FIELD, _marketplaceID);
+ for (const auto& scriptPath : _scriptPaths) {
+ mapping.insertMulti(SCRIPT_FIELD, scriptPath);
+ }
+ return mapping;
+}
+
+bool FST::write() {
+ QFile fst(_fstPath);
+ if (!fst.open(QIODevice::WriteOnly)) {
+ return false;
+ }
+ fst.write(FSTReader::writeMapping(getMapping()));
+ return true;
+}
+
+void FST::setMarketplaceID(QUuid marketplaceID) {
+ _marketplaceID = marketplaceID;
+ emit marketplaceIDChanged();
+}
diff --git a/libraries/fbx/src/FST.h b/libraries/fbx/src/FST.h
new file mode 100644
index 0000000000..0f4c1ecd3a
--- /dev/null
+++ b/libraries/fbx/src/FST.h
@@ -0,0 +1,71 @@
+//
+// FST.h
+//
+// Created by Ryan Huffman on 12/11/15.
+// Copyright 2018 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
+//
+
+#ifndef hifi_FST_h
+#define hifi_FST_h
+
+#include
+#include
+#include "FSTReader.h"
+
+namespace hfm {
+ class Model;
+};
+
+class FST : public QObject {
+ Q_OBJECT
+ Q_PROPERTY(QString name READ getName WRITE setName NOTIFY nameChanged)
+ Q_PROPERTY(QString modelPath READ getModelPath WRITE setModelPath NOTIFY modelPathChanged)
+ Q_PROPERTY(QUuid marketplaceID READ getMarketplaceID)
+ Q_PROPERTY(bool hasMarketplaceID READ getHasMarketplaceID NOTIFY marketplaceIDChanged)
+public:
+ FST(QString fstPath, QVariantHash data);
+
+ static FST* createFSTFromModel(const QString& fstPath, const QString& modelFilePath, const hfm::Model& hfmModel);
+
+ QString absoluteModelPath() const;
+
+ QString getName() const { return _name; }
+ void setName(const QString& name);
+
+ QString getModelPath() const { return _modelPath; }
+ void setModelPath(const QString& modelPath);
+
+ Q_INVOKABLE bool getHasMarketplaceID() const { return !_marketplaceID.isNull(); }
+ QUuid getMarketplaceID() const { return _marketplaceID; }
+ void setMarketplaceID(QUuid marketplaceID);
+
+ QStringList getScriptPaths() const { return _scriptPaths; }
+ void setScriptPaths(QStringList scriptPaths) { _scriptPaths = scriptPaths; }
+
+ QString getPath() const { return _fstPath; }
+
+ QVariantHash getMapping() const;
+
+ bool write();
+
+signals:
+ void nameChanged(const QString& name);
+ void modelPathChanged(const QString& modelPath);
+ void marketplaceIDChanged();
+
+private:
+ QString _fstPath;
+
+ QString _name{};
+ QString _modelPath{};
+ QUuid _marketplaceID{};
+
+ QStringList _scriptPaths{};
+
+ QVariantHash _other{};
+};
+
+#endif // hifi_FST_h
diff --git a/libraries/fbx/src/FSTReader.cpp b/libraries/fbx/src/FSTReader.cpp
index 75596862d2..43806560dc 100644
--- a/libraries/fbx/src/FSTReader.cpp
+++ b/libraries/fbx/src/FSTReader.cpp
@@ -84,7 +84,7 @@ void FSTReader::writeVariant(QBuffer& buffer, QVariantHash::const_iterator& it)
QByteArray FSTReader::writeMapping(const QVariantHash& mapping) {
static const QStringList PREFERED_ORDER = QStringList() << NAME_FIELD << TYPE_FIELD << SCALE_FIELD << FILENAME_FIELD
- << TEXDIR_FIELD << SCRIPT_FIELD << JOINT_FIELD << FREE_JOINT_FIELD
+ << MARKETPLACE_ID_FIELD << TEXDIR_FIELD << SCRIPT_FIELD << JOINT_FIELD << FREE_JOINT_FIELD
<< BLENDSHAPE_FIELD << JOINT_INDEX_FIELD;
QBuffer buffer;
buffer.open(QIODevice::WriteOnly);
diff --git a/libraries/fbx/src/FSTReader.h b/libraries/fbx/src/FSTReader.h
index 00244877b3..993d7c3148 100644
--- a/libraries/fbx/src/FSTReader.h
+++ b/libraries/fbx/src/FSTReader.h
@@ -18,6 +18,7 @@
static const QString NAME_FIELD = "name";
static const QString TYPE_FIELD = "type";
static const QString FILENAME_FIELD = "filename";
+static const QString MARKETPLACE_ID_FIELD = "marketplaceID";
static const QString TEXDIR_FIELD = "texdir";
static const QString LOD_FIELD = "lod";
static const QString JOINT_INDEX_FIELD = "jointIndex";
diff --git a/libraries/networking/src/AccountManager.cpp b/libraries/networking/src/AccountManager.cpp
index 5721ac9334..989661cb81 100644
--- a/libraries/networking/src/AccountManager.cpp
+++ b/libraries/networking/src/AccountManager.cpp
@@ -208,6 +208,44 @@ void AccountManager::setSessionID(const QUuid& sessionID) {
}
}
+QNetworkRequest AccountManager::createRequest(QString path, AccountManagerAuth::Type authType) {
+ QNetworkRequest networkRequest;
+ networkRequest.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
+ networkRequest.setHeader(QNetworkRequest::UserAgentHeader, _userAgentGetter());
+
+ networkRequest.setRawHeader(METAVERSE_SESSION_ID_HEADER,
+ uuidStringWithoutCurlyBraces(_sessionID).toLocal8Bit());
+
+ QUrl requestURL = _authURL;
+
+ if (requestURL.isEmpty()) { // Assignment client doesn't set _authURL.
+ requestURL = getMetaverseServerURL();
+ }
+
+ if (path.startsWith("/")) {
+ requestURL.setPath(path);
+ } else {
+ requestURL.setPath("/" + path);
+ }
+
+ if (authType != AccountManagerAuth::None ) {
+ if (hasValidAccessToken()) {
+ networkRequest.setRawHeader(ACCESS_TOKEN_AUTHORIZATION_HEADER,
+ _accountInfo.getAccessToken().authorizationHeaderValue());
+ } else {
+ if (authType == AccountManagerAuth::Required) {
+ qCDebug(networking) << "No valid access token present. Bailing on invoked request to"
+ << path << "that requires authentication";
+ return QNetworkRequest();
+ }
+ }
+ }
+
+ networkRequest.setUrl(requestURL);
+
+ return networkRequest;
+}
+
void AccountManager::sendRequest(const QString& path,
AccountManagerAuth::Type authType,
QNetworkAccessManager::Operation operation,
@@ -231,46 +269,10 @@ void AccountManager::sendRequest(const QString& path,
QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance();
- QNetworkRequest networkRequest;
- networkRequest.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
- networkRequest.setHeader(QNetworkRequest::UserAgentHeader, _userAgentGetter());
-
- networkRequest.setRawHeader(METAVERSE_SESSION_ID_HEADER,
- uuidStringWithoutCurlyBraces(_sessionID).toLocal8Bit());
-
- QUrl requestURL = _authURL;
-
- if (requestURL.isEmpty()) { // Assignment client doesn't set _authURL.
- requestURL = getMetaverseServerURL();
- }
-
- if (path.startsWith("/")) {
- requestURL.setPath(path);
- } else {
- requestURL.setPath("/" + path);
- }
-
- if (!query.isEmpty()) {
- requestURL.setQuery(query);
- }
-
- if (authType != AccountManagerAuth::None ) {
- if (hasValidAccessToken()) {
- networkRequest.setRawHeader(ACCESS_TOKEN_AUTHORIZATION_HEADER,
- _accountInfo.getAccessToken().authorizationHeaderValue());
- } else {
- if (authType == AccountManagerAuth::Required) {
- qCDebug(networking) << "No valid access token present. Bailing on invoked request to"
- << path << "that requires authentication";
- return;
- }
- }
- }
-
- networkRequest.setUrl(requestURL);
+ QNetworkRequest networkRequest = createRequest(path, authType);
if (VERBOSE_HTTP_REQUEST_DEBUGGING) {
- qCDebug(networking) << "Making a request to" << qPrintable(requestURL.toString());
+ qCDebug(networking) << "Making a request to" << qPrintable(networkRequest.url().toString());
if (!dataByteArray.isEmpty()) {
qCDebug(networking) << "The POST/PUT body -" << QString(dataByteArray);
diff --git a/libraries/networking/src/AccountManager.h b/libraries/networking/src/AccountManager.h
index d5406707e7..ca2b826c98 100644
--- a/libraries/networking/src/AccountManager.h
+++ b/libraries/networking/src/AccountManager.h
@@ -28,7 +28,8 @@
class JSONCallbackParameters {
public:
- JSONCallbackParameters(QObject* callbackReceiver = nullptr, const QString& jsonCallbackMethod = QString(),
+ JSONCallbackParameters(QObject* callbackReceiver = nullptr,
+ const QString& jsonCallbackMethod = QString(),
const QString& errorCallbackMethod = QString());
bool isEmpty() const { return !callbackReceiver; }
@@ -39,11 +40,11 @@ public:
};
namespace AccountManagerAuth {
- enum Type {
- None,
- Required,
- Optional
- };
+enum Type {
+ None,
+ Required,
+ Optional,
+};
}
Q_DECLARE_METATYPE(AccountManagerAuth::Type);
@@ -60,6 +61,7 @@ class AccountManager : public QObject, public Dependency {
public:
AccountManager(UserAgentGetter userAgentGetter = DEFAULT_USER_AGENT_GETTER);
+ QNetworkRequest createRequest(QString path, AccountManagerAuth::Type authType);
Q_INVOKABLE void sendRequest(const QString& path,
AccountManagerAuth::Type authType,
QNetworkAccessManager::Operation operation = QNetworkAccessManager::GetOperation,
@@ -84,7 +86,7 @@ public:
void requestProfile();
DataServerAccountInfo& getAccountInfo() { return _accountInfo; }
- void setAccountInfo(const DataServerAccountInfo &newAccountInfo);
+ void setAccountInfo(const DataServerAccountInfo& newAccountInfo);
static QJsonObject dataObjectFromResponse(QNetworkReply* requestReply);
@@ -104,7 +106,10 @@ public:
public slots:
void requestAccessToken(const QString& login, const QString& password);
void requestAccessTokenWithSteam(QByteArray authSessionTicket);
- void requestAccessTokenWithAuthCode(const QString& authCode, const QString& clientId, const QString& clientSecret, const QString& redirectUri);
+ void requestAccessTokenWithAuthCode(const QString& authCode,
+ const QString& clientId,
+ const QString& clientSecret,
+ const QString& redirectUri);
void refreshAccessToken();
void requestAccessTokenFinished();
@@ -159,4 +164,4 @@ private:
bool _limitedCommerce { false };
};
-#endif // hifi_AccountManager_h
+#endif // hifi_AccountManager_h