From a1ddf9b8e0c17e209e9291daef9306b2a26d04f2 Mon Sep 17 00:00:00 2001 From: Joachim Kohlhammer Date: Sat, 6 Sep 2025 01:19:11 +0200 Subject: [PATCH] New dialog to repeat planned workouts (#4692) * Added dialog to repeat planned workouts * New Wizard to select planned workouts based on past timerange * Implemented simple conflict resolution strategies (remove all preexisting, skip days with preexisting, fail) * Create new planned activities based on the selected, past ones --- src/Charts/PlanningCalendarWindow.cpp | 10 + src/Gui/Calendar.cpp | 22 + src/Gui/Calendar.h | 20 + src/Gui/RepeatScheduleWizard.cpp | 578 ++++++++++++++++++ src/Gui/RepeatScheduleWizard.h | 117 ++++ src/Resources/application.qrc | 1 + .../images/breeze/media-playlist-repeat.svg | 11 + src/src.pro | 4 +- 8 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 src/Gui/RepeatScheduleWizard.cpp create mode 100644 src/Gui/RepeatScheduleWizard.h create mode 100644 src/Resources/images/breeze/media-playlist-repeat.svg diff --git a/src/Charts/PlanningCalendarWindow.cpp b/src/Charts/PlanningCalendarWindow.cpp index 8aba624e5..89863592f 100644 --- a/src/Charts/PlanningCalendarWindow.cpp +++ b/src/Charts/PlanningCalendarWindow.cpp @@ -28,6 +28,7 @@ #include "RideMetadata.h" #include "Colors.h" #include "ManualActivityWizard.h" +#include "RepeatScheduleWizard.h" #include "WorkoutFilter.h" #define HLO "

" @@ -87,6 +88,15 @@ PlanningCalendarWindow::PlanningCalendarWindow(Context *context) wizard.exec(); context->tab->setNoSwitch(false); }); + connect(calendar, &Calendar::repeatSchedule, [=](const QDate &day) { + context->tab->setNoSwitch(true); + RepeatScheduleWizard wizard(context, day); + if (wizard.exec() == QDialog::Accepted) { + // Context::rideDeleted is not always emitted, therefore forcing the update + updateActivities(); + } + context->tab->setNoSwitch(false); + }); connect(calendar, &Calendar::delActivity, [=](CalendarEntry activity) { QMessageBox::StandardButton res = QMessageBox::question(this, tr("Delete Activity"), tr("Are you sure you want to delete %1?").arg(activity.reference)); if (res == QMessageBox::Yes) { diff --git a/src/Gui/Calendar.cpp b/src/Gui/Calendar.cpp index 6db8e772e..14532368f 100644 --- a/src/Gui/Calendar.cpp +++ b/src/Gui/Calendar.cpp @@ -1,3 +1,21 @@ +/* + * 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 "Calendar.h" #include @@ -639,6 +657,9 @@ CalendarMonthTable::showContextMenu } emit addActivity(true, day.date, time); }); + contextMenu.addAction("Repeat schedule...", this, [=]() { + emit repeatSchedule(day.date); + }); bool hasPlannedActivity = false; for (const CalendarEntry &calEntry : day.entries) { if (calEntry.type == ENTRY_TYPE_PLANNED_ACTIVITY) { @@ -719,6 +740,7 @@ Calendar::Calendar connect(monthTable, &CalendarMonthTable::showInTrainMode, this, &Calendar::showInTrainMode); connect(monthTable, &CalendarMonthTable::viewActivity, this, &Calendar::viewActivity); connect(monthTable, &CalendarMonthTable::addActivity, this, &Calendar::addActivity); + connect(monthTable, &CalendarMonthTable::repeatSchedule, this, &Calendar::repeatSchedule); connect(monthTable, &CalendarMonthTable::delActivity, this, &Calendar::delActivity); connect(monthTable, &CalendarMonthTable::entryMoved, this, &Calendar::moveActivity); connect(monthTable, &CalendarMonthTable::insertRestday, this, &Calendar::insertRestday); diff --git a/src/Gui/Calendar.h b/src/Gui/Calendar.h index 3f70cd454..abc0727d6 100644 --- a/src/Gui/Calendar.h +++ b/src/Gui/Calendar.h @@ -1,3 +1,21 @@ +/* + * 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 CALENDAR_H #define CALENDAR_H @@ -56,6 +74,7 @@ signals: void viewActivity(const CalendarEntry &activity); void addActivity(bool plan, const QDate &day, const QTime &time); void delActivity(const CalendarEntry &activity); + void repeatSchedule(const QDate &day); void insertRestday(const QDate &day); void delRestday(const QDate &day); @@ -121,6 +140,7 @@ signals: void viewActivity(const CalendarEntry &activity); void addActivity(bool plan, const QDate &day, const QTime &time); void delActivity(const CalendarEntry &activity); + void repeatSchedule(const QDate &day); void moveActivity(const CalendarEntry &activity, const QDate &srcDay, const QDate &destDay); void insertRestday(const QDate &day); void delRestday(const QDate &day); diff --git a/src/Gui/RepeatScheduleWizard.cpp b/src/Gui/RepeatScheduleWizard.cpp new file mode 100644 index 000000000..a0820030c --- /dev/null +++ b/src/Gui/RepeatScheduleWizard.cpp @@ -0,0 +1,578 @@ +/* + * 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 "RepeatScheduleWizard.h" + +#include +#include + +#include "Context.h" +#include "Athlete.h" +#include "AthleteTab.h" +#include "Seasons.h" +#include "Colors.h" + +#define ICON_COLOR QColor("#F79130") +#ifdef Q_OS_MAC +#define ICON_SIZE 250 +#define ICON_MARGIN 0 +#define ICON_TYPE QWizard::BackgroundPixmap +#else +#define ICON_SIZE 125 +#define ICON_MARGIN 5 +#define ICON_TYPE QWizard::LogoPixmap +#endif + +static QString rideItemName(RideItem const * const rideItem); +static QString rideItemSport(RideItem const * const rideItem); + + +//////////////////////////////////////////////////////////////////////////////// +// RepeatScheduleWizard + +RepeatScheduleWizard::RepeatScheduleWizard +(Context *context, const QDate &when, QWidget *parent) +: QWizard(parent), context(context), when(when) +{ + setWindowTitle(tr("Repeat Schedule")); + setMinimumSize(800 * dpiXFactor, 650 * dpiYFactor); + setModal(true); + +#ifdef Q_OS_MAC + setWizardStyle(QWizard::MacStyle); +#else + setWizardStyle(QWizard::ModernStyle); +#endif + setPixmap(ICON_TYPE, svgAsColoredPixmap(":images/breeze/media-playlist-repeat.svg", QSize(ICON_SIZE * dpiXFactor, ICON_SIZE * dpiYFactor), ICON_MARGIN * dpiXFactor, ICON_COLOR)); + + setPage(PageSetup, new RepeatSchedulePageSetup(context, when)); + setPage(PageActivities, new RepeatSchedulePageActivities(context)); + setPage(PageSummary, new RepeatSchedulePageSummary(context, when)); + setStartId(PageSetup); +} + + +void +RepeatScheduleWizard::done +(int result) +{ + int finalResult = result; + if (result == QDialog::Accepted) { + QApplication::setOverrideCursor(QCursor(Qt::WaitCursor)); + RepeatSchedulePageSummary *summaryPage = qobject_cast(page(PageSummary)); + QList deletionList = summaryPage->getDeletionList(); + QList> scheduleList = summaryPage->getScheduleList(); + context->tab->setNoSwitch(true); + for (RideItem *rideItem : deletionList) { + context->athlete->rideCache->removeRide(rideItem->fileName); + } + for (std::pair entry : scheduleList) { + RideItem *rideItem = entry.first; + QDate targetDate = entry.second; + RideFile *rideFile = rideItem->ride(); + QDateTime rideDateTime(targetDate, rideFile->startTime().time()); + rideFile->setStartTime(rideDateTime); + QString basename = rideDateTime.toString("yyyy_MM_dd_HH_mm_ss"); + QString filename = context->athlete->home->planned().canonicalPath() + "/" + basename + ".json"; + QFile out(filename); + if (RideFileFactory::instance().writeRideFile(context, rideFile, out, "json")) { + context->athlete->addRide(basename + ".json", true, true, false, rideItem->planned); + } + } + context->tab->setNoSwitch(false); + QApplication::restoreOverrideCursor(); + } + + QWizard::done(finalResult); +} + + +//////////////////////////////////////////////////////////////////////////////// +// RepeatSchedulePageSetup + +RepeatSchedulePageSetup::RepeatSchedulePageSetup +(Context *context, const QDate &when, QWidget *parent) +: QWizardPage(parent), context(context) +{ + setTitle(tr("Repeat Schedule Setup")); + setSubTitle(tr("Specify the time range and strategy for repeating the schedule. All planned activities within this range will be copied. You can optionally select a season or phase to prefill the start and end dates.")); + + QTreeWidget *seasonTree = new QTreeWidget(); + seasonTree->setColumnCount(1); + basicTreeWidgetStyle(seasonTree, false); + seasonTree->setHeaderHidden(true); + seasonTree->resetIndentation(); + QTreeWidgetItem *currentSeason = nullptr; + for (const Season &season : context->athlete->seasons->seasons) { + QTreeWidgetItem *seasonItem = new QTreeWidgetItem(); + seasonItem->setData(0, Qt::DisplayRole, season.getName()); + seasonItem->setData(0, Qt::UserRole, season.getStart()); + seasonItem->setData(0, Qt::UserRole + 1, season.getEnd()); + if (context->currentSeason() != nullptr && context->currentSeason()->id() == season.id()) { + currentSeason = seasonItem; + } + for (const Phase &phase : season.phases) { + QTreeWidgetItem *phaseItem = new QTreeWidgetItem(); + phaseItem->setData(0, Qt::DisplayRole, phase.getName()); + phaseItem->setData(0, Qt::UserRole, phase.getStart()); + phaseItem->setData(0, Qt::UserRole + 1, phase.getEnd()); + seasonItem->addChild(phaseItem); + if (context->currentSeason() != nullptr && context->currentSeason()->id() == phase.id()) { + currentSeason = phaseItem; + } + } + seasonTree->addTopLevelItem(seasonItem); + } + + QSpinBox *restDayBox = new QSpinBox(); + restDayBox->setSuffix(" " + tr("active days")); + restDayBox->setValue(3); + restDayBox->setRange(1, 99); + + QComboBox *conflictBox = new QComboBox(); + conflictBox->addItem("Delete all pre-existing activities"); + conflictBox->addItem("Skip days with pre-existing activities"); + conflictBox->addItem("Fail for all"); + + QDateEdit *startDate = new QDateEdit(); + startDate->setMaximumDate(when.addDays(-1)); + + QDateEdit *endDate = new QDateEdit(); + endDate->setMaximumDate(when.addDays(-1)); + + QCheckBox *sameDayCheck = new QCheckBox(tr("Copy same-day activities to consecutive days")); + + registerField("startDate", startDate); + registerField("endDate", endDate); + registerField("restDayHandling", restDayBox); + registerField("sameDay", sameDayCheck); + registerField("conflictHandling", conflictBox); + + QFormLayout *form = newQFormLayout(); + form->addRow(seasonTree); + form->addRow(tr("Start Date"), startDate); + form->addRow(tr("End Date"), endDate); + form->addRow(tr("Insert rest day after"), restDayBox); + form->addRow("", sameDayCheck); + form->addRow(tr("Conflict Handling"), conflictBox); + + QWidget *scrollWidget = new QWidget(); + QVBoxLayout *scrollLayout = new QVBoxLayout(scrollWidget); + scrollLayout->addWidget(centerLayoutInWidget(form, false)); + QScrollArea *scrollArea = new QScrollArea(); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidget(scrollWidget); + scrollArea->setWidgetResizable(true); + + QVBoxLayout *all = new QVBoxLayout(); + all->addWidget(scrollArea); + setLayout(all); + + connect(seasonTree, &QTreeWidget::currentItemChanged, [=](QTreeWidgetItem *current) { + if (current != nullptr) { + QDate seasonStart(current->data(0, Qt::UserRole).toDate()); + QDate seasonEnd(current->data(0, Qt::UserRole + 1).toDate()); + if (seasonEnd > when) { + seasonEnd = when; + } + if (seasonStart > when) { + seasonEnd = when; + } + startDate->setDate(seasonStart); + endDate->setDate(seasonEnd); + } + }); + connect(startDate, &QDateEdit::dateChanged, [=](QDate date) { + endDate->setMinimumDate(date); + }); + connect(endDate, &QDateEdit::dateChanged, [=](QDate date) { + startDate->setMaximumDate(date); + }); + + if (currentSeason != nullptr) { + seasonTree->setCurrentItem(currentSeason); + } else { + startDate->setDate(QDate::currentDate().addDays(-7)); + endDate->setDate(QDate::currentDate().addDays(-1)); + } +} + + +int +RepeatSchedulePageSetup::nextId +() const +{ + return RepeatScheduleWizard::PageActivities; +} + + +//////////////////////////////////////////////////////////////////////////////// +// RepeatSchedulePageActivities + +RepeatSchedulePageActivities::RepeatSchedulePageActivities +(Context *context, QWidget *parent) +: QWizardPage(parent), context(context) +{ + setTitle(tr("Repeat Schedule Activities")); + setSubTitle(tr("Review and choose the activities you wish to add to your schedule.")); + + setFinalPage(false); + + activityTree = new QTreeWidget(); + activityTree->setColumnCount(4); + basicTreeWidgetStyle(activityTree, false); + activityTree->setHeaderHidden(true); + + QWidget *formWidget = new QWidget(); + QVBoxLayout *form = new QVBoxLayout(formWidget); + form->addWidget(new QLabel("

" + tr("Activities for your new schedule") + "addWidget(activityTree); + + QWidget *scrollWidget = new QWidget(); + QVBoxLayout *scrollLayout = new QVBoxLayout(scrollWidget); + scrollLayout->addWidget(formWidget); + QScrollArea *scrollArea = new QScrollArea(); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidget(scrollWidget); + scrollArea->setWidgetResizable(true); + + QVBoxLayout *all = new QVBoxLayout(); + all->addWidget(scrollArea); + setLayout(all); +} + + +int +RepeatSchedulePageActivities::nextId +() const +{ + return RepeatScheduleWizard::PageSummary; +} + + +void +RepeatSchedulePageActivities::initializePage +() +{ + activityTree->clear(); + QDate startDate = field("startDate").toDate(); + QDate endDate = field("endDate").toDate(); + QLocale locale; + numSelected = 0; + for (RideItem *rideItem : context->athlete->rideCache->rides()) { + if ( rideItem == nullptr + || ! rideItem->planned + || rideItem->dateTime.date() < startDate + || rideItem->dateTime.date() > endDate) { + continue; + } + if (context->isfiltered && ! context->filters.contains(rideItem->fileName)) { + continue; + } + ++numSelected; + QTreeWidgetItem *item = new QTreeWidgetItem(); + item->setData(1, Qt::DisplayRole, locale.toString(rideItem->dateTime.date(), QLocale::ShortFormat)); + item->setData(1, Qt::UserRole, QVariant::fromValue(rideItem)); + item->setData(1, Qt::UserRole + 1, true); + item->setData(2, Qt::DisplayRole, rideItemSport(rideItem)); + item->setData(3, Qt::DisplayRole, rideItemName(rideItem)); + activityTree->addTopLevelItem(item); + + QCheckBox *selectionBox = new QCheckBox(); + selectionBox->setChecked(true); + QWidget *selectionWidget = new QWidget(activityTree); + QVBoxLayout *layout = new QVBoxLayout(selectionWidget); + layout->addWidget(selectionBox, 0, Qt::AlignCenter); + activityTree->setItemWidget(item, 0, selectionWidget); + connect(selectionBox, &QCheckBox::toggled, [=](bool checked) { + item->setData(1, Qt::UserRole + 1, checked); + numSelected += checked ? 1 : -1; + emit completeChanged(); + }); + } +} + + +bool +RepeatSchedulePageActivities::isComplete +() const +{ + return numSelected > 0; +} + + +QList +RepeatSchedulePageActivities::getSelectedRideItems +() const +{ + QList ret; + for (int i = 0; i < activityTree->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = activityTree->topLevelItem(i); + if (item->data(1, Qt::UserRole + 1).toBool()) { + ret << item->data(1, Qt::UserRole).value(); + } + } + return ret; +} + + +//////////////////////////////////////////////////////////////////////////////// +// RepeatSchedulePageSummary + +RepeatSchedulePageSummary::RepeatSchedulePageSummary +(Context *context, const QDate &when, QWidget *parent) +: QWizardPage(parent), context(context), when(when) +{ + setTitle(tr("Repeat Schedule Summary")); + setSubTitle(tr("Preview the schedule updates, including planned additions and deletions. No changes will be made until you continue.")); + + setFinalPage(true); + + failedLabel = new QLabel("

" + + tr("Unable to create a new schedule due to conflicts") + + "

" + + tr("Adjust conflict handling on the first page to proceed.") + + "
"); + failedLabel->setWordWrap(true); + + scheduleLabel = new QLabel("

" + tr("New Schedule Overview") + "setColumnCount(5); + basicTreeWidgetStyle(scheduleTree, false); + scheduleTree->setHeaderHidden(true); + + deletionLabel = new QLabel("

" + tr("Planned Activities Marked for Deletion") + "

"); + deletionTree = new QTreeWidget(); + deletionTree->setColumnCount(3); + basicTreeWidgetStyle(deletionTree, false); + deletionTree->setHeaderHidden(true); + + QWidget *formWidget = new QWidget(); + QVBoxLayout *form = new QVBoxLayout(formWidget); + form->addWidget(failedLabel); + form->addWidget(scheduleLabel); + form->addWidget(scheduleTree); + form->addWidget(deletionLabel); + form->addWidget(deletionTree); + + QWidget *scrollWidget = new QWidget(); + QVBoxLayout *scrollLayout = new QVBoxLayout(scrollWidget); + scrollLayout->addWidget(formWidget); + QScrollArea *scrollArea = new QScrollArea(); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidget(scrollWidget); + scrollArea->setWidgetResizable(true); + + QVBoxLayout *all = new QVBoxLayout(); + all->addWidget(scrollArea); + setLayout(all); +} + + +int +RepeatSchedulePageSummary::nextId +() const +{ + return RepeatScheduleWizard::Finalize; +} + + +void +RepeatSchedulePageSummary::initializePage +() +{ + failed = false; + scheduleList.clear(); + deletionList.clear(); + + scheduleTree->clear(); + deletionTree->clear(); + + failedLabel->setVisible(false); + scheduleLabel->setVisible(false); + scheduleTree->setVisible(false); + deletionLabel->setVisible(false); + deletionTree->setVisible(false); + + int restDayAfter = field("restDayHandling").toInt(); + bool sameDay = field("sameDay").toBool(); + int conflictHandling = field("conflictHandling").toInt(); + + QList preexistingPlanned; // Currently planned activities with date > when + QHash preexistingCount; // Number of preexisting activities with date > when per date + for (RideItem *rideItem : context->athlete->rideCache->rides()) { + if ( rideItem == nullptr + || ! rideItem->planned + || rideItem->dateTime.date() < when) { + continue; + } + if (context->isfiltered && ! context->filters.contains(rideItem->fileName)) { + continue; + } + preexistingPlanned << rideItem; + preexistingCount.insert(rideItem->dateTime.date(), preexistingCount.value(rideItem->dateTime.date(), 0) + 1); + } + + RepeatSchedulePageActivities *activitiesPage = qobject_cast(wizard()->page(RepeatScheduleWizard::PageActivities)); + QList selectedItems = activitiesPage->getSelectedRideItems(); // list of all selected activities that are to be copied to after when + QHash selectedCount; // Number of selected activities per date + for (RideItem *rideItem : selectedItems) { + selectedCount.insert(rideItem->dateTime.date(), selectedCount.value(rideItem->dateTime.date(), 0) + 1); + } + + QDate nextAddDate(when); + QDate lastSourceDate; + QDate minDate; + QDate maxDate; + int activeDays = 0; + for (RideItem *rideItem : selectedItems) { + bool found = sameDay && lastSourceDate.isValid() && lastSourceDate == rideItem->dateTime.date(); + while (! found) { + bool hasPreexisting = preexistingCount.value(nextAddDate, 0) > 0; + if (hasPreexisting) { + if (conflictHandling == 1) { // Skip days with preexisting + nextAddDate = nextAddDate.addDays(1); + ++activeDays; + continue; + } else if (conflictHandling == 2) { // Fail + failed = true; + break; + } + } + if (activeDays >= restDayAfter) { + activeDays = 0; + found = false; + nextAddDate = nextAddDate.addDays(1); + continue; + } + found = true; + } + if (failed) { + deletionList.clear(); + scheduleList.clear(); + break; + } + scheduleList << std::make_pair(rideItem, nextAddDate); + if (conflictHandling == 0) { + if (! minDate.isValid() || minDate > nextAddDate) { + minDate = nextAddDate; + } + if (! maxDate.isValid() || maxDate < nextAddDate) { + maxDate = nextAddDate; + } + } + + int remaining = selectedCount.value(rideItem->dateTime.date(), 1) - 1; + selectedCount.insert(rideItem->dateTime.date(), remaining); + if (! (sameDay && remaining > 0)) { + nextAddDate = nextAddDate.addDays(1); + ++activeDays; + } + lastSourceDate = rideItem->dateTime.date(); + } + if (! failed) { + QLocale locale; + if (minDate.isValid() && maxDate.isValid()) { + for (RideItem *rideItem : preexistingPlanned) { + if (rideItem->dateTime.date() >= minDate && rideItem->dateTime.date() <= maxDate) { + deletionList << rideItem; + } + } + } + if (! scheduleList.isEmpty()) { + scheduleLabel->setVisible(true); + scheduleTree->setVisible(true); + for (std::pair entry : scheduleList) { + QTreeWidgetItem *scheduleItem = new QTreeWidgetItem(); + scheduleItem->setData(0, Qt::DisplayRole, locale.toString(entry.first->dateTime.date(), QLocale::ShortFormat)); + scheduleItem->setData(1, Qt::DisplayRole, "→"); + scheduleItem->setData(2, Qt::DisplayRole, locale.toString(entry.second, QLocale::ShortFormat)); + scheduleItem->setData(3, Qt::DisplayRole, rideItemSport(entry.first)); + scheduleItem->setData(4, Qt::DisplayRole, rideItemName(entry.first)); + scheduleTree->addTopLevelItem(scheduleItem); + } + } + if (! deletionList.isEmpty()) { + deletionLabel->setVisible(true); + deletionTree->setVisible(true); + for (RideItem *rideItem : deletionList) { + QTreeWidgetItem *deletionItem = new QTreeWidgetItem(); + deletionItem->setData(0, Qt::DisplayRole, locale.toString(rideItem->dateTime.date(), QLocale::ShortFormat)); + deletionItem->setData(1, Qt::DisplayRole, rideItemSport(rideItem)); + deletionItem->setData(2, Qt::DisplayRole, rideItemName(rideItem)); + deletionTree->addTopLevelItem(deletionItem); + } + } + } else { + failedLabel->setVisible(true); + } +} + + +bool +RepeatSchedulePageSummary::isComplete +() const +{ + return ! failed; +} + + +QList +RepeatSchedulePageSummary::getDeletionList +() const +{ + return deletionList; +} + + +QList> +RepeatSchedulePageSummary::getScheduleList +() const +{ + return scheduleList; +} + + +//////////////////////////////////////////////////////////////////////////////// +// Helpers + +QString +rideItemName +(RideItem const * const rideItem) +{ + QString rideName = rideItem->getText("Route", "").trimmed(); + if (rideName.isEmpty()) { + rideName = rideItem->getText("Workout Code", "").trimmed(); + if (rideName.isEmpty()) { + rideName = QObject::tr(""); + } + } + return rideName; +} + + +QString +rideItemSport +(RideItem const * const rideItem) +{ + QString sport = rideItem->sport; + if (! rideItem->getText("SubSport", "").isEmpty()) { + sport += " / " + rideItem->getText("SubSport", ""); + } + return sport; +} diff --git a/src/Gui/RepeatScheduleWizard.h b/src/Gui/RepeatScheduleWizard.h new file mode 100644 index 000000000..02c2d8a8c --- /dev/null +++ b/src/Gui/RepeatScheduleWizard.h @@ -0,0 +1,117 @@ +/* + * 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_RepeatScheduleWizard_h +#define _GC_RepeatScheduleWizard_h 1 + +#include "GoldenCheetah.h" + +#include +#include +#include +#include +#include + + +class RepeatScheduleWizard : public QWizard +{ + Q_OBJECT + + public: + enum { + Finalize = -1, + PageSetup, + PageActivities, + PageSummary + }; + + RepeatScheduleWizard(Context *context, const QDate &when, QWidget *parent = nullptr); + + protected: + virtual void done(int result) override; + + private: + Context *context; + QDate when; +}; + + +class RepeatSchedulePageSetup : public QWizardPage +{ + Q_OBJECT + + public: + RepeatSchedulePageSetup(Context *context, const QDate &when, QWidget *parent = nullptr); + + int nextId() const override; + + private: + Context *context; +}; + + +class RepeatSchedulePageActivities : public QWizardPage +{ + Q_OBJECT + + public: + RepeatSchedulePageActivities(Context *context, QWidget *parent = nullptr); + + int nextId() const override; + void initializePage() override; + bool isComplete() const override; + + QList getSelectedRideItems() const; + + private: + Context *context; + QTreeWidget *activityTree; + int numSelected = 0; +}; + + +class RepeatSchedulePageSummary : public QWizardPage +{ + Q_OBJECT + + public: + RepeatSchedulePageSummary(Context *context, const QDate &when, QWidget *parent = nullptr); + + int nextId() const override; + void initializePage() override; + bool isComplete() const override; + + QList getDeletionList() const; + QList> getScheduleList() const; + + private: + Context *context; + QDate when; + + bool failed = false; + QList deletionList; // was: preexistingPlanned + QList> scheduleList; // was: targetMap + + QLabel *failedLabel; + QLabel *scheduleLabel; + QTreeWidget *scheduleTree; + QLabel *deletionLabel; + QTreeWidget *deletionTree; +}; + +#endif // _GC_ManualActivityWizard_h diff --git a/src/Resources/application.qrc b/src/Resources/application.qrc index 81d2686d5..58127defd 100644 --- a/src/Resources/application.qrc +++ b/src/Resources/application.qrc @@ -124,6 +124,7 @@ xml/home-perspectives.xml ini/measures.ini html/ltm-summary.html + images/breeze/media-playlist-repeat.svg images/breeze/games-highscores.svg images/breeze/network-mobile-0.svg images/breeze/network-mobile-20.svg diff --git a/src/Resources/images/breeze/media-playlist-repeat.svg b/src/Resources/images/breeze/media-playlist-repeat.svg new file mode 100644 index 000000000..5af564203 --- /dev/null +++ b/src/Resources/images/breeze/media-playlist-repeat.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/src.pro b/src/src.pro index 90cca5f16..6127e87ae 100644 --- a/src/src.pro +++ b/src/src.pro @@ -650,7 +650,7 @@ HEADERS += Gui/AboutDialog.h Gui/AddIntervalDialog.h Gui/AnalysisSidebar.h Gui/C Gui/MergeActivityWizard.h Gui/RideImportWizard.h Gui/SplitActivityWizard.h Gui/SolverDisplay.h Gui/MetricSelect.h \ 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/MetricOverrideDialog.h Gui/RepeatScheduleWizard.h \ Gui/Calendar.h Gui/CalendarData.h Gui/CalendarItemDelegates.h # metrics and models @@ -762,7 +762,7 @@ SOURCES += Gui/AboutDialog.cpp Gui/AddIntervalDialog.cpp Gui/AnalysisSidebar.cpp Gui/MergeActivityWizard.cpp Gui/RideImportWizard.cpp Gui/SplitActivityWizard.cpp Gui/SolverDisplay.cpp Gui/MetricSelect.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/MetricOverrideDialog.cpp Gui/RepeatScheduleWizard.cpp \ Gui/Calendar.cpp Gui/CalendarItemDelegates.cpp ## Models and Metrics