diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index 1ca40177..a3318920 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -500,10 +500,6 @@ "label": "Farbquelle", "description": "Haupteinstellungen für Noctalias Farben." }, - "dark-mode": { - "label": "Dunkler Modus", - "description": "Wechselt zu einem dunkleren Theme für einfachere Betrachtung bei Nacht." - }, "use-wallpaper-colors": { "label": "Hintergrundbild-Farben verwenden", "description": "Farbschemata aus Ihrem Hintergrundbild mit Matugen generieren. Extrahiert automatisch Farben für ein kohärentes Design." @@ -513,6 +509,19 @@ "description": "Wähle einen Farbstil für Matugen aus." } }, + "dark-mode": { + "switch": { + "label": "Dunkler Modus", + "description": "Wechselt zu einem dunkleren Theme für einfachere Betrachtung bei Nacht." + }, + "mode": { + "label": "Automatischer dunkler Modus", + "description": "Ermöglicht automatisches Wechseln zwischen dem hellen und dunklen Modus.", + "off": "Aus", + "manual": "Manuell", + "location": "Standort" + } + }, "predefined": { "section": { "label": "Vordefinierte Farbschemata", diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 50abdeca..cbad3dd8 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -500,10 +500,6 @@ "label": "Color source", "description": "Main settings for Noctalia's colors." }, - "dark-mode": { - "label": "Dark mode", - "description": "Switches to a darker theme for easier viewing at night." - }, "use-wallpaper-colors": { "label": "Use wallpaper colors", "description": "Generate color schemes from your wallpaper using Matugen. Automatically extracts colors to create a cohesive theme." @@ -513,6 +509,19 @@ "description": "Choose the color scheme generation algorithm for Matugen." } }, + "dark-mode": { + "switch": { + "label": "Dark mode", + "description": "Switches to a darker theme for easier viewing at night." + }, + "mode": { + "label": "Dark mode schedule", + "description": "Enables automatic switching between light and dark mode.", + "off": "Off", + "manual": "Manual", + "location": "Location" + } + }, "predefined": { "section": { "label": "Predefined color schemes", diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index eb79d97e..2db6da8f 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -500,10 +500,6 @@ "label": "Fuente de color", "description": "Configuración principal de los colores de Noctalia." }, - "dark-mode": { - "label": "Modo oscuro", - "description": "Cambia a un tema más oscuro para una visualización más fácil por la noche." - }, "use-wallpaper-colors": { "label": "Usar colores del fondo de pantalla", "description": "Generar esquemas de color desde tu fondo de pantalla usando Matugen. Extrae automáticamente colores para crear un tema cohesivo." @@ -513,6 +509,12 @@ "description": "Elige el algoritmo de generación de esquema de colores para Matugen." } }, + "dark-mode": { + "switch": { + "label": "Modo oscuro", + "description": "Cambia a un tema más oscuro para una visualización más fácil por la noche." + } + }, "predefined": { "section": { "label": "Esquemas de colores predefinidos", diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index 1ef4161c..8131592a 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -500,10 +500,6 @@ "label": "Source des couleurs", "description": "Paramètres principaux pour les couleurs de Noctalia." }, - "dark-mode": { - "label": "Mode sombre", - "description": "Passe à un thème plus sombre pour une visualisation plus facile la nuit." - }, "use-wallpaper-colors": { "label": "Utiliser les couleurs du fond d'écran", "description": "Générer des schémas de couleurs à partir de votre fond d'écran avec Matugen. Extrait automatiquement les couleurs pour créer un thème cohérent." @@ -513,6 +509,12 @@ "description": "Choisissez l'algorithme de génération de schéma de couleurs pour Matugen." } }, + "dark-mode": { + "switch": { + "label": "Mode sombre", + "description": "Passe à un thème plus sombre pour une visualisation plus facile la nuit." + } + }, "predefined": { "section": { "label": "Jeux de couleurs prédéfinis", diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index 2acdc07b..dd3fe7f0 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -462,10 +462,6 @@ "label": "Fonte de cor", "description": "Configurações principais para as cores do Noctalia." }, - "dark-mode": { - "label": "Modo escuro", - "description": "Muda para um tema mais escuro para facilitar a visualização à noite." - }, "use-wallpaper-colors": { "label": "Usar cores do papel de parede", "description": "Gerar esquemas de cores do seu papel de parede usando Matugen. Extrai automaticamente cores para criar um tema coeso." @@ -475,6 +471,12 @@ "description": "Escolha o algoritmo de geração de esquema de cores para Matugen." } }, + "dark-mode": { + "switch": { + "label": "Modo escuro", + "description": "Muda para um tema mais escuro para facilitar a visualização à noite." + } + }, "predefined": { "section": { "label": "Esquemas de cores predefinidos", diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index 438191cf..4738030b 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -500,10 +500,6 @@ "label": "颜色来源", "description": "Noctalia 颜色的主要设置。" }, - "dark-mode": { - "label": "深色模式", - "description": "切换到更暗的主题,便于夜间观看。" - }, "use-wallpaper-colors": { "label": "使用壁纸颜色", "description": "使用 Matugen 从壁纸生成颜色方案。自动提取颜色以创建一致的主题。" @@ -513,6 +509,12 @@ "description": "为 Matugen 选择配色方案生成算法。" } }, + "dark-mode": { + "switch": { + "label": "深色模式", + "description": "切换到更暗的主题,便于夜间观看。" + } + }, "predefined": { "section": { "label": "预定义配色方案", diff --git a/Commons/Settings.qml b/Commons/Settings.qml index a1c239e5..f98e3e3e 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -357,6 +357,9 @@ Singleton { property bool useWallpaperColors: false property string predefinedScheme: "Noctalia (default)" property bool darkMode: true + property string schedulingMode: "off" + property string manualSunrise: "06:30" + property string manualSunset: "18:30" property string matugenSchemeType: "scheme-fruit-salad" property bool generateTemplatesForPredefined: true } diff --git a/Modules/Settings/Tabs/ColorSchemeTab.qml b/Modules/Settings/Tabs/ColorSchemeTab.qml index 5909040f..7cfcf595 100644 --- a/Modules/Settings/Tabs/ColorSchemeTab.qml +++ b/Modules/Settings/Tabs/ColorSchemeTab.qml @@ -13,6 +13,24 @@ ColumnLayout { property var schemeColorsCache: ({}) property int cacheVersion: 0 // Increment to trigger UI updates + // Time dropdown options (00:00 .. 23:30) + ListModel { + id: timeOptions + } + Component.onCompleted: { + for (var h = 0; h < 24; h++) { + for (var m = 0; m < 60; m += 30) { + var hh = ("0" + h).slice(-2) + var mm = ("0" + m).slice(-2) + var key = hh + ":" + mm + timeOptions.append({ + "key": key, + "name": key + }) + } + } + } + spacing: Style.marginL // Helper function to extract scheme name from path @@ -138,8 +156,8 @@ ColumnLayout { // Dark Mode Toggle NToggle { - label: I18n.tr("settings.color-scheme.color-source.dark-mode.label") - description: I18n.tr("settings.color-scheme.color-source.dark-mode.description") + label: I18n.tr("settings.color-scheme.dark-mode.switch.label") + description: I18n.tr("settings.color-scheme.dark-mode.switch.description") checked: Settings.data.colorSchemes.darkMode enabled: true onToggled: checked => { @@ -148,6 +166,77 @@ ColumnLayout { } } + NComboBox { + label: I18n.tr("settings.color-scheme.dark-mode.mode.label") + description: I18n.tr("settings.color-scheme.dark-mode.mode.description") + + model: [{ + "name": I18n.tr("settings.color-scheme.dark-mode.mode.off"), + "key": "off" + }, { + "name": I18n.tr("settings.color-scheme.dark-mode.mode.manual"), + "key": "manual" + }, { + "name": I18n.tr("settings.color-scheme.dark-mode.mode.location"), + "key": "location" + }] + + currentKey: Settings.data.colorSchemes.schedulingMode + + onSelected: key => { + Settings.data.colorSchemes.schedulingMode = key + AppThemeService.generate() + } + } + + // Manual scheduling + ColumnLayout { + spacing: Style.marginS + visible: Settings.data.colorSchemes.schedulingMode === "manual" + + NLabel { + label: I18n.tr("settings.display.night-light.manual-schedule.label") + description: I18n.tr("settings.display.night-light.manual-schedule.description") + } + + RowLayout { + Layout.fillWidth: false + spacing: Style.marginS + + NText { + text: I18n.tr("settings.display.night-light.manual-schedule.sunrise") + pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + } + + NComboBox { + model: timeOptions + currentKey: Settings.data.colorSchemes.manualSunrise + placeholder: I18n.tr("settings.display.night-light.manual-schedule.select-start") + onSelected: key => Settings.data.colorSchemes.manualSunrise = key + minimumWidth: 120 + } + + Item { + Layout.preferredWidth: 20 + } + + NText { + text: I18n.tr("settings.display.night-light.manual-schedule.sunset") + pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + } + + NComboBox { + model: timeOptions + currentKey: Settings.data.colorSchemes.manualSunset + placeholder: I18n.tr("settings.display.night-light.manual-schedule.select-stop") + onSelected: key => Settings.data.colorSchemes.manualSunset = key + minimumWidth: 120 + } + } + } + // Use Wallpaper Colors NToggle { label: I18n.tr("settings.color-scheme.color-source.use-wallpaper-colors.label") diff --git a/Services/DarkModeService.qml b/Services/DarkModeService.qml new file mode 100644 index 00000000..ad3eef00 --- /dev/null +++ b/Services/DarkModeService.qml @@ -0,0 +1,169 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Commons +import qs.Services + +Singleton { + id: root + + property bool initComplete: false + property bool nextDarkModeState: false + + Connections { + target: LocationService.data + enabled: Settings.data.colorSchemes.schedulingMode == "location" + function onWeatherChanged() { + if (LocationService.data.weather !== null) { + const changes = root.collectWeatherChanges(LocationService.data.weather) + if (!root.initComplete) { + root.initComplete = true + root.applyCurrentMode(changes) + } + root.scheduleNextMode(changes) + } + } + } + + Connections { + target: Settings.data.colorSchemes + enabled: Settings.data.colorSchemes.schedulingMode == "manual" + function onManualSunriseChanged() { + const changes = root.collectManualChanges() + root.applyCurrentMode(changes) + root.scheduleNextMode(changes) + } + function onManualSunsetChanged() { + const changes = root.collectManualChanges() + root.applyCurrentMode(changes) + root.scheduleNextMode(changes) + } + } + + Connections { + target: Settings.data.colorSchemes + function onSchedulingModeChanged() { + root.init() + } + } + + Timer { + id: timer + onTriggered: { + Settings.data.colorSchemes.darkMode = root.nextDarkModeState + if (LocationService.data.weather !== null) { + const changes = root.collectWeatherChanges(LocationService.data.weather) + root.scheduleNextMode(changes) + } + } + } + + function init() { + Logger.i("DarkModeService", "Service started") + + if (Settings.data.colorSchemes.schedulingMode == "manual") { + const changes = collectManualChanges() + initComplete = true + applyCurrentMode(changes) + scheduleNextMode(changes) + } + + if (Settings.data.colorSchemes.schedulingMode == "location" && LocationService.data.weather) { + const changes = collectWeatherChanges(LocationService.data.weather) + initComplete = true + applyCurrentMode(changes) + scheduleNextMode(changes) + } + } + + function parseTime(timeString) { + const parts = timeString.split(":").map(Number) + return { + "hour": parts[0], + "minute": parts[1] + } + } + + function collectManualChanges() { + const sunriseTime = parseTime(Settings.data.colorSchemes.manualSunrise) + const sunsetTime = parseTime(Settings.data.colorSchemes.manualSunset) + + const now = new Date() + const year = now.getFullYear() + const month = now.getMonth() + const day = now.getDate() + + const yesterdaysSunset = new Date(year, month, day - 1, sunsetTime.hour, sunsetTime.minute) + const todaysSunrise = new Date(year, month, day, sunriseTime.hour, sunriseTime.minute) + const todaysSunset = new Date(year, month, day, sunsetTime.hour, sunsetTime.minute) + const tomorrowsSunrise = new Date(year, month, day + 1, sunriseTime.hour, sunriseTime.minute) + + return [{ + "time": yesterdaysSunset.getTime(), + "darkMode": true + }, { + "time": todaysSunrise.getTime(), + "darkMode": false + }, { + "time": todaysSunset.getTime(), + "darkMode": true + }, { + "time": tomorrowsSunrise.getTime(), + "darkMode": false + }] + } + + function collectWeatherChanges(weather) { + const changes = [] + + if (Date.now() < Date.parse(weather.daily.sunrise[0])) { + // The sun has not risen yet + changes.push({ + "time": Date.now() - 1, + "darkMode": true + }) + } + + for (var i = 0; i < weather.daily.sunrise.length; i++) { + changes.push({ + "time": Date.parse(weather.daily.sunrise[i]), + "darkMode": false + }) + changes.push({ + "time": Date.parse(weather.daily.sunset[i]), + "darkMode": true + }) + } + + return changes + } + + function applyCurrentMode(changes) { + const now = Date.now() + + // changes.findLast(change => change.time < now) // not available in QML... + let lastChange = null + for (var i = 0; i < changes.length; i++) { + if (changes[i].time < now) { + lastChange = changes[i] + } + } + + if (lastChange) { + Settings.data.colorSchemes.darkMode = lastChange.darkMode + Logger.d("DarkModeService", `Reset: darkmode=${lastChange.darkMode}`) + } + } + + function scheduleNextMode(changes) { + const now = Date.now() + const nextChange = changes.find(change => change.time > now) + if (nextChange) { + root.nextDarkModeState = nextChange.darkMode + timer.interval = nextChange.time - now + timer.restart() + Logger.d("DarkModeService", `Scheduled: darkmode=${nextChange.darkMode} in ${timer.interval} ms`) + } + } +} diff --git a/Services/LocationService.qml b/Services/LocationService.qml index 0886e142..7c2df5d4 100644 --- a/Services/LocationService.qml +++ b/Services/LocationService.qml @@ -69,7 +69,7 @@ Singleton { Timer { id: updateTimer interval: 20 * 1000 - running: Settings.data.location.weatherEnabled + running: Settings.data.location.weatherEnabled || Settings.data.colorSchemes.schedulingMode == "location" repeat: true onTriggered: { updateWeather() @@ -190,7 +190,7 @@ Singleton { // -------------------------------- function _fetchWeather(latitude, longitude, errorCallback) { Logger.d("Location", "Fetching weather from api.open-meteo.com") - var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto" + var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode,sunset,sunrise&timezone=auto" var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { diff --git a/shell.qml b/shell.qml index e3a41352..16227fd1 100644 --- a/shell.qml +++ b/shell.qml @@ -86,6 +86,7 @@ ShellRoot { BarWidgetRegistry.init() LocationService.init() NightLightService.apply() + DarkModeService.init() FontService.init() HooksService.init() BluetoothService.init()