diff --git a/.gitignore b/.gitignore
index 0a66b6eb33..60b59e6829 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,7 +42,6 @@ plugins/cura-siemensnx-plugin
plugins/CuraBlenderPlugin
plugins/CuraCloudPlugin
plugins/CuraDrivePlugin
-plugins/CuraDrive
plugins/CuraLiveScriptingPlugin
plugins/CuraOpenSCADPlugin
plugins/CuraPrintProfileCreator
diff --git a/plugins/CuraDrive/__init__.py b/plugins/CuraDrive/__init__.py
new file mode 100644
index 0000000000..6612a5d614
--- /dev/null
+++ b/plugins/CuraDrive/__init__.py
@@ -0,0 +1,14 @@
+# Copyright (c) 2017 Ultimaker B.V.
+import os
+
+is_testing = os.getenv('ENV_NAME', "development") == "testing"
+
+# Only load the whole plugin when not running tests as __init__.py is automatically loaded by PyTest
+if not is_testing:
+ from .src.DrivePluginExtension import DrivePluginExtension
+
+ def getMetaData():
+ return {}
+
+ def register(app):
+ return {"extension": DrivePluginExtension(app)}
diff --git a/plugins/CuraDrive/plugin.json b/plugins/CuraDrive/plugin.json
new file mode 100644
index 0000000000..134cd31a77
--- /dev/null
+++ b/plugins/CuraDrive/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "Cura Backups",
+ "author": "Ultimaker B.V.",
+ "description": "Backup and restore your configuration.",
+ "version": "1.2.1",
+ "api": 5,
+ "i18n-catalog": "cura_drive"
+}
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/DriveApiService.py b/plugins/CuraDrive/src/DriveApiService.py
new file mode 100644
index 0000000000..a677466838
--- /dev/null
+++ b/plugins/CuraDrive/src/DriveApiService.py
@@ -0,0 +1,185 @@
+# Copyright (c) 2017 Ultimaker B.V.
+import base64
+import hashlib
+from datetime import datetime
+from tempfile import NamedTemporaryFile
+from typing import Optional, List, Dict
+
+import requests
+
+from UM.Logger import Logger
+from UM.Message import Message
+from UM.Signal import Signal
+
+from .UploadBackupJob import UploadBackupJob
+from .Settings import Settings
+
+
+class DriveApiService:
+ """
+ The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.
+ """
+
+ GET_BACKUPS_URL = "{}/backups".format(Settings.DRIVE_API_URL)
+ PUT_BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
+ DELETE_BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
+
+ # Emit signal when restoring backup started or finished.
+ onRestoringStateChanged = Signal()
+
+ # Emit signal when creating backup started or finished.
+ onCreatingStateChanged = Signal()
+
+ def __init__(self, cura_api) -> None:
+ """Create a new instance of the Drive API service and set the cura_api object."""
+ self._cura_api = cura_api
+
+ def getBackups(self) -> List[Dict[str, any]]:
+ """Get all backups from the API."""
+ access_token = self._cura_api.account.accessToken
+ if not access_token:
+ Logger.log("w", "Could not get access token.")
+ return []
+
+ backup_list_request = requests.get(self.GET_BACKUPS_URL, headers={
+ "Authorization": "Bearer {}".format(access_token)
+ })
+ if backup_list_request.status_code > 299:
+ Logger.log("w", "Could not get backups list from remote: %s", backup_list_request.text)
+ Message(Settings.translatable_messages["get_backups_error"], title = Settings.MESSAGE_TITLE,
+ lifetime = 10).show()
+ return []
+ return backup_list_request.json()["data"]
+
+ def createBackup(self) -> None:
+ """Create a backup and upload it to CuraDrive cloud storage."""
+ self.onCreatingStateChanged.emit(is_creating=True)
+
+ # Create the backup.
+ backup_zip_file, backup_meta_data = self._cura_api.backups.createBackup()
+ if not backup_zip_file or not backup_meta_data:
+ self.onCreatingStateChanged.emit(is_creating=False, error_message="Could not create backup.")
+ return
+
+ # Create an upload entry for the backup.
+ timestamp = datetime.now().isoformat()
+ backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"])
+ backup_upload_url = self._requestBackupUpload(backup_meta_data, len(backup_zip_file))
+ if not backup_upload_url:
+ self.onCreatingStateChanged.emit(is_creating=False, error_message="Could not upload backup.")
+ return
+
+ # Upload the backup to storage.
+ upload_backup_job = UploadBackupJob(backup_upload_url, backup_zip_file)
+ upload_backup_job.finished.connect(self._onUploadFinished)
+ upload_backup_job.start()
+
+ def _onUploadFinished(self, job: "UploadBackupJob") -> None:
+ """
+ Callback handler for the upload job.
+ :param job: The executed job.
+ """
+ if job.backup_upload_error_message != "":
+ # If the job contains an error message we pass it along so the UI can display it.
+ self.onCreatingStateChanged.emit(is_creating=False, error_message=job.backup_upload_error_message)
+ else:
+ self.onCreatingStateChanged.emit(is_creating=False)
+
+ def restoreBackup(self, backup: Dict[str, any]) -> None:
+ """
+ Restore a previously exported backup from cloud storage.
+ :param backup: A dict containing an entry from the API list response.
+ """
+ self.onRestoringStateChanged.emit(is_restoring=True)
+ download_url = backup.get("download_url")
+ if not download_url:
+ # If there is no download URL, we can't restore the backup.
+ return self._emitRestoreError()
+
+ download_package = requests.get(download_url, stream=True)
+ if download_package.status_code != 200:
+ # Something went wrong when attempting to download the backup.
+ Logger.log("w", "Could not download backup from url %s: %s", download_url, download_package.text)
+ return self._emitRestoreError()
+
+ # We store the file in a temporary path fist to ensure integrity.
+ temporary_backup_file = NamedTemporaryFile(delete=False)
+ with open(temporary_backup_file.name, "wb") as write_backup:
+ for chunk in download_package:
+ write_backup.write(chunk)
+
+ if not self._verifyMd5Hash(temporary_backup_file.name, backup.get("md5_hash")):
+ # Don't restore the backup if the MD5 hashes do not match.
+ # This can happen if the download was interrupted.
+ Logger.log("w", "Remote and local MD5 hashes do not match, not restoring backup.")
+ return self._emitRestoreError()
+
+ # Tell Cura to place the backup back in the user data folder.
+ with open(temporary_backup_file.name, "rb") as read_backup:
+ self._cura_api.backups.restoreBackup(read_backup.read(), backup.get("data"))
+ self.onRestoringStateChanged.emit(is_restoring=False)
+
+ def _emitRestoreError(self, error_message: str = Settings.translatable_messages["backup_restore_error_message"]):
+ """Helper method for emitting a signal when restoring failed."""
+ self.onRestoringStateChanged.emit(
+ is_restoring=False,
+ error_message=error_message
+ )
+
+ @staticmethod
+ def _verifyMd5Hash(file_path: str, known_hash: str) -> bool:
+ """
+ Verify the MD5 hash of a file.
+ :param file_path: Full path to the file.
+ :param known_hash: The known MD5 hash of the file.
+ :return: Success or not.
+ """
+ with open(file_path, "rb") as read_backup:
+ local_md5_hash = base64.b64encode(hashlib.md5(read_backup.read()).digest(), altchars=b"_-").decode("utf-8")
+ return known_hash == local_md5_hash
+
+ def deleteBackup(self, backup_id: str) -> bool:
+ """
+ Delete a backup from the server by ID.
+ :param backup_id: The ID of the backup to delete.
+ :return: Success bool.
+ """
+ access_token = self._cura_api.account.accessToken
+ if not access_token:
+ Logger.log("w", "Could not get access token.")
+ return False
+
+ delete_backup = requests.delete("{}/{}".format(self.DELETE_BACKUP_URL, backup_id), headers = {
+ "Authorization": "Bearer {}".format(access_token)
+ })
+ if delete_backup.status_code > 299:
+ Logger.log("w", "Could not delete backup: %s", delete_backup.text)
+ return False
+ return True
+
+ def _requestBackupUpload(self, backup_metadata: Dict[str, any], backup_size: int) -> Optional[str]:
+ """
+ Request a backup upload slot from the API.
+ :param backup_metadata: A dict containing some meta data about the backup.
+ :param backup_size: The size of the backup file in bytes.
+ :return: The upload URL for the actual backup file if successful, otherwise None.
+ """
+ access_token = self._cura_api.account.accessToken
+ if not access_token:
+ Logger.log("w", "Could not get access token.")
+ return None
+
+ backup_upload_request = requests.put(self.PUT_BACKUP_URL, json={
+ "data": {
+ "backup_size": backup_size,
+ "metadata": backup_metadata
+ }
+ }, headers={
+ "Authorization": "Bearer {}".format(access_token)
+ })
+
+ if backup_upload_request.status_code > 299:
+ Logger.log("w", "Could not request backup upload: %s", backup_upload_request.text)
+ return None
+
+ return backup_upload_request.json()["data"]["upload_url"]
diff --git a/plugins/CuraDrive/src/DrivePluginExtension.py b/plugins/CuraDrive/src/DrivePluginExtension.py
new file mode 100644
index 0000000000..556fb187df
--- /dev/null
+++ b/plugins/CuraDrive/src/DrivePluginExtension.py
@@ -0,0 +1,200 @@
+# Copyright (c) 2017 Ultimaker B.V.
+import os
+from datetime import datetime
+from typing import Optional
+
+from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal
+
+from UM.Extension import Extension
+from UM.Message import Message
+
+from .Settings import Settings
+from .DriveApiService import DriveApiService
+from .models.BackupListModel import BackupListModel
+
+
+class DrivePluginExtension(QObject, Extension):
+ """
+ The DivePluginExtension provides functionality to backup and restore your Cura configuration to Ultimaker's cloud.
+ """
+
+ # Signal emitted when the list of backups changed.
+ backupsChanged = pyqtSignal()
+
+ # Signal emitted when restoring has started. Needed to prevent parallel restoring.
+ restoringStateChanged = pyqtSignal()
+
+ # Signal emitted when creating has started. Needed to prevent parallel creation of backups.
+ creatingStateChanged = pyqtSignal()
+
+ # Signal emitted when preferences changed (like auto-backup).
+ preferencesChanged = pyqtSignal()
+
+ DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
+
+ def __init__(self, application):
+ super(DrivePluginExtension, self).__init__()
+
+ # Re-usable instance of application.
+ self._application = application
+
+ # Local data caching for the UI.
+ self._drive_window = None # type: Optional[QObject]
+ self._backups_list_model = BackupListModel()
+ self._is_restoring_backup = False
+ self._is_creating_backup = False
+
+ # Initialize services.
+ self._preferences = self._application.getPreferences()
+ self._cura_api = self._application.getCuraAPI()
+ self._drive_api_service = DriveApiService(self._cura_api)
+
+ # Attach signals.
+ self._cura_api.account.loginStateChanged.connect(self._onLoginStateChanged)
+ self._drive_api_service.onRestoringStateChanged.connect(self._onRestoringStateChanged)
+ self._drive_api_service.onCreatingStateChanged.connect(self._onCreatingStateChanged)
+
+ # Register preferences.
+ self._preferences.addPreference(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, False)
+ self._preferences.addPreference(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY, datetime.now()
+ .strftime(self.DATE_FORMAT))
+
+ # Register menu items.
+ self._updateMenuItems()
+
+ # Make auto-backup on boot if required.
+ self._application.engineCreatedSignal.connect(self._autoBackup)
+
+ def showDriveWindow(self) -> None:
+ """Show the Drive UI popup window."""
+ if not self._drive_window:
+ self._drive_window = self.createDriveWindow()
+ self.refreshBackups()
+ self._drive_window.show()
+
+ def createDriveWindow(self) -> Optional["QObject"]:
+ """
+ Create an instance of the Drive UI popup window.
+ :return: The popup window object.
+ """
+ path = os.path.join(os.path.dirname(__file__), "qml", "main.qml")
+ return self._application.createQmlComponent(path, {"CuraDrive": self})
+
+ def _updateMenuItems(self) -> None:
+ """Update the menu items."""
+ self.addMenuItem(Settings.translatable_messages["extension_menu_entry"], self.showDriveWindow)
+
+ def _autoBackup(self) -> None:
+ """Automatically make a backup on boot if enabled."""
+ if self._preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._lastBackupTooLongAgo():
+ self.createBackup()
+
+ def _lastBackupTooLongAgo(self) -> bool:
+ """Check if the last backup was longer than 1 day ago."""
+ current_date = datetime.now()
+ last_backup_date = self._getLastBackupDate()
+ date_diff = current_date - last_backup_date
+ return date_diff.days > 1
+
+ def _getLastBackupDate(self) -> "datetime":
+ """Get the last backup date as datetime object."""
+ last_backup_date = self._preferences.getValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY)
+ return datetime.strptime(last_backup_date, self.DATE_FORMAT)
+
+ def _storeBackupDate(self) -> None:
+ """Store the current date as last backup date."""
+ backup_date = datetime.now().strftime(self.DATE_FORMAT)
+ self._preferences.setValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY, backup_date)
+
+ def _onLoginStateChanged(self, logged_in: bool = False) -> None:
+ """Callback handler for changes in the login state."""
+ if logged_in:
+ self.refreshBackups()
+
+ def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
+ """Callback handler for changes in the restoring state."""
+ self._is_restoring_backup = is_restoring
+ self.restoringStateChanged.emit()
+ if error_message:
+ Message(error_message, title = Settings.MESSAGE_TITLE, lifetime = 5).show()
+
+ def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
+ """Callback handler for changes in the creation state."""
+ self._is_creating_backup = is_creating
+ self.creatingStateChanged.emit()
+ if error_message:
+ Message(error_message, title = Settings.MESSAGE_TITLE, lifetime = 5).show()
+ else:
+ self._storeBackupDate()
+ if not is_creating:
+ # We've finished creating a new backup, to the list has to be updated.
+ self.refreshBackups()
+
+ @pyqtSlot(bool, name = "toggleAutoBackup")
+ def toggleAutoBackup(self, enabled: bool) -> None:
+ """Enable or disable the auto-backup feature."""
+ self._preferences.setValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, enabled)
+ self.preferencesChanged.emit()
+
+ @pyqtProperty(bool, notify = preferencesChanged)
+ def autoBackupEnabled(self) -> bool:
+ """Check if auto-backup is enabled or not."""
+ return bool(self._preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY))
+
+ @pyqtProperty(QObject, notify = backupsChanged)
+ def backups(self) -> BackupListModel:
+ """
+ Get a list of the backups.
+ :return: The backups as Qt List Model.
+ """
+ return self._backups_list_model
+
+ @pyqtSlot(name = "refreshBackups")
+ def refreshBackups(self) -> None:
+ """
+ Forcefully refresh the backups list.
+ """
+ self._backups_list_model.loadBackups(self._drive_api_service.getBackups())
+ self.backupsChanged.emit()
+
+ @pyqtProperty(bool, notify = restoringStateChanged)
+ def isRestoringBackup(self) -> bool:
+ """
+ Get the current restoring state.
+ :return: Boolean if we are restoring or not.
+ """
+ return self._is_restoring_backup
+
+ @pyqtProperty(bool, notify = creatingStateChanged)
+ def isCreatingBackup(self) -> bool:
+ """
+ Get the current creating state.
+ :return: Boolean if we are creating or not.
+ """
+ return self._is_creating_backup
+
+ @pyqtSlot(str, name = "restoreBackup")
+ def restoreBackup(self, backup_id: str) -> None:
+ """
+ Download and restore a backup by ID.
+ :param backup_id: The ID of the backup.
+ """
+ index = self._backups_list_model.find("backup_id", backup_id)
+ backup = self._backups_list_model.getItem(index)
+ self._drive_api_service.restoreBackup(backup)
+
+ @pyqtSlot(name = "createBackup")
+ def createBackup(self) -> None:
+ """
+ Create a new backup.
+ """
+ self._drive_api_service.createBackup()
+
+ @pyqtSlot(str, name = "deleteBackup")
+ def deleteBackup(self, backup_id: str) -> None:
+ """
+ Delete a backup by ID.
+ :param backup_id: The ID of the backup.
+ """
+ self._drive_api_service.deleteBackup(backup_id)
+ self.refreshBackups()
diff --git a/plugins/CuraDrive/src/Settings.py b/plugins/CuraDrive/src/Settings.py
new file mode 100644
index 0000000000..277a976cc7
--- /dev/null
+++ b/plugins/CuraDrive/src/Settings.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2018 Ultimaker B.V.
+from UM import i18nCatalog
+
+
+class Settings:
+ """
+ Keeps the application settings.
+ """
+ UM_CLOUD_API_ROOT = "https://api.ultimaker.com"
+ DRIVE_API_VERSION = 1
+ DRIVE_API_URL = "{}/cura-drive/v{}".format(UM_CLOUD_API_ROOT, str(DRIVE_API_VERSION))
+
+ AUTO_BACKUP_ENABLED_PREFERENCE_KEY = "cura_drive/auto_backup_enabled"
+ AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY = "cura_drive/auto_backup_date"
+
+ I18N_CATALOG_ID = "cura_drive"
+ I18N_CATALOG = i18nCatalog(I18N_CATALOG_ID)
+
+ MESSAGE_TITLE = I18N_CATALOG.i18nc("@info:title", "Backups"),
+
+ # Translatable messages for the entire plugin.
+ translatable_messages = {
+
+ # Menu items.
+ "extension_menu_entry": I18N_CATALOG.i18nc("@item:inmenu", "Manage backups"),
+
+ # Notification messages.
+ "backup_failed": I18N_CATALOG.i18nc("@info:backup_status", "There was an error while creating your backup."),
+ "uploading_backup": I18N_CATALOG.i18nc("@info:backup_status", "Uploading your backup..."),
+ "uploading_backup_success": I18N_CATALOG.i18nc("@info:backup_status", "Your backup has finished uploading."),
+ "uploading_backup_error": I18N_CATALOG.i18nc("@info:backup_status",
+ "There was an error while uploading your backup."),
+ "get_backups_error": I18N_CATALOG.i18nc("@info:backup_status", "There was an error listing your backups."),
+ "backup_restore_error_message": I18N_CATALOG.i18nc("@info:backup_status",
+ "There was an error trying to restore your backup.")
+ }
diff --git a/plugins/CuraDrive/src/UploadBackupJob.py b/plugins/CuraDrive/src/UploadBackupJob.py
new file mode 100644
index 0000000000..039e6d1a09
--- /dev/null
+++ b/plugins/CuraDrive/src/UploadBackupJob.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2018 Ultimaker B.V.
+import requests
+
+from UM.Job import Job
+from UM.Logger import Logger
+from UM.Message import Message
+
+from .Settings import Settings
+
+
+class UploadBackupJob(Job):
+ """
+ This job is responsible for uploading the backup file to cloud storage.
+ As it can take longer than some other tasks, we schedule this using a Cura Job.
+ """
+
+ def __init__(self, signed_upload_url: str, backup_zip: bytes):
+ super().__init__()
+ self._signed_upload_url = signed_upload_url
+ self._backup_zip = backup_zip
+ self._upload_success = False
+ self.backup_upload_error_message = ""
+
+ def run(self):
+ Message(Settings.translatable_messages["uploading_backup"], title = Settings.MESSAGE_TITLE,
+ lifetime = 10).show()
+
+ backup_upload = requests.put(self._signed_upload_url, data = self._backup_zip)
+ if backup_upload.status_code not in (200, 201):
+ self.backup_upload_error_message = backup_upload.text
+ Logger.log("w", "Could not upload backup file: %s", backup_upload.text)
+ Message(Settings.translatable_messages["uploading_backup_error"], title = Settings.MESSAGE_TITLE,
+ lifetime = 10).show()
+ else:
+ self._upload_success = True
+ Message(Settings.translatable_messages["uploading_backup_success"], title = Settings.MESSAGE_TITLE,
+ lifetime = 10).show()
+
+ self.finished.emit(self)
diff --git a/plugins/CuraDrive/src/__init__.py b/plugins/CuraDrive/src/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plugins/CuraDrive/src/models/BackupListModel.py b/plugins/CuraDrive/src/models/BackupListModel.py
new file mode 100644
index 0000000000..9567b3d255
--- /dev/null
+++ b/plugins/CuraDrive/src/models/BackupListModel.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2018 Ultimaker B.V.
+from typing import List, Dict
+
+from UM.Qt.ListModel import ListModel
+
+from PyQt5.QtCore import Qt
+
+
+class BackupListModel(ListModel):
+ """
+ The BackupListModel transforms the backups data that came from the server so it can be served to the Qt UI.
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.addRoleName(Qt.UserRole + 1, "backup_id")
+ self.addRoleName(Qt.UserRole + 2, "download_url")
+ self.addRoleName(Qt.UserRole + 3, "generated_time")
+ self.addRoleName(Qt.UserRole + 4, "md5_hash")
+ self.addRoleName(Qt.UserRole + 5, "data")
+
+ def loadBackups(self, data: List[Dict[str, any]]) -> None:
+ """
+ Populate the model with server data.
+ :param data:
+ """
+ items = []
+ for backup in data:
+ # We do this loop because we only want to append these specific fields.
+ # Without this, ListModel will break.
+ items.append({
+ "backup_id": backup["backup_id"],
+ "download_url": backup["download_url"],
+ "generated_time": backup["generated_time"],
+ "md5_hash": backup["md5_hash"],
+ "data": backup["metadata"]
+ })
+ self.setItems(items)
diff --git a/plugins/CuraDrive/src/models/__init__.py b/plugins/CuraDrive/src/models/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plugins/CuraDrive/src/qml/components/ActionButton.qml b/plugins/CuraDrive/src/qml/components/ActionButton.qml
new file mode 100644
index 0000000000..843079ed88
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/ActionButton.qml
@@ -0,0 +1,67 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.1 as UM
+
+Button
+{
+ id: button
+ property alias cursorShape: mouseArea.cursorShape
+ property var iconSource: ""
+ property var busy: false
+ property var color: UM.Theme.getColor("primary")
+ property var hoverColor: UM.Theme.getColor("primary_hover")
+ property var disabledColor: color
+ property var textColor: UM.Theme.getColor("button_text")
+ property var textHoverColor: UM.Theme.getColor("button_text_hover")
+ property var textDisabledColor: textColor
+ property var textFont: UM.Theme.getFont("action_button")
+
+ contentItem: RowLayout
+ {
+ Icon
+ {
+ id: buttonIcon
+ iconSource: button.iconSource
+ width: 16 * screenScaleFactor
+ color: button.hovered ? button.textHoverColor : button.textColor
+ visible: button.iconSource != "" && !loader.visible
+ }
+
+ Icon
+ {
+ id: loader
+ iconSource: "../images/loading.gif"
+ width: 16 * screenScaleFactor
+ color: button.hovered ? button.textHoverColor : button.textColor
+ visible: button.busy
+ animated: true
+ }
+
+ Label
+ {
+ id: buttonText
+ text: button.text
+ color: button.enabled ? (button.hovered ? button.textHoverColor : button.textColor): button.textDisabledColor
+ font: button.textFont
+ visible: button.text != ""
+ renderType: Text.NativeRendering
+ }
+ }
+
+ background: Rectangle
+ {
+ color: button.enabled ? (button.hovered ? button.hoverColor : button.color) : button.disabledColor
+ }
+
+ MouseArea
+ {
+ id: mouseArea
+ anchors.fill: parent
+ onPressed: mouse.accepted = false
+ hoverEnabled: true
+ cursorShape: button.enabled ? (hovered ? Qt.PointingHandCursor : Qt.ArrowCursor) : Qt.ForbiddenCursor
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/ActionCheckBox.qml b/plugins/CuraDrive/src/qml/components/ActionCheckBox.qml
new file mode 100644
index 0000000000..71f5e6035d
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/ActionCheckBox.qml
@@ -0,0 +1,49 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.3 as UM
+
+CheckBox
+{
+ id: checkbox
+ hoverEnabled: true
+
+ property var label: ""
+
+ indicator: Rectangle {
+ implicitWidth: 30 * screenScaleFactor
+ implicitHeight: 30 * screenScaleFactor
+ x: 0
+ y: Math.round(parent.height / 2 - height / 2)
+ color: UM.Theme.getColor("sidebar")
+ border.color: UM.Theme.getColor("text")
+
+ Rectangle {
+ width: 14 * screenScaleFactor
+ height: 14 * screenScaleFactor
+ x: 8 * screenScaleFactor
+ y: 8 * screenScaleFactor
+ color: UM.Theme.getColor("primary")
+ visible: checkbox.checked
+ }
+ }
+
+ contentItem: Label {
+ anchors
+ {
+ left: checkbox.indicator.right
+ leftMargin: 5 * screenScaleFactor
+ }
+ text: catalog.i18nc("@checkbox:description", "Auto Backup")
+ color: UM.Theme.getColor("text")
+ renderType: Text.NativeRendering
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ ActionToolTip
+ {
+ text: checkbox.label
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/ActionToolTip.qml b/plugins/CuraDrive/src/qml/components/ActionToolTip.qml
new file mode 100644
index 0000000000..93b92bc2df
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/ActionToolTip.qml
@@ -0,0 +1,29 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.1 as UM
+
+ToolTip
+{
+ id: tooltip
+ visible: parent.hovered
+ opacity: 0.9
+ delay: 500
+
+ background: Rectangle
+ {
+ color: UM.Theme.getColor("sidebar")
+ border.color: UM.Theme.getColor("primary")
+ border.width: 1 * screenScaleFactor
+ }
+
+ contentItem: Label
+ {
+ text: tooltip.text
+ color: UM.Theme.getColor("text")
+ font: UM.Theme.getFont("very_small")
+ renderType: Text.NativeRendering
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/BackupList.qml b/plugins/CuraDrive/src/qml/components/BackupList.qml
new file mode 100644
index 0000000000..231f25afc8
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/BackupList.qml
@@ -0,0 +1,31 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.1 as UM
+
+ListView
+{
+ id: backupList
+ width: parent.width
+ clip: true
+ delegate: Item
+ {
+ width: parent.width
+ height: childrenRect.height
+
+ BackupListItem
+ {
+ id: backupListItem
+ width: parent.width
+ }
+
+ Divider
+ {
+ width: parent.width
+ anchors.top: backupListItem.bottom
+ }
+ }
+ ScrollBar.vertical: RightSideScrollBar {}
+}
diff --git a/plugins/CuraDrive/src/qml/components/BackupListFooter.qml b/plugins/CuraDrive/src/qml/components/BackupListFooter.qml
new file mode 100644
index 0000000000..80f47d6cba
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/BackupListFooter.qml
@@ -0,0 +1,42 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.3 as UM
+
+import "../components"
+
+RowLayout
+{
+ id: backupListFooter
+ width: parent.width
+ property bool showInfoButton: false
+
+ ActionButton
+ {
+ id: infoButton
+ text: catalog.i18nc("@button", "Want more?")
+ iconSource: "../images/info.svg"
+ onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
+ visible: backupListFooter.showInfoButton
+ }
+
+ ActionButton
+ {
+ id: createBackupButton
+ text: catalog.i18nc("@button", "Backup Now")
+ iconSource: "../images/backup.svg"
+ enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
+ onClicked: CuraDrive.createBackup()
+ busy: CuraDrive.isCreatingBackup
+ }
+
+ ActionCheckBox
+ {
+ id: autoBackupEnabled
+ checked: CuraDrive.autoBackupEnabled
+ onClicked: CuraDrive.toggleAutoBackup(autoBackupEnabled.checked)
+ label: catalog.i18nc("@checkbox:description", "Automatically create a backup each day that Cura is started.")
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/BackupListItem.qml b/plugins/CuraDrive/src/qml/components/BackupListItem.qml
new file mode 100644
index 0000000000..abe9a1acf9
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/BackupListItem.qml
@@ -0,0 +1,112 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+import QtQuick.Dialogs 1.1
+
+import UM 1.1 as UM
+
+Item
+{
+ id: backupListItem
+ width: parent.width
+ height: showDetails ? dataRow.height + backupDetails.height : dataRow.height
+ property bool showDetails: false
+
+ // Backup details toggle animation.
+ Behavior on height
+ {
+ PropertyAnimation
+ {
+ duration: 70
+ }
+ }
+
+ RowLayout
+ {
+ id: dataRow
+ spacing: UM.Theme.getSize("default_margin").width * 2
+ width: parent.width
+ height: 50 * screenScaleFactor
+
+ ActionButton
+ {
+ color: "transparent"
+ hoverColor: "transparent"
+ textColor: UM.Theme.getColor("text")
+ textHoverColor: UM.Theme.getColor("primary")
+ iconSource: "../images/info.svg"
+ onClicked: backupListItem.showDetails = !backupListItem.showDetails
+ }
+
+ Label
+ {
+ text: new Date(model["generated_time"]).toLocaleString(UM.Preferences.getValue("general/language"))
+ color: UM.Theme.getColor("text")
+ elide: Text.ElideRight
+ Layout.minimumWidth: 100 * screenScaleFactor
+ Layout.maximumWidth: 500 * screenScaleFactor
+ Layout.fillWidth: true
+ renderType: Text.NativeRendering
+ }
+
+ Label
+ {
+ text: model["data"]["description"]
+ color: UM.Theme.getColor("text")
+ elide: Text.ElideRight
+ Layout.minimumWidth: 100 * screenScaleFactor
+ Layout.maximumWidth: 500 * screenScaleFactor
+ Layout.fillWidth: true
+ renderType: Text.NativeRendering
+ }
+
+ ActionButton
+ {
+ text: catalog.i18nc("@button", "Restore")
+ color: "transparent"
+ hoverColor: "transparent"
+ textColor: UM.Theme.getColor("text")
+ textHoverColor: UM.Theme.getColor("text_link")
+ enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
+ onClicked: confirmRestoreDialog.visible = true
+ }
+
+ ActionButton
+ {
+ color: "transparent"
+ hoverColor: "transparent"
+ textColor: UM.Theme.getColor("setting_validation_error")
+ textHoverColor: UM.Theme.getColor("setting_validation_error")
+ iconSource: "../images/delete.svg"
+ onClicked: confirmDeleteDialog.visible = true
+ }
+ }
+
+ BackupListItemDetails
+ {
+ id: backupDetails
+ backupDetailsData: model
+ width: parent.width
+ visible: parent.showDetails
+ anchors.top: dataRow.bottom
+ }
+
+ MessageDialog
+ {
+ id: confirmDeleteDialog
+ title: catalog.i18nc("@dialog:title", "Delete Backup")
+ text: catalog.i18nc("@dialog:info", "Are you sure you want to delete this backup? This cannot be undone.")
+ standardButtons: StandardButton.Yes | StandardButton.No
+ onYes: CuraDrive.deleteBackup(model["backup_id"])
+ }
+
+ MessageDialog
+ {
+ id: confirmRestoreDialog
+ title: catalog.i18nc("@dialog:title", "Restore Backup")
+ text: catalog.i18nc("@dialog:info", "You will need to restart Cura before your backup is restored. Do you want to close Cura now?")
+ standardButtons: StandardButton.Yes | StandardButton.No
+ onYes: CuraDrive.restoreBackup(model["backup_id"])
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/BackupListItemDetails.qml b/plugins/CuraDrive/src/qml/components/BackupListItemDetails.qml
new file mode 100644
index 0000000000..74d4c5ab57
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/BackupListItemDetails.qml
@@ -0,0 +1,61 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.1 as UM
+
+ColumnLayout
+{
+ id: backupDetails
+ width: parent.width
+ spacing: 10 * screenScaleFactor
+ property var backupDetailsData
+
+ // Cura version
+ BackupListItemDetailsRow
+ {
+ iconSource: "../images/cura.svg"
+ label: catalog.i18nc("@backuplist:label", "Cura Version")
+ value: backupDetailsData["data"]["cura_release"]
+ }
+
+ // Machine count.
+ BackupListItemDetailsRow
+ {
+ iconSource: "../images/printer.svg"
+ label: catalog.i18nc("@backuplist:label", "Machines")
+ value: backupDetailsData["data"]["machine_count"]
+ }
+
+ // Meterial count.
+ BackupListItemDetailsRow
+ {
+ iconSource: "../images/material.svg"
+ label: catalog.i18nc("@backuplist:label", "Materials")
+ value: backupDetailsData["data"]["material_count"]
+ }
+
+ // Meterial count.
+ BackupListItemDetailsRow
+ {
+ iconSource: "../images/profile.svg"
+ label: catalog.i18nc("@backuplist:label", "Profiles")
+ value: backupDetailsData["data"]["profile_count"]
+ }
+
+ // Meterial count.
+ BackupListItemDetailsRow
+ {
+ iconSource: "../images/plugin.svg"
+ label: catalog.i18nc("@backuplist:label", "Plugins")
+ value: backupDetailsData["data"]["plugin_count"]
+ }
+
+ // Spacer.
+ Item
+ {
+ width: parent.width
+ height: 10 * screenScaleFactor
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/BackupListItemDetailsRow.qml b/plugins/CuraDrive/src/qml/components/BackupListItemDetailsRow.qml
new file mode 100644
index 0000000000..dad1674fe7
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/BackupListItemDetailsRow.qml
@@ -0,0 +1,52 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.3 as UM
+
+RowLayout
+{
+ id: detailsRow
+ width: parent.width
+ height: 40 * screenScaleFactor
+
+ property var iconSource
+ property var label
+ property var value
+
+ // Spacing.
+ Item
+ {
+ width: 40 * screenScaleFactor
+ }
+
+ Icon
+ {
+ width: 18 * screenScaleFactor
+ iconSource: detailsRow.iconSource
+ color: UM.Theme.getColor("text")
+ }
+
+ Label
+ {
+ text: detailsRow.label
+ color: UM.Theme.getColor("text")
+ elide: Text.ElideRight
+ Layout.minimumWidth: 50 * screenScaleFactor
+ Layout.maximumWidth: 100 * screenScaleFactor
+ Layout.fillWidth: true
+ renderType: Text.NativeRendering
+ }
+
+ Label
+ {
+ text: detailsRow.value
+ color: UM.Theme.getColor("text")
+ elide: Text.ElideRight
+ Layout.minimumWidth: 50 * screenScaleFactor
+ Layout.maximumWidth: 100 * screenScaleFactor
+ Layout.fillWidth: true
+ renderType: Text.NativeRendering
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/Divider.qml b/plugins/CuraDrive/src/qml/components/Divider.qml
new file mode 100644
index 0000000000..bba2f2f29c
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/Divider.qml
@@ -0,0 +1,11 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+
+import UM 1.3 as UM
+
+Rectangle
+{
+ id: divider
+ color: UM.Theme.getColor("lining")
+ height: UM.Theme.getSize("default_lining").height
+}
diff --git a/plugins/CuraDrive/src/qml/components/Icon.qml b/plugins/CuraDrive/src/qml/components/Icon.qml
new file mode 100644
index 0000000000..3cb822bf82
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/Icon.qml
@@ -0,0 +1,56 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtGraphicalEffects 1.0
+
+Item
+{
+ id: icon
+ width: parent.height
+ height: width
+ property var color: "transparent"
+ property var iconSource
+ property bool animated: false
+
+ Image
+ {
+ id: iconImage
+ width: parent.height
+ height: width
+ smooth: true
+ source: icon.iconSource
+ sourceSize.width: width
+ sourceSize.height: height
+ antialiasing: true
+ visible: !icon.animated
+ }
+
+ AnimatedImage
+ {
+ id: animatedIconImage
+ width: parent.height
+ height: width
+ smooth: true
+ antialiasing: true
+ source: "../images/loading.gif"
+ visible: icon.animated
+ }
+
+ ColorOverlay
+ {
+ anchors.fill: iconImage
+ source: iconImage
+ color: icon.color
+ antialiasing: true
+ visible: !icon.animated
+ }
+
+ ColorOverlay
+ {
+ anchors.fill: animatedIconImage
+ source: animatedIconImage
+ color: icon.color
+ antialiasing: true
+ visible: icon.animated
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/components/RightSideScrollBar.qml b/plugins/CuraDrive/src/qml/components/RightSideScrollBar.qml
new file mode 100644
index 0000000000..5ac5df15ff
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/components/RightSideScrollBar.qml
@@ -0,0 +1,13 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+ScrollBar
+{
+ active: true
+ size: parent.height
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+}
diff --git a/plugins/CuraDrive/src/qml/images/avatar_default.png b/plugins/CuraDrive/src/qml/images/avatar_default.png
new file mode 100644
index 0000000000..0c306680f7
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/avatar_default.png differ
diff --git a/plugins/CuraDrive/src/qml/images/background.svg b/plugins/CuraDrive/src/qml/images/background.svg
new file mode 100644
index 0000000000..cbcfdbaa2d
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/background.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/backup.svg b/plugins/CuraDrive/src/qml/images/backup.svg
new file mode 100644
index 0000000000..51f6be4cba
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/backup.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/cura.svg b/plugins/CuraDrive/src/qml/images/cura.svg
new file mode 100644
index 0000000000..6b1b6c0c79
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/cura.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/cura_logo.jpg b/plugins/CuraDrive/src/qml/images/cura_logo.jpg
new file mode 100644
index 0000000000..621c03f035
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/cura_logo.jpg differ
diff --git a/plugins/CuraDrive/src/qml/images/cura_logo.png b/plugins/CuraDrive/src/qml/images/cura_logo.png
new file mode 100644
index 0000000000..f846f2a0f0
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/cura_logo.png differ
diff --git a/plugins/CuraDrive/src/qml/images/delete.svg b/plugins/CuraDrive/src/qml/images/delete.svg
new file mode 100644
index 0000000000..2f6190ad43
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/delete.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/folder.svg b/plugins/CuraDrive/src/qml/images/folder.svg
new file mode 100644
index 0000000000..f66f83a888
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/folder.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/home.svg b/plugins/CuraDrive/src/qml/images/home.svg
new file mode 100644
index 0000000000..9d0e4d802c
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/home.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/icon.png b/plugins/CuraDrive/src/qml/images/icon.png
new file mode 100644
index 0000000000..3f75491786
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/icon.png differ
diff --git a/plugins/CuraDrive/src/qml/images/info.svg b/plugins/CuraDrive/src/qml/images/info.svg
new file mode 100644
index 0000000000..36154d6729
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/info.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/inverted_circle.png b/plugins/CuraDrive/src/qml/images/inverted_circle.png
new file mode 100644
index 0000000000..3612b37d4d
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/inverted_circle.png differ
diff --git a/plugins/CuraDrive/src/qml/images/loading.gif b/plugins/CuraDrive/src/qml/images/loading.gif
new file mode 100644
index 0000000000..791dcaa0c9
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/loading.gif differ
diff --git a/plugins/CuraDrive/src/qml/images/material.svg b/plugins/CuraDrive/src/qml/images/material.svg
new file mode 100644
index 0000000000..eac724e471
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/material.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/plugin.svg b/plugins/CuraDrive/src/qml/images/plugin.svg
new file mode 100644
index 0000000000..674eb99a54
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/plugin.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/preview_banner.png b/plugins/CuraDrive/src/qml/images/preview_banner.png
new file mode 100644
index 0000000000..414019531b
Binary files /dev/null and b/plugins/CuraDrive/src/qml/images/preview_banner.png differ
diff --git a/plugins/CuraDrive/src/qml/images/printer.svg b/plugins/CuraDrive/src/qml/images/printer.svg
new file mode 100644
index 0000000000..f7dc83987d
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/printer.svg
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/profile.svg b/plugins/CuraDrive/src/qml/images/profile.svg
new file mode 100644
index 0000000000..ec2130f3d6
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/profile.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/images/restore.svg b/plugins/CuraDrive/src/qml/images/restore.svg
new file mode 100644
index 0000000000..803215eada
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/images/restore.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/plugins/CuraDrive/src/qml/main.qml b/plugins/CuraDrive/src/qml/main.qml
new file mode 100644
index 0000000000..4a2219cf1f
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/main.qml
@@ -0,0 +1,42 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Window 2.2
+
+import UM 1.3 as UM
+import Cura 1.1 as Cura
+
+import "components"
+import "pages"
+
+Window
+{
+ id: curaDriveDialog
+ minimumWidth: Math.round(UM.Theme.getSize("modal_window_minimum").width)
+ minimumHeight: Math.round(UM.Theme.getSize("modal_window_minimum").height)
+ maximumWidth: minimumWidth * 1.2
+ maximumHeight: minimumHeight * 1.2
+ width: minimumWidth
+ height: minimumHeight
+ color: UM.Theme.getColor("sidebar")
+ title: catalog.i18nc("@title:window", "Cura Backups")
+
+ // Globally available.
+ UM.I18nCatalog
+ {
+ id: catalog
+ name: "cura_drive"
+ }
+
+ WelcomePage
+ {
+ id: welcomePage
+ visible: !Cura.API.account.isLoggedIn
+ }
+
+ BackupsPage
+ {
+ id: backupsPage
+ visible: Cura.API.account.isLoggedIn
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/pages/BackupsPage.qml b/plugins/CuraDrive/src/qml/pages/BackupsPage.qml
new file mode 100644
index 0000000000..88ce766383
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/pages/BackupsPage.qml
@@ -0,0 +1,73 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Layouts 1.3
+
+import UM 1.3 as UM
+import Cura 1.1 as Cura
+
+import "../components"
+
+Item
+{
+ id: backupsPage
+ anchors.fill: parent
+ anchors.margins: UM.Theme.getSize("default_margin").width * 3
+
+ ColumnLayout
+ {
+ spacing: UM.Theme.getSize("default_margin").height * 2
+ width: parent.width
+ anchors.fill: parent
+
+ Label
+ {
+ id: backupTitle
+ text: catalog.i18nc("@title", "My Backups")
+ font: UM.Theme.getFont("large")
+ color: UM.Theme.getColor("text")
+ Layout.fillWidth: true
+ renderType: Text.NativeRendering
+ }
+
+ Label
+ {
+ text: catalog.i18nc("@empty_state",
+ "You don't have any backups currently. Use the 'Backup Now' button to create one.")
+ width: parent.width
+ font: UM.Theme.getFont("default")
+ color: UM.Theme.getColor("text")
+ wrapMode: Label.WordWrap
+ visible: backupList.count == 0
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ renderType: Text.NativeRendering
+ }
+
+ BackupList
+ {
+ id: backupList
+ model: CuraDrive.backups
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+
+ Label
+ {
+ text: catalog.i18nc("@backup_limit_info",
+ "During the preview phase, you'll be limited to 5 visible backups. Remove a backup to see older ones.")
+ width: parent.width
+ font: UM.Theme.getFont("default")
+ color: UM.Theme.getColor("text")
+ wrapMode: Label.WordWrap
+ visible: backupList.count > 4
+ renderType: Text.NativeRendering
+ }
+
+ BackupListFooter
+ {
+ id: backupListFooter
+ showInfoButton: backupList.count > 4
+ }
+ }
+}
diff --git a/plugins/CuraDrive/src/qml/pages/WelcomePage.qml b/plugins/CuraDrive/src/qml/pages/WelcomePage.qml
new file mode 100644
index 0000000000..882656dc4a
--- /dev/null
+++ b/plugins/CuraDrive/src/qml/pages/WelcomePage.qml
@@ -0,0 +1,48 @@
+// Copyright (c) 2018 Ultimaker B.V.
+import QtQuick 2.7
+import QtQuick.Controls 2.1
+import QtQuick.Window 2.2
+
+import UM 1.3 as UM
+import Cura 1.1 as Cura
+
+import "../components"
+
+Column
+{
+ id: welcomePage
+ spacing: UM.Theme.getSize("wide_margin").height
+ width: parent.width
+ topPadding: 150 * screenScaleFactor
+
+ Image
+ {
+ id: profileImage
+ fillMode: Image.PreserveAspectFit
+ source: "../images/icon.png"
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: Math.round(parent.width / 4)
+ }
+
+ Label
+ {
+ id: welcomeTextLabel
+ text: catalog.i18nc("@description", "Backup and synchronize your Cura settings.")
+ width: Math.round(parent.width / 2)
+ font: UM.Theme.getFont("default")
+ color: UM.Theme.getColor("text")
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ wrapMode: Label.WordWrap
+ renderType: Text.NativeRendering
+ }
+
+ ActionButton
+ {
+ id: loginButton
+ onClicked: Cura.API.account.login()
+ text: catalog.i18nc("@button", "Sign In")
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+}