diff --git a/.gitignore b/.gitignore index 0d945cd..73f211a 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,9 @@ Icon *.dmg build test* +!tests/ +!tests/*.cpp + +# AW +.aw/ +CLAUDE.md diff --git a/CMakeLists.txt b/CMakeLists.txt index b3b55f4..384b3dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,8 @@ project(Clouck) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(CTest) + # Find Qt6 find_package(Qt6 REQUIRED COMPONENTS Core Widgets) @@ -23,12 +25,14 @@ qt6_standard_project_setup() # Sources set(SOURCES main.cpp + autostart_manager.cpp clock_widget.cpp config_manager.cpp window_helper.cpp ) set(HEADERS + autostart_manager.h clock_widget.h config_manager.h window_helper.h @@ -73,3 +77,27 @@ install(TARGETS Clouck RUNTIME DESTINATION bin BUNDLE DESTINATION bin ) + +if(BUILD_TESTING) + find_package(Qt6 REQUIRED COMPONENTS Test) + + qt6_add_executable(ConfigManagerTest + tests/config_manager_test.cpp + config_manager.cpp + config_manager.h + ) + target_include_directories(ConfigManagerTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(ConfigManagerTest PRIVATE Qt6::Core Qt6::Gui Qt6::Test) + + add_test(NAME ConfigManagerTest COMMAND ConfigManagerTest) + + qt6_add_executable(AutoStartManagerTest + tests/autostart_manager_test.cpp + autostart_manager.cpp + autostart_manager.h + ) + target_include_directories(AutoStartManagerTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(AutoStartManagerTest PRIVATE Qt6::Core Qt6::Test) + + add_test(NAME AutoStartManagerTest COMMAND AutoStartManagerTest) +endif() diff --git a/autostart_manager.cpp b/autostart_manager.cpp new file mode 100644 index 0000000..e870327 --- /dev/null +++ b/autostart_manager.cpp @@ -0,0 +1,79 @@ +#include "autostart_manager.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString launchAgentLabel = QStringLiteral("com.clouck.clock"); +} + +bool AutoStartManager::enableForCurrentApplication() +{ +#ifdef Q_OS_MAC + const QString agentPath = launchAgentPath(); + if (agentPath.isEmpty()) + { + return false; + } + + const QFileInfo agentFileInfo(agentPath); + if (!QDir().mkpath(agentFileInfo.absolutePath())) + { + return false; + } + + QSaveFile agentFile(agentPath); + if (!agentFile.open(QIODevice::WriteOnly | QIODevice::Text)) + { + return false; + } + + agentFile.write(launchAgentContents(QCoreApplication::applicationFilePath()).toUtf8()); + return agentFile.commit(); +#else + return false; +#endif +} + +QString AutoStartManager::launchAgentContents(const QString &executablePath) +{ + QString contents; + QXmlStreamWriter writer(&contents); + writer.setAutoFormatting(true); + writer.writeStartDocument(); + writer.writeDTD(""); + writer.writeStartElement("plist"); + writer.writeAttribute("version", "1.0"); + writer.writeStartElement("dict"); + + writer.writeTextElement("key", "Label"); + writer.writeTextElement("string", launchAgentLabel); + writer.writeTextElement("key", "ProgramArguments"); + writer.writeStartElement("array"); + writer.writeTextElement("string", executablePath); + writer.writeEndElement(); + writer.writeTextElement("key", "RunAtLoad"); + writer.writeEmptyElement("true"); + + writer.writeEndElement(); + writer.writeEndElement(); + writer.writeEndDocument(); + + return contents; +} + +QString AutoStartManager::launchAgentPath() +{ + const QString homePath = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); + if (homePath.isEmpty()) + { + return QString(); + } + + return QDir(homePath).filePath("Library/LaunchAgents/" + launchAgentLabel + ".plist"); +} diff --git a/autostart_manager.h b/autostart_manager.h new file mode 100644 index 0000000..f0ab9ed --- /dev/null +++ b/autostart_manager.h @@ -0,0 +1,16 @@ +#ifndef AUTOSTART_MANAGER_H +#define AUTOSTART_MANAGER_H + +#include + +class AutoStartManager +{ +public: + static bool enableForCurrentApplication(); + static QString launchAgentContents(const QString &executablePath); + +private: + static QString launchAgentPath(); +}; + +#endif // AUTOSTART_MANAGER_H diff --git a/clock_widget.cpp b/clock_widget.cpp index 94e2bd0..51392fd 100644 --- a/clock_widget.cpp +++ b/clock_widget.cpp @@ -2,13 +2,14 @@ #include "config_manager.h" #include "window_helper.h" #include -#include +#include #include #include #include #include #include #include +#include ClockWidget::ClockWidget(QWidget *parent) : QWidget(parent), m_dragging(false), m_resizing(false), m_resizeBorder(5) @@ -48,11 +49,14 @@ ClockWidget::~ClockWidget() void ClockWidget::setupUI() { - m_timeLabel = new QLabel(this); - m_timeLabel->setAlignment(Qt::AlignCenter); + m_primaryTimeLabel = new QLabel(this); + m_secondaryTimeLabel = new QLabel(this); + m_primaryTimeLabel->setAlignment(Qt::AlignCenter); + m_secondaryTimeLabel->setAlignment(Qt::AlignCenter); QVBoxLayout *layout = new QVBoxLayout(this); - layout->addWidget(m_timeLabel); + layout->addWidget(m_primaryTimeLabel); + layout->addWidget(m_secondaryTimeLabel); layout->setContentsMargins(10, 10, 10, 10); } @@ -74,6 +78,12 @@ void ClockWidget::createMenu() QAction *fontSizeAction = new QAction("Set Font Size", this); connect(fontSizeAction, &QAction::triggered, this, &ClockWidget::changeFontSize); + QAction *primaryTimeZoneAction = new QAction("Set First Time Zone", this); + connect(primaryTimeZoneAction, &QAction::triggered, this, &ClockWidget::changePrimaryTimeZone); + + QAction *secondaryTimeZoneAction = new QAction("Set Second Time Zone", this); + connect(secondaryTimeZoneAction, &QAction::triggered, this, &ClockWidget::changeSecondaryTimeZone); + QAction *resetAction = new QAction("Reset Settings", this); connect(resetAction, &QAction::triggered, this, &ClockWidget::resetSettings); @@ -86,6 +96,9 @@ void ClockWidget::createMenu() m_contextMenu->addAction(bgColorAction); m_contextMenu->addAction(fontSizeAction); m_contextMenu->addSeparator(); + m_contextMenu->addAction(primaryTimeZoneAction); + m_contextMenu->addAction(secondaryTimeZoneAction); + m_contextMenu->addSeparator(); m_contextMenu->addAction(resetAction); m_contextMenu->addAction(quitAction); } @@ -179,8 +192,8 @@ void ClockWidget::contextMenuEvent(QContextMenuEvent *event) void ClockWidget::updateTime() { - QString currentTime = QTime::currentTime().toString("HH:mm:ss"); - m_timeLabel->setText(currentTime); + m_primaryTimeLabel->setText(formattedTime(m_primaryTimeZoneId)); + m_secondaryTimeLabel->setText(formattedTime(m_secondaryTimeZoneId)); } void ClockWidget::toggleAlwaysOnTop() @@ -240,6 +253,16 @@ void ClockWidget::changeFontSize() } } +void ClockWidget::changePrimaryTimeZone() +{ + changeTimeZone(true); +} + +void ClockWidget::changeSecondaryTimeZone() +{ + changeTimeZone(false); +} + void ClockWidget::resetSettings() { int ret = QMessageBox::question(this, "Reset Settings", "Are you sure you want to reset all settings?"); @@ -304,6 +327,8 @@ void ClockWidget::loadSettings() m_backgroundColor = m_configManager->backgroundColor(); m_fontSize = m_configManager->fontSize(); m_alwaysOnTop = m_configManager->alwaysOnTop(); + m_primaryTimeZoneId = m_configManager->primaryTimeZoneId(); + m_secondaryTimeZoneId = m_configManager->secondaryTimeZoneId(); // Restore window position and size QPoint windowPos = m_configManager->windowPosition(); @@ -344,6 +369,8 @@ void ClockWidget::saveSettings() m_configManager->setAlwaysOnTop(m_alwaysOnTop); m_configManager->setWindowPosition(pos()); m_configManager->setWindowSize(size()); + m_configManager->setPrimaryTimeZoneId(m_primaryTimeZoneId); + m_configManager->setSecondaryTimeZoneId(m_secondaryTimeZoneId); // Save to XML file if (!m_configManager->saveSettings()) @@ -357,6 +384,7 @@ void ClockWidget::updateStyleSheet() QString styleSheet = QString( "QLabel { " "color: %1; " + "font-family: 'SF Pro Rounded'; " "font-size: %2pt; " "font-weight: bold; " "background-color: %3; " @@ -367,6 +395,65 @@ void ClockWidget::updateStyleSheet() QString::number(m_fontSize), m_backgroundColor.name(QColor::HexArgb)); - m_timeLabel->setStyleSheet(styleSheet); + m_primaryTimeLabel->setStyleSheet(styleSheet); + m_secondaryTimeLabel->setStyleSheet(styleSheet); setStyleSheet(QString("background-color: transparent;")); } + +void ClockWidget::changeTimeZone(bool primaryClock) +{ + QStringList timeZoneIds; + for (const QByteArray &timeZoneId : QTimeZone::availableTimeZoneIds()) + { + timeZoneIds.append(QString::fromUtf8(timeZoneId)); + } + + const QString currentTimeZoneId = primaryClock ? m_primaryTimeZoneId : m_secondaryTimeZoneId; + const int currentIndex = timeZoneIds.indexOf(currentTimeZoneId); + bool accepted = false; + const QString selectedTimeZoneId = QInputDialog::getItem( + this, + primaryClock ? "Set First Time Zone" : "Set Second Time Zone", + "Time Zone:", + timeZoneIds, + currentIndex < 0 ? 0 : currentIndex, + false, + &accepted); + + if (!accepted) + { + return; + } + + if (primaryClock) + { + m_primaryTimeZoneId = selectedTimeZoneId; + m_configManager->setPrimaryTimeZoneId(selectedTimeZoneId); + } + else + { + m_secondaryTimeZoneId = selectedTimeZoneId; + m_configManager->setSecondaryTimeZoneId(selectedTimeZoneId); + } + + if (!m_configManager->saveSettings()) + { + qDebug() << "Failed to save time zone settings to config.xml"; + } + updateTime(); +} + +QString ClockWidget::formattedTime(const QString &timeZoneId) const +{ + const QTimeZone timeZone(timeZoneId.toUtf8()); + if (!timeZone.isValid()) + { + qWarning() << "Invalid time zone configured:" << timeZoneId; + return QString("Invalid time zone\n%1").arg(timeZoneId); + } + + const QDateTime timeInZone = QDateTime::currentDateTimeUtc().toTimeZone(timeZone); + const QString cityName = QString::fromUtf8(timeZone.id()).section('/', -1).replace('_', ' '); + return QString("%1\n%2") + .arg(timeInZone.time().toString("HH:mm"), cityName); +} diff --git a/clock_widget.h b/clock_widget.h index db11cef..afac2bd 100644 --- a/clock_widget.h +++ b/clock_widget.h @@ -31,6 +31,8 @@ private slots: void changeFontColor(); void changeBackgroundColor(); void changeFontSize(); + void changePrimaryTimeZone(); + void changeSecondaryTimeZone(); void resetSettings(); void quitApplication(); @@ -40,8 +42,11 @@ private: void loadSettings(); void saveSettings(); void updateStyleSheet(); + void changeTimeZone(bool primaryClock); + QString formattedTime(const QString &timeZoneId) const; - QLabel *m_timeLabel; + QLabel *m_primaryTimeLabel; + QLabel *m_secondaryTimeLabel; QTimer *m_timer; QMenu *m_contextMenu; @@ -61,6 +66,8 @@ private: QColor m_backgroundColor; int m_fontSize; bool m_alwaysOnTop; + QString m_primaryTimeZoneId; + QString m_secondaryTimeZoneId; // Helper methods bool isInResizeArea(const QPoint &pos) const; diff --git a/config.xml b/config.xml index 06e7a33..b58996c 100644 --- a/config.xml +++ b/config.xml @@ -1,9 +1,11 @@ - #ffffffff - #96000000 + #ff00ff00 + #00000000 24 true 1236,784 - 137,59 + 240,160 + Europe/Bucharest + America/Los_Angeles diff --git a/config_manager.cpp b/config_manager.cpp index d21086d..6ccf54f 100644 --- a/config_manager.cpp +++ b/config_manager.cpp @@ -1,6 +1,10 @@ #include "config_manager.h" +#include #include +#include #include +#include +#include ConfigManager::ConfigManager(QObject *parent) : QObject(parent) @@ -8,9 +12,27 @@ ConfigManager::ConfigManager(QObject *parent) setDefaultValues(); } +QString ConfigManager::defaultSettingsPath() +{ + const QString configDirectory = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (configDirectory.isEmpty()) + { + qWarning() << "No application configuration directory is available"; + return QString(); + } + + return QDir(configDirectory).filePath("config.xml"); +} + bool ConfigManager::loadSettings(const QString &filename) { - QFile file(filename); + const QString settingsPath = filename.isEmpty() ? defaultSettingsPath() : filename; + if (settingsPath.isEmpty()) + { + return false; + } + + QFile file(settingsPath); if (!file.exists()) { qDebug() << "Config file does not exist, using defaults"; @@ -77,6 +99,14 @@ bool ConfigManager::loadSettings(const QString &filename) m_windowSize = QSize(width, height); } } + else if (name == "PrimaryTimeZone") + { + m_primaryTimeZoneId = text; + } + else if (name == "SecondaryTimeZone") + { + m_secondaryTimeZoneId = text; + } else { reader.skipCurrentElement(); @@ -96,7 +126,20 @@ bool ConfigManager::loadSettings(const QString &filename) bool ConfigManager::saveSettings(const QString &filename) { - QFile file(filename); + const QString settingsPath = filename.isEmpty() ? defaultSettingsPath() : filename; + if (settingsPath.isEmpty()) + { + return false; + } + + const QFileInfo fileInfo(settingsPath); + if (!QDir().mkpath(fileInfo.absolutePath())) + { + qDebug() << "Cannot create configuration directory:" << fileInfo.absolutePath(); + return false; + } + + QFile file(settingsPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { qDebug() << "Cannot open config file for writing:" << file.errorString(); @@ -134,6 +177,9 @@ bool ConfigManager::saveSettings(const QString &filename) .arg(m_windowSize.height()); writer.writeTextElement("WindowSize", sizeStr); + writer.writeTextElement("PrimaryTimeZone", m_primaryTimeZoneId); + writer.writeTextElement("SecondaryTimeZone", m_secondaryTimeZoneId); + // Close root element writer.writeEndElement(); @@ -150,13 +196,14 @@ void ConfigManager::resetToDefaults() bool ConfigManager::configExists(const QString &filename) const { - return QFile::exists(filename); + const QString settingsPath = filename.isEmpty() ? defaultSettingsPath() : filename; + return !settingsPath.isEmpty() && QFile::exists(settingsPath); } void ConfigManager::setDefaultValues() { - m_fontColor = QColor(255, 255, 255); // White - m_backgroundColor = QColor(0, 0, 0, 150); // Semi-transparent black + m_fontColor = QColor(0, 255, 0); + m_backgroundColor = QColor(0, 0, 0, 0); m_fontSize = 24; m_alwaysOnTop = true; @@ -164,5 +211,7 @@ void ConfigManager::setDefaultValues() // Position will be set properly when widget is shown for the first time m_windowPosition = QPoint(-1, -1); // Special value indicating "not set yet" - m_windowSize = QSize(200, 80); // Default clock size + m_windowSize = QSize(240, 160); // Default clock size + m_primaryTimeZoneId = QString::fromUtf8(QTimeZone::systemTimeZoneId()); + m_secondaryTimeZoneId = QStringLiteral("America/Los_Angeles"); } diff --git a/config_manager.h b/config_manager.h index c89338d..7e2a654 100644 --- a/config_manager.h +++ b/config_manager.h @@ -16,11 +16,13 @@ class ConfigManager : public QObject public: explicit ConfigManager(QObject *parent = nullptr); + static QString defaultSettingsPath(); + // Load settings from XML file - bool loadSettings(const QString &filename = "config.xml"); + bool loadSettings(const QString &filename = QString()); // Save settings to XML file - bool saveSettings(const QString &filename = "config.xml"); + bool saveSettings(const QString &filename = QString()); // Getters QColor fontColor() const { return m_fontColor; } @@ -29,6 +31,8 @@ public: bool alwaysOnTop() const { return m_alwaysOnTop; } QPoint windowPosition() const { return m_windowPosition; } QSize windowSize() const { return m_windowSize; } + QString primaryTimeZoneId() const { return m_primaryTimeZoneId; } + QString secondaryTimeZoneId() const { return m_secondaryTimeZoneId; } // Setters void setFontColor(const QColor &color) { m_fontColor = color; } @@ -37,12 +41,14 @@ public: void setAlwaysOnTop(bool onTop) { m_alwaysOnTop = onTop; } void setWindowPosition(const QPoint &pos) { m_windowPosition = pos; } void setWindowSize(const QSize &size) { m_windowSize = size; } + void setPrimaryTimeZoneId(const QString &timeZoneId) { m_primaryTimeZoneId = timeZoneId; } + void setSecondaryTimeZoneId(const QString &timeZoneId) { m_secondaryTimeZoneId = timeZoneId; } // Reset to default values void resetToDefaults(); // Check if config file exists - bool configExists(const QString &filename = "config.xml") const; + bool configExists(const QString &filename = QString()) const; private: // Default values @@ -55,6 +61,8 @@ private: bool m_alwaysOnTop; QPoint m_windowPosition; QSize m_windowSize; + QString m_primaryTimeZoneId; + QString m_secondaryTimeZoneId; }; #endif // CONFIG_MANAGER_H diff --git a/main.cpp b/main.cpp index 9ab8e54..70c8175 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,45 @@ #include +#include +#include +#include +#include +#include +#include "autostart_manager.h" #include "clock_widget.h" +namespace +{ +// Standard macOS menu bar icon extent (points). Matches surrounding icons. +constexpr int trayIconExtent = 16; + +QIcon createTrayIcon(const QColor &color) +{ + QPixmap pixmap(trayIconExtent, trayIconExtent); + pixmap.setDevicePixelRatio(qApp->devicePixelRatio()); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(color, 1.5)); + painter.drawEllipse(QPointF(8, 8), 5.5, 5.5); + painter.drawLine(QPointF(8, 4.5), QPointF(8, 8)); + painter.drawLine(QPointF(8, 8), QPointF(10.5, 9.5)); + + QIcon icon(pixmap); +#ifdef Q_OS_MAC + // Template image: the system tints it black/white to match the + // active appearance, like native menu bar icons. + icon.setIsMask(true); +#endif + return icon; +} + +QColor trayIconColorForScheme(Qt::ColorScheme scheme) +{ + return scheme == Qt::ColorScheme::Dark ? QColor(Qt::white) : QColor(Qt::black); +} +} + int main(int argc, char *argv[]) { QApplication app(argc, argv); @@ -12,5 +51,46 @@ int main(int argc, char *argv[]) ClockWidget clock; clock.show(); + AutoStartManager::enableForCurrentApplication(); + + const QStyleHints *styleHints = app.styleHints(); + QSystemTrayIcon trayIcon(createTrayIcon(trayIconColorForScheme(styleHints->colorScheme())), &app); + trayIcon.setToolTip("Clouck"); + + QObject::connect(styleHints, &QStyleHints::colorSchemeChanged, &trayIcon, + [&trayIcon](Qt::ColorScheme scheme) { + trayIcon.setIcon(createTrayIcon(trayIconColorForScheme(scheme))); + }); + + QMenu trayMenu; + QAction *toggleClockAction = trayMenu.addAction("Turn Clock Off"); + QAction *quitAction = trayMenu.addAction("Quit"); + + const auto toggleClock = [&clock, toggleClockAction]() { + if (clock.isVisible()) + { + clock.hide(); + toggleClockAction->setText("Turn Clock On"); + return; + } + + clock.show(); + clock.raise(); + toggleClockAction->setText("Turn Clock Off"); + }; + + QObject::connect(toggleClockAction, &QAction::triggered, &app, toggleClock); + QObject::connect(quitAction, &QAction::triggered, &app, &QApplication::quit); + QObject::connect(&trayIcon, &QSystemTrayIcon::activated, &app, + [toggleClock](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger) + { + toggleClock(); + } + }); + + trayIcon.setContextMenu(&trayMenu); + trayIcon.show(); + return app.exec(); } diff --git a/tests/autostart_manager_test.cpp b/tests/autostart_manager_test.cpp new file mode 100644 index 0000000..55eda3f --- /dev/null +++ b/tests/autostart_manager_test.cpp @@ -0,0 +1,26 @@ +#include "autostart_manager.h" + +#include + +class AutoStartManagerTest : public QObject +{ + Q_OBJECT + +private slots: + void createsRunAtLoadLaunchAgent(); +}; + +void AutoStartManagerTest::createsRunAtLoadLaunchAgent() +{ + const QString executablePath = "/Applications/Clouck.app/Contents/MacOS/Clouck"; + const QString contents = AutoStartManager::launchAgentContents(executablePath); + + QVERIFY(contents.contains("ProgramArguments")); + QVERIFY(contents.contains(executablePath)); + QVERIFY(contents.contains("RunAtLoad")); + QVERIFY(contents.contains("")); +} + +QTEST_APPLESS_MAIN(AutoStartManagerTest) + +#include "autostart_manager_test.moc" diff --git a/tests/config_manager_test.cpp b/tests/config_manager_test.cpp new file mode 100644 index 0000000..fec1b99 --- /dev/null +++ b/tests/config_manager_test.cpp @@ -0,0 +1,40 @@ +#include "config_manager.h" + +#include +#include +#include + +class ConfigManagerTest : public QObject +{ + Q_OBJECT + +private slots: + void roundTripsBothClockTimeZones(); + void usesAnAbsoluteDefaultSettingsPath(); +}; + +void ConfigManagerTest::roundTripsBothClockTimeZones() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + const QString filename = directory.filePath("config.xml"); + ConfigManager writer; + writer.setPrimaryTimeZoneId("Europe/Bucharest"); + writer.setSecondaryTimeZoneId("America/Los_Angeles"); + QVERIFY(writer.saveSettings(filename)); + + ConfigManager reader; + QVERIFY(reader.loadSettings(filename)); + QCOMPARE(reader.primaryTimeZoneId(), QString("Europe/Bucharest")); + QCOMPARE(reader.secondaryTimeZoneId(), QString("America/Los_Angeles")); +} + +void ConfigManagerTest::usesAnAbsoluteDefaultSettingsPath() +{ + QVERIFY(QDir::isAbsolutePath(ConfigManager::defaultSettingsPath())); +} + +QTEST_APPLESS_MAIN(ConfigManagerTest) + +#include "config_manager_test.moc"