Daily & Weekly Named Schedules #2
@@ -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
|
||||
|
||||
+7
-28
@@ -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 <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
@@ -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<AudioSettingsPage>());
|
||||
break;
|
||||
case 2:
|
||||
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
|
||||
state_->pages.push(*state_, std::make_unique<SystemSettingsPage>());
|
||||
break;
|
||||
case 3:
|
||||
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
|
||||
break;
|
||||
case 4:
|
||||
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
|
||||
break;
|
||||
case 5:
|
||||
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
|
||||
break;
|
||||
case 6:
|
||||
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
|
||||
break;
|
||||
case 7:
|
||||
state_->pages.push(*state_, std::make_unique<LogViewerPage>());
|
||||
break;
|
||||
case 8:
|
||||
case 4:
|
||||
state_->pages.replace(*state_, std::make_unique<Home>());
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "settings_menu.h"
|
||||
|
||||
namespace radiantwave::settings {
|
||||
|
||||
std::vector<SystemSettingEntry> 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
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<SystemSettingEntry> systemSettingEntries();
|
||||
|
||||
} // namespace radiantwave::settings
|
||||
@@ -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 <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <algorithm>
|
||||
|
||||
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<int>(items_.size());
|
||||
if (count > 0)
|
||||
selectedItem_ = (selectedItem_ - 1 + count) % count;
|
||||
});
|
||||
state.keybinds.bind(SDLK_DOWN, "system_settings_down", [this]()
|
||||
{
|
||||
int count = static_cast<int>(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<int>(entries_.size()))
|
||||
{
|
||||
state.pages.pop(state);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (entries_[selectedItem_].id)
|
||||
{
|
||||
case radiantwave::settings::SystemSettingId::EmailAddress:
|
||||
state.pages.push(state, std::make_unique<EmailAddressPage>());
|
||||
break;
|
||||
case radiantwave::settings::SystemSettingId::LicenseKey:
|
||||
state.pages.push(state, std::make_unique<LicenseKeyPage>());
|
||||
break;
|
||||
case radiantwave::settings::SystemSettingId::WiFiSetup:
|
||||
state.pages.push(state, std::make_unique<WiFiSetupPage>());
|
||||
break;
|
||||
case radiantwave::settings::SystemSettingId::Timezone:
|
||||
state.pages.push(state, std::make_unique<TimeZonePage>());
|
||||
break;
|
||||
case radiantwave::settings::SystemSettingId::DateFormat:
|
||||
state.pages.push(state, std::make_unique<DateFormatPage>());
|
||||
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<float>(winW);
|
||||
float fh = static_cast<float>(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<int>(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<int>(entries_.size()) + 1)
|
||||
{
|
||||
selectedItem_ = 0;
|
||||
}
|
||||
|
||||
std::vector<std::string> 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<float>(bgSurface->w);
|
||||
bgHeight_ = static_cast<float>(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<float>(surface->w);
|
||||
titleHeight_ = static_cast<float>(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<float>(surface->w);
|
||||
item.height = static_cast<float>(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();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include "page.h"
|
||||
#include "settings_menu.h"
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// 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<radiantwave::settings::SystemSettingEntry> entries_;
|
||||
// Setting rows followed by a trailing "Back to Settings" row.
|
||||
std::vector<MenuItem> items_;
|
||||
|
||||
SDL_Texture* bgTexture_ = nullptr;
|
||||
float bgWidth_ = 0;
|
||||
float bgHeight_ = 0;
|
||||
|
||||
SDL_Texture* titleTexture_ = nullptr;
|
||||
float titleWidth_ = 0;
|
||||
float titleHeight_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "settings_menu.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
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<std::string> labels;
|
||||
std::set<int> ids;
|
||||
for (const auto& entry : entries) {
|
||||
EXPECT_FALSE(entry.label.empty());
|
||||
labels.insert(entry.label);
|
||||
ids.insert(static_cast<int>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user