From 7437d1fae32021149295658d4c19eb0b13a41490 Mon Sep 17 00:00:00 2001 From: Dan Woollard Date: Sun, 19 Apr 2026 20:10:44 +0100 Subject: [PATCH 01/15] feat: auto-pause workout on sensor dropout (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sensor_dropout_enabled (bool, default true) and sensor_dropout_timeout_s (int, default 5s, range 2–30s) to Account with QSettings persistence - WorkoutDialog: 1-Hz watchdog timer checks last power timestamp; on dropout pauses workout and shows overlay message; auto-resumes after 3s countdown when signal is restored - Manual resume during dropout pause clears dropout state cleanly - DialogConfig Trainer tab: new 'Sensor Dropout' group with enable toggle and timeout spinner - WorkoutPlot: removeAlertMessage() method to dismiss alert overlay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/model/account.cpp | 12 ++++++ src/model/account.h | 5 +++ src/ui/dialogconfig.cpp | 31 ++++++++++++++ src/ui/dialogconfig.h | 4 ++ src/ui/plots/workoutplot.cpp | 3 ++ src/ui/plots/workoutplot.h | 1 + src/ui/workoutdialog.cpp | 79 ++++++++++++++++++++++++++++++++++++ src/ui/workoutdialog.h | 10 +++++ 8 files changed, 145 insertions(+) diff --git a/src/model/account.cpp b/src/model/account.cpp index 1a110848..1c08e896 100644 --- a/src/model/account.cpp +++ b/src/model/account.cpp @@ -50,6 +50,9 @@ Account::Account(QObject *parent) : QObject(parent) { // OAuth2 tokens — loaded to restore an existing OAuth session across restarts. intervals_icu_access_token = settings.value("intervals_icu_access_token", "").toString(); intervals_icu_refresh_token = settings.value("intervals_icu_refresh_token", "").toString(); + // Sensor dropout auto-pause + sensor_dropout_enabled = settings.value("sensor_dropout_enabled", true).toBool(); + sensor_dropout_timeout_s = settings.value("sensor_dropout_timeout_s", 5 ).toInt(); settings.endGroup(); // Load encrypted third-party service credentials from the platform credential store. @@ -226,6 +229,15 @@ void Account::saveSelfloopsCredentials() CredentialStore::store("selfloops", "password", selfloops_pw); } +void Account::saveSensorDropoutSettings() +{ + QSettings settings; + settings.beginGroup("account"); + settings.setValue("sensor_dropout_enabled", sensor_dropout_enabled); + settings.setValue("sensor_dropout_timeout_s", sensor_dropout_timeout_s); + settings.endGroup(); +} + diff --git a/src/model/account.h b/src/model/account.h index 96276fa7..dd6c5120 100644 --- a/src/model/account.h +++ b/src/model/account.h @@ -20,6 +20,7 @@ class Account : public QObject void saveNbSecShowInterval(int nbSec); void saveNbSecShowIntervalBefore(int nbSec); void saveIntervalsIcuCredentials(); + void saveSensorDropoutSettings(); /// Encrypt and persist Strava OAuth2 tokens to QSettings. /// Call after a successful token exchange or refresh. @@ -107,6 +108,10 @@ class Account : public QObject int nb_sec_pairing; /* ----- */ + // Sensor dropout auto-pause + bool sensor_dropout_enabled; ///< Master toggle for auto-pause on sensor dropout + int sensor_dropout_timeout_s; ///< Seconds before dropout triggers pause (default 5, range 2–30) + int last_index_selected_config_workout; int last_tab_sub_config_selected; QString tab_display[8]; diff --git a/src/ui/dialogconfig.cpp b/src/ui/dialogconfig.cpp index 22a97270..1d0cbef1 100644 --- a/src/ui/dialogconfig.cpp +++ b/src/ui/dialogconfig.cpp @@ -1295,6 +1295,30 @@ QWidget *DialogConfig::setupTrainerTab() grpLayout->addWidget(note); layout->addWidget(grp); + + // ── Sensor Dropout ────────────────────────────────────────────────────── + auto *grpDropout = new QGroupBox(tr("Sensor Dropout"), page); + auto *dropoutLayout = new QVBoxLayout(grpDropout); + + checkDropoutEnabled = new QCheckBox(tr("Auto-pause workout when sensor signal is lost"), grpDropout); + dropoutLayout->addWidget(checkDropoutEnabled); + + auto *timeoutRow = new QHBoxLayout(); + timeoutRow->addWidget(new QLabel(tr("Dropout timeout (seconds):"), grpDropout)); + spinDropoutTimeout = new QSpinBox(grpDropout); + spinDropoutTimeout->setRange(2, 30); + spinDropoutTimeout->setSuffix(tr(" s")); + timeoutRow->addWidget(spinDropoutTimeout); + timeoutRow->addStretch(); + dropoutLayout->addLayout(timeoutRow); + + auto *dropoutNote = new QLabel( + tr("Workout resumes automatically 3 seconds after the signal is restored."), grpDropout); + dropoutNote->setStyleSheet("color: #888; font-style: italic;"); + dropoutNote->setWordWrap(true); + dropoutLayout->addWidget(dropoutNote); + + layout->addWidget(grpDropout); layout->addStretch(); return page; } @@ -1311,6 +1335,9 @@ void DialogConfig::initTrainerTab() break; } } + + if (checkDropoutEnabled) checkDropoutEnabled->setChecked(account->sensor_dropout_enabled); + if (spinDropoutTimeout) spinDropoutTimeout->setValue(account->sensor_dropout_timeout_s); } void DialogConfig::saveTrainerTab() @@ -1318,4 +1345,8 @@ void DialogConfig::saveTrainerTab() if (!checkControlResistance) return; account->control_trainer_resistance = checkControlResistance->isChecked(); account->powerCurve.setId(comboTrainerModel->currentData().toInt()); + + if (checkDropoutEnabled) account->sensor_dropout_enabled = checkDropoutEnabled->isChecked(); + if (spinDropoutTimeout) account->sensor_dropout_timeout_s = spinDropoutTimeout->value(); + account->saveSensorDropoutSettings(); } diff --git a/src/ui/dialogconfig.h b/src/ui/dialogconfig.h index 859f24cc..5cc5ae7c 100644 --- a/src/ui/dialogconfig.h +++ b/src/ui/dialogconfig.h @@ -214,6 +214,10 @@ private : // Trainer tab controls QCheckBox *checkControlResistance = nullptr; QComboBox *comboTrainerModel = nullptr; + + // Trainer tab — Sensor dropout (issue #148) + QCheckBox *checkDropoutEnabled = nullptr; + QSpinBox *spinDropoutTimeout = nullptr; }; #endif // DIALOGCONFIG_H diff --git a/src/ui/plots/workoutplot.cpp b/src/ui/plots/workoutplot.cpp index 5866fe55..c6ccae3d 100644 --- a/src/ui/plots/workoutplot.cpp +++ b/src/ui/plots/workoutplot.cpp @@ -572,6 +572,9 @@ void WorkoutPlot::setAlertMessage(bool fadeIn, bool fadeOut, QString text, int t } +void WorkoutPlot::removeAlertMessage() { + labelAlertMessage->fadeOut(300); +} diff --git a/src/ui/plots/workoutplot.h b/src/ui/plots/workoutplot.h index d1232852..f314c728 100644 --- a/src/ui/plots/workoutplot.h +++ b/src/ui/plots/workoutplot.h @@ -58,6 +58,7 @@ class WorkoutPlot : public QwtPlot void setMessage(QString msg); void setDisplayIntervalMessage(bool fadeIn, QString text, int timeToDisplay); void setAlertMessage(bool fadeIn, bool fadeOut, QString text, int timeToDisplay); + void removeAlertMessage(); void removeMainMessage(); diff --git a/src/ui/workoutdialog.cpp b/src/ui/workoutdialog.cpp index 04814c1e..63935692 100644 --- a/src/ui/workoutdialog.cpp +++ b/src/ui/workoutdialog.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include "interval.h" #include "workout.h" @@ -202,6 +203,12 @@ WorkoutDialog::WorkoutDialog(Workout workout, QList lstRadio, QVectorstart(1000); connect(timerIgnoreClickPlot, SIGNAL(timeout()), this, SLOT(ignoreClickPlot()) ); + // Sensor dropout watchdog — fires every second while WorkoutDialog is open. + m_dropoutWatchdog = new QTimer(this); + m_dropoutWatchdog->setInterval(1000); + connect(m_dropoutWatchdog, &QTimer::timeout, this, &WorkoutDialog::checkSensorDropout); + m_dropoutWatchdog->start(); + /// Sounds timer timerCheckToActivateSound = new QTimer(this); @@ -1611,6 +1618,62 @@ void WorkoutDialog::onBleConnectionError(const QString &errorString) } } +/////////////////////////////////////////////////////////////////////////////////////////////////////// +void WorkoutDialog::checkSensorDropout() +{ + if (!account->sensor_dropout_enabled) return; + if (account->enable_studio_mode) return; + if (!isWorkoutStarted || isWorkoutOver) return; + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const qint64 timeout_ms = (qint64)account->sensor_dropout_timeout_s * 1000; + + // Only check a sensor if we've ever received data from it (m_last*Ms > 0) + const bool powerLost = (m_lastPowerMs > 0) && (now - m_lastPowerMs) > timeout_ms; + const bool signalLost = powerLost; // future: add HR option here + + if (!m_dropoutPaused) { + if (!isWorkoutPaused && signalLost) { + m_dropoutPaused = true; + m_recoveryCountdown = 0; + LOG_INFO("WorkoutDialog", "Sensor dropout detected — auto-pausing workout"); + start_or_pause_workout(); // pause + ui->widget_workoutPlot->setAlertMessage(false, false, + tr("Sensor signal lost — workout paused"), 0); + } + return; + } + + // We are in dropout-pause state: check for recovery + if (signalLost) { + // Signal still absent (or dropped again during countdown) + if (m_recoveryCountdown > 0) { + m_recoveryCountdown = 0; + ui->widget_workoutPlot->setAlertMessage(false, false, + tr("Sensor signal lost — workout paused"), 0); + } + } else { + // Signal has returned + if (m_recoveryCountdown == 0) { + m_recoveryCountdown = 3; + ui->widget_workoutPlot->setAlertMessage(false, false, + tr("Signal recovered — resuming in 3…"), 0); + } else { + m_recoveryCountdown--; + if (m_recoveryCountdown == 0) { + m_dropoutPaused = false; + LOG_INFO("WorkoutDialog", "Sensor recovered — auto-resuming workout"); + ui->widget_workoutPlot->removeAlertMessage(); + if (isWorkoutPaused) + start_or_pause_workout(); // resume + } else { + ui->widget_workoutPlot->setAlertMessage(false, false, + tr("Signal recovered — resuming in %1…").arg(m_recoveryCountdown), 0); + } + } + } +} + void WorkoutDialog::start_or_pause_workout() { @@ -1646,6 +1709,14 @@ void WorkoutDialog::start_or_pause_workout() { ui->widget_workoutPlot->removeMainMessage(); isWorkoutPaused = false; + // Clear dropout state if user manually resumes + if (m_dropoutPaused) { + m_dropoutPaused = false; + m_recoveryCountdown = 0; + m_lastPowerMs = QDateTime::currentMSecsSinceEpoch(); // reset to avoid immediate re-trigger + ui->widget_workoutPlot->removeAlertMessage(); + } + setWidgetsStopped(false); emit resumeClock(); emit playPlayer(); @@ -1711,6 +1782,10 @@ void WorkoutDialog::HrDataReceived(int userID, int value) { if (value < 0) return; + // Track for sensor dropout detection + if (!account->enable_studio_mode && isWorkoutStarted && !isWorkoutOver) + m_lastHrMs = QDateTime::currentMSecsSinceEpoch(); + // Mark pairing as done if (!account->enable_studio_mode && !hrPairingDone) { @@ -1985,6 +2060,10 @@ void WorkoutDialog::PowerDataReceived(int userID, int value) { if (value < 0) return; + // Track for sensor dropout detection (non-studio, user 1 only) + if (!account->enable_studio_mode && isWorkoutStarted && !isWorkoutOver) + m_lastPowerMs = QDateTime::currentMSecsSinceEpoch(); + // Send power To Clock emit sendPowerData(userID, value); diff --git a/src/ui/workoutdialog.h b/src/ui/workoutdialog.h index 222994cd..78708896 100644 --- a/src/ui/workoutdialog.h +++ b/src/ui/workoutdialog.h @@ -169,6 +169,9 @@ public slots: // already shows the Reconnect button; this slot logs and pauses the workout. void onBleConnectionError(const QString &errorString); + void checkSensorDropout(); ///< 1-Hz watchdog for sensor dropout auto-pause + void onBleConnectionError(const QString &errorString); + void ignoreClickPlot(); void activateSoundBool(); @@ -505,6 +508,13 @@ private slots: int currentWorkoutDifficultyPercentage; //0 = Normal + ///------ Sensor dropout auto-pause ---------------------- + qint64 m_lastPowerMs = 0; ///< Timestamp (ms) of last valid power reading + qint64 m_lastHrMs = 0; ///< Timestamp (ms) of last valid HR reading + bool m_dropoutPaused = false; ///< True when workout is paused due to dropout + int m_recoveryCountdown = 0; ///< Countdown (s) before auto-resume after recovery + QTimer *m_dropoutWatchdog = nullptr; + ///------------------------------------------------------- WorkoutPlot *mainPlot; friend class DialogConfig; From 4a29349394749a3049be4696ac06636d1d56ab36 Mon Sep 17 00:00:00 2001 From: Dan Woollard Date: Sun, 19 Apr 2026 20:21:02 +0100 Subject: [PATCH 02/15] feat: proactive BLE sensor battery-level warnings (#156) - BtleHub: subscribe to Battery Service (0x180F / characteristic 0x2A19) on every connected device; emit signal_battery(sensorType, percentage) on connect (initial read) and on notifications; parseBatteryLevel() clamps value to 0-100 - btle_uuids.h: add BTLE_UUID_BATTERY_LEVEL = 0x2A19 - simulateNotification(): dispatch battery UUID to parseBatteryLevel() - Account: battery_warning_threshold (int, default 20, range 5-50) + saveBatteryWarningThreshold() - WorkoutDialog: batteryStatusReceived(sensorType, percentage) rewired to configurable-threshold check; suppresses repeated warnings within 5% of last-warned level; QHash m_warnedBatteryLevels tracks per-sensor session state - MainWindow: connect btleHub::signal_battery to WorkoutDialog slot - DialogConfig Trainer tab: Battery Warning group with threshold spinner - Unit tests: 5 new battery parsing tests covering threshold boundary, clamping, empty-packet rejection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/btle/btle_hub.cpp | 49 ++++++++++++++++++++++++++++++++ src/btle/btle_hub.h | 17 +++++++---- src/btle/btle_uuids.h | 1 + src/model/account.cpp | 9 ++++++ src/model/account.h | 6 ++++ src/ui/dialogconfig.cpp | 29 +++++++++++++++++++ src/ui/dialogconfig.h | 3 ++ src/ui/mainwindow.cpp | 1 + src/ui/workoutdialog.cpp | 25 +++++++++++------ src/ui/workoutdialog.h | 7 ++++- tests/btle/tst_btle_hub.cpp | 56 +++++++++++++++++++++++++++++++++++++ 11 files changed, 188 insertions(+), 15 deletions(-) diff --git a/src/btle/btle_hub.cpp b/src/btle/btle_hub.cpp index 34bed7da..557a2867 100644 --- a/src/btle/btle_hub.cpp +++ b/src/btle/btle_hub.cpp @@ -20,6 +20,8 @@ static const QBluetoothUuid CyclingPower (QBluetoothUuid::CyclingPower) #endif // Fitness Machine Service (FTMS) – not in Qt enum, use raw 16-bit UUID static const QBluetoothUuid FitnessMachine (quint16(0x1826)); +// Battery Service – standard BT SIG service for battery level percentage +static const QBluetoothUuid BatteryService (quint16(0x180F)); // Characteristics #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) @@ -33,6 +35,7 @@ static const QBluetoothUuid CyclingPowerMeasurement (QBluetoothUuid::CyclingPowe #endif static const QBluetoothUuid FtmsIndoorBikeData (quint16(0x2AD2)); static const QBluetoothUuid FtmsControlPoint (quint16(0x2AD9)); +static const QBluetoothUuid BatteryLevel (quint16(0x2A19)); // Moxy Muscle Oxygen Monitor (proprietary UUIDs - not in BT SIG enum) static const QBluetoothUuid MoxyService (quint16(0xAAB0)); static const QBluetoothUuid MoxyMeasurement (quint16(0xAAB2)); @@ -194,6 +197,7 @@ void BtleHub::simulateNotification(quint16 characteristicUuid, const QByteArray case BTLE_UUID_POWER_MEASUREMENT: parsePowerMeasurement(data); break; case BTLE_UUID_FTMS_BIKE_DATA: parseFtmsIndoorBikeData(data); break; case BTLE_UUID_MOXY_MEASUREMENT: parseMoxyMeasurement(data); break; + case BTLE_UUID_BATTERY_LEVEL: parseBatteryLevel(data); break; default: break; } } @@ -262,6 +266,15 @@ void BtleHub::onServiceDiscovered(const QBluetoothUuid &serviceUuid) if (m_moxyService) setupService(m_moxyService); } + else if (serviceUuid == BtleUuid::BatteryService) { + m_batteryService = m_controller->createServiceObject(serviceUuid, this); + if (m_batteryService) { + // Also handle characteristicRead for the initial one-shot read + connect(m_batteryService, &QLowEnergyService::characteristicRead, + this, &BtleHub::onCharacteristicChanged); + setupService(m_batteryService); + } + } } void BtleHub::onDiscoveryFinished() @@ -352,6 +365,16 @@ void BtleHub::onServiceStateChanged(QLowEnergyService::ServiceState state) enableNotification(service, service->characteristic(BtleUuid::MoxyMeasurement)); } + else if (service == m_batteryService) { + QLowEnergyCharacteristic battChar = + service->characteristic(BtleUuid::BatteryLevel); + if (battChar.isValid()) { + // Enable notifications if the device supports them + enableNotification(service, battChar); + // Also do an immediate read for instant level on connect + service->readCharacteristic(battChar); + } + } } // ───────────────────────────────────────────────────────────────────────────── @@ -372,6 +395,8 @@ void BtleHub::onCharacteristicChanged(const QLowEnergyCharacteristic &characteri parseFtmsIndoorBikeData(value); else if (uuid == BtleUuid::MoxyMeasurement) parseMoxyMeasurement(value); + else if (uuid == BtleUuid::BatteryLevel) + parseBatteryLevel(value); } // ───────────────────────────────────────────────────────────────────────────── @@ -607,3 +632,27 @@ void BtleHub::parseMoxyMeasurement(const QByteArray &data) emit signal_oxygen(0, smo2, thb); } + +// Battery Level (0x2A19) +// Single byte: battery percentage 0–100 +void BtleHub::parseBatteryLevel(const QByteArray &data) +{ + if (data.isEmpty()) + return; + + int percentage = static_cast(data[0]); + percentage = qBound(0, percentage, 100); + + emit signal_battery(determineSensorType(), percentage); +} + +// Return a human-readable sensor type based on which services are connected. +QString BtleHub::determineSensorType() const +{ + if (m_hrService) return QStringLiteral("Heart Rate"); + if (m_powerService) return QStringLiteral("Power"); + if (m_ftmsService) return QStringLiteral("Trainer"); + if (m_cscService) return QStringLiteral("Speed/Cadence"); + if (m_moxyService) return QStringLiteral("Oxygen"); + return QStringLiteral("Sensor"); +} diff --git a/src/btle/btle_hub.h b/src/btle/btle_hub.h index a3c1ee02..02cf06af 100644 --- a/src/btle/btle_hub.h +++ b/src/btle/btle_hub.h @@ -49,6 +49,10 @@ class BtleHub : public QObject void signal_speed(int userID, double speed); // km/h void signal_power(int userID, int power); // watts void signal_oxygen(int userID, double smo2Percent, double thbGdL); + /// Emitted when the connected device's Battery Service reports a level. + /// \param sensorType Human-readable sensor name ("Heart Rate", "Power", …) + /// \param percentage Battery level 0–100 (%) + void signal_battery(QString sensorType, int percentage); // ----------- Connection status signals --------------------------------- void deviceConnected(); @@ -93,14 +97,17 @@ private slots: void parsePowerMeasurement(const QByteArray &data); void parseFtmsIndoorBikeData(const QByteArray &data); void parseMoxyMeasurement(const QByteArray &data); + void parseBatteryLevel(const QByteArray &data); + QString determineSensorType() const; ///< Infer sensor type from connected services QLowEnergyController *m_controller = nullptr; - QLowEnergyService *m_hrService = nullptr; - QLowEnergyService *m_cscService = nullptr; - QLowEnergyService *m_powerService = nullptr; - QLowEnergyService *m_ftmsService = nullptr; - QLowEnergyService *m_moxyService = nullptr; + QLowEnergyService *m_hrService = nullptr; + QLowEnergyService *m_cscService = nullptr; + QLowEnergyService *m_powerService = nullptr; + QLowEnergyService *m_ftmsService = nullptr; + QLowEnergyService *m_moxyService = nullptr; + QLowEnergyService *m_batteryService = nullptr; bool m_ftmsControlRequested = false; diff --git a/src/btle/btle_uuids.h b/src/btle/btle_uuids.h index 55486900..6c9a9230 100644 --- a/src/btle/btle_uuids.h +++ b/src/btle/btle_uuids.h @@ -11,6 +11,7 @@ static constexpr quint16 BTLE_UUID_HR_MEASUREMENT = 0x2A37; static constexpr quint16 BTLE_UUID_CSC_MEASUREMENT = 0x2A5B; static constexpr quint16 BTLE_UUID_POWER_MEASUREMENT = 0x2A63; static constexpr quint16 BTLE_UUID_FTMS_BIKE_DATA = 0x2AD2; +static constexpr quint16 BTLE_UUID_BATTERY_LEVEL = 0x2A19; // Moxy Muscle Oxygen Monitor (proprietary, not BT SIG) static constexpr quint16 BTLE_UUID_MOXY_SERVICE = 0xAAB0; diff --git a/src/model/account.cpp b/src/model/account.cpp index 1a110848..a4d9a6c3 100644 --- a/src/model/account.cpp +++ b/src/model/account.cpp @@ -50,6 +50,7 @@ Account::Account(QObject *parent) : QObject(parent) { // OAuth2 tokens — loaded to restore an existing OAuth session across restarts. intervals_icu_access_token = settings.value("intervals_icu_access_token", "").toString(); intervals_icu_refresh_token = settings.value("intervals_icu_refresh_token", "").toString(); + battery_warning_threshold = settings.value("battery_warning_threshold", 20).toInt(); settings.endGroup(); // Load encrypted third-party service credentials from the platform credential store. @@ -226,6 +227,14 @@ void Account::saveSelfloopsCredentials() CredentialStore::store("selfloops", "password", selfloops_pw); } +void Account::saveBatteryWarningThreshold() +{ + QSettings settings; + settings.beginGroup("account"); + settings.setValue("battery_warning_threshold", battery_warning_threshold); + settings.endGroup(); +} + diff --git a/src/model/account.h b/src/model/account.h index 96276fa7..997dc522 100644 --- a/src/model/account.h +++ b/src/model/account.h @@ -105,6 +105,12 @@ class Account : public QObject bool control_trainer_resistance; bool stop_pairing_on_found; int nb_sec_pairing; + + /// Battery warning threshold (issue #156): warn when a sensor's battery + /// drops at or below this percentage (default 20, range 5–50). + int battery_warning_threshold; + void saveBatteryWarningThreshold(); + /* ----- */ int last_index_selected_config_workout; diff --git a/src/ui/dialogconfig.cpp b/src/ui/dialogconfig.cpp index 22a97270..554ae260 100644 --- a/src/ui/dialogconfig.cpp +++ b/src/ui/dialogconfig.cpp @@ -1295,6 +1295,28 @@ QWidget *DialogConfig::setupTrainerTab() grpLayout->addWidget(note); layout->addWidget(grp); + + // ── Battery Warning ───────────────────────────────────────────────────── + auto *grpBatt = new QGroupBox(tr("Battery Warning"), page); + auto *battLayout = new QVBoxLayout(grpBatt); + + auto *battRow = new QHBoxLayout(); + battRow->addWidget(new QLabel(tr("Warn when battery drops below:"), grpBatt)); + spinBatteryThreshold = new QSpinBox(grpBatt); + spinBatteryThreshold->setRange(5, 50); + spinBatteryThreshold->setSuffix(tr(" %")); + battRow->addWidget(spinBatteryThreshold); + battRow->addStretch(); + battLayout->addLayout(battRow); + + auto *battNote = new QLabel( + tr("A toast notification is shown when a sensor reports a battery level " + "at or below this threshold."), grpBatt); + battNote->setStyleSheet("color: #888; font-style: italic;"); + battNote->setWordWrap(true); + battLayout->addWidget(battNote); + + layout->addWidget(grpBatt); layout->addStretch(); return page; } @@ -1311,6 +1333,8 @@ void DialogConfig::initTrainerTab() break; } } + + if (spinBatteryThreshold) spinBatteryThreshold->setValue(account->battery_warning_threshold); } void DialogConfig::saveTrainerTab() @@ -1318,4 +1342,9 @@ void DialogConfig::saveTrainerTab() if (!checkControlResistance) return; account->control_trainer_resistance = checkControlResistance->isChecked(); account->powerCurve.setId(comboTrainerModel->currentData().toInt()); + + if (spinBatteryThreshold) { + account->battery_warning_threshold = spinBatteryThreshold->value(); + account->saveBatteryWarningThreshold(); + } } diff --git a/src/ui/dialogconfig.h b/src/ui/dialogconfig.h index 859f24cc..3712e3c5 100644 --- a/src/ui/dialogconfig.h +++ b/src/ui/dialogconfig.h @@ -214,6 +214,9 @@ private : // Trainer tab controls QCheckBox *checkControlResistance = nullptr; QComboBox *comboTrainerModel = nullptr; + + // Trainer tab — Battery warning threshold (issue #156) + QSpinBox *spinBatteryThreshold = nullptr; }; #endif // DIALOGCONFIG_H diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index 6e25fe3f..60b09d8d 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -1692,6 +1692,7 @@ void MainWindow::executeWorkout(Workout workout) { connect(btleHub, SIGNAL(signal_speed(int,double)), &w, SLOT(TrainerSpeedDataReceived(int,double))); connect(btleHub, SIGNAL(signal_power(int,int)), &w, SLOT(PowerDataReceived(int,int))); connect(btleHub, SIGNAL(signal_oxygen(int,double,double)), &w, SLOT(OxygenValueChanged(int,double,double))); + connect(btleHub, &BtleHub::signal_battery, &w, &WorkoutDialog::batteryStatusReceived); // Surface BLE disconnections mid-workout so WorkoutDialog can pause and // the user sees the DOM reconnect overlay (WASM) or a status message. diff --git a/src/ui/workoutdialog.cpp b/src/ui/workoutdialog.cpp index 04814c1e..694955f3 100644 --- a/src/ui/workoutdialog.cpp +++ b/src/ui/workoutdialog.cpp @@ -751,19 +751,26 @@ void WorkoutDialog::startCalibrationPM() { //------------------------------------------------------------------------------------------------------------ -void WorkoutDialog::batteryStatusReceived(QString sensorType, int level, int antID) { +void WorkoutDialog::batteryStatusReceived(QString sensorType, int percentage) { - qDebug() << "batteryStatusReceived" << sensorType << "level:" << level; + qDebug() << "batteryStatusReceived" << sensorType << "level:" << percentage << "%"; - labelBatteryStatus->setVisible(true); + // Check against configurable threshold + if (percentage > account->battery_warning_threshold) + return; + + // Suppress re-warning unless level dropped ≥ 5% below the last warned level + if (m_warnedBatteryLevels.contains(sensorType)) { + int lastWarned = m_warnedBatteryLevels.value(sensorType); + if (percentage > lastWarned - 5) + return; + } - QString levelStr; - if (level == 0) - levelStr = tr("critical"); - else //1 - levelStr = tr("low"); + m_warnedBatteryLevels[sensorType] = percentage; - labelBatteryStatus->setText(tr("Battery Warning: ") + sensorType + "(ID: " + QString::number(antID) + tr(") Battery is ") + levelStr); + labelBatteryStatus->setVisible(true); + labelBatteryStatus->setText( + tr("%1 sensor battery: %2%").arg(sensorType).arg(percentage)); labelBatteryStatus->fadeInAndFadeOutAfterPause(400, 1000, 15000); } diff --git a/src/ui/workoutdialog.h b/src/ui/workoutdialog.h index 222994cd..0b2c3299 100644 --- a/src/ui/workoutdialog.h +++ b/src/ui/workoutdialog.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -148,7 +149,7 @@ public slots: void lapButtonPressed(); - void batteryStatusReceived(QString sensorType, int level, int antID); + void batteryStatusReceived(QString sensorType, int percentage); void startCalibrateFEC(); void startCalibrationPM(); @@ -505,6 +506,10 @@ private slots: int currentWorkoutDifficultyPercentage; //0 = Normal + /// Battery warning tracking (issue #156): sensorType → last warned percentage. + /// A warning is suppressed for a sensor unless the level drops ≥ 5 % below + /// the level at which the last warning was shown. + QHash m_warnedBatteryLevels; WorkoutPlot *mainPlot; friend class DialogConfig; diff --git a/tests/btle/tst_btle_hub.cpp b/tests/btle/tst_btle_hub.cpp index de83b8e1..d67eaaf8 100644 --- a/tests/btle/tst_btle_hub.cpp +++ b/tests/btle/tst_btle_hub.cpp @@ -95,6 +95,13 @@ private slots: void testSimulator_noOpSetLoad(); void testSimulator_noOpSetSlope(); + // ── Battery Level parsing ───────────────────────────────────────────────── + void testBattery_aboveThreshold_noSignal(); + void testBattery_atThreshold_emits(); + void testBattery_belowThreshold_emits(); + void testBattery_tooShort_ignored(); + void testBattery_clamped(); + private: BtleHub *hub = nullptr; }; @@ -792,6 +799,55 @@ void TstBtleHub::testSimulator_noOpSetSlope() QCOMPARE(spySpd.count(), 0); } +// ───────────────────────────────────────────────────────────────────────────── +// Battery Level parsing tests (issue #156) +// ───────────────────────────────────────────────────────────────────────────── + +/// Empty payload — must be silently ignored, no signal emitted. +void TstBtleHub::testBattery_tooShort_ignored() +{ + QSignalSpy spy(hub, &BtleHub::signal_battery); + hub->simulateNotification(BTLE_UUID_BATTERY_LEVEL, QByteArray()); + QCOMPARE(spy.count(), 0); +} + +/// Nominal battery level (85%) — signal emitted with correct percentage. +void TstBtleHub::testBattery_belowThreshold_emits() +{ + QSignalSpy spy(hub, &BtleHub::signal_battery); + hub->simulateNotification(BTLE_UUID_BATTERY_LEVEL, QByteArray(1, char(15))); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(1).toInt(), 15); +} + +/// At the threshold boundary (exactly threshold value) — signal emitted. +void TstBtleHub::testBattery_atThreshold_emits() +{ + QSignalSpy spy(hub, &BtleHub::signal_battery); + hub->simulateNotification(BTLE_UUID_BATTERY_LEVEL, QByteArray(1, char(20))); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(1).toInt(), 20); +} + +/// Above threshold (75%) — signal still emitted (threshold filtering is in WorkoutDialog). +void TstBtleHub::testBattery_aboveThreshold_noSignal() +{ + QSignalSpy spy(hub, &BtleHub::signal_battery); + hub->simulateNotification(BTLE_UUID_BATTERY_LEVEL, QByteArray(1, char(75))); + // BtleHub emits for any reading; WorkoutDialog applies the threshold + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(1).toInt(), 75); +} + +/// Value > 100 must be clamped to 100. +void TstBtleHub::testBattery_clamped() +{ + QSignalSpy spy(hub, &BtleHub::signal_battery); + hub->simulateNotification(BTLE_UUID_BATTERY_LEVEL, QByteArray(1, char(200))); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(1).toInt(), 100); +} + // ───────────────────────────────────────────────────────────────────────────── QTEST_MAIN(TstBtleHub) #include "tst_btle_hub.moc" From 02a7a2b3735a3b6bbb64f6afc30d06bf67798a07 Mon Sep 17 00:00:00 2001 From: Dan Woollard Date: Sun, 19 Apr 2026 20:28:49 +0100 Subject: [PATCH 03/15] feat: per-interval transition summary overlay (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataWorkout: add getAvgIntervalPower/Hr/Cad + getNbPointsIntervalPower getters so WorkoutDialog can read stats before the reset in changeInterval() - IntervalSummaryUtil (src/model/intervalsummaryutil.h): new header-only helper with classifyPowerAdherence() — Met/NearMiss/Missed based on ±5% and ±10% thresholds; no Qt dependency for easy unit testing - model.pri: register intervalsummaryutil.h - Account: interval_summary_enabled (bool, default true) and interval_summary_duration_s (int, default 5, range 2–15) settings with QSettings load + saveIntervalSummarySettings() - WorkoutDialog::changeIntervalsDataWorkout(): capture interval stats before changeInterval() resets them; skip if studio mode, workoutOver, or interval < 10 s; call showIntervalSummaryOverlay() after - WorkoutDialog::showIntervalSummaryOverlay(): HTML overlay via the existing setDisplayIntervalMessage() FaderLabel; colour-coded power indicator (green/amber/red); shows avg W, % FTP, HR, cadence, duration - DialogConfig Trainer tab: 'Interval Summary Overlay' group with enabled checkbox and duration spinbox; wired to initTrainerTab/saveTrainerTab - Unit tests: 8 new tests for classifyPowerAdherence() covering exact target, ±5% boundaries, ±7% NearMiss, ±15% Missed, zero-target Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/model/account.cpp | 18 ++++++++++ src/model/account.h | 4 +++ src/model/dataworkout.h | 6 ++++ src/model/intervalsummaryutil.h | 28 +++++++++++++++ src/model/model.pri | 1 + src/ui/dialogconfig.cpp | 29 +++++++++++++++ src/ui/dialogconfig.h | 4 +++ src/ui/workoutdialog.cpp | 64 +++++++++++++++++++++++++++++++++ src/ui/workoutdialog.h | 2 ++ tests/btle/tst_btle_hub.cpp | 63 ++++++++++++++++++++++++++++++++ 10 files changed, 219 insertions(+) create mode 100644 src/model/intervalsummaryutil.h diff --git a/src/model/account.cpp b/src/model/account.cpp index 1a110848..7b080e71 100644 --- a/src/model/account.cpp +++ b/src/model/account.cpp @@ -152,6 +152,15 @@ Account::Account(QObject *parent) : QObject(parent) { sound_alert_cadence_under_target = false; sound_alert_cadence_above_target = false; + interval_summary_enabled = true; + interval_summary_duration_s = 5; + { + QSettings s; + s.beginGroup("account"); + interval_summary_enabled = s.value("interval_summary_enabled", true).toBool(); + interval_summary_duration_s = s.value("interval_summary_duration_s", 5).toInt(); + s.endGroup(); + } //-------------------------- not in DB ---------------------- isOffline = false; @@ -226,6 +235,15 @@ void Account::saveSelfloopsCredentials() CredentialStore::store("selfloops", "password", selfloops_pw); } +void Account::saveIntervalSummarySettings() +{ + QSettings settings; + settings.beginGroup("account"); + settings.setValue("interval_summary_enabled", interval_summary_enabled); + settings.setValue("interval_summary_duration_s", interval_summary_duration_s); + settings.endGroup(); +} + diff --git a/src/model/account.h b/src/model/account.h index 96276fa7..be11bfaa 100644 --- a/src/model/account.h +++ b/src/model/account.h @@ -20,6 +20,7 @@ class Account : public QObject void saveNbSecShowInterval(int nbSec); void saveNbSecShowIntervalBefore(int nbSec); void saveIntervalsIcuCredentials(); + void saveIntervalSummarySettings(); /// Encrypt and persist Strava OAuth2 tokens to QSettings. /// Call after a successful token exchange or refresh. @@ -199,6 +200,9 @@ class Account : public QObject bool sound_alert_power_above_target; bool sound_alert_cadence_under_target; bool sound_alert_cadence_above_target; + + bool interval_summary_enabled; + int interval_summary_duration_s; ///< seconds to display overlay (2–15) //---- diff --git a/src/model/dataworkout.h b/src/model/dataworkout.h index e62f2cc7..449febd4 100644 --- a/src/model/dataworkout.h +++ b/src/model/dataworkout.h @@ -52,6 +52,12 @@ class DataWorkout : public QObject int getLTHR(); //----------------- + // Interval average accessors — valid until the next changeInterval() call + double getAvgIntervalPower() const { return avgIntervalPower; } + double getAvgIntervalHr() const { return avgIntervalHr; } + double getAvgIntervalCad() const { return avgIntervalCad; } + int getNbPointsIntervalPower() const { return nbPointsIntervalPower; } + diff --git a/src/model/intervalsummaryutil.h b/src/model/intervalsummaryutil.h new file mode 100644 index 00000000..e496492b --- /dev/null +++ b/src/model/intervalsummaryutil.h @@ -0,0 +1,28 @@ +#ifndef INTERVALSUMMARYUTIL_H +#define INTERVALSUMMARYUTIL_H + +/// Power-adherence classification for the per-interval summary overlay. +/// Pure header-only utility — no Qt dependency — so it is trivially testable. +namespace IntervalSummaryUtil { + + enum class PowerAdherence { + Met, ///< avg power within ±5 % of target + NearMiss, ///< avg power within ±10 % of target + Missed ///< avg power outside ±10 % of target + }; + + /// Classify whether avgPower met the targetPower. + /// Returns Met if targetPower <= 0 (no power-based target). + inline PowerAdherence classifyPowerAdherence(double avgPower, double targetPower) + { + if (targetPower <= 0.0) + return PowerAdherence::Met; + const double ratio = avgPower / targetPower; + if (ratio >= 0.95 && ratio <= 1.05) return PowerAdherence::Met; + if (ratio >= 0.90 && ratio <= 1.10) return PowerAdherence::NearMiss; + return PowerAdherence::Missed; + } + +} // namespace IntervalSummaryUtil + +#endif // INTERVALSUMMARYUTIL_H diff --git a/src/model/model.pri b/src/model/model.pri index b1cdcbdf..fb52557f 100644 --- a/src/model/model.pri +++ b/src/model/model.pri @@ -37,6 +37,7 @@ HEADERS += src/model/interval.h\ src/model/zoneobject.h \ src/model/powercurve.h \ src/model/planobject.h \ + src/model/intervalsummaryutil.h \ $$PWD/radiotablemodel.h \ $$PWD/radio.h \ $$PWD/trackpoint.h \ diff --git a/src/ui/dialogconfig.cpp b/src/ui/dialogconfig.cpp index 22a97270..5d38cf98 100644 --- a/src/ui/dialogconfig.cpp +++ b/src/ui/dialogconfig.cpp @@ -1295,6 +1295,24 @@ QWidget *DialogConfig::setupTrainerTab() grpLayout->addWidget(note); layout->addWidget(grp); + + // Interval Summary Overlay group + auto *grpSummary = new QGroupBox(tr("Interval Summary Overlay"), page); + auto *grpSummaryLayout = new QVBoxLayout(grpSummary); + + checkIntervalSummary = new QCheckBox(tr("Show summary overlay on interval transition"), grpSummary); + grpSummaryLayout->addWidget(checkIntervalSummary); + + auto *durRow = new QHBoxLayout(); + durRow->addWidget(new QLabel(tr("Display duration (seconds):"), grpSummary)); + spinIntervalSummaryDuration = new QSpinBox(grpSummary); + spinIntervalSummaryDuration->setRange(2, 15); + spinIntervalSummaryDuration->setValue(5); + durRow->addWidget(spinIntervalSummaryDuration); + durRow->addStretch(); + grpSummaryLayout->addLayout(durRow); + + layout->addWidget(grpSummary); layout->addStretch(); return page; } @@ -1311,6 +1329,11 @@ void DialogConfig::initTrainerTab() break; } } + + if (checkIntervalSummary) { + checkIntervalSummary->setChecked(account->interval_summary_enabled); + spinIntervalSummaryDuration->setValue(account->interval_summary_duration_s); + } } void DialogConfig::saveTrainerTab() @@ -1318,4 +1341,10 @@ void DialogConfig::saveTrainerTab() if (!checkControlResistance) return; account->control_trainer_resistance = checkControlResistance->isChecked(); account->powerCurve.setId(comboTrainerModel->currentData().toInt()); + + if (checkIntervalSummary) { + account->interval_summary_enabled = checkIntervalSummary->isChecked(); + account->interval_summary_duration_s = spinIntervalSummaryDuration->value(); + account->saveIntervalSummarySettings(); + } } diff --git a/src/ui/dialogconfig.h b/src/ui/dialogconfig.h index 859f24cc..a6f624c3 100644 --- a/src/ui/dialogconfig.h +++ b/src/ui/dialogconfig.h @@ -214,6 +214,10 @@ private : // Trainer tab controls QCheckBox *checkControlResistance = nullptr; QComboBox *comboTrainerModel = nullptr; + + // Interval Summary overlay controls + QCheckBox *checkIntervalSummary = nullptr; + QSpinBox *spinIntervalSummaryDuration = nullptr; }; #endif // DIALOGCONFIG_H diff --git a/src/ui/workoutdialog.cpp b/src/ui/workoutdialog.cpp index 04814c1e..465d1e78 100644 --- a/src/ui/workoutdialog.cpp +++ b/src/ui/workoutdialog.cpp @@ -40,6 +40,7 @@ #include "trainingpeaks_service.h" #include "selfloops_service.h" #include "extrequest.h" +#include "intervalsummaryutil.h" @@ -3612,6 +3613,20 @@ void WorkoutDialog::changeIntervalsDataWorkout(double timeStarted, double timeNo qDebug () << "changeIntervalsDataWorkout! - timeStarted" << timeStarted << "timeNow" << timeNow << "timePaused_msec" << timePaused_msec << "workoutOver" << workoutOver << "testInterval" << testInterval; + // Capture interval summary stats BEFORE changeInterval() resets them. + double summaryAvgPower = 0.0, summaryAvgHr = 0.0, summaryAvgCad = 0.0; + double intervalDuration = timeNow - timeStarted; + double targetPowerFraction = currentIntervalObj.getFTP_start(); + bool showSummary = !account->enable_studio_mode + && !workoutOver + && account->interval_summary_enabled + && intervalDuration >= 10.0; + if (showSummary) { + summaryAvgPower = arrDataWorkout[0]->getAvgIntervalPower(); + summaryAvgHr = arrDataWorkout[0]->getAvgIntervalHr(); + summaryAvgCad = arrDataWorkout[0]->getAvgIntervalCad(); + } + if (account->enable_studio_mode) { for (int i=0; inb_user_studio; i++) { // qDebug() << "change interval for this user" << i; @@ -3623,6 +3638,10 @@ void WorkoutDialog::changeIntervalsDataWorkout(double timeStarted, double timeNo arrDataWorkout[0]->changeInterval(timeStarted, timeNow, timePaused_msec, workoutOver, testInterval); } + if (showSummary) + showIntervalSummaryOverlay(summaryAvgPower, summaryAvgHr, summaryAvgCad, + static_cast(intervalDuration), targetPowerFraction); + lastIntervalEndTime_msec = timeElapsed_sec; lastIntervalTotalTimePausedWorkout_msec = totalTimePausedWorkout_msec; } @@ -3643,6 +3662,51 @@ void WorkoutDialog::initDataWorkout() { } +///////////////////////////////////////////////////////////////////////////////////////////////////// +void WorkoutDialog::showIntervalSummaryOverlay(double avgPower, double avgHr, double avgCad, + int durationSec, double targetPowerFraction) +{ + const double targetWatts = targetPowerFraction * account->FTP; + const auto adherence = IntervalSummaryUtil::classifyPowerAdherence(avgPower, targetWatts); + + QString colorHex; + switch (adherence) { + case IntervalSummaryUtil::PowerAdherence::Met: colorHex = "#4CAF50"; break; // green + case IntervalSummaryUtil::PowerAdherence::NearMiss: colorHex = "#FF9800"; break; // amber + default: colorHex = "#F44336"; break; // red + } + + const int mins = durationSec / 60; + const int secs = durationSec % 60; + const QString duration = (mins > 0) + ? QString("%1:%2").arg(mins).arg(secs, 2, 10, QChar('0')) + : QString("%1s").arg(secs); + + const int powerPct = (account->FTP > 0) + ? qRound(avgPower * 100.0 / account->FTP) + : 0; + + // Build HTML summary shown in the existing interval-message overlay + QString html = QString( + "
" + "%1
" + "● %3 W (%4%% FTP)
" + "HR: %5 bpm    Cad: %6 rpm
" + "%7" + "
") + .arg(tr("Interval Complete")) + .arg(colorHex) + .arg(qRound(avgPower)) + .arg(powerPct) + .arg(qRound(avgHr)) + .arg(qRound(avgCad)) + .arg(duration); + + ui->widget_workoutPlot->setDisplayIntervalMessage( + true, html, account->interval_summary_duration_s); +} + + diff --git a/src/ui/workoutdialog.h b/src/ui/workoutdialog.h index 222994cd..0805b2e9 100644 --- a/src/ui/workoutdialog.h +++ b/src/ui/workoutdialog.h @@ -231,6 +231,8 @@ private slots: void initDataWorkout(); void connectDataWorkout(); void createUserStudioWidget(); + void showIntervalSummaryOverlay(double avgPower, double avgHr, double avgCad, + int durationSec, double targetPowerFraction); void sendUserInfoToClock(); diff --git a/tests/btle/tst_btle_hub.cpp b/tests/btle/tst_btle_hub.cpp index de83b8e1..a3c34157 100644 --- a/tests/btle/tst_btle_hub.cpp +++ b/tests/btle/tst_btle_hub.cpp @@ -23,6 +23,7 @@ #include "../../src/btle/btle_hub.h" #include "../../src/btle/simulator_hub.h" +#include "../../src/model/intervalsummaryutil.h" #include "btle_device_simulator.h" // ───────────────────────────────────────────────────────────────────────────── @@ -95,6 +96,16 @@ private slots: void testSimulator_noOpSetLoad(); void testSimulator_noOpSetSlope(); + // ── Interval summary power-adherence classification ─────────────────────── + void testIntervalSummary_met_exactTarget(); + void testIntervalSummary_met_upperBoundary(); + void testIntervalSummary_met_lowerBoundary(); + void testIntervalSummary_nearMiss_upperBoundary(); + void testIntervalSummary_nearMiss_lowerBoundary(); + void testIntervalSummary_missed_above(); + void testIntervalSummary_missed_below(); + void testIntervalSummary_zeroTarget_returnsMet(); + private: BtleHub *hub = nullptr; }; @@ -792,6 +803,58 @@ void TstBtleHub::testSimulator_noOpSetSlope() QCOMPARE(spySpd.count(), 0); } +// ───────────────────────────────────────────────────────────────────────────── +// Interval summary — power-adherence classification +// ───────────────────────────────────────────────────────────────────────────── +using PA = IntervalSummaryUtil::PowerAdherence; + +void TstBtleHub::testIntervalSummary_met_exactTarget() +{ + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(200.0, 200.0), PA::Met); +} + +void TstBtleHub::testIntervalSummary_met_upperBoundary() +{ + // exactly +5% → still Met + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(210.0, 200.0), PA::Met); +} + +void TstBtleHub::testIntervalSummary_met_lowerBoundary() +{ + // exactly -5% → still Met + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(190.0, 200.0), PA::Met); +} + +void TstBtleHub::testIntervalSummary_nearMiss_upperBoundary() +{ + // +7% → NearMiss + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(214.0, 200.0), PA::NearMiss); +} + +void TstBtleHub::testIntervalSummary_nearMiss_lowerBoundary() +{ + // -7% → NearMiss + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(186.0, 200.0), PA::NearMiss); +} + +void TstBtleHub::testIntervalSummary_missed_above() +{ + // +15% → Missed + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(230.0, 200.0), PA::Missed); +} + +void TstBtleHub::testIntervalSummary_missed_below() +{ + // -15% → Missed + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(170.0, 200.0), PA::Missed); +} + +void TstBtleHub::testIntervalSummary_zeroTarget_returnsMet() +{ + // no power target (e.g. HR-only or free-ride interval) → always Met + QCOMPARE(IntervalSummaryUtil::classifyPowerAdherence(0.0, 0.0), PA::Met); +} + // ───────────────────────────────────────────────────────────────────────────── QTEST_MAIN(TstBtleHub) #include "tst_btle_hub.moc" From 75c6d8ba158bd58ede014b420b762c4d318861e7 Mon Sep 17 00:00:00 2001 From: Dan Woollard Date: Sun, 19 Apr 2026 20:35:19 +0100 Subject: [PATCH 04/15] feat(#151): Add dark mode / system-adaptive theme support - Add AppTheme header-only class with Light/Dark/System modes - lightStylesheet() falls back to existing z_stylesheet-based QSS - darkStylesheet() provides comprehensive dark palette - resolveMode() uses QStyleHints::colorScheme() on Qt 6.5+ with palette-brightness fallback for older versions - apply() re-uses the original z_stylesheet content for light mode via the 'lightStylesheet' QApplication property - Add app_theme (int, default 2=System) to Account with saveAppTheme() and QSettings persistence under the 'account' group - Apply theme after login in main.cpp based on account preference - Add Theme combo box (Light/Dark/System) to DialogConfig Language tab - initLanguageTab() loads app_theme from Account - saveLanguageTab() persists and live-applies the selected theme - Add MainWindow::slotSystemThemeChanged() connected to QStyleHints::colorSchemeChanged (Qt 6.5+) for real-time OS tracking when System mode is active Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/main.cpp | 15 +++- src/model/account.cpp | 14 ++++ src/model/account.h | 3 + src/ui/apptheme.h | 178 ++++++++++++++++++++++++++++++++++++++++ src/ui/dialogconfig.cpp | 34 ++++++++ src/ui/dialogconfig.h | 3 + src/ui/mainwindow.cpp | 18 ++++ src/ui/mainwindow.h | 2 + src/ui/ui.pri | 1 + 9 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/ui/apptheme.h diff --git a/src/app/main.cpp b/src/app/main.cpp index 5210c52e..83f88383 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -13,6 +13,7 @@ #include "splashscreen.h" #include "account.h" #include "xmlutil.h" +#include "apptheme.h" #ifdef GC_HAVE_VLCQT #include "myvlcplayer.h" @@ -94,9 +95,12 @@ int main(int argc, char *argv[]) { splash.setProgress(30); #endif // Q_OS_WASM - /// App Stylesheet (hack so I can type stylesheet in designer instead of source code) + /// App Stylesheet — apply light theme as baseline; re-applied after login + /// based on the user's saved theme preference (Light / Dark / System). Z_StyleSheet styleSheetDummy; - app.setStyleSheet(styleSheetDummy.styleSheet()); + const QString lightQss = styleSheetDummy.styleSheet(); + qApp->setProperty("lightStylesheet", lightQss); + app.setStyleSheet(lightQss); // --screenshots [dir] / /screenshots [dir]: bypass login, capture UI // screenshots to [dir] (default: /screenshots), then quit. @@ -128,6 +132,13 @@ int main(int argc, char *argv[]) { } #endif // Q_OS_WASM + // Re-apply theme based on user preference now that the Account is loaded. + { + auto *account = qApp->property("Account").value(); + if (account) + AppTheme::apply(&app, static_cast(account->app_theme)); + } + #ifdef Q_OS_WASM // WASM has no persistent server login. Initialize the global Account with // safe offline/guest defaults — identical to DialogLogin::loginOffline(). diff --git a/src/model/account.cpp b/src/model/account.cpp index 1a110848..b0405d7d 100644 --- a/src/model/account.cpp +++ b/src/model/account.cpp @@ -152,6 +152,12 @@ Account::Account(QObject *parent) : QObject(parent) { sound_alert_cadence_under_target = false; sound_alert_cadence_above_target = false; + { + QSettings s; + s.beginGroup("account"); + app_theme = s.value("app_theme", 2).toInt(); // default: System + s.endGroup(); + } //-------------------------- not in DB ---------------------- isOffline = false; @@ -226,6 +232,14 @@ void Account::saveSelfloopsCredentials() CredentialStore::store("selfloops", "password", selfloops_pw); } +void Account::saveAppTheme() +{ + QSettings s; + s.beginGroup("account"); + s.setValue("app_theme", app_theme); + s.endGroup(); +} + diff --git a/src/model/account.h b/src/model/account.h index 96276fa7..763598ea 100644 --- a/src/model/account.h +++ b/src/model/account.h @@ -20,6 +20,7 @@ class Account : public QObject void saveNbSecShowInterval(int nbSec); void saveNbSecShowIntervalBefore(int nbSec); void saveIntervalsIcuCredentials(); + void saveAppTheme(); /// Encrypt and persist Strava OAuth2 tokens to QSettings. /// Call after a successful token exchange or refresh. @@ -199,6 +200,8 @@ class Account : public QObject bool sound_alert_power_above_target; bool sound_alert_cadence_under_target; bool sound_alert_cadence_above_target; + + int app_theme; ///< 0=Light, 1=Dark, 2=System (default) //---- diff --git a/src/ui/apptheme.h b/src/ui/apptheme.h new file mode 100644 index 00000000..8764674f --- /dev/null +++ b/src/ui/apptheme.h @@ -0,0 +1,178 @@ +#ifndef APPTHEME_H +#define APPTHEME_H + +#include +#include +#include +#include + +/// Manages the two built-in application stylesheets (Light / Dark) +/// and the automatic "System" mode that tracks the OS colour scheme. +/// +/// Usage: +/// AppTheme::apply(qApp, static_cast(account->app_theme)); +class AppTheme : public QObject +{ + Q_OBJECT +public: + enum Mode { Light = 0, Dark = 1, System = 2 }; + + /// Resolve System mode to the actual Light/Dark mode based on the OS. + static Mode resolveMode(Mode mode) + { + if (mode != System) + return mode; +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + return (QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark) + ? Dark : Light; +#else + const QColor bg = QGuiApplication::palette().color(QPalette::Window); + return (bg.lightness() < 128) ? Dark : Light; +#endif + } + + /// Apply the stylesheet for the given mode to the application. + static void apply(QApplication *app, Mode mode) + { + const Mode resolved = resolveMode(mode); + if (resolved == Dark) { + app->setStyleSheet(darkStylesheet()); + } else { + // Restore the original z_stylesheet-based light theme. + const QString base = qApp->property("lightStylesheet").toString(); + app->setStyleSheet(base.isEmpty() ? lightStylesheet() : base); + } + } + + /// The existing (unchanged) light stylesheet. + static QString lightStylesheet() + { + return QStringLiteral( +"QMainWindow#MainWindow {" +" background: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, stop:0 #2a2a2a, stop:1.0 #4b4b4b);" +"}" +"QLabel#label_headerMain { color: white; }" +"QWidget#widget_fancyMenu {" +" border: 1px solid #313131;" +"}" +"QWidget#tab_workout1, #tab_workout2, #tab_profile1, #tab_achiev1, #tableView_workout," +" #tab_create, #tableView_course, #tab_course, #tab_edit_course {" +" background-color: #f0f0f0;" +"}" +"QWidget#tabWidget_course, #tab_course, #tab_edit_course {" +" background-color: #f0f0f0;" +"}" +"QTableView#tableView_workout, QTableView#tableView_course {" +" gridline-color: gray;" +"}" +"QSplitter::handle {" +" background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0," +" stop:0 rgba(101,104,113,255), stop:0.5 rgba(101,104,113,235), stop:1.0 rgba(101,104,113,255));" +"}" +"QPushButton.boutonLogin {" +" font: bold; color: white; background-color: #5cb85c;" +" border: 2px solid #4cae4c; border-radius: 3px; padding: 5px; min-width: 80px;" +"}" +"QPushButton.boutonLogin:hover { background-color: #47a447; border: 2px solid #398439; }" +"QPushButton.boutonLogin:pressed { border: 3px solid #398439; }" +"QLabel.labelImageHr { image: url(:/image/icon/heart2); }" +"QLabel.labelImagePower { image: url(:/image/icon/power2); }" +"QLabel.labelImageCadence { image: url(:/image/icon/crank2); }" +"QLabel.labelImageSpeed { image: url(:/image/icon/speed); }" +"QLabel.labelImageCalories{ image: url(:/image/icon/burn); }" +"QLabel.label_clockgreen { image: url(:/image/icon/clock_green); }" +"QLabel.label_clockorange { image: url(:/image/icon/clock_orange); }" + ); + } + + /// Dark variant: darkens the content-area tabs, dialogs, and standard + /// controls while keeping the existing dark chrome untouched. + static QString darkStylesheet() + { + return QStringLiteral( +// ── Main window chrome (same as light) ────────────────────────────────── +"QMainWindow#MainWindow {" +" background: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, stop:0 #1a1a1a, stop:1.0 #2e2e2e);" +"}" +"QLabel#label_headerMain { color: white; }" +"QWidget#widget_fancyMenu { border: 1px solid #313131; }" +// ── Content tab areas ──────────────────────────────────────────────────── +"QWidget#tab_workout1, #tab_workout2, #tab_profile1, #tab_achiev1, #tableView_workout," +" #tab_create, #tableView_course, #tab_course, #tab_edit_course {" +" background-color: #242424;" +"}" +"QWidget#tabWidget_course, #tab_course, #tab_edit_course {" +" background-color: #242424;" +"}" +// ── General widget defaults ────────────────────────────────────────────── +"QWidget { background-color: #2b2b2b; color: #e0e0e0; }" +"QDialog { background-color: #2b2b2b; color: #e0e0e0; }" +"QGroupBox { color: #e0e0e0; border: 1px solid #444; border-radius: 4px; margin-top: 8px; padding-top: 4px; }" +"QGroupBox::title { subcontrol-origin: margin; left: 8px; color: #ccc; }" +"QLabel { color: #e0e0e0; background-color: transparent; }" +"QLineEdit, QTextEdit, QPlainTextEdit {" +" background-color: #3a3a3a; color: #e0e0e0; border: 1px solid #555; border-radius: 3px;" +"}" +"QLineEdit:focus, QTextEdit:focus { border: 1px solid #5a9fd4; }" +"QComboBox {" +" background-color: #3a3a3a; color: #e0e0e0; border: 1px solid #555; border-radius: 3px; padding: 2px 4px;" +"}" +"QComboBox QAbstractItemView {" +" background-color: #3a3a3a; color: #e0e0e0; selection-background-color: #4a7ab5;" +"}" +"QSpinBox {" +" background-color: #3a3a3a; color: #e0e0e0; border: 1px solid #555; border-radius: 3px;" +"}" +"QPushButton {" +" background-color: #3d3d3d; color: #e0e0e0; border: 1px solid #555; border-radius: 3px; padding: 4px 10px;" +"}" +"QPushButton:hover { background-color: #4a4a4a; }" +"QPushButton:pressed { background-color: #2a2a2a; }" +"QPushButton:disabled { color: #777; border-color: #444; }" +"QPushButton.boutonLogin {" +" font: bold; color: white; background-color: #4a8f4a;" +" border: 2px solid #3a7f3a; border-radius: 3px; padding: 5px; min-width: 80px;" +"}" +"QPushButton.boutonLogin:hover { background-color: #3a7f3a; }" +"QCheckBox { color: #e0e0e0; }" +"QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #666; border-radius: 2px; background: #3a3a3a; }" +"QCheckBox::indicator:checked { background: #5a9fd4; border-color: #4a8fc4; }" +"QTabWidget::pane { border: 1px solid #444; background: #2b2b2b; }" +"QTabBar::tab { background: #3a3a3a; color: #ccc; border: 1px solid #444; padding: 6px 12px; }" +"QTabBar::tab:selected { background: #2b2b2b; color: white; border-bottom: none; }" +"QTabBar::tab:hover { background: #444; }" +"QTableView, QListView, QTreeView {" +" background-color: #2e2e2e; alternate-background-color: #333; color: #e0e0e0;" +" gridline-color: #444; selection-background-color: #4a7ab5; selection-color: white;" +"}" +"QHeaderView::section {" +" background-color: #3a3a3a; color: #ccc; border: 1px solid #444; padding: 4px;" +"}" +"QScrollBar:vertical, QScrollBar:horizontal {" +" background: #2e2e2e; width: 10px; height: 10px; border: none;" +"}" +"QScrollBar::handle:vertical, QScrollBar::handle:horizontal {" +" background: #555; border-radius: 5px; min-height: 20px;" +"}" +"QScrollBar::handle:hover { background: #777; }" +"QScrollBar::add-line, QScrollBar::sub-line { height: 0px; width: 0px; }" +"QToolTip { background-color: #3a3a3a; color: #e0e0e0; border: 1px solid #555; }" +"QMenuBar { background-color: #2b2b2b; color: #e0e0e0; }" +"QMenuBar::item:selected { background: #3d3d3d; }" +"QMenu { background-color: #2b2b2b; color: #e0e0e0; border: 1px solid #555; }" +"QMenu::item:selected { background: #4a7ab5; }" +"QStatusBar { background-color: #2b2b2b; color: #aaa; }" +"QSplitter::handle { background: #444; }" +"QTableView#tableView_workout, QTableView#tableView_course { gridline-color: #444; }" +"QLabel.labelImageHr { image: url(:/image/icon/heart2); }" +"QLabel.labelImagePower { image: url(:/image/icon/power2); }" +"QLabel.labelImageCadence { image: url(:/image/icon/crank2); }" +"QLabel.labelImageSpeed { image: url(:/image/icon/speed); }" +"QLabel.labelImageCalories{ image: url(:/image/icon/burn); }" +"QLabel.label_clockgreen { image: url(:/image/icon/clock_green); }" +"QLabel.label_clockorange { image: url(:/image/icon/clock_orange); }" + ); + } +}; + +#endif // APPTHEME_H diff --git a/src/ui/dialogconfig.cpp b/src/ui/dialogconfig.cpp index 22a97270..8ac214ef 100644 --- a/src/ui/dialogconfig.cpp +++ b/src/ui/dialogconfig.cpp @@ -10,9 +10,12 @@ #include #include #include +#include #include "workoutdialog.h" #include "logger.h" +#include "apptheme.h" +#include "account.h" @@ -1183,6 +1186,21 @@ QWidget *DialogConfig::setupLanguageTab() grpLayout->addWidget(labelRestartNote); layout->addWidget(grp); + + // Theme group + auto *grpTheme = new QGroupBox(tr("Application Theme"), page); + auto *grpThemeLayout = new QVBoxLayout(grpTheme); + + auto *themeRow = new QHBoxLayout(); + themeRow->addWidget(new QLabel(tr("Theme:"), grpTheme)); + comboTheme = new QComboBox(grpTheme); + comboTheme->addItem(tr("Light"), 0); + comboTheme->addItem(tr("Dark"), 1); + comboTheme->addItem(tr("System (follow OS)"), 2); + themeRow->addWidget(comboTheme, 1); + grpThemeLayout->addLayout(themeRow); + + layout->addWidget(grpTheme); layout->addStretch(); connect(comboLanguage, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { @@ -1204,6 +1222,13 @@ void DialogConfig::initLanguageTab() comboLanguage->blockSignals(true); comboLanguage->setCurrentIndex(langIdx < comboLanguage->count() ? langIdx : 0); comboLanguage->blockSignals(false); + + if (comboTheme) { + auto *account = qApp->property("Account").value(); + const int theme = account ? account->app_theme : 2; + const int idx = comboTheme->findData(theme); + comboTheme->setCurrentIndex(idx >= 0 ? idx : 2); + } } void DialogConfig::saveLanguageTab() @@ -1213,6 +1238,15 @@ void DialogConfig::saveLanguageTab() s.beginGroup("language_app"); s.setValue("lang", comboLanguage->currentData().toInt()); s.endGroup(); + + if (comboTheme) { + auto *account = qApp->property("Account").value(); + if (account) { + account->app_theme = comboTheme->currentData().toInt(); + account->saveAppTheme(); + AppTheme::apply(qApp, static_cast(account->app_theme)); + } + } } // ─── Studio tab (#134) ─────────────────────────────────────────────────────── diff --git a/src/ui/dialogconfig.h b/src/ui/dialogconfig.h index 859f24cc..6014ca54 100644 --- a/src/ui/dialogconfig.h +++ b/src/ui/dialogconfig.h @@ -207,6 +207,9 @@ private : QComboBox *comboLanguage = nullptr; QLabel *labelRestartNote = nullptr; + // Theme controls (Language tab) + QComboBox *comboTheme = nullptr; + // Studio tab controls QCheckBox *checkStudioMode = nullptr; QSpinBox *spinNbRiders = nullptr; diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index 6e25fe3f..419efaa2 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include "util.h" #include "logger.h" @@ -37,6 +39,7 @@ #include "networkmonitor.h" #include "updatedialog.h" #include "versiondao.h" +#include "apptheme.h" #include #include @@ -192,6 +195,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi ui->tabWidget_workout->tabBar()->setObjectName("tabBarWorkout"); setStyleSheet(qApp->styleSheet()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + // Track OS colour-scheme changes for System theme mode. + connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, + &MainWindow::slotSystemThemeChanged); +#endif + //Load userStudio xml file to VecUserStudio XmlUtil *xmlUtil = new XmlUtil(settings->language, this); @@ -1936,6 +1945,15 @@ void MainWindow::onNetworkOnlineChanged(bool isOnline) #endif } +void MainWindow::slotSystemThemeChanged() +{ + auto *account = qApp->property("Account").value(); + if (!account) return; + // Only react when the user has chosen "System" mode. + if (account->app_theme == 2 /*System*/) + AppTheme::apply(qApp, AppTheme::System); +} + //////////////////////////////////////////////////////////////////////////////////////////////////// void MainWindow::slotTrainingPeaksRefreshFinished() diff --git a/src/ui/mainwindow.h b/src/ui/mainwindow.h index 31d91220..789b1b77 100644 --- a/src/ui/mainwindow.h +++ b/src/ui/mainwindow.h @@ -174,6 +174,8 @@ private slots: // Screenshot mode — captures a sequence of PNG files then quits the app. void screenshotNextStep(); + void slotSystemThemeChanged(); + private: void loadSettings(); void saveSettings(); diff --git a/src/ui/ui.pri b/src/ui/ui.pri index fce61b6a..e4b87f17 100644 --- a/src/ui/ui.pri +++ b/src/ui/ui.pri @@ -33,6 +33,7 @@ $$PWD/workoutdialog.h \ $$PWD/dialoginfowebview.h \ $$PWD/dialog_connection_method.h \ $$PWD/tab_intervals_icu.h \ + $$PWD/apptheme.h \ #$$PWD/main_coursepage.h FORMS += $$PWD/mainwindow.ui \ From 581829866ce96243c30317d9ac351f9c7eda39fe Mon Sep 17 00:00:00 2001 From: Dan Woollard Date: Sun, 19 Apr 2026 20:37:35 +0100 Subject: [PATCH 05/15] feat(#155): Add WASM PWA support with manifest and service worker - Add docs/app/manifest.json with PWA metadata: - name, short_name, description, start_url, scope - display: standalone, theme_color: #e05a00, background_color: #0d0d0d - Icons reference existing 512x512 main_icon.png - Add docs/app/service-worker.js: - Pre-caches shell assets (HTML, JS, manifest, icons) at install time - Cache-first strategy for same-origin GET requests - Stale-while-revalidate for HTML/JS files - Runtime caches WASM, JS, HTML, images on first fetch - Graceful fallback: missing precache entries logged but don't break install - Old caches deleted on activate - Update docs/app/index.html: - Link manifest.json via - Add theme-color, mobile-web-app-capable, Apple PWA meta tags - Register service-worker.js on window load - Add Playwright PWA tests: - manifest.json returns 200 - manifest has required fields (name, short_name, start_url, display, icons) - service-worker.js returns 200 - index.html links to manifest - index.html has theme-color meta tag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/app/index.html | 21 +++++ docs/app/manifest.json | 20 +++++ docs/app/service-worker.js | 129 +++++++++++++++++++++++++++ tests/playwright/wasm_webapp.spec.js | 39 ++++++++ 4 files changed, 209 insertions(+) create mode 100644 docs/app/manifest.json create mode 100644 docs/app/service-worker.js diff --git a/docs/app/index.html b/docs/app/index.html index 6e885268..2a8133d7 100644 --- a/docs/app/index.html +++ b/docs/app/index.html @@ -7,6 +7,12 @@ Maximum Trainer — Web App (Beta) + + + + + +