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; + } + } +}