#include "home.h" #include "app_state.h" #include "settings.h" #include "scroller_page.h" #include "license_blocked_page.h" #include "proclamation_options_page.h" #include "date_format.h" #include "audio/audio_downloader.h" #include "db/db_keys.h" #include "network/proclamation_sync.h" #include "logical_size.h" #include #include #include #include #include #ifndef APP_VERSION #define APP_VERSION "dev" #endif static TextLine renderText(SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color) { TextLine line; SDL_Surface *surface = TTF_RenderText_Blended(font, text, 0, color); if (!surface) { SDL_Log("Failed to render text: %s", SDL_GetError()); return line; } line.texture = SDL_CreateTextureFromSurface(renderer, surface); line.width = static_cast(surface->w); line.height = static_cast(surface->h); SDL_DestroySurface(surface); return line; } void Home::onPush(AppState &state) { state_ = &state; // Home is the end of the First-Run walkthrough. Reaching it (via the // walkthrough or Settings) means no walkthrough step is active. state.provisioningMode = false; syncProclamationsIfEmpty(state); TTF_Init(); createTextures(state); refreshStatus(); state.licenseTracker.refresh(); state.keybinds.bind(SDLK_UP, "menu_up", [this]() { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); state.keybinds.bind(SDLK_DOWN, "menu_down", [this]() { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); } void Home::onResume(AppState &state) { state_ = &state; syncProclamationsIfEmpty(state); createTextures(state); refreshStatus(); state.licenseTracker.refresh(); state.keybinds.bind(SDLK_UP, "menu_up", [this]() { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); state.keybinds.bind(SDLK_DOWN, "menu_down", [this]() { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); } void Home::onPause(AppState &state) { state.keybinds.unbindAll(); destroyTextures(); } void Home::onPop(AppState &state) { destroyTextures(); TTF_Quit(); } void Home::handleEvent(const SDL_Event &event) { if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_RETURN) { if (state_->currentMenuItem == 0) { // Only a confirmed-valid license unlocks the scroller. if (state_->licenseTracker.isValid()) { state_->pages.push(*state_, std::make_unique()); } else { state_->pages.push(*state_, std::make_unique()); } } else if (state_->currentMenuItem == 1) { state_->pages.replace(*state_, std::make_unique()); } } } void Home::update(float dt) { internetRefreshTimer_ -= dt; if (internetRefreshTimer_ <= 0.0f) { internetRefreshTimer_ = 5.0f; refreshStatus(); } } void Home::render(SDL_Renderer *renderer) { int winW, winH; radiantwave::getLayoutSize(renderer, &winW, &winH); float fw = static_cast(winW); float fh = static_cast(winH); if (bg_.texture) { float scaleX = fw / bg_.width; float scaleY = fh / bg_.height; float scale = (scaleX < scaleY) ? scaleX : scaleY; float dw = bg_.width * scale; float dh = bg_.height * scale; float dx = (fw - dw) / 2.0f; float dy = (fh - dh) / 2.0f; SDL_FRect dst{dx, dy, dw, dh}; SDL_RenderTexture(renderer, bg_.texture, nullptr, &dst); } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160); SDL_FRect overlay{0, 0, fw, fh}; SDL_RenderFillRect(renderer, &overlay); if (!title_.texture) return; float titleX = (fw - title_.width) / 2.0f; float titleY = fh / 3.0f - 60.0f; SDL_FRect titleDst{titleX, titleY, title_.width, title_.height}; SDL_RenderTexture(renderer, title_.texture, nullptr, &titleDst); float menuY = titleY + title_.height + 48.0f; if (play_.texture) { float playX = (fw - play_.width) / 2.0f; SDL_FRect playDst{playX, menuY, play_.width, play_.height}; SDL_RenderTexture(renderer, play_.texture, nullptr, &playDst); playY_ = menuY; menuY += play_.height + 12.0f; } if (settings_.texture) { float settingsX = (fw - settings_.width) / 2.0f; SDL_FRect settingsDst{settingsX, menuY, settings_.width, settings_.height}; SDL_RenderTexture(renderer, settings_.texture, nullptr, &settingsDst); settingsY_ = menuY; } if (state_ && state_->currentMenuItem == 0 && play_.texture) { float playX = (fw - play_.width) / 2.0f; SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); SDL_FRect line{playX, playY_ + play_.height + 2.0f, play_.width, 6.0f}; SDL_RenderFillRect(renderer, &line); } if (state_ && state_->currentMenuItem == 1 && settings_.texture) { float settingsX = (fw - settings_.width) / 2.0f; SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); SDL_FRect line{settingsX, settingsY_ + settings_.height + 2.0f, settings_.width, 6.0f}; SDL_RenderFillRect(renderer, &line); } // Bottom-right status footer (20pt, aligned to a virtual rectangle: // names left-aligned at the rect's left edge, values right-aligned at // the rect's right edge; the longest name's colon sits 4 spaces from its // value, and the rect is sized from that pair) if (footerFont_) { SDL_Color footerColor = {200, 200, 200, 255}; SDL_Color okGreen = {110, 220, 130, 255}; SDL_Color badRed = {240, 80, 80, 255}; // The version string sits at the very top of the footer; it is baked // in at build time via -DAPP_VERSION (defaults to the project version). // License validity lives right below it: OK (green) or Invalid (red, // bold). Checking/Unknown render gray until a check lands. const radiantwave::license::Status licenseStatus = state_ ? state_->licenseTracker.status() : radiantwave::license::Status::Unknown; std::string licenseValue; SDL_Color licenseColor = footerColor; bool licenseBold = false; switch (licenseStatus) { case radiantwave::license::Status::Valid: licenseValue = "OK"; licenseColor = okGreen; break; case radiantwave::license::Status::Invalid: licenseValue = "Invalid"; licenseColor = badRed; licenseBold = true; break; case radiantwave::license::Status::Checking: licenseValue = "Checking..."; break; case radiantwave::license::Status::Unknown: licenseValue = "Unknown"; break; } time_t now = std::time(nullptr); std::vector names = { "Version:", "License:", "Key:", "Email:", "WiFi:", "Internet:", "Audio:", "Date:", "Time:", }; std::vector values = { APP_VERSION, licenseValue, license_.empty() ? std::string("Not set") : license_, email_.empty() ? std::string("Not set") : email_, ssid_.empty() ? std::string("Not connected") : ssid_, internet_ ? std::string("Connected") : std::string("Offline"), audioStatus(), radiantwave::datetime::formatDate(now, dateFormat_), radiantwave::datetime::formatTime(now), }; std::vector valueColors(values.size(), footerColor); std::vector valueBold(values.size(), false); // License sits at index 1, below the version row. valueColors[1] = licenseColor; valueBold[1] = licenseBold; if (footerNames_.size() != names.size()) footerNames_.resize(names.size()); if (footerValues_.size() != values.size()) footerValues_.resize(values.size()); for (size_t i = 0; i < names.size(); ++i) { footerNames_[i].ensure(renderer, footerFont_, names[i], footerColor); } for (size_t i = 0; i < values.size(); ++i) { if (valueBold[i]) TTF_SetFontStyle(footerFont_, TTF_STYLE_BOLD); footerValues_[i].ensure(renderer, footerFont_, values[i], valueColors[i], valueBold[i] ? TTF_STYLE_BOLD : TTF_STYLE_NORMAL); if (valueBold[i]) TTF_SetFontStyle(footerFont_, TTF_STYLE_NORMAL); } float maxNameW = 0.0f; float maxValueW = 0.0f; for (size_t i = 0; i < footerNames_.size(); ++i) { if (footerNames_[i].width > maxNameW) maxNameW = footerNames_[i].width; if (footerValues_[i].width > maxValueW) maxValueW = footerValues_[i].width; } CachedText fourSpaces; fourSpaces.ensure(renderer, footerFont_, " ", footerColor); float spaceW4 = fourSpaces.width; fourSpaces.destroy(); const float margin = 20.0f; float rectRight = fw - margin; float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW); float lineY = fh - margin; const float lineGap = 6.0f; for (size_t i = footerNames_.size(); i-- > 0;) { float h = std::max(footerNames_[i].height, footerValues_[i].height); if (footerNames_[i].texture) { SDL_FRect dst{rectLeft, lineY - h, footerNames_[i].width, footerNames_[i].height}; SDL_RenderTexture(renderer, footerNames_[i].texture, nullptr, &dst); } if (footerValues_[i].texture) { SDL_FRect dst{rectRight - footerValues_[i].width, lineY - h, footerValues_[i].width, footerValues_[i].height}; SDL_RenderTexture(renderer, footerValues_[i].texture, nullptr, &dst); } lineY -= h + lineGap; } } } void Home::createTextures(AppState &state) { TTF_Font *font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 64); if (!font) { SDL_Log("Failed to load font: %s", SDL_GetError()); return; } SDL_Color white = {255, 255, 255, 255}; TextLine textWidthRef = renderText(state.renderer, font, "Radiant Wave", white); float targetWidth = textWidthRef.width * 1.5f; if (textWidthRef.texture) SDL_DestroyTexture(textWidthRef.texture); TTF_CloseFont(font); SDL_Surface *logoSurface = IMG_Load("assets/images/RDW-Title-White-medium.png"); if (!logoSurface) { SDL_Log("Failed to load logo: %s", SDL_GetError()); } if (logoSurface) { title_.texture = SDL_CreateTextureFromSurface(state.renderer, logoSurface); float origW = static_cast(logoSurface->w); float origH = static_cast(logoSurface->h); SDL_DestroySurface(logoSurface); float scale = targetWidth / origW; title_.width = targetWidth; title_.height = origH * scale; } font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 32); if (!font) { SDL_Log("Failed to load font: %s", SDL_GetError()); return; } play_ = renderText(state.renderer, font, "Play", white); settings_ = renderText(state.renderer, font, "Settings", white); TTF_CloseFont(font); SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png"); if (bgSurface) { bg_.texture = SDL_CreateTextureFromSurface(state.renderer, bgSurface); bg_.width = static_cast(bgSurface->w); bg_.height = static_cast(bgSurface->h); SDL_DestroySurface(bgSurface); SDL_SetTextureAlphaMod(bg_.texture, 204); } footerFont_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 20); const std::vector footerNames = { "Version:", "License:", "Key:", "Email:", "WiFi:", "Internet:", "Audio:", "Date:", "Time:", }; footerNames_.clear(); footerValues_.clear(); footerNames_.resize(footerNames.size()); footerValues_.resize(footerNames.size()); SDL_Color footerColor = {200, 200, 200, 255}; if (footerFont_) { for (size_t i = 0; i < footerNames.size(); ++i) { footerNames_[i].ensure(state.renderer, footerFont_, footerNames[i], footerColor); } } } void Home::destroyTextures() { if (bg_.texture) { SDL_DestroyTexture(bg_.texture); bg_.texture = nullptr; } if (title_.texture) { SDL_DestroyTexture(title_.texture); title_.texture = nullptr; } if (play_.texture) { SDL_DestroyTexture(play_.texture); play_.texture = nullptr; } if (settings_.texture) { SDL_DestroyTexture(settings_.texture); settings_.texture = nullptr; } for (auto &t : footerNames_) t.destroy(); for (auto &t : footerValues_) t.destroy(); footerNames_.clear(); footerValues_.clear(); if (footerFont_) { TTF_CloseFont(footerFont_); footerFont_ = nullptr; } } void Home::refreshStatus() { if (!state_) return; email_ = state_->db.getKV(radiantwave::db::KV_EMAIL_ADDRESS); license_ = state_->db.getKV(radiantwave::db::KV_LICENSE_KEY); dateFormat_ = state_->db.getKV("date_format", radiantwave::datetime::DEFAULT_DATE_FORMAT); ssid_ = state_->network.currentSSID(); internet_ = state_->network.hasInternet(); } void Home::syncProclamationsIfEmpty(AppState &state) { // Pull the proclamation list on first run so Play has the default // proclamation immediately, even if the user never opens the // Proclamation options. Skipped once the database has content so a // blocking sync never runs on every Home push. The API call is also // skipped when the database is closed (fallback path in provisioning). if (state.db.isOpen() && state.db.getActiveProclamations().empty()) { // TODO(portal #4/#5): once the portal exposes color palettes, sync them // alongside proclamations here (sync::syncColorPalettesFromApi) so Play's // scroller has portal colors from the very first run. radiantwave::sync::syncProclamationsFromApi(state.db, DEFAULT_PROPLEAMATION_ID); } } std::string Home::audioStatus() const { if (!state_) return "Idle"; std::string status = state_->audioDownloader.statusString(); return status.empty() ? "Idle" : status; }