Differentiate between local and remote packages

There is a distinction between packages which are already
installed on the local machine and packages which are
available on the remote server. Even with this difference
it is important that they are handled the same and can be
reused in the same GUI elements.

In order to reduce code duplication I created a parent object
PackageList which contains the base logic and interface for
the QML and let both RemotePackageList and LocalPackageList
inherit from this.

UX specified that the gear icon (Settings.svg) should be
separate from the tabs of material and plugins. This also
ment that the current tab  item couldn't set the pageTitle
anymore. This is now defined in the Package component and
set when the loader has loaded the external QML file.

Contributes to CURA-8558
This commit is contained in:
Jelle Spijker 2021-11-01 17:02:07 +01:00
parent 03e1fc34b4
commit 86d5d315bc
No known key found for this signature in database
GPG key ID: 6662DC033BE6B99A
11 changed files with 307 additions and 136 deletions

View file

@ -0,0 +1,48 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtSlot, Qt
from typing import TYPE_CHECKING
from UM.i18n import i18nCatalog
from cura.CuraApplication import CuraApplication
from .PackageList import PackageList
from .PackageModel import PackageModel # The contents of this list.
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
catalog = i18nCatalog("cura")
class LocalPackageList(PackageList):
PackageRole = Qt.UserRole + 1
def __init__(self, parent: "QObject" = None) -> None:
super().__init__(parent)
self._application = CuraApplication.getInstance()
@pyqtSlot()
def updatePackages(self) -> None:
"""
Make a request for the first paginated page of packages.
When the request is done, the list will get updated with the new package models.
"""
self.setErrorMessage("") # Clear any previous errors.
self.setIsLoading(True)
self._getLocalPackages()
def _getLocalPackages(self) -> None:
plugin_registry = self._application.getPluginRegistry()
package_manager = self._application.getPackageManager()
bundled = plugin_registry.getInstalledPlugins()
for b in bundled:
package = PackageModel({"package_id": b, "display_name": b}, parent = self)
self.appendItem({"package": package})
packages = package_manager.getInstalledPackageIDs()
self.setIsLoading(False)
self.setHasMore(False)

View file

@ -13,7 +13,8 @@ from UM.Extension import Extension # We are implementing the main object of an
from UM.Logger import Logger from UM.Logger import Logger
from UM.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way). from UM.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way).
from .PackageList import PackageList # To register this type with QML. from .RemotePackageList import RemotePackageList # To register this type with QML.
from .LocalPackageList import LocalPackageList # To register this type with QML.
if TYPE_CHECKING: if TYPE_CHECKING:
from PyQt5.QtCore import QObject from PyQt5.QtCore import QObject
@ -31,7 +32,8 @@ class Marketplace(Extension):
super().__init__() super().__init__()
self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here. self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here.
qmlRegisterType(PackageList, "Marketplace", 1, 0, "PackageList") qmlRegisterType(RemotePackageList, "Marketplace", 1, 0, "RemotePackageList")
qmlRegisterType(LocalPackageList, "Marketplace", 1, 0, "LocalPackageList")
@pyqtSlot() @pyqtSlot()
def show(self) -> None: def show(self) -> None:

View file

@ -2,19 +2,10 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
from PyQt5.QtNetwork import QNetworkReply from typing import TYPE_CHECKING
from typing import Optional, TYPE_CHECKING
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API.
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API.
from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
from .PackageModel import PackageModel # The contents of this list.
if TYPE_CHECKING: if TYPE_CHECKING:
from PyQt5.QtCore import QObject from PyQt5.QtCore import QObject
@ -23,99 +14,60 @@ catalog = i18nCatalog("cura")
class PackageList(ListModel): class PackageList(ListModel):
"""
Represents a list of packages to be displayed in the interface.
The list can be filtered (e.g. on package type, materials vs. plug-ins) and
paginated.
"""
PackageRole = Qt.UserRole + 1 PackageRole = Qt.UserRole + 1
ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
def __init__(self, parent: "QObject" = None) -> None: def __init__(self, parent: "QObject" = None) -> None:
super().__init__(parent) super().__init__(parent)
self._ongoing_request: Optional[HttpRequestData] = None
self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
self._error_message = "" self._error_message = ""
self._package_type_filter = ""
self._request_url = self._initialRequestUrl()
self.addRoleName(self.PackageRole, "package") self.addRoleName(self.PackageRole, "package")
self._is_loading = False
def __del__(self) -> None: self._has_more = False
"""
When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
object.
"""
self.abortRequest()
@pyqtSlot() @pyqtSlot()
def request(self) -> None: def updatePackages(self) -> None:
""" """
Make a request for the first paginated page of packages. Initialize the first page of packages
When the request is done, the list will get updated with the new package models.
""" """
self.setErrorMessage("") # Clear any previous errors. self.setErrorMessage("") # Clear any previous errors.
http = HttpRequestManager.getInstance()
self._ongoing_request = http.get(
self._request_url,
scope = self._scope,
callback = self._parseResponse,
error_callback = self._onError
)
self.isLoadingChanged.emit() self.isLoadingChanged.emit()
@pyqtSlot() @pyqtSlot()
def abortRequest(self) -> None: def abortUpdating(self) -> None:
HttpRequestManager.getInstance().abortRequest(self._ongoing_request) pass
self._ongoing_request = None
def reset(self) -> None: def reset(self) -> None:
self.clear() self.clear()
self._request_url = self._initialRequestUrl()
isLoadingChanged = pyqtSignal() isLoadingChanged = pyqtSignal()
@pyqtProperty(bool, notify = isLoadingChanged) def setIsLoading(self, value: bool) -> None:
if self._is_loading != value:
self._is_loading = value
self.isLoadingChanged.emit()
@pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
def isLoading(self) -> bool: def isLoading(self) -> bool:
""" """
Gives whether the list is currently loading the first page or loading more pages. Gives whether the list is currently loading the first page or loading more pages.
:return: ``True`` if the list is downloading, or ``False`` if not downloading. :return: ``True`` if the list is being gathered, or ``False`` if .
""" """
return self._ongoing_request is not None return self._is_loading
hasMoreChanged = pyqtSignal() hasMoreChanged = pyqtSignal()
@pyqtProperty(bool, notify = hasMoreChanged) def setHasMore(self, value: bool) -> None:
if self._has_more != value:
self._has_more = value
self.hasMoreChanged.emit()
@pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
def hasMore(self) -> bool: def hasMore(self) -> bool:
""" """
Returns whether there are more packages to load. Returns whether there are more packages to load.
:return: ``True`` if there are more packages to load, or ``False`` if we've reached the last page of the :return: ``True`` if there are more packages to load, or ``False`` if we've reached the last page of the
pagination. pagination.
""" """
return self._request_url != "" return self._has_more
packageTypeFilterChanged = pyqtSignal()
def setPackageTypeFilter(self, new_filter: str) -> None:
if new_filter != self._package_type_filter:
self._package_type_filter = new_filter
self.reset()
self.packageTypeFilterChanged.emit()
@pyqtProperty(str, notify = packageTypeFilterChanged, fset = setPackageTypeFilter)
def packageTypeFilter(self) -> str:
"""
Get the package type this package list is filtering on, like ``plugin`` or ``material``.
:return: The package type this list is filtering on.
"""
return self._package_type_filter
def setErrorMessage(self, error_message: str) -> None: def setErrorMessage(self, error_message: str) -> None:
if self._error_message != error_message: if self._error_message != error_message:
@ -133,49 +85,3 @@ class PackageList(ListModel):
:return: An error message, if any, or an empty string if everything went okay. :return: An error message, if any, or an empty string if everything went okay.
""" """
return self._error_message return self._error_message
def _initialRequestUrl(self) -> str:
"""
Get the URL to request the first paginated page with.
:return: A URL to request.
"""
if self._package_type_filter != "":
return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}"
return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
def _parseResponse(self, reply: "QNetworkReply") -> None:
"""
Parse the response from the package list API request.
This converts that response into PackageModels, and triggers the ListModel to update.
:param reply: A reply containing information about a number of packages.
"""
response_data = HttpRequestManager.readJSON(reply)
if "data" not in response_data or "links" not in response_data:
Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
return
for package_data in response_data["data"]:
package = PackageModel(package_data, parent = self)
self.appendItem({"package": package}) # Add it to this list model.
self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
self.hasMoreChanged.emit()
self._ongoing_request = None
self.isLoadingChanged.emit()
def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
"""
Handles networking and server errors when requesting the list of packages.
:param reply: The reply with packages. This will most likely be incomplete and should be ignored.
:param error: The error status of the request.
"""
if error == QNetworkReply.NetworkError.OperationCanceledError:
Logger.debug("Cancelled request for packages.")
self._ongoing_request = None
return # Don't show an error about this to the user.
Logger.error("Could not reach Marketplace server.")
self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
self._ongoing_request = None
self.isLoadingChanged.emit()

View file

@ -0,0 +1,131 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
from PyQt5.QtNetwork import QNetworkReply
from typing import Optional, TYPE_CHECKING
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API.
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API.
from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
from .PackageList import PackageList
from .PackageModel import PackageModel # The contents of this list.
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
from PyQt5.QtNetwork import QNetworkReply
catalog = i18nCatalog("cura")
class RemotePackageList(PackageList):
ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
def __init__(self, parent: "QObject" = None) -> None:
super().__init__(parent)
self._ongoing_request: Optional[HttpRequestData] = None
self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
self._package_type_filter = ""
self._request_url = self._initialRequestUrl()
def __del__(self) -> None:
"""
When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
object.
"""
self.abortUpdating()
@pyqtSlot()
def updatePackages(self) -> None:
"""
Make a request for the first paginated page of packages.
When the request is done, the list will get updated with the new package models.
"""
self.setErrorMessage("") # Clear any previous errors.
self.setIsLoading(True)
self._ongoing_request = HttpRequestManager.getInstance().get(
self._request_url,
scope = self._scope,
callback = self._parseResponse,
error_callback = self._onError
)
@pyqtSlot()
def abortUpdating(self) -> None:
HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
self._ongoing_request = None
def reset(self) -> None:
self.clear()
self._request_url = self._initialRequestUrl()
packageTypeFilterChanged = pyqtSignal()
def setPackageTypeFilter(self, new_filter: str) -> None:
if new_filter != self._package_type_filter:
self._package_type_filter = new_filter
self.reset()
self.packageTypeFilterChanged.emit()
@pyqtProperty(str, fset = setPackageTypeFilter, notify = packageTypeFilterChanged)
def packageTypeFilter(self) -> str:
"""
Get the package type this package list is filtering on, like ``plugin`` or ``material``.
:return: The package type this list is filtering on.
"""
return self._package_type_filter
def _initialRequestUrl(self) -> str:
"""
Get the URL to request the first paginated page with.
:return: A URL to request.
"""
if self._package_type_filter != "":
return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}"
return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
def _parseResponse(self, reply: "QNetworkReply") -> None:
"""
Parse the response from the package list API request.
This converts that response into PackageModels, and triggers the ListModel to update.
:param reply: A reply containing information about a number of packages.
"""
response_data = HttpRequestManager.readJSON(reply)
if "data" not in response_data or "links" not in response_data:
Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
return
for package_data in response_data["data"]:
package = PackageModel(package_data, parent = self)
self.appendItem({"package": package}) # Add it to this list model.
self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
self._ongoing_request = None
self.setIsLoading(False)
self.setHasMore(self._request_url != "")
def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
"""
Handles networking and server errors when requesting the list of packages.
:param reply: The reply with packages. This will most likely be incomplete and should be ignored.
:param error: The error status of the request.
"""
if error == QNetworkReply.NetworkError.OperationCanceledError:
Logger.debug("Cancelled request for packages.")
self._ongoing_request = None
return # Don't show an error about this to the user.
Logger.error("Could not reach Marketplace server.")
self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
self._ongoing_request = None
self.setIsLoading(False)

View file

@ -0,0 +1,16 @@
// Copyright (c) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Marketplace 1.0 as Marketplace
import UM 1.4 as UM
Packages
{
pageTitle: catalog.i18nc("@header", "Manage packages")
model: Marketplace.LocalPackageList
{
}
}

View file

@ -34,7 +34,8 @@ Window
title: "Marketplace" //Seen by Ultimaker as a brand name, so this doesn't get translated. title: "Marketplace" //Seen by Ultimaker as a brand name, so this doesn't get translated.
modality: Qt.NonModal modality: Qt.NonModal
Rectangle //Background color. // Background color
Rectangle
{ {
anchors.fill: parent anchors.fill: parent
color: UM.Theme.getColor("main_background") color: UM.Theme.getColor("main_background")
@ -45,13 +46,15 @@ Window
spacing: UM.Theme.getSize("default_margin").height spacing: UM.Theme.getSize("default_margin").height
Item //Page title. // Page title.
Item
{ {
Layout.preferredWidth: parent.width Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height
Label Label
{ {
id: pageTitle
anchors anchors
{ {
left: parent.left left: parent.left
@ -63,7 +66,7 @@ Window
font: UM.Theme.getFont("large") font: UM.Theme.getFont("large")
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
text: pageSelectionTabBar.currentItem.pageTitle text: ""
} }
} }
@ -72,10 +75,56 @@ Window
Layout.preferredWidth: parent.width Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height Layout.preferredHeight: childrenRect.height
TabBar //Page selection. Button
{
id: managePackagesButton
hoverEnabled: true
width: childrenRect.width
height: childrenRect.height
anchors.right: parent.right
anchors.rightMargin: UM.Theme.getSize("default_margin").width
background: Rectangle
{
color: UM.Theme.getColor("action_button")
border.color: "transparent"
border.width: UM.Theme.getSize("default_lining").width
}
Cura.ToolTip
{
id: managePackagesTooltip
tooltipText: catalog.i18nc("@info:tooltip", "Manage packages")
arrowSize: 0
visible: managePackagesButton.hovered
}
UM.RecolorImage
{
id: managePackagesIcon
width: UM.Theme.getSize("section_icon").width
height: UM.Theme.getSize("section_icon").height
color: UM.Theme.getColor("icon")
source: UM.Theme.getIcon("Settings")
}
onClicked:
{
content.source = "ManagedPackages.qml"
}
}
// Page selection.
TabBar
{ {
id: pageSelectionTabBar id: pageSelectionTabBar
anchors.right: parent.right anchors.right: managePackagesButton.left
anchors.rightMargin: UM.Theme.getSize("default_margin").width anchors.rightMargin: UM.Theme.getSize("default_margin").width
spacing: 0 spacing: 0
@ -84,31 +133,40 @@ Window
{ {
width: implicitWidth width: implicitWidth
text: catalog.i18nc("@button", "Plug-ins") text: catalog.i18nc("@button", "Plug-ins")
pageTitle: catalog.i18nc("@header", "Install Plugins")
onClicked: content.source = "Plugins.qml" onClicked: content.source = "Plugins.qml"
} }
PackageTypeTab PackageTypeTab
{ {
width: implicitWidth width: implicitWidth
text: catalog.i18nc("@button", "Materials") text: catalog.i18nc("@button", "Materials")
pageTitle: catalog.i18nc("@header", "Install Materials")
onClicked: content.source = "Materials.qml" onClicked: content.source = "Materials.qml"
} }
} }
} }
Rectangle //Page contents. // Page contents.
Rectangle
{ {
Layout.preferredWidth: parent.width Layout.preferredWidth: parent.width
Layout.fillHeight: true Layout.fillHeight: true
color: UM.Theme.getColor("detail_background") color: UM.Theme.getColor("detail_background")
Loader //Page contents. // Page contents.
Loader
{ {
id: content id: content
anchors.fill: parent anchors.fill: parent
anchors.margins: UM.Theme.getSize("default_margin").width anchors.margins: UM.Theme.getSize("default_margin").width
source: "Plugins.qml" source: "Plugins.qml"
Connections
{
target: content
onLoaded: function()
{
pageTitle.text = content.item.pageTitle
}
}
} }
} }
} }

View file

@ -5,7 +5,8 @@ import Marketplace 1.0 as Marketplace
Packages Packages
{ {
model: Marketplace.PackageList pageTitle: catalog.i18nc("@header", "Install Materials")
model: Marketplace.RemotePackageList
{ {
packageTypeFilter: "material" packageTypeFilter: "material"
} }

View file

@ -12,9 +12,10 @@ ScrollView
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
property alias model: packagesListview.model property alias model: packagesListview.model
property string pageTitle
Component.onCompleted: model.request() Component.onCompleted: model.updatePackages()
Component.onDestruction: model.abortRequest() Component.onDestruction: model.abortUpdating()
ListView ListView
{ {
@ -43,7 +44,8 @@ ScrollView
} }
} }
footer: Item //Wrapper item to add spacing between content and footer. //Wrapper item to add spacing between content and footer.
footer: Item
{ {
width: parent.width width: parent.width
height: UM.Theme.getSize("card").height + packagesListview.spacing height: UM.Theme.getSize("card").height + packagesListview.spacing
@ -55,7 +57,7 @@ ScrollView
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
enabled: packages.model.hasMore && !packages.model.isLoading || packages.model.errorMessage != "" enabled: packages.model.hasMore && !packages.model.isLoading || packages.model.errorMessage != ""
onClicked: packages.model.request() //Load next page in plug-in list. onClicked: packages.model.updatePackages() //Load next page in plug-in list.
background: Rectangle background: Rectangle
{ {

View file

@ -5,7 +5,8 @@ import Marketplace 1.0 as Marketplace
Packages Packages
{ {
model: Marketplace.PackageList pageTitle: catalog.i18nc("@header", "Install Plugins")
model: Marketplace.RemotePackageList
{ {
packageTypeFilter: "plugin" packageTypeFilter: "plugin"
} }

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12C19 11.895 18.9888 11.7928 18.9843 11.6889L21.2354 9.9966L18.3525 5.0034L15.7525 6.1005C15.5837 5.9927 15.4112 5.8905 15.2325 5.7972L14.8828 3H9.1172L8.7672 5.7972C8.5855 5.8923 8.4096 5.9964 8.2372 6.1066L5.6472 5.0034L2.7646 9.9966L5.0152 11.7C5.0109 11.8 5 11.8988 5 12C5 12.105 5.0112 12.2072 5.0157 12.3111L2.7646 14.0034L5.6475 18.9966L8.2475 17.8994C8.4163 18.0072 8.5888 18.1094 8.7675 18.2028L9.1172 21H14.8828L15.2328 18.2028C15.4145 18.1077 15.5904 18.0036 15.7628 17.8934L18.3528 18.9966L21.2357 14.0034L18.9848 12.3C18.9891 12.2 19 12.1012 19 12ZM18.62 14.5327L17.5028 16.4673L15.4513 15.6018C14.8702 16.1531 14.1648 16.5564 13.3949 16.7773L13.1172 19H10.8828L10.605 16.7773C9.83505 16.5564 9.12968 16.1531 8.5486 15.6018L6.4971 16.4673L5.38 14.5327L7.1579 13.1865C6.94752 12.4095 6.94752 11.5905 7.1579 10.8135L5.38 9.4673L6.4971 7.5327L8.5486 8.3982C9.12968 7.84686 9.83505 7.44364 10.605 7.2227L10.8828 5H13.1172L13.395 7.2227C14.1649 7.44364 14.8703 7.84686 15.4514 8.3982L17.5029 7.5327L18.62 9.4673L16.8421 10.8135C17.0525 11.5905 17.0525 12.4095 16.8421 13.1865L18.62 14.5327ZM12 9C11.4067 9 10.8266 9.17595 10.3333 9.50559C9.83994 9.83524 9.45542 10.3038 9.22836 10.8519C9.0013 11.4001 8.94189 12.0033 9.05764 12.5853C9.1734 13.1672 9.45912 13.7018 9.87868 14.1213C10.2982 14.5409 10.8328 14.8266 11.4147 14.9424C11.9967 15.0581 12.5999 14.9987 13.148 14.7716C13.6962 14.5446 14.1648 14.1601 14.4944 13.6667C14.8241 13.1734 15 12.5933 15 12C14.9991 11.2046 14.6828 10.4421 14.1204 9.87964C13.5579 9.31722 12.7954 9.00087 12 9ZM12 13C11.8022 13 11.6089 12.9414 11.4444 12.8315C11.28 12.7216 11.1518 12.5654 11.0761 12.3827C11.0004 12.2 10.9806 11.9989 11.0192 11.8049C11.0578 11.6109 11.153 11.4327 11.2929 11.2929C11.4327 11.153 11.6109 11.0578 11.8049 11.0192C11.9989 10.9806 12.2 11.0004 12.3827 11.0761C12.5654 11.1518 12.7216 11.28 12.8315 11.4444C12.9413 11.6089 13 11.8022 13 12C12.9998 12.2651 12.8943 12.5194 12.7068 12.7068C12.5194 12.8943 12.2651 12.9998 12 13Z" fill="#000E1A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.6058 21.5314C31.4745 21.131 31.3116 20.7417 31.1185 20.3671C30.3501 18.8691 29.131 17.6501 27.6331 16.8816C26.5097 16.3023 25.2641 16.0001 24.0001 16.0001C22.7361 16.0001 21.4905 16.3023 20.3671 16.8816C18.8691 17.6501 17.65 18.8692 16.8815 20.3672C16.3021 21.4906 15.9998 22.7362 15.9998 24.0002C15.9998 25.2642 16.3021 26.5098 16.8815 27.6332C17.65 29.1311 18.869 30.3502 20.367 31.1186C21.4904 31.698 22.736 32.0003 24 32.0003C25.264 32.0003 26.5096 31.698 27.633 31.1186C29.1309 30.3502 30.3499 29.1312 31.1183 27.6333C31.5999 26.6993 31.8911 25.6789 31.9747 24.6314C32.0584 23.5839 31.933 22.5302 31.6057 21.5316L31.6058 21.5314ZM28.3781 28.09C28.2852 28.1894 28.189 28.2855 28.0897 28.3784C27.0333 29.3689 25.6529 29.9417 24.2056 29.99C24.1367 29.9923 24.0694 30 24 30C23.9306 30 23.8633 29.9919 23.7944 29.99C22.3469 29.9416 20.9664 29.3688 19.91 28.3781C19.8106 28.2852 19.7145 28.189 19.6216 28.0897C18.6312 27.0335 18.0585 25.6533 18.01 24.2062C18.0081 24.1371 18 24.07 18 24C18 23.93 18.0081 23.8629 18.01 23.7938C18.0585 22.3466 18.6314 20.9663 19.6219 19.91C19.7148 19.8106 19.811 19.7145 19.9103 19.6216C20.9665 18.6312 22.3467 18.0585 23.7938 18.01C23.8629 18.0077 23.9304 18 24 18C24.0696 18 24.1371 18.0081 24.2062 18.01C25.6534 18.0585 27.0337 18.6314 28.09 19.6219C28.1894 19.7148 28.2855 19.811 28.3784 19.9103C29.3689 20.9667 29.9417 22.3471 29.99 23.7944C29.9923 23.8633 30 23.9306 30 24C30 24.0694 29.9919 24.1367 29.99 24.2056C29.9416 25.6531 29.3688 27.0336 28.3781 28.09ZM42 27.8467V20.1528L37.193 19.3516C37.0257 18.8773 36.8327 18.4124 36.6149 17.9591L39.4482 13.9927L34.0082 8.5518L30.0413 11.3851C29.5879 11.1673 29.1229 10.9742 28.6485 10.8069L27.8467 6H20.1528L19.3516 10.807C18.8774 10.9743 18.4126 11.1673 17.9593 11.385L13.9927 8.5518L8.5518 13.9927L11.385 17.9593C11.1673 18.4126 10.9743 18.8774 10.807 19.3516L6 20.1528V27.8467L10.8069 28.6481C10.9742 29.1225 11.1673 29.5875 11.3851 30.0409L8.5518 34.0078L13.9927 39.4478L17.9591 36.6145C18.4124 36.8323 18.8773 37.0253 19.3516 37.1926L20.1528 42H27.8467L28.6481 37.1931C29.1226 37.0257 29.5876 36.8327 30.0411 36.6149L34.0078 39.4482L39.4478 34.0082L36.6145 30.0415C36.8323 29.588 37.0253 29.123 37.1927 28.6485L42 27.8467ZM35.6375 26.88C35.5326 27.305 35.4043 27.7238 35.2532 28.1346C35.1409 28.4401 35.0184 28.7399 34.8824 29.0331C34.6981 29.4301 34.4921 29.8166 34.2654 30.191L35.52 31.947L36.8372 33.791L33.791 36.8369L31.947 35.52L30.1912 34.2657C29.8168 34.4924 29.4303 34.6984 29.0333 34.8827C28.74 35.0186 28.4403 35.1412 28.1348 35.2535C27.724 35.4046 27.3052 35.5329 26.8802 35.6378L26.5264 37.7609L26.1533 40H21.8472L21.474 37.7607L21.12 35.6376C20.695 35.5326 20.2761 35.4043 19.8652 35.2532C19.5594 35.1408 19.2597 35.0182 18.9662 34.8823C18.5692 34.6978 18.1825 34.4917 17.808 34.265L16.052 35.5191L14.2083 36.8359L11.1641 33.791L12.4808 31.9476L13.735 30.1918C13.5081 29.8173 13.302 29.4305 13.1176 29.0333C12.9816 28.7399 12.8591 28.4402 12.7467 28.1345C12.5956 27.7238 12.4674 27.3051 12.3625 26.8802L10.2394 26.5264L8 26.1533V21.8472L10.2393 21.474L12.3624 21.1202C12.4673 20.6953 12.5956 20.2764 12.7466 19.8656C12.8591 19.5598 12.9817 19.2599 13.1177 18.9664C13.3021 18.5693 13.5082 18.1825 13.735 17.808L12.481 16.0522L11.1641 14.2085L14.2085 11.1641L16.0522 12.481L17.808 13.735C18.1825 13.5082 18.5693 13.3021 18.9664 13.1177C19.2599 12.9817 19.5598 12.8591 19.8656 12.7466C20.2764 12.5956 20.6953 12.4673 21.1202 12.3624L21.474 10.2393L21.8472 8H26.1533L26.5264 10.2394L26.8802 12.3625C27.3051 12.4673 27.7238 12.5955 28.1345 12.7466C28.4401 12.859 28.7399 12.9816 29.0333 13.1176C29.4305 13.302 29.8173 13.5081 30.1918 13.735L31.9476 12.4808L33.791 11.1641L36.8359 14.2085L35.5191 16.0522L34.265 17.8082C34.4918 18.1827 34.6979 18.5693 34.8823 18.9664C35.0183 19.2599 35.1409 19.5597 35.2533 19.8654C35.4044 20.2763 35.5327 20.6952 35.6376 21.1202L37.7607 21.474L40 21.8472V26.1533L37.7606 26.5264L35.6375 26.88Z" fill="#000E1A"/>
</svg>

After

Width:  |  Height:  |  Size: 4 KiB