From be55156336fcbaf753f5827cc3de0c1da09ec5fb Mon Sep 17 00:00:00 2001 From: Joachim Kohlhammer Date: Sat, 6 Sep 2025 14:49:40 +0200 Subject: [PATCH] Global config dialog to assign icons to Sport / SubSport (#4695) * New dialog Options > Data Fields > Icons * New IconManager to centrally assign icons (svg only) to Sport / SubSport * Removed all material symbols due to license incompatibility * Using IconManager in PlanningCalendarWindow and ManualActivityWizard --- src/Charts/PlanningCalendarWindow.cpp | 17 +- src/Gui/IconManager.cpp | 315 +++++++++++ src/Gui/IconManager.h | 73 +++ src/Gui/ManualActivityWizard.cpp | 20 +- src/Gui/Pages.cpp | 512 ++++++++++++++++++ src/Gui/Pages.h | 35 ++ src/Resources/application.qrc | 8 +- .../images/breeze/edit-image-face-add.svg | 13 + src/Resources/images/breeze/trash-empty.svg | 13 + src/Resources/images/material/bike.svg | 1 - src/Resources/images/material/rowing.svg | 1 - src/Resources/images/material/run.svg | 1 - src/Resources/images/material/ski.svg | 1 - src/Resources/images/material/swim.svg | 1 - .../images/material/weight-lifter.svg | 1 - src/src.pro | 6 +- 16 files changed, 974 insertions(+), 44 deletions(-) create mode 100644 src/Gui/IconManager.cpp create mode 100644 src/Gui/IconManager.h create mode 100644 src/Resources/images/breeze/edit-image-face-add.svg create mode 100644 src/Resources/images/breeze/trash-empty.svg delete mode 100644 src/Resources/images/material/bike.svg delete mode 100644 src/Resources/images/material/rowing.svg delete mode 100644 src/Resources/images/material/run.svg delete mode 100644 src/Resources/images/material/ski.svg delete mode 100644 src/Resources/images/material/swim.svg delete mode 100644 src/Resources/images/material/weight-lifter.svg diff --git a/src/Charts/PlanningCalendarWindow.cpp b/src/Charts/PlanningCalendarWindow.cpp index 89863592f..4380a5d13 100644 --- a/src/Charts/PlanningCalendarWindow.cpp +++ b/src/Charts/PlanningCalendarWindow.cpp @@ -30,6 +30,7 @@ #include "ManualActivityWizard.h" #include "RepeatScheduleWizard.h" #include "WorkoutFilter.h" +#include "IconManager.h" #define HLO "

" #define HLC "

" @@ -490,21 +491,7 @@ PlanningCalendarWindow::getActivities activity.secondaryMetric = ""; } - if (sport == "Bike") { - activity.iconFile = ":images/material/bike.svg"; - } else if (sport == "Run") { - activity.iconFile = ":images/material/run.svg"; - } else if (sport == "Swim") { - activity.iconFile = ":images/material/swim.svg"; - } else if (sport == "Row") { - activity.iconFile = ":images/material/rowing.svg"; - } else if (sport == "Ski") { - activity.iconFile = ":images/material/ski.svg"; - } else if (sport == "Gym") { - activity.iconFile = ":images/material/weight-lifter.svg"; - } else { - activity.iconFile = ":images/breeze/games-highscores.svg"; - } + activity.iconFile = IconManager::instance().getFilepath(rideItem); if (rideItem->color.alpha() < 255 || rideItem->planned) { activity.color = QColor("#F79130"); } else { diff --git a/src/Gui/IconManager.cpp b/src/Gui/IconManager.cpp new file mode 100644 index 000000000..a90b5115a --- /dev/null +++ b/src/Gui/IconManager.cpp @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2025 Joachim Kohlhammer (joachim.kohlhammer@gmx.de) + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "IconManager.h" + +#include + +#include "../qzip/zipwriter.h" +#include "../qzip/zipreader.h" + + +IconManager& +IconManager::instance +() +{ + static IconManager instance; + return instance; +} + + +IconManager::IconManager +() +{ + baseDir.mkpath("."); + loadMapping(); +} + + +QString +IconManager::getFilepath +(const QString &sport, const QString &subSport) const +{ + QString ret; + if ( ! sport.isEmpty() + && icons.contains("Sport") + && ! icons["Sport"].value(sport, "").isEmpty()) { + ret = baseDir.absoluteFilePath(icons["Sport"].value(sport, "")); + } + if ( ! subSport.isEmpty() + && icons.contains("SubSport") + && ! icons["SubSport"].value(subSport, "").isEmpty()) { + ret = baseDir.absoluteFilePath(icons["SubSport"].value(subSport, "")); + } + QFileInfo fileInfo(ret); + if (! fileInfo.isFile() || ! fileInfo.isReadable()) { + ret = defaultIcon; + } + return ret; +} + + +QString +IconManager::getFilepath +(RideItem const * const rideItem) const +{ + if (rideItem != nullptr) { + return getFilepath(rideItem->sport, rideItem->getText("SubSport", "")); + } + return defaultIcon; +} + + +QString +IconManager::getDefault +() const +{ + return defaultIcon; +} + + +QStringList +IconManager::listIconFiles +() const +{ + QStringList nameFilter { "*.svg" }; + return baseDir.entryList(nameFilter, QDir::Files | QDir::Readable); +} + + +QString +IconManager::toFilepath +(const QString &filename) +{ + return baseDir.absoluteFilePath(filename); +} + + +QString +IconManager::assignedIcon +(const QString &field, const QString &value) const +{ + if (icons.contains(field)) { + return icons[field].value(value, ""); + } + return ""; +} + + +void +IconManager::assignIcon +(const QString &field, const QString &value, const QString &filename) +{ + if (! filename.isEmpty()) { + icons[field][value] = filename; + } else if (icons.contains(field)) { + icons[field].remove(value); + } + saveConfig(); +} + + +bool +IconManager::addIconFile +(const QFile &sourceFile) +{ + QFileInfo fileInfo(sourceFile); + if (fileInfo.suffix() != "svg") { + return false; + } + QSvgRenderer renderer(sourceFile.fileName()); + if (! renderer.isValid()) { + return false; + } + return QFile::copy(fileInfo.absoluteFilePath(), baseDir.absoluteFilePath(fileInfo.fileName())); +} + + +bool +IconManager::deleteIconFile +(const QString &filename) +{ + QString filepath = toFilepath(filename); + QFileInfo fileInfo(filepath); + if (fileInfo.suffix() != "svg") { + return false; + } + if (QFile::remove(filepath)) { + bool save = false; + for (QString &field : icons.keys()) { + for (QString &value : icons[field].keys()) { + if (icons[field].value(value, "") == filename) { + save = true; + icons[field].remove(value); + } + } + } + if (save) { + saveConfig(); + } + return true; + } else { + return false; + } +} + + +bool +IconManager::exportBundle +(const QString &filename) +{ + QFile zipFile(filename); + if (! zipFile.open(QIODevice::WriteOnly)) { + return false; + } + zipFile.close(); + ZipWriter writer(zipFile.fileName()); + QStringList files = listIconFiles(); + files << "LICENSE" + << "LICENSE.md" + << "LICENSE.txt" + << "README" + << "README.md" + << "README.txt" + << "COPYING" + << "mapping.json"; + for (QString file : files) { + QFile sourceFile(toFilepath(file)); + if (sourceFile.open(QIODevice::ReadOnly)) { + writer.addFile(file, sourceFile.readAll()); + sourceFile.close(); + } + } + writer.close(); + return true; +} + + +bool +IconManager::importBundle +(const QString &filename) +{ + QFile zipFile(filename); + if (! zipFile.open(QIODevice::ReadOnly)) { + return false; + } + zipFile.close(); + ZipReader reader(zipFile.fileName()); + for (ZipReader::FileInfo info : reader.fileInfoList()) { + if (info.isFile) { + QByteArray data = reader.fileData(info.filePath); + QFile file(baseDir.absoluteFilePath(info.filePath)); + QFileInfo fileInfo(file); + if (! file.open(QIODevice::WriteOnly)) { + continue; + } + qint64 bytesWritten = file.write(data); + if (bytesWritten == -1) { + continue; + } + file.close(); + } + } + reader.close(); + return loadMapping(); +} + + +bool +IconManager::saveConfig +() const +{ + QJsonObject rootObj; + for (const QString &field : icons.keys()) { + writeGroup(rootObj, field, icons[field]); + } + + QJsonDocument jsonDoc(rootObj); + + QFile file(baseDir.absoluteFilePath(configFile)); + if (! file.open(QIODevice::WriteOnly | QIODevice::Text)) { + qWarning() << "Could not open file for writing:" << file.errorString(); + return false; + } + file.write(jsonDoc.toJson(QJsonDocument::Indented)); + file.close(); + + return true; +} + + +bool +IconManager::loadMapping +() +{ + QFile file(baseDir.absoluteFilePath(configFile)); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QByteArray jsonData = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); + + if ( ! jsonDoc.isNull() + && jsonDoc.isObject()) { + QJsonObject root = jsonDoc.object(); + icons["Sport"] = readGroup(root, "Sport"); + icons["SubSport"] = readGroup(root, "SubSport"); + } + } else { + qWarning().noquote().nospace() + << "Cannot read icon mappings (" + << file.fileName() + << "): " + << file.errorString(); + return false; + } + return true; +} + + +void +IconManager::writeGroup +(QJsonObject &rootObj, const QString &group, const QHash &data) const +{ + QJsonObject groupObj; + if (data.size() > 0) { + for (auto it = data.constBegin(); it != data.constEnd(); ++it) { + groupObj.insert(it.key(), it.value()); + } + rootObj.insert(group, groupObj); + } +} + + +QHash +IconManager::readGroup +(const QJsonObject &root, const QString &group) +{ + QHash result; + if (root.contains(group)) { + QJsonObject groupObj = root.value(group).toObject(); + for (auto it = groupObj.constBegin(); it != groupObj.constEnd(); ++it) { + QString filename = it.value().toString(); + if (filename.endsWith(".svg") && QFile::exists(toFilepath(filename))) { + result.insert(it.key(), filename); + } + } + } + return result; +} diff --git a/src/Gui/IconManager.h b/src/Gui/IconManager.h new file mode 100644 index 000000000..292cce1fa --- /dev/null +++ b/src/Gui/IconManager.h @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Joachim Kohlhammer (joachim.kohlhammer@gmx.de) + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef _GC_IconManager_h +#define _GC_IconManager_h + +#include +#include +#include + +#include "MainWindow.h" +#include "RideItem.h" + + +class IconManager { +public: + static IconManager &instance(); + + IconManager(const IconManager&) = delete; + IconManager &operator=(const IconManager&) = delete; + + // Full path + QString getFilepath(const QString &sport, const QString &subSport = "") const; + QString getFilepath(RideItem const * const rideItem) const; + QString getDefault() const; + + // Filename only + QStringList listIconFiles() const; + QString toFilepath(const QString &filename); + + QString assignedIcon(const QString &field, const QString &value) const; + void assignIcon(const QString &field, const QString &value, const QString &filename); + + bool addIconFile(const QFile &sourceFile); + bool deleteIconFile(const QString &filename); + + bool exportBundle(const QString &filepath); + bool importBundle(const QString &filepath); + + bool saveConfig() const; + +private: + IconManager(); + + const QString defaultIcon = ":images/breeze/games-highscores.svg"; + QHash> icons; // Key 1: Fieldname (Sport, SubSport, ...) + // Key 2: Value (Bike, Run, ...) + // Value: Icon filename (no path) + + const QDir baseDir = QDir(gcroot + "/.icons"); + const QString configFile = "mapping.json"; + + bool loadMapping(); + void writeGroup(QJsonObject &rootObj, const QString &field, const QHash &data) const; + QHash readGroup(const QJsonObject &rootObj, const QString &field); +}; + +#endif diff --git a/src/Gui/ManualActivityWizard.cpp b/src/Gui/ManualActivityWizard.cpp index 1371e34ba..66921493b 100644 --- a/src/Gui/ManualActivityWizard.cpp +++ b/src/Gui/ManualActivityWizard.cpp @@ -40,6 +40,7 @@ #include "RideMetadata.h" #include "Units.h" #include "HelpWhatsThis.h" +#include "IconManager.h" #define MANDATORY " *" #define TRADEMARK "TM" @@ -81,7 +82,7 @@ ManualActivityWizard::ManualActivityWizard #else setWizardStyle(QWizard::ModernStyle); #endif - setPixmap(ICON_TYPE, svgAsColoredPixmap(":images/breeze/games-highscores.svg", QSize(ICON_SIZE * dpiXFactor, ICON_SIZE * dpiYFactor), ICON_MARGIN * dpiXFactor, ICON_COLOR)); + setPixmap(ICON_TYPE, svgAsColoredPixmap(IconManager::instance().getDefault(), QSize(ICON_SIZE * dpiXFactor, ICON_SIZE * dpiYFactor), ICON_MARGIN * dpiXFactor, ICON_COLOR)); setPage(PageBasics, new ManualActivityPageBasics(context, plan, when)); setPage(PageWorkout, new ManualActivityPageWorkout(context)); @@ -331,6 +332,7 @@ ManualActivityPageBasics::ManualActivityPageBasics connect(timeEdit, &QTimeEdit::timeChanged, this, &ManualActivityPageBasics::checkDateTime); connect(sportEdit, &QLineEdit::editingFinished, this, &ManualActivityPageBasics::sportsChanged); connect(sportEdit, &QLineEdit::textChanged, this, [this]() { emit completeChanged(); }); + connect(subSportEdit, &QLineEdit::editingFinished, this, &ManualActivityPageBasics::sportsChanged); registerField("activityDate", dateEdit); registerField("activityTime", timeEdit, "time", SIGNAL(timeChanged(QTime))); @@ -419,21 +421,9 @@ void ManualActivityPageBasics::sportsChanged () { - QString path(":images/breeze/games-highscores.svg"); QString sport = RideFile::sportTag(field("sport").toString().trimmed()); - if (sport == "Bike") { - path = ":images/material/bike.svg"; - } else if (sport == "Run") { - path = ":images/material/run.svg"; - } else if (sport == "Swim") { - path = ":images/material/swim.svg"; - } else if (sport == "Row") { - path = ":images/material/rowing.svg"; - } else if (sport == "Ski") { - path = ":images/material/ski.svg"; - } else if (sport == "Gym") { - path = ":images/material/weight-lifter.svg"; - } + QString subSport = field("subSport").toString().trimmed(); + QString path = IconManager::instance().getFilepath(sport, subSport); wizard()->setPixmap(ICON_TYPE, svgAsColoredPixmap(path, QSize(ICON_SIZE * dpiXFactor, ICON_SIZE * dpiYFactor), ICON_MARGIN * dpiXFactor, ICON_COLOR)); } diff --git a/src/Gui/Pages.cpp b/src/Gui/Pages.cpp index bc2ac1e77..9f8438aef 100644 --- a/src/Gui/Pages.cpp +++ b/src/Gui/Pages.cpp @@ -42,6 +42,7 @@ #include "LocalFileStore.h" #include "Secrets.h" #include "Utils.h" +#include "IconManager.h" #ifdef GC_WANT_PYTHON #include "PythonEmbed.h" #include "FixPySettings.h" @@ -2152,12 +2153,14 @@ MetadataPage::MetadataPage(Context *context) : context(context) // setup maintenance pages using current config fieldsPage = new FieldsPage(this, fieldDefinitions); keywordsPage = new KeywordsPage(this, keywordDefinitions); + iconsPage = new IconsPage(fieldDefinitions, this); defaultsPage = new DefaultsPage(this, defaultDefinitions); processorPage = new ProcessorPage(context); tabs = new QTabWidget(this); tabs->addTab(fieldsPage, tr("Fields")); tabs->addTab(keywordsPage, tr("Colour Keywords")); + tabs->addTab(iconsPage, tr("Icons")); tabs->addTab(defaultsPage, tr("Defaults")); tabs->addTab(processorPage, tr("Processors && Automation")); @@ -2198,6 +2201,8 @@ MetadataPage::saveClicked() if (b4.fieldFingerprint != FieldDefinition::fingerprint(fieldDefinitions)) state += CONFIG_FIELDS; + state |= iconsPage->saveClicked(); + return state; } @@ -2402,6 +2407,513 @@ KeywordsPage::getDefinitions(QList &keywordList) } } + +// +// Icons page +// + +#define ICONSPAGE_L_W 64 * dpiXFactor +#define ICONSPAGE_L_H 64 * dpiXFactor +#define ICONSPAGE_L QSize(ICONSPAGE_L_W, ICONSPAGE_L_H) +#define ICONSPAGE_L_SPACE QSize(80 * dpiXFactor, 80 * dpiYFactor) +#define ICONSPAGE_S_W 48 * dpiXFactor +#define ICONSPAGE_S_H 48 * dpiXFactor +#define ICONSPAGE_S QSize(ICONSPAGE_S_W, ICONSPAGE_S_H) +#define ICONSPAGE_MARGIN 2 * dpiXFactor + +IconsPage::IconsPage +(const QList &fieldDefinitions, QWidget *parent) +: QWidget(parent), fieldDefinitions(fieldDefinitions) +{ + QPalette palette; + + sportTree = new QTreeWidget(); + sportTree->setColumnCount(3); + basicTreeWidgetStyle(sportTree); + sportTree->setHeaderLabels({ tr("Field"), tr("Value"), tr("Icon") }); + sportTree->setIconSize(ICONSPAGE_S); + sportTree->setAcceptDrops(true); + sportTree->installEventFilter(this); + sportTree->viewport()->installEventFilter(this); + initSportTree(); + + iconList = new QListWidget(); + iconList->setViewMode(QListView::IconMode); + iconList->setIconSize(ICONSPAGE_L); + iconList->setGridSize(ICONSPAGE_L_SPACE); + iconList->setResizeMode(QListView::Adjust); + iconList->setWrapping(true); + iconList->setFlow(QListView::LeftToRight); + iconList->setSpacing(10 * dpiXFactor); + iconList->setMovement(QListView::Static); + iconList->setUniformItemSizes(true); + iconList->setSelectionMode(QAbstractItemView::SingleSelection); + iconList->setDragEnabled(false); + iconList->setAcceptDrops(true); + iconList->installEventFilter(this); + iconList->viewport()->installEventFilter(this); + updateIconList(); + + QPixmap trashPixmap = svgAsColoredPixmap(":images/breeze/trash-empty.svg", ICONSPAGE_L, ICONSPAGE_MARGIN, palette.color(QPalette::WindowText)); + QPixmap trashPixmapActive = svgAsColoredPixmap(":images/breeze/trash-empty.svg", ICONSPAGE_L, ICONSPAGE_MARGIN, QColor("#F79130")); + trashIcon.addPixmap(trashPixmap, QIcon::Normal); + trashIcon.addPixmap(trashPixmapActive, QIcon::Active); + + trash = new QLabel(); + trash->setAcceptDrops(true); + trash->setPixmap(trashIcon.pixmap(ICONSPAGE_L, QIcon::Normal)); + trash->installEventFilter(this); + QPushButton *importButton = new QPushButton(tr("Import")); + QPushButton *exportButton = new QPushButton(tr("Export")); + + QHBoxLayout *contentLayout = new QHBoxLayout(); + contentLayout->addWidget(sportTree); + contentLayout->addWidget(iconList); + + QHBoxLayout *actionLayout = new QHBoxLayout(); + actionLayout->addWidget(trash); + actionLayout->addStretch(); + actionLayout->addWidget(importButton); + actionLayout->addWidget(exportButton); + + QVBoxLayout *mainLayout = new QVBoxLayout(this); + mainLayout->addLayout(contentLayout); + mainLayout->addLayout(actionLayout); + + connect(importButton, &QPushButton::clicked, [=]() { + QString zipFile = QFileDialog::getOpenFileName(this, tr("Import Icons"), "", tr("Zip Files (*.zip)")); + if (zipFile.isEmpty() || ! IconManager::instance().importBundle(zipFile)) { + QMessageBox::warning(nullptr, tr("Icons Bundle"), tr("Bundle file %1 cannot be imported.").arg(zipFile)); + } else { + initSportTree(); + updateIconList(); + } + }); + connect(exportButton, &QPushButton::clicked, [=]() { + QString zipFile = QFileDialog::getSaveFileName(this, tr("Export Icons"), "", tr("Zip Files (*.zip)")); + if (zipFile.isEmpty() || ! IconManager::instance().exportBundle(zipFile)) { + QMessageBox::warning(nullptr, tr("Icons Bundle"), tr("Bundle file %1 cannot be created.").arg(zipFile)); + } + }); +} + + +qint32 +IconsPage::saveClicked +() +{ + bool changed = false; + + int rowCount = sportTree->topLevelItemCount(); + for (int i = 0; i < rowCount; ++i) { + QTreeWidgetItem *item = sportTree->topLevelItem(i); + if (! item) { + continue; + } + QString originalIcon = item->data(0, Qt::UserRole + 1).toString(); + QString newIcon = item->data(0, Qt::UserRole + 2).toString(); + if (originalIcon != newIcon) { + QString type = item->data(0, Qt::UserRole).toString(); + QString key = item->data(1, Qt::DisplayRole).toString(); + IconManager::instance().assignIcon(type, key, newIcon); + } + } + + if (changed) { + return CONFIG_APPEARANCE; + } else { + return 0; + } +} + + +bool +IconsPage::eventFilter +(QObject *watched, QEvent *event) +{ + bool handled = false; + if (watched == trash) { + handled = eventFilterTrash(event); + } else if (watched == sportTree) { + handled = eventFilterSportTree(event); + } else if (watched == sportTree->viewport()) { + handled = eventFilterSportTreeViewport(event); + } else if (watched == iconList) { + handled = eventFilterIconList(event); + } else if (watched == iconList->viewport()) { + handled = eventFilterIconListViewport(event); + } + return handled ? true : QObject::eventFilter(watched, event); +} + + +bool +IconsPage::eventFilterTrash +(QEvent *event) +{ + if ( event->type() == QEvent::DragEnter + || event->type() == QEvent::Drop) { + QDropEvent *dropEvent = static_cast(event); + if ( dropEvent->mimeData()->hasFormat("application/x-gc-icon") + && (dropEvent->possibleActions() & Qt::MoveAction) + && ( dropEvent->source() == sportTree + || dropEvent->source() == iconList)) { + dropEvent->setDropAction(Qt::MoveAction); + dropEvent->accept(); + trash->setPixmap(trashIcon.pixmap(ICONSPAGE_L, event->type() == QEvent::Drop ? QIcon::Normal : QIcon::Active)); + return true; + } + } else if (event->type() == QEvent::DragLeave) { + trash->setPixmap(trashIcon.pixmap(ICONSPAGE_L, QIcon::Normal)); + return true; + } + return false; +} + + +bool +IconsPage::eventFilterSportTree +(QEvent *event) +{ + if (event->type() == QEvent::DragEnter) { + QDragEnterEvent *dragEnterEvent = static_cast(event); + if ( dragEnterEvent->mimeData()->hasFormat("application/x-gc-icon") + && (dragEnterEvent->possibleActions() & Qt::LinkAction) + && dragEnterEvent->source() == iconList) { + sportTree->setStyleSheet("QTreeWidget { border: 2px solid #F79130; border-radius: 8px; }"); + dragEnterEvent->setDropAction(Qt::LinkAction); + dragEnterEvent->accept(); + return true; + } + } else if (event->type() == QEvent::DragMove) { + QDragMoveEvent *dragMoveEvent = static_cast(event); + if ( dragMoveEvent->mimeData()->hasFormat("application/x-gc-icon") + && (dragMoveEvent->possibleActions() & Qt::LinkAction) + && dragMoveEvent->source() == iconList) { + QPoint globalCursor = QCursor::pos(); + QPoint pos = sportTree->viewport()->mapFromGlobal(globalCursor); + QTreeWidgetItem* targetItem = sportTree->itemAt(pos); + if (targetItem) { + sportTree->setCurrentItem(targetItem); + dragMoveEvent->setDropAction(Qt::LinkAction); + dragMoveEvent->accept(); + } else { + dragMoveEvent->ignore(); + } + return true; + } + } else if (event->type() == QEvent::DragLeave) { + sportTree->setStyleSheet(""); + return true; + } else if (event->type() == QEvent::Drop) { + QDropEvent *dropEvent = static_cast(event); + if ( dropEvent->mimeData()->hasFormat("application/x-gc-icon") + && (dropEvent->possibleActions() & Qt::LinkAction) + && dropEvent->source() == iconList) { + QByteArray iconBytes = dropEvent->mimeData()->data("application/x-gc-icon"); + QString iconFile = QString::fromUtf8(iconBytes); + QPoint globalCursor = QCursor::pos(); + QPoint pos = sportTree->viewport()->mapFromGlobal(globalCursor); + QTreeWidgetItem* targetItem = sportTree->itemAt(pos); + if (targetItem) { + QPalette palette; + QElapsedTimer timer; + timer.start(); + QPixmap pixmap = svgAsColoredPixmap(IconManager::instance().toFilepath(iconFile), QSize(1000, 1000), 0, palette.color(QPalette::Text)); + qint64 elapsed = timer.elapsed(); + if ( elapsed < 50 + || QMessageBox::question(this, tr("Complex Icon"), tr("The selected icon %1 appears to be complex and could impact performance. Are you sure you want to use this icon?").arg(iconFile)) == QMessageBox::Yes) { + QPixmap pixmap = svgAsColoredPixmap(IconManager::instance().toFilepath(iconFile), ICONSPAGE_S, ICONSPAGE_MARGIN, palette.color(QPalette::Text)); + targetItem->setData(0, Qt::UserRole + 2, iconFile); + targetItem->setIcon(2, QIcon(pixmap)); + dropEvent->setDropAction(Qt::LinkAction); + dropEvent->accept(); + } else { + dropEvent->ignore(); + } + } + } + sportTree->setStyleSheet(""); + return true; + } + return false; +} + + +bool +IconsPage::eventFilterSportTreeViewport +(QEvent *event) +{ + if (event->type() == QEvent::MouseButtonPress) { + QMouseEvent *mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::LeftButton) { +#if QT_VERSION >= 0x060000 + QTreeWidgetItem *item = sportTree->itemAt(mouseEvent->position().toPoint()); +#else + QTreeWidgetItem *item = sportTree->itemAt(mouseEvent->pos()); +#endif + if (item && ! item->data(0, Qt::UserRole + 2).toString().isEmpty()) { +#if QT_VERSION >= 0x060000 + sportTreeDragStartPos = mouseEvent->position().toPoint(); +#else + sportTreeDragStartPos = mouseEvent->pos(); +#endif + sportTreeDragWatch = true; + } else { + sportTreeDragWatch = false; + } + } + return true; + } else if (event->type() == QEvent::MouseMove) { + QMouseEvent *mouseEvent = static_cast(event); + if ( ! (mouseEvent->buttons() & Qt::LeftButton) + || ! sportTreeDragWatch +#if QT_VERSION >= 0x060000 + || (mouseEvent->position().toPoint() - sportTreeDragStartPos).manhattanLength() < QApplication::startDragDistance()) { +#else + || (mouseEvent->pos() - sportTreeDragStartPos).manhattanLength() < QApplication::startDragDistance()) { +#endif + return true; + } + sportTreeDragWatch = false; +#if QT_VERSION >= 0x060000 + QTreeWidgetItem *item = sportTree->itemAt(mouseEvent->position().toPoint()); +#else + QTreeWidgetItem *item = sportTree->itemAt(mouseEvent->pos()); +#endif + if (! item) { + return true; + } + + QByteArray entryBytes = item->data(0, Qt::UserRole).toString().toUtf8(); + QMimeData *mimeData = new QMimeData(); + mimeData->setData("application/x-gc-icon", entryBytes); + + QDrag *drag = new QDrag(sportTree); + drag->setMimeData(mimeData); + drag->setPixmap(item->icon(2).pixmap(ICONSPAGE_S)); + drag->setHotSpot(QPoint(ICONSPAGE_S_W / 2, ICONSPAGE_S_H / 2)); + Qt::DropAction dropAction = drag->exec(Qt::MoveAction); + if (dropAction == Qt::MoveAction) { + QPixmap pixmap(ICONSPAGE_S); + pixmap.fill(Qt::transparent); + QIcon icon(pixmap); + item->setData(0, Qt::UserRole + 2, ""); + item->setIcon(2, icon); + } + return true; + } + return false; +} + + +bool +IconsPage::eventFilterIconList +(QEvent *event) +{ + if (event->type() == QEvent::DragEnter) { + QDragEnterEvent *dragEnterEvent = static_cast(event); + if ( dragEnterEvent->mimeData()->hasFormat("application/x-gc-icon") + && (dragEnterEvent->possibleActions() & Qt::MoveAction) + && dragEnterEvent->source() == sportTree) { + dragEnterEvent->setDropAction(Qt::MoveAction); + dragEnterEvent->accept(); + } else if ( dragEnterEvent->mimeData()->hasUrls() + && (dragEnterEvent->possibleActions() & Qt::CopyAction)) { + const QList urls = dragEnterEvent->mimeData()->urls(); + int svgs = 0; + for (const QUrl &url : urls) { + QString path = url.toLocalFile(); + if (path.endsWith(".svg")) { + ++svgs; + } + } + if (svgs > 0) { + dragEnterEvent->setDropAction(Qt::CopyAction); + dragEnterEvent->accept(); + } + } + if (dragEnterEvent->isAccepted()) { + iconList->setStyleSheet("QListWidget { border: 2px solid #F79130; border-radius: 8px; }"); + } + return true; + } else if (event->type() == QEvent::DragLeave) { + iconList->setStyleSheet(""); + return true; + } else if (event->type() == QEvent::Drop) { + QDropEvent *dropEvent = static_cast(event); + if ( dropEvent->mimeData()->hasFormat("application/x-gc-icon") + && (dropEvent->possibleActions() & Qt::MoveAction) + && dropEvent->source() == sportTree) { + dropEvent->setDropAction(Qt::MoveAction); + dropEvent->accept(); + } else if ( dropEvent->mimeData()->hasUrls() + && (dropEvent->possibleActions() & Qt::CopyAction)) { + const QList urls = dropEvent->mimeData()->urls(); + int added = 0; + for (const QUrl &url : urls) { + QString path = url.toLocalFile(); + if (IconManager::instance().addIconFile(path)) { + ++added; + } + } + if (added > 0) { + dropEvent->setDropAction(Qt::CopyAction); + dropEvent->accept(); + updateIconList(); + } + } + iconList->setStyleSheet(""); + return true; + } + return false; +} + + +bool +IconsPage::eventFilterIconListViewport +(QEvent *event) +{ + if (event->type() == QEvent::Paint) { + if (iconList->count() == 0) { + QPaintEvent *paintEvent = static_cast(event); + QPainter painter(iconList->viewport()); + QPalette palette; + QColor pixmapColor = palette.color(QPalette::Disabled, QPalette::Text); + if (palette.color(QPalette::Base).lightness() < 127) { + pixmapColor = pixmapColor.darker(135); + } else { + pixmapColor = pixmapColor.lighter(135); + } + QPixmap pixmap = svgAsColoredPixmap(":/images/breeze/edit-image-face-add.svg", QSize(256 * dpiXFactor, 256 * dpiYFactor), 0, pixmapColor); + painter.drawPixmap((paintEvent->rect().width() - pixmap.width()) / 2, (paintEvent->rect().height() - pixmap.height()) / 2, pixmap); + painter.drawText(paintEvent->rect(), Qt::AlignCenter | Qt::TextWordWrap, tr("No icons available.\nDrag and drop .svg files here to add icons.")); + return true; + } + return false; + } else if (event->type() == QEvent::MouseButtonPress) { + QMouseEvent *mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::LeftButton) { +#if QT_VERSION >= 0x060000 + QListWidgetItem *item = iconList->itemAt(mouseEvent->position().toPoint()); +#else + QListWidgetItem *item = iconList->itemAt(mouseEvent->pos()); +#endif + if (item) { +#if QT_VERSION >= 0x060000 + iconListDragStartPos = mouseEvent->position().toPoint(); +#else + iconListDragStartPos = mouseEvent->pos(); +#endif + iconListDragWatch = true; + } else { + iconListDragWatch = false; + } + } + return true; + } else if (event->type() == QEvent::MouseMove) { + QMouseEvent *mouseEvent = static_cast(event); + if ( ! (mouseEvent->buttons() & Qt::LeftButton) + || ! iconListDragWatch +#if QT_VERSION >= 0x060000 + || (mouseEvent->position().toPoint() - iconListDragStartPos).manhattanLength() < QApplication::startDragDistance()) { +#else + || (mouseEvent->pos() - iconListDragStartPos).manhattanLength() < QApplication::startDragDistance()) { +#endif + return true; + } + iconListDragWatch = false; +#if QT_VERSION >= 0x060000 + QListWidgetItem *item = iconList->itemAt(mouseEvent->position().toPoint()); +#else + QListWidgetItem *item = iconList->itemAt(mouseEvent->pos()); +#endif + if (! item) { + return true; + } + + QString iconFile = item->data(Qt::UserRole).toString(); + QMimeData *mimeData = new QMimeData(); + mimeData->setData("application/x-gc-icon", iconFile.toUtf8()); + + QDrag *drag = new QDrag(iconList); + drag->setMimeData(mimeData); + drag->setPixmap(item->icon().pixmap(ICONSPAGE_L)); + drag->setHotSpot(QPoint(ICONSPAGE_L_W / 2, ICONSPAGE_L_H / 2)); + Qt::DropAction dropAction = drag->exec(Qt::MoveAction | Qt::LinkAction); + if (dropAction == Qt::MoveAction) { + if (IconManager::instance().deleteIconFile(iconFile)) { + updateIconList(); + int rowCount = sportTree->topLevelItemCount(); + for (int i = 0; i < rowCount; ++i) { + QTreeWidgetItem *item = sportTree->topLevelItem(i); + if (item && item->data(0, Qt::UserRole + 2).toString() == iconFile) { + QPixmap pixmap(ICONSPAGE_S); + pixmap.fill(Qt::transparent); + item->setData(0, Qt::UserRole + 2, ""); + item->setIcon(2, QIcon(pixmap)); + } + } + } + } + return true; + } + return false; +} + + +void +IconsPage::initSportTree +() +{ + sportTree->clear(); + QPalette palette; + SpecialFields &specials = SpecialFields::getInstance(); + for (QString field : { "Sport", "SubSport" }) { + for (const FieldDefinition &fieldDefinition : fieldDefinitions) { + if (fieldDefinition.name == field) { + for (const QString &fieldValue : fieldDefinition.values) { + QIcon icon; + QString assignedFile = IconManager::instance().assignedIcon(fieldDefinition.name, fieldValue); + if (! assignedFile.isEmpty()) { + QPixmap pixmap = svgAsColoredPixmap(IconManager::instance().toFilepath(assignedFile), ICONSPAGE_S, ICONSPAGE_MARGIN, palette.color(QPalette::Text)); + icon.addPixmap(pixmap); + } else { + QPixmap pixmap(ICONSPAGE_S); + pixmap.fill(Qt::transparent); + icon.addPixmap(pixmap); + } + QTreeWidgetItem *item = new QTreeWidgetItem(); + item->setData(0, Qt::DisplayRole, specials.displayName(fieldDefinition.name)); + item->setData(0, Qt::UserRole, fieldDefinition.name); + item->setData(0, Qt::UserRole + 1, assignedFile); + item->setData(0, Qt::UserRole + 2, assignedFile); + item->setData(1, Qt::DisplayRole, fieldValue); + item->setIcon(2, icon); + sportTree->addTopLevelItem(item); + } + } + } + } +} + + +void +IconsPage::updateIconList +() +{ + iconList->clear(); + QPalette palette; + QStringList icons = IconManager::instance().listIconFiles(); + for (QString icon : icons) { + QPixmap pixmap = svgAsColoredPixmap(IconManager::instance().toFilepath(icon), ICONSPAGE_L, ICONSPAGE_MARGIN, palette.color(QPalette::Text)); + QListWidgetItem *item = new QListWidgetItem(QIcon(pixmap), ""); + item->setData(Qt::UserRole, icon); + iconList->addItem(item); + } +} + + // // Ride metadata page // diff --git a/src/Gui/Pages.h b/src/Gui/Pages.h index 31925f05e..9ee7f8172 100644 --- a/src/Gui/Pages.h +++ b/src/Gui/Pages.h @@ -423,6 +423,40 @@ class KeywordsPage : public QWidget MetadataPage *parent; }; +class IconsPage : public QWidget +{ + Q_OBJECT + + public: + IconsPage(const QList &fieldDefinitions, QWidget *parent = nullptr); + qint32 saveClicked(); + + public slots: + + protected: + bool eventFilter(QObject *watched, QEvent *event) override; + + private: + QList fieldDefinitions; + QTreeWidget *sportTree; + QListWidget *iconList; + QLabel *trash; + QIcon trashIcon; + QPoint sportTreeDragStartPos; + bool sportTreeDragWatch = false; + QPoint iconListDragStartPos; + bool iconListDragWatch = false; + + bool eventFilterTrash(QEvent *event); + bool eventFilterSportTree(QEvent *event); + bool eventFilterSportTreeViewport(QEvent *event); + bool eventFilterIconList(QEvent *event); + bool eventFilterIconListViewport(QEvent *event); + + void initSportTree(); + void updateIconList(); +}; + class ColorsPage : public QWidget { Q_OBJECT @@ -594,6 +628,7 @@ class MetadataPage : public QWidget QTabWidget *tabs; KeywordsPage *keywordsPage; + IconsPage *iconsPage; FieldsPage *fieldsPage; DefaultsPage *defaultsPage; ProcessorPage *processorPage; diff --git a/src/Resources/application.qrc b/src/Resources/application.qrc index 58127defd..5b90a33f0 100644 --- a/src/Resources/application.qrc +++ b/src/Resources/application.qrc @@ -124,6 +124,8 @@ xml/home-perspectives.xml ini/measures.ini html/ltm-summary.html + images/breeze/trash-empty.svg + images/breeze/edit-image-face-add.svg images/breeze/media-playlist-repeat.svg images/breeze/games-highscores.svg images/breeze/network-mobile-0.svg @@ -241,12 +243,6 @@ images/services/polarflow.png images/services/sporttracks.png images/services/nolio.png - images/material/bike.svg - images/material/run.svg - images/material/swim.svg - images/material/rowing.svg - images/material/ski.svg - images/material/weight-lifter.svg python/library.py images/devices/imagic.png data/powerprofile.csv diff --git a/src/Resources/images/breeze/edit-image-face-add.svg b/src/Resources/images/breeze/edit-image-face-add.svg new file mode 100644 index 000000000..ba5869bc0 --- /dev/null +++ b/src/Resources/images/breeze/edit-image-face-add.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/Resources/images/breeze/trash-empty.svg b/src/Resources/images/breeze/trash-empty.svg new file mode 100644 index 000000000..0a5869dc0 --- /dev/null +++ b/src/Resources/images/breeze/trash-empty.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/Resources/images/material/bike.svg b/src/Resources/images/material/bike.svg deleted file mode 100644 index a425e223b..000000000 --- a/src/Resources/images/material/bike.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/Resources/images/material/rowing.svg b/src/Resources/images/material/rowing.svg deleted file mode 100644 index 7a75c1f55..000000000 --- a/src/Resources/images/material/rowing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/Resources/images/material/run.svg b/src/Resources/images/material/run.svg deleted file mode 100644 index 7b69ebe4a..000000000 --- a/src/Resources/images/material/run.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/Resources/images/material/ski.svg b/src/Resources/images/material/ski.svg deleted file mode 100644 index cb709a9d2..000000000 --- a/src/Resources/images/material/ski.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/Resources/images/material/swim.svg b/src/Resources/images/material/swim.svg deleted file mode 100644 index d3632deb7..000000000 --- a/src/Resources/images/material/swim.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/Resources/images/material/weight-lifter.svg b/src/Resources/images/material/weight-lifter.svg deleted file mode 100644 index 678428012..000000000 --- a/src/Resources/images/material/weight-lifter.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/src.pro b/src/src.pro index 6127e87ae..12a9d5704 100644 --- a/src/src.pro +++ b/src/src.pro @@ -651,7 +651,8 @@ HEADERS += Gui/AboutDialog.h Gui/AddIntervalDialog.h Gui/AnalysisSidebar.h Gui/C Gui/AddTileWizard.h Gui/NavigationModel.h Gui/AthleteView.h Gui/AthleteConfigDialog.h Gui/AthletePages.h Gui/Perspective.h \ Gui/PerspectiveDialog.h Gui/SplashScreen.h Gui/StyledItemDelegates.h Gui/MetadataDialog.h Gui/ActionButtonBox.h \ Gui/MetricOverrideDialog.h Gui/RepeatScheduleWizard.h \ - Gui/Calendar.h Gui/CalendarData.h Gui/CalendarItemDelegates.h + Gui/Calendar.h Gui/CalendarData.h Gui/CalendarItemDelegates.h \ + Gui/IconManager.h # metrics and models HEADERS += Metrics/Banister.h Metrics/CPSolver.h Metrics/Estimator.h Metrics/ExtendedCriticalPower.h Metrics/HrZones.h Metrics/PaceZones.h \ @@ -763,7 +764,8 @@ SOURCES += Gui/AboutDialog.cpp Gui/AddIntervalDialog.cpp Gui/AnalysisSidebar.cpp Gui/AddTileWizard.cpp Gui/NavigationModel.cpp Gui/AthleteView.cpp Gui/AthleteConfigDialog.cpp Gui/AthletePages.cpp Gui/Perspective.cpp \ Gui/PerspectiveDialog.cpp Gui/SplashScreen.cpp Gui/StyledItemDelegates.cpp Gui/MetadataDialog.cpp Gui/ActionButtonBox.cpp \ Gui/MetricOverrideDialog.cpp Gui/RepeatScheduleWizard.cpp \ - Gui/Calendar.cpp Gui/CalendarItemDelegates.cpp + Gui/Calendar.cpp Gui/CalendarItemDelegates.cpp \ + Gui/IconManager.cpp ## Models and Metrics SOURCES += Metrics/aBikeScore.cpp Metrics/aCoggan.cpp Metrics/AerobicDecoupling.cpp Metrics/Banister.cpp Metrics/BasicRideMetrics.cpp \