From 003a90d81013080e7d80573e8527168eee536c3c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 08:45:16 -0500 Subject: [PATCH] Kiosk: add a data-driven System Settings menu Settings now shows Proclamations, Audio Options, System Settings, System Logs, and Back. The new SystemSettingsPage groups Email Address, License Key, WiFi Setup, Time Zone Setup, and Date Format, driven by the pure settings::systemSettingEntries() list so future entries need one row, not new menu plumbing. Adds unit tests for the entry list. --- CMakeLists.txt | 10 ++ src/settings.cpp | 35 +---- src/settings_menu.cpp | 15 ++ src/settings_menu.h | 29 ++++ src/system_settings_page.cpp | 272 +++++++++++++++++++++++++++++++++++ src/system_settings_page.h | 50 +++++++ tests/test_settings_menu.cpp | 50 +++++++ 7 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 src/settings_menu.cpp create mode 100644 src/settings_menu.h create mode 100644 src/system_settings_page.cpp create mode 100644 src/system_settings_page.h create mode 100644 tests/test_settings_menu.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c302fd..a4a34e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,8 @@ add_executable(radiantwave src/page_manager.cpp src/home.cpp src/settings.cpp + src/settings_menu.cpp + src/system_settings_page.cpp src/wifi_setup_page.cpp src/audio_settings_page.cpp src/audio/audio_manager.cpp @@ -226,6 +228,14 @@ add_executable(test_date_format target_include_directories(test_date_format PRIVATE src) target_link_libraries(test_date_format PRIVATE GTest::gtest_main) +# System Settings menu entry list (pure data, no SDL linkage) +add_executable(test_settings_menu + tests/test_settings_menu.cpp + src/settings_menu.cpp +) +target_include_directories(test_settings_menu PRIVATE src) +target_link_libraries(test_settings_menu PRIVATE GTest::gtest_main) + # TimeZone unit tests add_executable(test_timezone tests/test_timezone.cpp diff --git a/src/settings.cpp b/src/settings.cpp index 5a135ab..f7c9636 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -2,14 +2,10 @@ #include "app_state.h" #include "home.h" #include "license_blocked_page.h" -#include "wifi_setup_page.h" #include "audio_settings_page.h" -#include "timezone_page.h" +#include "system_settings_page.h" #include "log_viewer_page.h" #include "proclamation_options_page.h" -#include "email_address_page.h" -#include "license_key_page.h" -#include "date_format_page.h" #include "logical_size.h" #include #include @@ -18,18 +14,13 @@ namespace { - constexpr int MENU_ITEM_COUNT = 9; - // 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key, - // 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format, - // 7 = System Logs, 8 = Back + constexpr int MENU_ITEM_COUNT = 5; + // 0 = Proclamations, 1 = Audio Options, 2 = System Settings, + // 3 = System Logs, 4 = Back constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = { "Proclamations", "Audio Options", - "Email Address", - "License Key", - "WiFi Setup", - "Time Zone Setup", - "Date Format", + "System Settings", "System Logs", "Back to Home", }; @@ -92,24 +83,12 @@ void Settings::handleEvent(const SDL_Event &event) state_->pages.push(*state_, std::make_unique()); break; case 2: - state_->pages.push(*state_, std::make_unique()); + state_->pages.push(*state_, std::make_unique()); break; case 3: - state_->pages.push(*state_, std::make_unique()); - break; - case 4: - state_->pages.push(*state_, std::make_unique()); - break; - case 5: - state_->pages.push(*state_, std::make_unique()); - break; - case 6: - state_->pages.push(*state_, std::make_unique()); - break; - case 7: state_->pages.push(*state_, std::make_unique()); break; - case 8: + case 4: state_->pages.replace(*state_, std::make_unique()); break; default: diff --git a/src/settings_menu.cpp b/src/settings_menu.cpp new file mode 100644 index 0000000..cbf9e4f --- /dev/null +++ b/src/settings_menu.cpp @@ -0,0 +1,15 @@ +#include "settings_menu.h" + +namespace radiantwave::settings { + +std::vector systemSettingEntries() { + return { + {"Email Address", SystemSettingId::EmailAddress}, + {"License Key", SystemSettingId::LicenseKey}, + {"WiFi Setup", SystemSettingId::WiFiSetup}, + {"Time Zone Setup", SystemSettingId::Timezone}, + {"Date Format", SystemSettingId::DateFormat}, + }; +} + +} // namespace radiantwave::settings diff --git a/src/settings_menu.h b/src/settings_menu.h new file mode 100644 index 0000000..96ca701 --- /dev/null +++ b/src/settings_menu.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace radiantwave::settings { + +// Identifies a page reachable from the System Settings menu. +enum class SystemSettingId { + EmailAddress, + LicenseKey, + WiFiSetup, + Timezone, + DateFormat, +}; + +// One row in the System Settings menu. `label` is what the user sees; +// `id` selects the page the row opens. +struct SystemSettingEntry { + std::string label; + SystemSettingId id; +}; + +// The ordered System Settings entries. Adding a future setting (System Name in +// Phase 2, Schedule toggle in Phase 3) is a single entry here plus its page +// wiring — no new menu plumbing. Kept free of SDL so it is unit-testable. +std::vector systemSettingEntries(); + +} // namespace radiantwave::settings diff --git a/src/system_settings_page.cpp b/src/system_settings_page.cpp new file mode 100644 index 0000000..fda054a --- /dev/null +++ b/src/system_settings_page.cpp @@ -0,0 +1,272 @@ +#include "system_settings_page.h" +#include "app_state.h" +#include "date_format_page.h" +#include "email_address_page.h" +#include "license_key_page.h" +#include "logical_size.h" +#include "timezone_page.h" +#include "wifi_setup_page.h" +#include +#include +#include + +namespace +{ + constexpr const char *TITLE = "System Settings"; + constexpr const char *BACK_LABEL = "Back to Settings"; +} // anonymous namespace + +void SystemSettingsPage::onPush(AppState &state) +{ + state_ = &state; + TTF_Init(); + createTextures(state); + bindNavigation(state); +} + +void SystemSettingsPage::onResume(AppState &state) +{ + state_ = &state; + createTextures(state); + bindNavigation(state); +} + +void SystemSettingsPage::onPause(AppState &state) +{ + state.keybinds.unbindAll(); + destroyTextures(); +} + +void SystemSettingsPage::onPop(AppState &state) +{ + destroyTextures(); + TTF_Quit(); +} + +void SystemSettingsPage::bindNavigation(AppState &state) +{ + state.keybinds.bind(SDLK_UP, "system_settings_up", [this]() + { + int count = static_cast(items_.size()); + if (count > 0) + selectedItem_ = (selectedItem_ - 1 + count) % count; + }); + state.keybinds.bind(SDLK_DOWN, "system_settings_down", [this]() + { + int count = static_cast(items_.size()); + if (count > 0) + selectedItem_ = (selectedItem_ + 1) % count; + }); +} + +void SystemSettingsPage::activateSelected(AppState &state) +{ + // The trailing row (index == number of setting entries) goes back. + if (selectedItem_ < 0 || selectedItem_ >= static_cast(entries_.size())) + { + state.pages.pop(state); + return; + } + + switch (entries_[selectedItem_].id) + { + case radiantwave::settings::SystemSettingId::EmailAddress: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::LicenseKey: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::WiFiSetup: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::Timezone: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::DateFormat: + state.pages.push(state, std::make_unique()); + break; + } +} + +void SystemSettingsPage::handleEvent(const SDL_Event &event) +{ + if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_RETURN) + { + activateSelected(*state_); + } + else if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE) + { + state_->pages.pop(*state_); + } +} + +void SystemSettingsPage::update(float dt) +{ +} + +void SystemSettingsPage::render(SDL_Renderer *renderer) +{ + int winW, winH; + radiantwave::getLayoutSize(renderer, &winW, &winH); + float fw = static_cast(winW); + float fh = static_cast(winH); + + // 1. Logo Background Image + if (bgTexture_) + { + float scaleX = fw / bgWidth_; + float scaleY = fh / bgHeight_; + float scale = std::min(scaleX, scaleY); + float dw = bgWidth_ * scale; + float dh = bgHeight_ * scale; + float dx = (fw - dw) / 2.0f; + float dy = (fh - dh) / 2.0f; + SDL_FRect bgDst{dx, dy, dw, dh}; + SDL_RenderTexture(renderer, bgTexture_, nullptr, &bgDst); + } + + // 2. Dark Overlay + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160); + SDL_FRect overlayRect{0, 0, fw, fh}; + SDL_RenderFillRect(renderer, &overlayRect); + + // 3. Title Text + float titleY = 60.0f; + if (titleTexture_) + { + float x = (fw - titleWidth_) / 2.0f; + SDL_FRect dst{x, titleY, titleWidth_, titleHeight_}; + SDL_RenderTexture(renderer, titleTexture_, nullptr, &dst); + } + + // Compute bounding box for all menu items + float maxWidth = 0.0f; + for (const auto &item : items_) + { + maxWidth = std::max(maxWidth, item.width); + } + float margin = 12.0f; // 12px vertical margin rule + + // Shared left edge for left-aligned items with centered bounding box + float sharedX = (fw - maxWidth) / 2.0f; + + // Start menu list exactly 24 pixels below title text + float currentY = titleY + titleHeight_ + 24.0f; + + for (auto &item : items_) + { + item.y = currentY; + if (item.texture) + { + SDL_FRect dst{sharedX, item.y, item.width, item.height}; + SDL_RenderTexture(renderer, item.texture, nullptr, &dst); + } + currentY += item.height + margin; + } + + // 6px Underline for selected item occupying half of the 12px margin space + if (selectedItem_ >= 0 && selectedItem_ < static_cast(items_.size())) + { + const auto &item = items_[selectedItem_]; + if (item.texture) + { + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); + SDL_FRect line{sharedX, item.y + item.height + 2.0f, item.width, 6.0f}; + SDL_RenderFillRect(renderer, &line); + } + } +} + +void SystemSettingsPage::createTextures(AppState &state) +{ + destroyTextures(); + + entries_ = radiantwave::settings::systemSettingEntries(); + if (selectedItem_ >= static_cast(entries_.size()) + 1) + { + selectedItem_ = 0; + } + + std::vector labels; + labels.reserve(entries_.size() + 1); + for (const auto &entry : entries_) + { + labels.push_back(entry.label); + } + labels.push_back(BACK_LABEL); + + // Background Image + SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png"); + if (bgSurface) + { + bgTexture_ = SDL_CreateTextureFromSurface(state.renderer, bgSurface); + bgWidth_ = static_cast(bgSurface->w); + bgHeight_ = static_cast(bgSurface->h); + SDL_DestroySurface(bgSurface); + SDL_SetTextureAlphaMod(bgTexture_, 204); + } + + SDL_Color white = {255, 255, 255, 255}; + // Title Font + TTF_Font *fontLarge = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 64); + if (fontLarge) + { + SDL_Surface *surface = TTF_RenderText_Blended(fontLarge, TITLE, 0, white); + if (surface) + { + titleTexture_ = SDL_CreateTextureFromSurface(state.renderer, surface); + titleWidth_ = static_cast(surface->w); + titleHeight_ = static_cast(surface->h); + SDL_DestroySurface(surface); + } + TTF_CloseFont(fontLarge); + } + + // Item Font + TTF_Font *fontItem = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36); + if (!fontItem) + return; + + items_.reserve(labels.size()); + for (const auto &label : labels) + { + MenuItem item; + item.label = label; + + SDL_Surface *surface = TTF_RenderText_Blended(fontItem, label.c_str(), 0, white); + if (surface) + { + item.texture = SDL_CreateTextureFromSurface(state.renderer, surface); + item.width = static_cast(surface->w); + item.height = static_cast(surface->h); + SDL_DestroySurface(surface); + } + items_.push_back(std::move(item)); + } + + TTF_CloseFont(fontItem); +} + +void SystemSettingsPage::destroyTextures() +{ + if (bgTexture_) + { + SDL_DestroyTexture(bgTexture_); + bgTexture_ = nullptr; + } + if (titleTexture_) + { + SDL_DestroyTexture(titleTexture_); + titleTexture_ = nullptr; + } + for (auto &item : items_) + { + if (item.texture) + { + SDL_DestroyTexture(item.texture); + item.texture = nullptr; + } + } + items_.clear(); +} diff --git a/src/system_settings_page.h b/src/system_settings_page.h new file mode 100644 index 0000000..d4c3d00 --- /dev/null +++ b/src/system_settings_page.h @@ -0,0 +1,50 @@ +#pragma once + +#include "page.h" +#include "settings_menu.h" +#include +#include +#include + +// A single menu that groups the device's system-level settings (Email, License +// Key, WiFi, Timezone, Date Format). The row list is data-driven from +// `radiantwave::settings::systemSettingEntries()`, so adding a setting later is +// one entry plus its page — no new menu plumbing. +class SystemSettingsPage : public Page { +public: + void onPush(AppState& state) override; + void onResume(AppState& state) override; + void onPause(AppState& state) override; + void onPop(AppState& state) override; + void handleEvent(const SDL_Event& event) override; + void update(float dt) override; + void render(SDL_Renderer* renderer) override; + +private: + struct MenuItem { + std::string label; + SDL_Texture* texture = nullptr; + float width = 0; + float height = 0; + float y = 0; + }; + + void bindNavigation(AppState& state); + void activateSelected(AppState& state); + void createTextures(AppState& state); + void destroyTextures(); + + AppState* state_ = nullptr; + int selectedItem_ = 0; + std::vector entries_; + // Setting rows followed by a trailing "Back to Settings" row. + std::vector items_; + + SDL_Texture* bgTexture_ = nullptr; + float bgWidth_ = 0; + float bgHeight_ = 0; + + SDL_Texture* titleTexture_ = nullptr; + float titleWidth_ = 0; + float titleHeight_ = 0; +}; diff --git a/tests/test_settings_menu.cpp b/tests/test_settings_menu.cpp new file mode 100644 index 0000000..7a65950 --- /dev/null +++ b/tests/test_settings_menu.cpp @@ -0,0 +1,50 @@ +#include "settings_menu.h" +#include +#include +#include + +namespace settings = radiantwave::settings; + +TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) { + const auto entries = settings::systemSettingEntries(); + ASSERT_EQ(entries.size(), 5u); + EXPECT_EQ(entries[0].label, "Email Address"); + EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress); + EXPECT_EQ(entries[1].label, "License Key"); + EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey); + EXPECT_EQ(entries[2].label, "WiFi Setup"); + EXPECT_EQ(entries[2].id, settings::SystemSettingId::WiFiSetup); + EXPECT_EQ(entries[3].label, "Time Zone Setup"); + EXPECT_EQ(entries[3].id, settings::SystemSettingId::Timezone); + EXPECT_EQ(entries[4].label, "Date Format"); + EXPECT_EQ(entries[4].id, settings::SystemSettingId::DateFormat); +} + +TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) { + const auto entries = settings::systemSettingEntries(); + std::set labels; + std::set ids; + for (const auto& entry : entries) { + EXPECT_FALSE(entry.label.empty()); + labels.insert(entry.label); + ids.insert(static_cast(entry.id)); + } + EXPECT_EQ(labels.size(), entries.size()); + EXPECT_EQ(ids.size(), entries.size()); +} + +TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) { + for (const auto& entry : settings::systemSettingEntries()) { + switch (entry.id) { + case settings::SystemSettingId::EmailAddress: + case settings::SystemSettingId::LicenseKey: + case settings::SystemSettingId::WiFiSetup: + case settings::SystemSettingId::Timezone: + case settings::SystemSettingId::DateFormat: + SUCCEED(); + break; + default: + FAIL() << "Unhandled SystemSettingId for label " << entry.label; + } + } +}