Merge branch 'CURA-7290_manual_account_sync' of github.com:Ultimaker/Cura

This commit is contained in:
Jaime van Kessel 2020-05-26 11:52:14 +02:00
commit 280d3f07a6
No known key found for this signature in database
GPG key ID: 3710727397403C91
9 changed files with 156 additions and 100 deletions

View file

@ -23,6 +23,7 @@ class SyncState:
SYNCING = 0 SYNCING = 0
SUCCESS = 1 SUCCESS = 1
ERROR = 2 ERROR = 2
IDLE = 3
## The account API provides a version-proof bridge to use Ultimaker Accounts ## The account API provides a version-proof bridge to use Ultimaker Accounts
@ -50,6 +51,7 @@ class Account(QObject):
""" """
lastSyncDateTimeChanged = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal()
syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum
manualSyncEnabledChanged = pyqtSignal(bool)
def __init__(self, application: "CuraApplication", parent = None) -> None: def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -58,7 +60,8 @@ class Account(QObject):
self._error_message = None # type: Optional[Message] self._error_message = None # type: Optional[Message]
self._logged_in = False self._logged_in = False
self._sync_state = SyncState.SUCCESS self._sync_state = SyncState.IDLE
self._manual_sync_enabled = False
self._last_sync_str = "-" self._last_sync_str = "-"
self._callback_port = 32118 self._callback_port = 32118
@ -106,16 +109,21 @@ class Account(QObject):
:param state: One of SyncState :param state: One of SyncState
""" """
Logger.info("Service {service} enters sync state {state}", service = service_name, state = state)
prev_state = self._sync_state prev_state = self._sync_state
self._sync_services[service_name] = state self._sync_services[service_name] = state
if any(val == SyncState.SYNCING for val in self._sync_services.values()): if any(val == SyncState.SYNCING for val in self._sync_services.values()):
self._sync_state = SyncState.SYNCING self._sync_state = SyncState.SYNCING
self._setManualSyncEnabled(False)
elif any(val == SyncState.ERROR for val in self._sync_services.values()): elif any(val == SyncState.ERROR for val in self._sync_services.values()):
self._sync_state = SyncState.ERROR self._sync_state = SyncState.ERROR
self._setManualSyncEnabled(True)
else: else:
self._sync_state = SyncState.SUCCESS self._sync_state = SyncState.SUCCESS
self._setManualSyncEnabled(False)
if self._sync_state != prev_state: if self._sync_state != prev_state:
self.syncStateChanged.emit(self._sync_state) self.syncStateChanged.emit(self._sync_state)
@ -157,11 +165,31 @@ class Account(QObject):
self._logged_in = logged_in self._logged_in = logged_in
self.loginStateChanged.emit(logged_in) self.loginStateChanged.emit(logged_in)
if logged_in: if logged_in:
self.sync() self._setManualSyncEnabled(False)
self._sync()
else: else:
if self._update_timer.isActive(): if self._update_timer.isActive():
self._update_timer.stop() self._update_timer.stop()
def _sync(self) -> None:
"""Signals all sync services to start syncing
This can be considered a forced sync: even when a
sync is currently running, a sync will be requested.
"""
if self._update_timer.isActive():
self._update_timer.stop()
elif self._sync_state == SyncState.SYNCING:
Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
self.syncRequested.emit()
def _setManualSyncEnabled(self, enabled: bool) -> None:
if self._manual_sync_enabled != enabled:
self._manual_sync_enabled = enabled
self.manualSyncEnabledChanged.emit(enabled)
@pyqtSlot() @pyqtSlot()
@pyqtSlot(bool) @pyqtSlot(bool)
def login(self, force_logout_before_login: bool = False) -> None: def login(self, force_logout_before_login: bool = False) -> None:
@ -212,20 +240,23 @@ class Account(QObject):
def lastSyncDateTime(self) -> str: def lastSyncDateTime(self) -> str:
return self._last_sync_str return self._last_sync_str
@pyqtProperty(bool, notify=manualSyncEnabledChanged)
def manualSyncEnabled(self) -> bool:
return self._manual_sync_enabled
@pyqtSlot() @pyqtSlot()
def sync(self) -> None: @pyqtSlot(bool)
"""Signals all sync services to start syncing def sync(self, user_initiated: bool = False) -> None:
if user_initiated:
self._setManualSyncEnabled(False)
This can be considered a forced sync: even when a self._sync()
sync is currently running, a sync will be requested.
"""
if self._update_timer.isActive(): @pyqtSlot()
self._update_timer.stop() def popupOpened(self) -> None:
elif self._sync_state == SyncState.SYNCING: self._setManualSyncEnabled(True)
Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services)) self._sync_state = SyncState.IDLE
self.syncStateChanged.emit(self._sync_state)
self.syncRequested.emit()
@pyqtSlot() @pyqtSlot()
def logout(self) -> None: def logout(self) -> None:

View file

@ -67,6 +67,7 @@ class CloudPackageChecker(QObject):
self._application.getHttpRequestManager().get(url, self._application.getHttpRequestManager().get(url,
callback = self._onUserPackagesRequestFinished, callback = self._onUserPackagesRequestFinished,
error_callback = self._onUserPackagesRequestFinished, error_callback = self._onUserPackagesRequestFinished,
timeout=10,
scope = self._scope) scope = self._scope)
def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None:

View file

@ -6,11 +6,15 @@ from time import time
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast
from PyQt5.QtCore import QUrl from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from UM.Logger import Logger from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from cura.API import Account from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud import UltimakerCloudAuthentication from cura.UltimakerCloud import UltimakerCloudAuthentication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .ToolPathUploader import ToolPathUploader from .ToolPathUploader import ToolPathUploader
from ..Models.BaseModel import BaseModel from ..Models.BaseModel import BaseModel
from ..Models.Http.CloudClusterResponse import CloudClusterResponse from ..Models.Http.CloudClusterResponse import CloudClusterResponse
@ -33,16 +37,20 @@ class CloudApiClient:
CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH) CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH)
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
DEFAULT_REQUEST_TIMEOUT = 10 # seconds
# In order to avoid garbage collection we keep the callbacks in this list. # In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[], None]] _anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
## Initializes a new cloud API client. ## Initializes a new cloud API client.
# \param account: The user's account object # \param account: The user's account object
# \param on_error: The callback to be called whenever we receive errors from the server. # \param on_error: The callback to be called whenever we receive errors from the server.
def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None: def __init__(self, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None:
super().__init__() super().__init__()
self._manager = QNetworkAccessManager() self._app = app
self._account = account self._account = app.getCuraAPI().account
self._scope = JsonDecoratorScope(UltimakerCloudScope(app))
self._http = HttpRequestManager.getInstance()
self._on_error = on_error self._on_error = on_error
self._upload = None # type: Optional[ToolPathUploader] self._upload = None # type: Optional[ToolPathUploader]
@ -55,16 +63,21 @@ class CloudApiClient:
# \param on_finished: The function to be called after the result is parsed. # \param on_finished: The function to be called after the result is parsed.
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None: def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None:
url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT) url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
reply = self._manager.get(self._createEmptyRequest(url)) self._http.get(url,
self._addCallback(reply, on_finished, CloudClusterResponse, failed) scope = self._scope,
callback = self._parseCallback(on_finished, CloudClusterResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## Retrieves the status of the given cluster. ## Retrieves the status of the given cluster.
# \param cluster_id: The ID of the cluster. # \param cluster_id: The ID of the cluster.
# \param on_finished: The function to be called after the result is parsed. # \param on_finished: The function to be called after the result is parsed.
def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None:
url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
reply = self._manager.get(self._createEmptyRequest(url)) self._http.get(url,
self._addCallback(reply, on_finished, CloudClusterStatus) scope = self._scope,
callback = self._parseCallback(on_finished, CloudClusterStatus),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## Requests the cloud to register the upload of a print job mesh. ## Requests the cloud to register the upload of a print job mesh.
# \param request: The request object. # \param request: The request object.
@ -72,9 +85,13 @@ class CloudApiClient:
def requestUpload(self, request: CloudPrintJobUploadRequest, def requestUpload(self, request: CloudPrintJobUploadRequest,
on_finished: Callable[[CloudPrintJobResponse], Any]) -> None: on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
url = "{}/jobs/upload".format(self.CURA_API_ROOT) url = "{}/jobs/upload".format(self.CURA_API_ROOT)
body = json.dumps({"data": request.toDict()}) data = json.dumps({"data": request.toDict()}).encode()
reply = self._manager.put(self._createEmptyRequest(url), body.encode())
self._addCallback(reply, on_finished, CloudPrintJobResponse) self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, CloudPrintJobResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## Uploads a print job tool path to the cloud. ## Uploads a print job tool path to the cloud.
# \param print_job: The object received after requesting an upload with `self.requestUpload`. # \param print_job: The object received after requesting an upload with `self.requestUpload`.
@ -84,7 +101,7 @@ class CloudApiClient:
# \param on_error: A function to be called if the upload fails. # \param on_error: A function to be called if the upload fails.
def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
on_progress: Callable[[int], Any], on_error: Callable[[], Any]): on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) self._upload = ToolPathUploader(self._http, print_job, mesh, on_finished, on_progress, on_error)
self._upload.start() self._upload.start()
# Requests a cluster to print the given print job. # Requests a cluster to print the given print job.
@ -93,8 +110,11 @@ class CloudApiClient:
# \param on_finished: The function to be called after the result is parsed. # \param on_finished: The function to be called after the result is parsed.
def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None: def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None:
url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id)
reply = self._manager.post(self._createEmptyRequest(url), b"") self._http.post(url,
self._addCallback(reply, on_finished, CloudPrintResponse) scope = self._scope,
data = b"",
callback = self._parseCallback(on_finished, CloudPrintResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## Send a print job action to the cluster for the given print job. ## Send a print job action to the cluster for the given print job.
# \param cluster_id: The ID of the cluster. # \param cluster_id: The ID of the cluster.
@ -104,7 +124,10 @@ class CloudApiClient:
data: Optional[Dict[str, Any]] = None) -> None: data: Optional[Dict[str, Any]] = None) -> None:
body = json.dumps({"data": data}).encode() if data else b"" body = json.dumps({"data": data}).encode() if data else b""
url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action) url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action)
self._manager.post(self._createEmptyRequest(url), body) self._http.post(url,
scope = self._scope,
data = body,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## We override _createEmptyRequest in order to add the user credentials. ## We override _createEmptyRequest in order to add the user credentials.
# \param url: The URL to request # \param url: The URL to request
@ -162,13 +185,12 @@ class CloudApiClient:
# \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either # \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
# a list or a single item. # a list or a single item.
# \param model: The type of the model to convert the response to. # \param model: The type of the model to convert the response to.
def _addCallback(self, def _parseCallback(self,
reply: QNetworkReply, on_finished: Union[Callable[[CloudApiClientModel], Any],
on_finished: Union[Callable[[CloudApiClientModel], Any], Callable[[List[CloudApiClientModel]], Any]],
Callable[[List[CloudApiClientModel]], Any]], model: Type[CloudApiClientModel],
model: Type[CloudApiClientModel], on_error: Optional[Callable] = None) -> Callable[[QNetworkReply], None]:
on_error: Optional[Callable] = None) -> None: def parse(reply: QNetworkReply) -> None:
def parse() -> None:
self._anti_gc_callbacks.remove(parse) self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one # Don't try to parse the reply if we didn't get one
@ -184,6 +206,4 @@ class CloudApiClient:
self._parseModels(response, on_finished, model) self._parseModels(response, on_finished, model)
self._anti_gc_callbacks.append(parse) self._anti_gc_callbacks.append(parse)
reply.finished.connect(parse) return parse
if on_error is not None:
reply.error.connect(on_error)

View file

@ -4,6 +4,7 @@ import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
from PyQt5.QtNetwork import QNetworkReply
from UM import i18nCatalog from UM import i18nCatalog
from UM.Logger import Logger # To log errors talking to the API. from UM.Logger import Logger # To log errors talking to the API.
@ -40,7 +41,7 @@ class CloudOutputDeviceManager:
# Persistent dict containing the remote clusters for the authenticated user. # Persistent dict containing the remote clusters for the authenticated user.
self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] self._remote_clusters = {} # type: Dict[str, CloudOutputDevice]
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error))) self._api = CloudApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))
self._account.loginStateChanged.connect(self._onLoginStateChanged) self._account.loginStateChanged.connect(self._onLoginStateChanged)
# Ensure we don't start twice. # Ensure we don't start twice.
@ -118,7 +119,7 @@ class CloudOutputDeviceManager:
self._syncing = False self._syncing = False
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
def _onGetRemoteClusterFailed(self): def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
self._syncing = False self._syncing = False
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)

View file

@ -1,11 +1,11 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# !/usr/bin/env python # !/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager from typing import Callable, Any, Tuple, cast, Dict, Optional
from typing import Optional, Callable, Any, Tuple, cast
from UM.Logger import Logger from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
@ -23,16 +23,16 @@ class ToolPathUploader:
BYTES_PER_REQUEST = 256 * 1024 BYTES_PER_REQUEST = 256 * 1024
## Creates a mesh upload object. ## Creates a mesh upload object.
# \param manager: The network access manager that will handle the HTTP requests. # \param http: The HttpRequestManager that will handle the HTTP requests.
# \param print_job: The print job response that was returned by the cloud after registering the upload. # \param print_job: The print job response that was returned by the cloud after registering the upload.
# \param data: The mesh bytes to be uploaded. # \param data: The mesh bytes to be uploaded.
# \param on_finished: The method to be called when done. # \param on_finished: The method to be called when done.
# \param on_progress: The method to be called when the progress changes (receives a percentage 0-100). # \param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
# \param on_error: The method to be called when an error occurs. # \param on_error: The method to be called when an error occurs.
def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes, def __init__(self, http: HttpRequestManager, print_job: CloudPrintJobResponse, data: bytes,
on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any] on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]
) -> None: ) -> None:
self._manager = manager self._http = http
self._print_job = print_job self._print_job = print_job
self._data = data self._data = data
@ -43,25 +43,12 @@ class ToolPathUploader:
self._sent_bytes = 0 self._sent_bytes = 0
self._retries = 0 self._retries = 0
self._finished = False self._finished = False
self._reply = None # type: Optional[QNetworkReply]
## Returns the print job for which this object was created. ## Returns the print job for which this object was created.
@property @property
def printJob(self): def printJob(self):
return self._print_job return self._print_job
## Creates a network request to the print job upload URL, adding the needed content range header.
def _createRequest(self) -> QNetworkRequest:
request = QNetworkRequest(QUrl(self._print_job.upload_url))
request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type)
first_byte, last_byte = self._chunkRange()
content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
request.setRawHeader(b"Content-Range", content_range.encode())
Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
return request
## Determines the bytes that should be uploaded next. ## Determines the bytes that should be uploaded next.
# \return: A tuple with the first and the last byte to upload. # \return: A tuple with the first and the last byte to upload.
def _chunkRange(self) -> Tuple[int, int]: def _chunkRange(self) -> Tuple[int, int]:
@ -88,13 +75,23 @@ class ToolPathUploader:
raise ValueError("The upload is already finished") raise ValueError("The upload is already finished")
first_byte, last_byte = self._chunkRange() first_byte, last_byte = self._chunkRange()
request = self._createRequest() content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
# now send the reply and subscribe to the results headers = {
self._reply = self._manager.put(request, self._data[first_byte:last_byte]) "Content-Type": cast(str, self._print_job.content_type),
self._reply.finished.connect(self._finishedCallback) "Content-Range": content_range
self._reply.uploadProgress.connect(self._progressCallback) } # type: Dict[str, str]
self._reply.error.connect(self._errorCallback)
Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
self._http.put(
url = cast(str, self._print_job.upload_url),
headers_dict = headers,
data = self._data[first_byte:last_byte],
callback = self._finishedCallback,
error_callback = self._errorCallback,
upload_progress_callback = self._progressCallback
)
## Handles an update to the upload progress ## Handles an update to the upload progress
# \param bytes_sent: The amount of bytes sent in the current request. # \param bytes_sent: The amount of bytes sent in the current request.
@ -106,16 +103,14 @@ class ToolPathUploader:
self._on_progress(int(total_sent / len(self._data) * 100)) self._on_progress(int(total_sent / len(self._data) * 100))
## Handles an error uploading. ## Handles an error uploading.
def _errorCallback(self) -> None: def _errorCallback(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply = cast(QNetworkReply, self._reply)
body = bytes(reply.readAll()).decode() body = bytes(reply.readAll()).decode()
Logger.log("e", "Received error while uploading: %s", body) Logger.log("e", "Received error while uploading: %s", body)
self.stop() self.stop()
self._on_error() self._on_error()
## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed. ## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
def _finishedCallback(self) -> None: def _finishedCallback(self, reply: QNetworkReply) -> None:
reply = cast(QNetworkReply, self._reply)
Logger.log("i", "Finished callback %s %s", Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
@ -133,7 +128,7 @@ class ToolPathUploader:
# Http codes that are not to be retried are assumed to be errors. # Http codes that are not to be retried are assumed to be errors.
if status_code > 308: if status_code > 308:
self._errorCallback() self._errorCallback(reply, None)
return return
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code, Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,

View file

@ -108,7 +108,15 @@ Item
} }
} }
onClicked: popup.opened ? popup.close() : popup.open() onClicked: {
if (popup.opened)
{
popup.close()
} else {
Cura.API.account.popupOpened()
popup.open()
}
}
} }
Popup Popup
@ -119,6 +127,7 @@ Item
x: parent.width - width x: parent.width - width
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
onOpened: Cura.API.account.popupOpened()
opacity: opened ? 1 : 0 opacity: opened ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } } Behavior on opacity { NumberAnimation { duration: 100 } }

View file

@ -7,11 +7,7 @@ import Cura 1.1 as Cura
Row // sync state icon + message Row // sync state icon + message
{ {
property alias iconSource: icon.source id: syncRow
property alias labelText: stateLabel.text
property alias syncButtonVisible: accountSyncButton.visible
property alias animateIconRotation: updateAnimator.running
width: childrenRect.width width: childrenRect.width
height: childrenRect.height height: childrenRect.height
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
@ -23,7 +19,7 @@ Row // sync state icon + message
width: 20 * screenScaleFactor width: 20 * screenScaleFactor
height: width height: width
source: UM.Theme.getIcon("update") source: Cura.API.account.manualSyncEnabled ? UM.Theme.getIcon("update") : UM.Theme.getIcon("checked")
color: palette.text color: palette.text
RotationAnimator RotationAnimator
@ -54,10 +50,11 @@ Row // sync state icon + message
Label Label
{ {
id: stateLabel id: stateLabel
text: catalog.i18nc("@state", "Checking...") text: catalog.i18nc("@state", catalog.i18nc("@label", "You are in sync with your account"))
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
font: UM.Theme.getFont("medium") font: UM.Theme.getFont("medium")
renderType: Text.NativeRendering renderType: Text.NativeRendering
visible: !Cura.API.account.manualSyncEnabled
} }
Label Label
@ -67,11 +64,13 @@ Row // sync state icon + message
color: UM.Theme.getColor("secondary_button_text") color: UM.Theme.getColor("secondary_button_text")
font: UM.Theme.getFont("medium") font: UM.Theme.getFont("medium")
renderType: Text.NativeRendering renderType: Text.NativeRendering
visible: Cura.API.account.manualSyncEnabled
height: visible ? accountSyncButton.intrinsicHeight : 0
MouseArea MouseArea
{ {
anchors.fill: parent anchors.fill: parent
onClicked: Cura.API.account.sync() onClicked: Cura.API.account.sync(true)
hoverEnabled: true hoverEnabled: true
onEntered: accountSyncButton.font.underline = true onEntered: accountSyncButton.font.underline = true
onExited: accountSyncButton.font.underline = false onExited: accountSyncButton.font.underline = false
@ -82,25 +81,25 @@ Row // sync state icon + message
signal syncStateChanged(string newState) signal syncStateChanged(string newState)
onSyncStateChanged: { onSyncStateChanged: {
if(newState == Cura.AccountSyncState.SYNCING){ if(newState == Cura.AccountSyncState.IDLE){
syncRow.iconSource = UM.Theme.getIcon("update") icon.source = UM.Theme.getIcon("update")
syncRow.labelText = catalog.i18nc("@label", "Checking...") } else if(newState == Cura.AccountSyncState.SYNCING){
icon.source = UM.Theme.getIcon("update")
stateLabel.text = catalog.i18nc("@label", "Checking...")
} else if (newState == Cura.AccountSyncState.SUCCESS) { } else if (newState == Cura.AccountSyncState.SUCCESS) {
syncRow.iconSource = UM.Theme.getIcon("checked") icon.source = UM.Theme.getIcon("checked")
syncRow.labelText = catalog.i18nc("@label", "You are up to date") stateLabel.text = catalog.i18nc("@label", "You are in sync with your account")
} else if (newState == Cura.AccountSyncState.ERROR) { } else if (newState == Cura.AccountSyncState.ERROR) {
syncRow.iconSource = UM.Theme.getIcon("warning_light") icon.source = UM.Theme.getIcon("warning_light")
syncRow.labelText = catalog.i18nc("@label", "Something went wrong...") stateLabel.text = catalog.i18nc("@label", "Something went wrong...")
} else { } else {
print("Error: unexpected sync state: " + newState) print("Error: unexpected sync state: " + newState)
} }
if(newState == Cura.AccountSyncState.SYNCING){ if(newState == Cura.AccountSyncState.SYNCING){
syncRow.animateIconRotation = true updateAnimator.running = true
syncRow.syncButtonVisible = false
} else { } else {
syncRow.animateIconRotation = false updateAnimator.running = false
syncRow.syncButtonVisible = true
} }
} }

View file

@ -9,7 +9,10 @@ import Cura 1.1 as Cura
Column Column
{ {
width: Math.max(title.width, accountButton.width) + 2 * UM.Theme.getSize("default_margin").width width: Math.max(
Math.max(title.width, accountButton.width) + 2 * UM.Theme.getSize("default_margin").width,
syncRow.width
)
spacing: UM.Theme.getSize("default_margin").height spacing: UM.Theme.getSize("default_margin").height
@ -29,13 +32,10 @@ Column
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
} }
SyncState SyncState {
{
id: syncRow id: syncRow
} }
Label Label
{ {
id: lastSyncLabel id: lastSyncLabel

View file

@ -4,9 +4,9 @@
<title>checked</title> <title>checked</title>
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<g id="checked" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="checked" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Check-circle" fill="#000000" fill-rule="nonzero"> <g transform="translate(1.000000, 1.000000)" fill="#000000" fill-rule="nonzero">
<path d="M8,0 C3.581722,0 1.42108547e-15,3.581722 1.42108547e-15,8 C1.42108547e-15,12.418278 3.581722,16 8,16 C12.418278,16 16,12.418278 16,8 C16,5.87826808 15.1571453,3.84343678 13.6568542,2.34314575 C12.1565632,0.842854723 10.1217319,0 8,0 Z M8,14.4 C4.4653776,14.4 1.6,11.5346224 1.6,8 C1.6,4.4653776 4.4653776,1.6 8,1.6 C11.5346224,1.6 14.4,4.4653776 14.4,8 C14.4,9.69738553 13.7257162,11.3252506 12.5254834,12.5254834 C11.3252506,13.7257162 9.69738553,14.4 8,14.4 Z" id="Shape"></path> <path d="M7,0 C3.13400675,0 1.55431223e-15,3.13400675 1.55431223e-15,7 C1.55431223e-15,10.8659933 3.13400675,14 7,14 C10.8659933,14 14,10.8659933 14,7 C14,5.14348457 13.2625021,3.36300718 11.9497474,2.05025253 C10.6369928,0.737497883 8.85651541,0 7,0 Z M7,12.6 C3.9072054,12.6 1.4,10.0927946 1.4,7 C1.4,3.9072054 3.9072054,1.4 7,1.4 C10.0927946,1.4 12.6,3.9072054 12.6,7 C12.6,8.48521234 12.0100017,9.90959428 10.959798,10.959798 C9.90959428,12.0100017 8.48521234,12.6 7,12.6 Z" id="Shape"></path>
<polygon id="Path" points="11.44 5.04 7.2 9.28 4.56 6.64 3.44 7.76 7.2 11.52 12.56 6.16"></polygon> <polygon id="Path" points="10.01 4.41 6.3 8.12 3.99 5.81 3.01 6.79 6.3 10.08 10.99 5.39"></polygon>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After