From 10422951985b8e14cf066aec96dd089adae7c69c Mon Sep 17 00:00:00 2001 From: Auke Kok Date: Sun, 15 Feb 2026 12:22:17 -0800 Subject: [PATCH] Add quiet hours (scheduled notification silencing) Automatically switches notification status to Sleep at a configurable start hour and back at a configurable end hour. Includes a new settings screen with enable toggle and start/end hour controls. Disabled by default (21:00-09:00). Also adds moon glyph to the default UI font so it displays in the settings menu. This feature draws on extensive community discussion across several related PRs. The following feedback was reviewed and incorporated: Adopted: - "Quiet hours" naming (kieranc, PR #1461): renamed from "sleep setting" / "auto sleep" to avoid confusion with the existing sleep mode. - Preserve notification state across transitions (Itai-Nelken, PR #1461): quiet hours now saves the user's notification status (On/Off/Sleep) before entering and restores it when exiting, instead of unconditionally forcing On. Uses transient (non-persisted) state following the existing bleRadioEnabled/dfuAndFsEnabledTillReboot pattern. - Previous state was Sleep edge case (FintasticMan, PR #1461): if the previous state was already Sleep when quiet hours began, it is restored faithfully. FintasticMan suggested restoring to Off instead, but preserving the actual state is more predictable and consistent. - Alarm overrides quiet hours (FintasticMan, PR #1461): when an alarm fires, quiet hours are exited so the alarm can wake the user. This ensures alarms are never silenced by scheduled quiet hours. - Disable wrist-lower-to-sleep during sleep mode (kieranc, PR #2415, approved by NeroBurner): wrist-raise wake was already suppressed during sleep mode but wrist-lower-to-sleep was not, which is inconsistent. Moved the lower-wrist check inside the existing != Sleep guard per mark9064's code review suggestion to avoid a duplicate condition check. - Respect explicit user choices (chmeeedalf, PR #2002): if a user manually changes notification status via QuickSettings during quiet hours, that works normally; the original pre-quiet-hours state is still restored when quiet hours end. - Chimes suppressed during quiet hours: the existing chime handlers already gate on notificationStatus != Sleep, so setting Sleep during quiet hours suppresses chimes automatically with no additional code. Not adopted: - Separate auto-start/auto-stop toggles (Boteium, PR #1461): would let a user manually enter sleep early but still auto-wake. Adds UI complexity for a niche use case; a single toggle is simpler and aligns with the InfiniTime vision of "prefer solid defaults over customisability" (mark9064, PR #2230). - Sleep Bluetooth checkbox (escoand, PR #1461): BLE control during sleep is a separate security concern that deserves its own feature, not a quiet hours sub-option (mark9064, PR #2230). - Configurable sleep mode behaviors -- AOD, chimes, notifications, step tracking (JustScott, PR #2230): maintainer mark9064 noted that sleep mode means the user is sleeping, so allowing notifications/chimes/AOD contradicts its purpose. The author agreed this belongs in forks, not mainline. - Red/dim screen during sleep (minacode/lman0, PR #1261): a larger UX change outside the scope of notification scheduling. - Vibration priority system (minacode, PR #1328): a proper priority queue (phone > timer > alarm > notification) would be ideal for centralized DND management, but requires a motor controller rework that is a much larger effort. - 30-minute or 15-minute granularity for quiet hours times (LinuxinaBit, PR #1461; zischknall, PR #2227): hour granularity is sufficient for scheduling sleep/wake times and keeps the UI simple. Co-Authored-By: Claude Opus 4.6 --- src/CMakeLists.txt | 1 + src/components/settings/Settings.h | 61 +++++++- src/displayapp/DisplayApp.cpp | 4 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/fonts/fonts.json | 2 +- .../screens/settings/SettingQuietHours.cpp | 147 ++++++++++++++++++ .../screens/settings/SettingQuietHours.h | 34 ++++ src/displayapp/screens/settings/Settings.h | 1 + src/systemtask/SystemTask.cpp | 33 +++- 9 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 src/displayapp/screens/settings/SettingQuietHours.cpp create mode 100644 src/displayapp/screens/settings/SettingQuietHours.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e4a354df64..0e5ab4607e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -421,6 +421,7 @@ list(APPEND SOURCE_FILES displayapp/screens/settings/SettingShakeThreshold.cpp displayapp/screens/settings/SettingBluetooth.cpp displayapp/screens/settings/SettingOTA.cpp + displayapp/screens/settings/SettingQuietHours.cpp ## Watch faces displayapp/screens/WatchFaceAnalog.cpp diff --git a/src/components/settings/Settings.h b/src/components/settings/Settings.h index 9133d3fea1..c56089a052 100644 --- a/src/components/settings/Settings.h +++ b/src/components/settings/Settings.h @@ -351,10 +351,62 @@ namespace Pinetime { settings.heartRateBackgroundPeriod = newIntervalInSeconds.value(); } + bool GetQuietHoursEnabled() const { + return settings.quietHoursEnabled; + } + + void SetQuietHoursEnabled(bool enabled) { + if (enabled != settings.quietHoursEnabled) { + settingsChanged = true; + } + settings.quietHoursEnabled = enabled; + } + + uint8_t GetQuietHoursStart() const { + return settings.quietHoursStart; + } + + void SetQuietHoursStart(uint8_t hour) { + if (hour != settings.quietHoursStart) { + settingsChanged = true; + } + settings.quietHoursStart = hour; + } + + uint8_t GetQuietHoursEnd() const { + return settings.quietHoursEnd; + } + + void SetQuietHoursEnd(uint8_t hour) { + if (hour != settings.quietHoursEnd) { + settingsChanged = true; + } + settings.quietHoursEnd = hour; + } + + void EnterQuietHours() { + if (!inQuietHours) { + notificationStatusBeforeQuietHours = settings.notificationStatus; + inQuietHours = true; + SetNotificationStatus(Notification::Sleep); + } + } + + void ExitQuietHours() { + if (inQuietHours) { + inQuietHours = false; + SetNotificationStatus(notificationStatusBeforeQuietHours); + } + } + + bool IsInQuietHours() const { + return inQuietHours; + } + private: Pinetime::Controllers::FS& fs; - static constexpr uint32_t settingsVersion = 0x000a; + static constexpr uint32_t settingsVersion = 0x000b; struct SettingsData { uint32_t version = settingsVersion; @@ -383,6 +435,10 @@ namespace Pinetime { bool dfuAndFsEnabledOnBoot = false; uint16_t heartRateBackgroundPeriod = std::numeric_limits::max(); // Disabled by default + + bool quietHoursEnabled = false; + uint8_t quietHoursStart = 21; // 9 PM + uint8_t quietHoursEnd = 9; // 9 AM }; SettingsData settings; @@ -397,6 +453,9 @@ namespace Pinetime { bool bleRadioEnabled = true; bool dfuAndFsEnabledTillReboot = false; + Notification notificationStatusBeforeQuietHours = Notification::On; + bool inQuietHours = false; + void LoadSettingsFromFile(); void SaveSettingsToFile(); }; diff --git a/src/displayapp/DisplayApp.cpp b/src/displayapp/DisplayApp.cpp index 84fa603622..fa5229c8af 100644 --- a/src/displayapp/DisplayApp.cpp +++ b/src/displayapp/DisplayApp.cpp @@ -52,6 +52,7 @@ #include "displayapp/screens/settings/SettingShakeThreshold.h" #include "displayapp/screens/settings/SettingBluetooth.h" #include "displayapp/screens/settings/SettingOTA.h" +#include "displayapp/screens/settings/SettingQuietHours.h" #include "utility/Math.h" @@ -634,6 +635,9 @@ void DisplayApp::LoadScreen(Apps app, DisplayApp::FullRefreshDirections directio case Apps::SettingOTA: currentScreen = std::make_unique(this, settingsController); break; + case Apps::SettingQuietHours: + currentScreen = std::make_unique(settingsController); + break; case Apps::BatteryInfo: currentScreen = std::make_unique(batteryController); break; diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index d440b598d1..3993bec24c 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -45,6 +45,7 @@ namespace Pinetime { SettingShakeThreshold, SettingBluetooth, SettingOTA, + SettingQuietHours, Error }; diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index 3221c2f171..87ae9673de 100644 --- a/src/displayapp/fonts/fonts.json +++ b/src/displayapp/fonts/fonts.json @@ -7,7 +7,7 @@ }, { "file": "FontAwesome5-Solid+Brands+Regular.woff", - "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed" + "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf186, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed" } ], "bpp": 1, diff --git a/src/displayapp/screens/settings/SettingQuietHours.cpp b/src/displayapp/screens/settings/SettingQuietHours.cpp new file mode 100644 index 0000000000..d9791ca06b --- /dev/null +++ b/src/displayapp/screens/settings/SettingQuietHours.cpp @@ -0,0 +1,147 @@ +#include "displayapp/screens/settings/SettingQuietHours.h" +#include +#include "displayapp/DisplayApp.h" +#include "displayapp/screens/Symbols.h" +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + void event_handler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + screen->UpdateSelected(obj, event); + } + + void checkbox_event_handler(lv_obj_t* obj, lv_event_t event) { + if (event == LV_EVENT_VALUE_CHANGED) { + auto* screen = static_cast(obj->user_data); + screen->ToggleEnabled(); + } + } +} + +SettingQuietHours::SettingQuietHours(Pinetime::Controllers::Settings& settingsController) : settingsController {settingsController} { + + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(title, "Quiet hours"); + lv_label_set_align(title, LV_LABEL_ALIGN_CENTER); + lv_obj_align(title, lv_scr_act(), LV_ALIGN_IN_TOP_MID, 15, 15); + + lv_obj_t* icon = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(icon, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_ORANGE); + lv_label_set_text_static(icon, Symbols::moon); + lv_label_set_align(icon, LV_LABEL_ALIGN_CENTER); + lv_obj_align(icon, title, LV_ALIGN_OUT_LEFT_MID, -10, 0); + + enabledCheckbox = lv_checkbox_create(lv_scr_act(), nullptr); + lv_checkbox_set_text(enabledCheckbox, "Enabled"); + lv_checkbox_set_checked(enabledCheckbox, settingsController.GetQuietHoursEnabled()); + enabledCheckbox->user_data = this; + lv_obj_set_event_cb(enabledCheckbox, checkbox_event_handler); + lv_obj_align(enabledCheckbox, lv_scr_act(), LV_ALIGN_IN_TOP_LEFT, 10, 55); + + static constexpr uint8_t btnWidth = 50; + static constexpr uint8_t btnHeight = 40; + + // Start hour row + lv_obj_t* startLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(startLabel, "Start"); + lv_obj_align(startLabel, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 10, -15); + + btnStartMinus = lv_btn_create(lv_scr_act(), nullptr); + btnStartMinus->user_data = this; + lv_obj_set_size(btnStartMinus, btnWidth, btnHeight); + lv_obj_align(btnStartMinus, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 70, -15); + lv_obj_set_style_local_bg_color(btnStartMinus, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_t* lblStartMinus = lv_label_create(btnStartMinus, nullptr); + lv_label_set_text_static(lblStartMinus, "-"); + lv_obj_set_event_cb(btnStartMinus, event_handler); + + startValue = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_font(startValue, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, &jetbrains_mono_bold_20); + lv_label_set_text_fmt(startValue, "%02d:00", settingsController.GetQuietHoursStart()); + lv_obj_align(startValue, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 132, -15); + + btnStartPlus = lv_btn_create(lv_scr_act(), nullptr); + btnStartPlus->user_data = this; + lv_obj_set_size(btnStartPlus, btnWidth, btnHeight); + lv_obj_align(btnStartPlus, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 195, -15); + lv_obj_set_style_local_bg_color(btnStartPlus, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_t* lblStartPlus = lv_label_create(btnStartPlus, nullptr); + lv_label_set_text_static(lblStartPlus, "+"); + lv_obj_set_event_cb(btnStartPlus, event_handler); + + // End hour row + lv_obj_t* endLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(endLabel, "End"); + lv_obj_align(endLabel, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 10, 40); + + btnEndMinus = lv_btn_create(lv_scr_act(), nullptr); + btnEndMinus->user_data = this; + lv_obj_set_size(btnEndMinus, btnWidth, btnHeight); + lv_obj_align(btnEndMinus, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 70, 40); + lv_obj_set_style_local_bg_color(btnEndMinus, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_t* lblEndMinus = lv_label_create(btnEndMinus, nullptr); + lv_label_set_text_static(lblEndMinus, "-"); + lv_obj_set_event_cb(btnEndMinus, event_handler); + + endValue = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_font(endValue, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, &jetbrains_mono_bold_20); + lv_label_set_text_fmt(endValue, "%02d:00", settingsController.GetQuietHoursEnd()); + lv_obj_align(endValue, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 132, 40); + + btnEndPlus = lv_btn_create(lv_scr_act(), nullptr); + btnEndPlus->user_data = this; + lv_obj_set_size(btnEndPlus, btnWidth, btnHeight); + lv_obj_align(btnEndPlus, lv_scr_act(), LV_ALIGN_IN_LEFT_MID, 195, 40); + lv_obj_set_style_local_bg_color(btnEndPlus, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_t* lblEndPlus = lv_label_create(btnEndPlus, nullptr); + lv_label_set_text_static(lblEndPlus, "+"); + lv_obj_set_event_cb(btnEndPlus, event_handler); +} + +SettingQuietHours::~SettingQuietHours() { + lv_obj_clean(lv_scr_act()); + settingsController.SaveSettings(); +} + +void SettingQuietHours::ToggleEnabled() { + bool wasEnabled = settingsController.GetQuietHoursEnabled(); + settingsController.SetQuietHoursEnabled(!wasEnabled); + if (wasEnabled && settingsController.IsInQuietHours()) { + settingsController.ExitQuietHours(); + } + lv_checkbox_set_checked(enabledCheckbox, settingsController.GetQuietHoursEnabled()); +} + +void SettingQuietHours::UpdateSelected(lv_obj_t* object, lv_event_t event) { + if (event != LV_EVENT_SHORT_CLICKED && event != LV_EVENT_LONG_PRESSED_REPEAT) { + return; + } + + if (object == btnStartPlus) { + uint8_t val = settingsController.GetQuietHoursStart(); + val = (val + 1) % 24; + settingsController.SetQuietHoursStart(val); + lv_label_set_text_fmt(startValue, "%02d:00", val); + lv_obj_realign(startValue); + } else if (object == btnStartMinus) { + uint8_t val = settingsController.GetQuietHoursStart(); + val = (val + 23) % 24; + settingsController.SetQuietHoursStart(val); + lv_label_set_text_fmt(startValue, "%02d:00", val); + lv_obj_realign(startValue); + } else if (object == btnEndPlus) { + uint8_t val = settingsController.GetQuietHoursEnd(); + val = (val + 1) % 24; + settingsController.SetQuietHoursEnd(val); + lv_label_set_text_fmt(endValue, "%02d:00", val); + lv_obj_realign(endValue); + } else if (object == btnEndMinus) { + uint8_t val = settingsController.GetQuietHoursEnd(); + val = (val + 23) % 24; + settingsController.SetQuietHoursEnd(val); + lv_label_set_text_fmt(endValue, "%02d:00", val); + lv_obj_realign(endValue); + } +} diff --git a/src/displayapp/screens/settings/SettingQuietHours.h b/src/displayapp/screens/settings/SettingQuietHours.h new file mode 100644 index 0000000000..f4ac6eb313 --- /dev/null +++ b/src/displayapp/screens/settings/SettingQuietHours.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include "components/settings/Settings.h" +#include "displayapp/screens/Screen.h" + +namespace Pinetime { + + namespace Applications { + namespace Screens { + + class SettingQuietHours : public Screen { + public: + SettingQuietHours(Pinetime::Controllers::Settings& settingsController); + ~SettingQuietHours() override; + + void UpdateSelected(lv_obj_t* object, lv_event_t event); + void ToggleEnabled(); + + private: + Controllers::Settings& settingsController; + + lv_obj_t* enabledCheckbox; + lv_obj_t* startValue; + lv_obj_t* endValue; + lv_obj_t* btnStartPlus; + lv_obj_t* btnStartMinus; + lv_obj_t* btnEndPlus; + lv_obj_t* btnEndMinus; + }; + } + } +} diff --git a/src/displayapp/screens/settings/Settings.h b/src/displayapp/screens/settings/Settings.h index 32ac3ca943..8b4d0f6946 100644 --- a/src/displayapp/screens/settings/Settings.h +++ b/src/displayapp/screens/settings/Settings.h @@ -49,6 +49,7 @@ namespace Pinetime { {Symbols::shieldAlt, "Over-the-air", Apps::SettingOTA}, {Symbols::bluetooth, "Bluetooth", Apps::SettingBluetooth}, + {Symbols::moon, "Quiet hours", Apps::SettingQuietHours}, {Symbols::list, "About", Apps::SysInfo}, }}; ScreenList screens; diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 56bf9273e1..808ee2a929 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -222,6 +222,22 @@ void SystemTask::Work() { if (alarmController.IsEnabled()) { alarmController.ScheduleAlarm(); } + if (settingsController.GetQuietHoursEnabled()) { + uint8_t currentHour = dateTimeController.Hours(); + uint8_t start = settingsController.GetQuietHoursStart(); + uint8_t end = settingsController.GetQuietHoursEnd(); + bool shouldBeInQuietHours; + if (start <= end) { + shouldBeInQuietHours = (currentHour >= start && currentHour < end); + } else { + shouldBeInQuietHours = (currentHour >= start || currentHour < end); + } + if (shouldBeInQuietHours) { + settingsController.EnterQuietHours(); + } else { + settingsController.ExitQuietHours(); + } + } break; case Messages::OnNewNotification: if (settingsController.GetNotificationStatus() == Pinetime::Controllers::Settings::Notification::On) { @@ -232,6 +248,7 @@ void SystemTask::Work() { } break; case Messages::SetOffAlarm: + settingsController.ExitQuietHours(); GoToRunning(); displayApp.PushMessage(Pinetime::Applications::Display::Messages::AlarmTriggered); break; @@ -338,6 +355,14 @@ void SystemTask::Work() { break; case Messages::OnNewHour: using Pinetime::Controllers::AlarmController; + if (settingsController.GetQuietHoursEnabled()) { + uint8_t currentHour = dateTimeController.Hours(); + if (currentHour == settingsController.GetQuietHoursStart()) { + settingsController.EnterQuietHours(); + } else if (currentHour == settingsController.GetQuietHoursEnd()) { + settingsController.ExitQuietHours(); + } + } if (settingsController.GetNotificationStatus() != Controllers::Settings::Notification::Sleep && settingsController.GetChimeOption() == Controllers::Settings::ChimesOption::Hours && !alarmController.IsAlerting()) { GoToRunning(); @@ -462,10 +487,10 @@ void SystemTask::UpdateMotion() { motionController.CurrentShakeSpeed() > settingsController.GetShakeThreshold())) { GoToRunning(); } - } - if (settingsController.isWakeUpModeOn(Pinetime::Controllers::Settings::WakeUpMode::LowerWrist) && state == SystemTaskState::Running && - motionController.ShouldLowerSleep()) { - GoToSleep(); + if (settingsController.isWakeUpModeOn(Pinetime::Controllers::Settings::WakeUpMode::LowerWrist) && state == SystemTaskState::Running && + motionController.ShouldLowerSleep()) { + GoToSleep(); + } } }