diff --git a/lv_conf.h b/lv_conf.h index cba362c..3287779 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -36,9 +36,10 @@ extern const lv_font_t lv_font_ratdeck_14; #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN #define LV_LOG_PRINTF 1 -// Theme -#define LV_USE_THEME_DEFAULT 1 -#define LV_THEME_DEFAULT_DARK 1 +// Theme — disabled; we use our own LvTheme system which handles all styling. +// The default theme's blue focus outline conflicts with our green design. +#define LV_USE_THEME_DEFAULT 0 +#define LV_THEME_DEFAULT_DARK 0 // Fonts - built-in (only 16 still used for titles; 10/12/14 replaced by custom ratdeck fonts) #define LV_FONT_MONTSERRAT_10 0 @@ -48,27 +49,27 @@ extern const lv_font_t lv_font_ratdeck_14; #define LV_FONT_UNSCII_8 0 #define LV_FONT_DEFAULT &lv_font_ratdeck_14 -// Widgets +// Widgets — only enable what we actually use #define LV_USE_LABEL 1 #define LV_USE_BTN 1 -#define LV_USE_BTNMATRIX 1 +#define LV_USE_BTNMATRIX 1 // Required by LVGL calendar/msgbox internals #define LV_USE_TEXTAREA 1 -#define LV_USE_LIST 1 -#define LV_USE_MENU 1 +#define LV_USE_LIST 0 +#define LV_USE_MENU 0 #define LV_USE_BAR 1 -#define LV_USE_SLIDER 1 -#define LV_USE_SWITCH 1 -#define LV_USE_DROPDOWN 1 +#define LV_USE_SLIDER 0 +#define LV_USE_SWITCH 0 +#define LV_USE_DROPDOWN 1 // Required by LVGL calendar internals #define LV_USE_ROLLER 1 -#define LV_USE_TABLE 1 -#define LV_USE_TABVIEW 1 -#define LV_USE_IMG 1 -#define LV_USE_LINE 1 -#define LV_USE_ARC 1 -#define LV_USE_SPINNER 1 -#define LV_USE_MSGBOX 1 -#define LV_USE_KEYBOARD 1 -#define LV_USE_CHECKBOX 1 +#define LV_USE_TABLE 0 +#define LV_USE_TABVIEW 0 +#define LV_USE_IMG 0 +#define LV_USE_LINE 0 +#define LV_USE_ARC 0 +#define LV_USE_SPINNER 0 +#define LV_USE_MSGBOX 0 +#define LV_USE_KEYBOARD 0 +#define LV_USE_CHECKBOX 0 #define LV_USE_CANVAS 0 // Layouts diff --git a/src/main.cpp b/src/main.cpp index c89b669..b1990ad 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -169,8 +169,6 @@ static void announceWithName(bool silent = false) { silent ? "yes" : "no"); rns.announce(appData); if (!silent) { - ui.statusBar().flashAnnounce(); - ui.statusBar().showToast("Announce sent!"); ui.lvStatusBar().flashAnnounce(); ui.lvStatusBar().showToast("Announce sent!"); } @@ -221,15 +219,15 @@ void onHotkeyHelp() { } void onHotkeyMessages() { ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); - ui.setLvScreen(&lvMessagesScreen); + ui.setScreen(&lvMessagesScreen); } void onHotkeyNewMsg() { ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); - ui.setLvScreen(&lvMessagesScreen); + ui.setScreen(&lvMessagesScreen); } void onHotkeySettings() { ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETTINGS); - ui.setLvScreen(&lvSettingsScreen); + ui.setScreen(&lvSettingsScreen); } void onHotkeyAnnounce() { announceWithName(); @@ -407,10 +405,9 @@ void setup() { } // Step 6: UI manager (initializes both legacy and LVGL UI layers) - ui.begin(&display.gfx()); + ui.begin(); ui.setBootMode(true); - ui.setLvScreen(&lvBootScreen); - ui.statusBar().setLoRaOnline(radioOnline); + ui.setScreen(&lvBootScreen); ui.lvStatusBar().setLoRaOnline(radioOnline); lvBootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED"); @@ -450,7 +447,7 @@ void setup() { hotkeys.setTabCycleCallback([](int dir) { ui.lvTabBar().cycleTab(dir); int tab = ui.lvTabBar().getActiveTab(); - if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); + if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); }); lvBootScreen.setProgress(0.58f, "Hotkeys registered"); // (LVGL boot renders via lv_timer_handler in setProgress) @@ -507,7 +504,6 @@ void setup() { lxmf.begin(&rns, &messageStore); lxmf.setMessageCallback([](const LXMFMessage& msg) { Serial.printf("[LXMF] Message from %s\n", msg.sourceHash.toHex().substr(0, 8).c_str()); - ui.tabBar().setUnreadCount(TabBar::TAB_MSGS, lxmf.unreadCount()); ui.lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, lxmf.unreadCount()); audio.playMessage(); }); @@ -606,7 +602,6 @@ void setup() { wifiIface.mode(RNS::Type::Interface::MODE_GATEWAY); RNS::Transport::register_interface(wifiIface); wifiImpl->start(); - ui.statusBar().setWiFiActive(true); ui.lvStatusBar().setWiFiActive(true); } else if (wifiMode == RAT_WIFI_STA) { lvBootScreen.setProgress(0.87f, "WiFi STA starting..."); @@ -642,7 +637,6 @@ void setup() { bleInterface.injectIncoming(pkt); }); - ui.statusBar().setBLEActive(true); ui.lvStatusBar().setBLEActive(true); Serial.println("[BLE] Transport + Sideband ready"); } @@ -684,8 +678,6 @@ void setup() { audio.playBoot(); bootComplete = true; - ui.statusBar().setTransportMode("RatDeck"); - ui.lvStatusBar().setTransportMode("RatDeck"); // Keep LVGL responsive during blocking radio operations (if screen is on) // Re-entrancy guard prevents nested lv_timer_handler() calls @@ -717,7 +709,7 @@ void setup() { lvContactsScreen.setNodeSelectedCallback([](const std::string& peerHex) { lvMessageView.setPeerHex(peerHex); ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); - ui.setLvScreen(&lvMessageView); + ui.setScreen(&lvMessageView); }); lvNodesScreen.setAnnounceManager(announceManager); @@ -726,7 +718,7 @@ void setup() { lvNodesScreen.setNodeSelectedCallback([](const std::string& peerHex) { lvMessageView.setPeerHex(peerHex); ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); - ui.setLvScreen(&lvMessageView); + ui.setScreen(&lvMessageView); }); lvMessagesScreen.setLXMFManager(&lxmf); @@ -734,14 +726,14 @@ void setup() { lvMessagesScreen.setUIManager(&ui); lvMessagesScreen.setOpenCallback([](const std::string& peerHex) { lvMessageView.setPeerHex(peerHex); - ui.setLvScreen(&lvMessageView); + ui.setScreen(&lvMessageView); }); lvMessageView.setLXMFManager(&lxmf); lvMessageView.setAnnounceManager(announceManager); lvMessageView.setUIManager(&ui); lvMessageView.setBackCallback([]() { - ui.setLvScreen(&lvMessagesScreen); + ui.setScreen(&lvMessagesScreen); }); lvSettingsScreen.setUserConfig(&userConfig); @@ -793,7 +785,7 @@ void setup() { lvTabScreens[LvTabBar::TAB_SETTINGS] = &lvSettingsScreen; ui.lvTabBar().setTabCallback([](int tab) { - if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); + if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); }); // Data clean screen (first boot only — when SD has old data) @@ -809,7 +801,7 @@ void setup() { ESP.restart(); } else { Serial.println("[BOOT] User chose to keep old data"); - ui.setLvScreen(&lvNameInputScreen); + ui.setScreen(&lvNameInputScreen); } }); @@ -817,7 +809,7 @@ void setup() { // Transition to home screen (shared by name input, timezone, and normal boot) auto goHome = []() { ui.setBootMode(false); - ui.setLvScreen(&lvHomeScreen); + ui.setScreen(&lvHomeScreen); ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); announceWithName(); lastAutoAnnounce = millis(); @@ -828,7 +820,7 @@ void setup() { auto showTimezone = [goHome]() { if (!userConfig.settings().timezoneSet) { lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx); - ui.setLvScreen(&lvTimezoneScreen); + ui.setScreen(&lvTimezoneScreen); Serial.println("[BOOT] Showing timezone selection"); } else { goHome(); @@ -885,12 +877,12 @@ void setup() { if (userConfig.settings().displayName.isEmpty()) { // First boot — go to name input - ui.setLvScreen(&lvNameInputScreen); + ui.setScreen(&lvNameInputScreen); Serial.println("[BOOT] Showing name input screen"); } else if (!userConfig.settings().timezoneSet) { // Name set but timezone not — show timezone picker lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx); - ui.setLvScreen(&lvTimezoneScreen); + ui.setScreen(&lvTimezoneScreen); Serial.println("[BOOT] Showing timezone selection (name already set)"); } else { // Everything configured — go straight to home @@ -941,12 +933,12 @@ void loop() { } // Ctrl+hotkeys first else if (!hotkeys.process(evt)) { - // Feed key to LVGL input system - LvInput::feedKey(evt); - - // Screen gets the key next + // Screen gets the key first bool consumed = ui.handleKey(evt); + // Feed to LVGL input system only if the screen didn't consume it + if (!consumed) LvInput::feedKey(evt); + // Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume) if (!consumed && !evt.ctrl) { bool tabLeft = (evt.character == ',') || evt.left; @@ -954,12 +946,12 @@ void loop() { if (tabLeft) { ui.lvTabBar().cycleTab(-1); int tab = ui.lvTabBar().getActiveTab(); - if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); + if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); } if (tabRight) { ui.lvTabBar().cycleTab(1); int tab = ui.lvTabBar().getActiveTab(); - if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); + if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); } } } @@ -1014,7 +1006,6 @@ void loop() { bool connected = (WiFi.status() == WL_CONNECTED); if (connected && !wifiSTAConnected) { wifiSTAConnected = true; - ui.statusBar().setWiFiActive(true); ui.lvStatusBar().setWiFiActive(true); Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str()); @@ -1032,7 +1023,6 @@ void loop() { wifiConnectedAt = millis(); } else if (!connected && wifiSTAConnected) { wifiSTAConnected = false; - ui.statusBar().setWiFiActive(false); ui.lvStatusBar().setWiFiActive(false); ui.lvStatusBar().setTCPConnected(false); // Stop and deregister TCP clients cleanly @@ -1095,7 +1085,6 @@ void loop() { if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) { lastStatusUpdate = millis(); if (powerMgr.isScreenOn()) { - ui.statusBar().setBatteryPercent(powerMgr.batteryPercent()); ui.lvStatusBar().setBatteryPercent(powerMgr.batteryPercent()); // Update TCP connection indicator bool anyTcpUp = false; @@ -1115,10 +1104,7 @@ void loop() { } } - // 12. Render any dirty regions - if (powerMgr.isScreenOn()) { - ui.render(); - } + // 12.5. RSSI monitor (non-blocking, one sample per loop iteration) if (rssiMonitorActive && radioOnline) { diff --git a/src/ui/LvInput.cpp b/src/ui/LvInput.cpp index 69cc34e..b58e877 100644 --- a/src/ui/LvInput.cpp +++ b/src/ui/LvInput.cpp @@ -1,10 +1,12 @@ #include "LvInput.h" +#include "Theme.h" #include "hal/Keyboard.h" #include "hal/Trackball.h" #include "hal/TouchInput.h" namespace LvInput { +#if 0 // Dead code: bitmap cursor icon — replaced by LVGL shape cursor in init() const uint8_t mouse_cursor_icon_map[] = { #if LV_COLOR_DEPTH == 1 || LV_COLOR_DEPTH == 8 /*Pixel format: Alpha 8 bit, Red: 3 bit, Green: 3 bit, Blue: 2 bit*/ @@ -109,6 +111,7 @@ static lv_img_dsc_t mouse_cursor_icon = { .data_size = 280 * LV_IMG_PX_SIZE_ALPHA_BYTE, .data = mouse_cursor_icon_map, }; +#endif // Dead cursor icon static Keyboard* s_kb = nullptr; static Trackball* s_tb = nullptr; @@ -189,17 +192,20 @@ void init(Keyboard* kb, Trackball* tb, TouchInput* touch) { lv_indev_t* touchIndev = lv_indev_drv_register(&touchDrv); lv_indev_set_group(touchIndev, s_group); - // Touch cursor: black center with thin green ring (on-brand, minimal) + // Touch cursor: semi-transparent center with brand orange ring s_cursor = lv_obj_create(lv_layer_sys()); - lv_obj_set_size(s_cursor, 12, 12); - lv_obj_set_style_radius(s_cursor, 6, 0); - lv_obj_set_style_bg_color(s_cursor, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(s_cursor, LV_OPA_COVER, 0); - lv_obj_set_style_outline_color(s_cursor, lv_color_hex(0x00FF41), 0); + lv_obj_set_size(s_cursor, 14, 14); + lv_obj_set_style_radius(s_cursor, 7, 0); + lv_obj_set_style_bg_color(s_cursor, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(s_cursor, LV_OPA_60, 0); + lv_obj_set_style_outline_color(s_cursor, lv_color_hex(Theme::PRIMARY), 0); lv_obj_set_style_outline_width(s_cursor, 1, 0); lv_obj_set_style_outline_opa(s_cursor, LV_OPA_COVER, 0); lv_obj_set_style_border_width(s_cursor, 0, 0); lv_obj_clear_flag(s_cursor, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + // Center the cursor on the touch point (LVGL positions top-left by default) + lv_obj_set_style_translate_x(s_cursor, -7, 0); + lv_obj_set_style_translate_y(s_cursor, -7, 0); lv_indev_set_cursor(touchIndev, s_cursor); lv_obj_add_flag(s_cursor, LV_OBJ_FLAG_HIDDEN); diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp index da8adb9..9c06b4c 100644 --- a/src/ui/LvStatusBar.cpp +++ b/src/ui/LvStatusBar.cpp @@ -10,7 +10,7 @@ void LvStatusBar::create(lv_obj_t* parent) { lv_obj_align(_bar, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_set_style_bg_color(_bar, lv_color_hex(Theme::BG), 0); lv_obj_set_style_bg_opa(_bar, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(_bar, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_color(_bar, lv_color_hex(Theme::DIVIDER), 0); lv_obj_set_style_border_width(_bar, 1, 0); lv_obj_set_style_border_side(_bar, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_pad_all(_bar, 0, 0); @@ -19,14 +19,14 @@ void LvStatusBar::create(lv_obj_t* parent) { const lv_font_t* font = &lv_font_ratdeck_12; - // Left: Time display (hidden until valid time is available) + // Left: Time display (hidden until valid time) _lblTime = lv_label_create(_bar); lv_obj_set_style_text_font(_lblTime, font, 0); - lv_obj_set_style_text_color(_lblTime, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_text_color(_lblTime, lv_color_hex(Theme::TEXT_PRIMARY), 0); lv_label_set_text(_lblTime, ""); lv_obj_align(_lblTime, LV_ALIGN_LEFT_MID, 4, 0); - // Center: "Ratspeak.org" + // Center: Brand _lblBrand = lv_label_create(_bar); lv_obj_set_style_text_font(_lblBrand, font, 0); lv_obj_set_style_text_color(_lblBrand, lv_color_hex(Theme::ACCENT), 0); @@ -35,7 +35,7 @@ void LvStatusBar::create(lv_obj_t* parent) { // Right: Battery % _lblBatt = lv_label_create(_bar); - lv_obj_set_style_text_font(_lblBatt, font, 0); + lv_obj_set_style_text_font(_lblBatt, &lv_font_ratdeck_10, 0); lv_label_set_text(_lblBatt, ""); lv_obj_align(_lblBatt, LV_ALIGN_RIGHT_MID, -4, 0); @@ -43,7 +43,7 @@ void LvStatusBar::create(lv_obj_t* parent) { _toast = lv_obj_create(parent); lv_obj_set_size(_toast, Theme::SCREEN_W, Theme::STATUS_BAR_H); lv_obj_align(_toast, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_set_style_bg_color(_toast, lv_color_hex(Theme::ACCENT), 0); + lv_obj_set_style_bg_color(_toast, lv_color_hex(Theme::PRIMARY), 0); lv_obj_set_style_bg_opa(_toast, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(_toast, 0, 0); lv_obj_set_style_radius(_toast, 0, 0); @@ -58,12 +58,9 @@ void LvStatusBar::create(lv_obj_t* parent) { } void LvStatusBar::update() { - // Handle announce flash timeout if (_announceFlashEnd > 0 && millis() >= _announceFlashEnd) { _announceFlashEnd = 0; } - - // Handle toast timeout if (_toastEnd > 0 && millis() >= _toastEnd) { _toastEnd = 0; lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN); @@ -72,26 +69,21 @@ void LvStatusBar::update() { void LvStatusBar::updateTime() { if (!_lblTime) return; - time_t now = time(nullptr); if (now <= 1700000000) { lv_label_set_text(_lblTime, ""); _lastHour = -1; _lastMinute = -1; return; } - struct tm* local = localtime(&now); if (!local) { lv_label_set_text(_lblTime, ""); _lastHour = -1; _lastMinute = -1; return; } - - // Only update label when minute changes (avoids needless LVGL invalidation) if (local->tm_hour == _lastHour && local->tm_min == _lastMinute) return; _lastHour = local->tm_hour; _lastMinute = local->tm_min; - char buf[8]; if (_use24h) { snprintf(buf, sizeof(buf), "%02d:%02d", local->tm_hour, local->tm_min); @@ -103,9 +95,7 @@ void LvStatusBar::updateTime() { lv_label_set_text(_lblTime, buf); } -void LvStatusBar::setGPSFix(bool hasFix) { - _gpsFix = hasFix; -} +void LvStatusBar::setGPSFix(bool hasFix) { _gpsFix = hasFix; } void LvStatusBar::setBatteryPercent(int pct) { if (_battPct == pct) return; @@ -114,20 +104,14 @@ void LvStatusBar::setBatteryPercent(int pct) { char buf[8]; snprintf(buf, sizeof(buf), "%d%%", pct); lv_label_set_text(_lblBatt, buf); - uint32_t col = Theme::PRIMARY; + uint32_t col = Theme::TEXT_PRIMARY; if (pct <= 15) col = Theme::ERROR_CLR; else if (pct <= 30) col = Theme::WARNING_CLR; lv_obj_set_style_text_color(_lblBatt, lv_color_hex(col), 0); } } -void LvStatusBar::setTransportMode(const char* mode) { - (void)mode; -} - -void LvStatusBar::flashAnnounce() { - _announceFlashEnd = millis() + 1000; -} +void LvStatusBar::flashAnnounce() { _announceFlashEnd = millis() + 1000; } void LvStatusBar::showToast(const char* msg, uint32_t durationMs) { lv_label_set_text(_lblToast, msg); diff --git a/src/ui/LvStatusBar.h b/src/ui/LvStatusBar.h index 35f0762..84af0d8 100644 --- a/src/ui/LvStatusBar.h +++ b/src/ui/LvStatusBar.h @@ -8,7 +8,7 @@ public: void create(lv_obj_t* parent); void update(); - // Status setters (kept for API compatibility, no bars drawn) + // Status setters void setLoRaOnline(bool online) { _loraOnline = online; } void setBLEActive(bool active) { _bleActive = active; } void setBLEEnabled(bool enabled) { _bleEnabled = enabled; } @@ -17,7 +17,6 @@ public: void setTCPConnected(bool connected) { _tcpConnected = connected; } void setGPSFix(bool hasFix); void setBatteryPercent(int pct); - void setTransportMode(const char* mode); void flashAnnounce(); void showToast(const char* msg, uint32_t durationMs = 1500); @@ -29,12 +28,13 @@ public: private: lv_obj_t* _bar = nullptr; - lv_obj_t* _lblTime = nullptr; // Top-left: current time - lv_obj_t* _lblBrand = nullptr; // Center: "Ratspeak.org" - lv_obj_t* _lblBatt = nullptr; // Right: battery % + lv_obj_t* _lblTime = nullptr; // Left: current time + lv_obj_t* _lblBrand = nullptr; // Center: "Ratspeak.org" + lv_obj_t* _lblBatt = nullptr; // Right: battery % lv_obj_t* _toast = nullptr; lv_obj_t* _lblToast = nullptr; + bool _loraOnline = false; bool _bleActive = false; bool _bleEnabled = false; diff --git a/src/ui/LvTabBar.cpp b/src/ui/LvTabBar.cpp index b5b5774..8862ebe 100644 --- a/src/ui/LvTabBar.cpp +++ b/src/ui/LvTabBar.cpp @@ -1,5 +1,6 @@ #include "LvTabBar.h" #include "Theme.h" +#include "LvTheme.h" #include #include "fonts/fonts.h" @@ -24,7 +25,7 @@ void LvTabBar::create(lv_obj_t* parent) { lv_obj_align(_bar, LV_ALIGN_BOTTOM_LEFT, 0, 0); lv_obj_set_style_bg_color(_bar, lv_color_hex(Theme::BG), 0); lv_obj_set_style_bg_opa(_bar, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(_bar, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_color(_bar, lv_color_hex(Theme::DIVIDER), 0); lv_obj_set_style_border_width(_bar, 1, 0); lv_obj_set_style_border_side(_bar, LV_BORDER_SIDE_TOP, 0); lv_obj_set_style_pad_all(_bar, 0, 0); @@ -39,6 +40,7 @@ void LvTabBar::create(lv_obj_t* parent) { for (int i = 0; i < TAB_COUNT; i++) { _tabs[i] = lv_label_create(_bar); lv_obj_set_style_text_font(_tabs[i], font, 0); + lv_label_set_recolor(_tabs[i], true); lv_label_set_text(_tabs[i], TAB_NAMES[i]); lv_obj_add_flag(_tabs[i], LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(_tabs[i], tab_click_cb, LV_EVENT_CLICKED, this); @@ -72,9 +74,11 @@ void LvTabBar::refreshTab(int idx) { lv_obj_set_style_text_color(_tabs[idx], lv_color_hex(active ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE), 0); - char buf[24]; + char buf[48]; if (_unread[idx] > 0) { - snprintf(buf, sizeof(buf), "%s(%d)", TAB_NAMES[idx], _unread[idx]); + // Unread count in badge color using LVGL recolor + snprintf(buf, sizeof(buf), "%s #%06X %d#", + TAB_NAMES[idx], (unsigned int)Theme::BADGE_BG, _unread[idx]); } else { snprintf(buf, sizeof(buf), "%s", TAB_NAMES[idx]); } diff --git a/src/ui/LvTabBar.h b/src/ui/LvTabBar.h index 3bea16c..d969913 100644 --- a/src/ui/LvTabBar.h +++ b/src/ui/LvTabBar.h @@ -29,5 +29,5 @@ private: int _unread[TAB_COUNT] = {}; TabCallback _tabCb = nullptr; - static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Friends", "Msgs", "Peers", "Setup"}; + static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Friends", "Msgs", "Peers", "Settings"}; }; diff --git a/src/ui/LvTheme.cpp b/src/ui/LvTheme.cpp index 65e4c8e..fb55bd7 100644 --- a/src/ui/LvTheme.cpp +++ b/src/ui/LvTheme.cpp @@ -5,6 +5,7 @@ namespace LvTheme { +// Existing styles (16) static lv_style_t s_screen; static lv_style_t s_label; static lv_style_t s_labelMuted; @@ -22,52 +23,67 @@ static lv_style_t s_listBtnFocused; static lv_style_t s_dropdown; static lv_style_t s_slider; +// New styles (6) +static lv_style_t s_btnFocused; +static lv_style_t s_textareaFocused; +static lv_style_t s_sectionHeader; +static lv_style_t s_modal; +static lv_style_t s_scrollbar; +static lv_style_t s_roller; + void init(lv_disp_t* disp) { - // Screen background + // Screen background (LV_USE_THEME_DEFAULT is disabled in lv_conf.h, + // so there's no default blue focus outline to worry about) lv_style_init(&s_screen); lv_style_set_bg_color(&s_screen, lv_color_hex(Theme::BG)); lv_style_set_bg_opa(&s_screen, LV_OPA_COVER); - lv_style_set_text_color(&s_screen, lv_color_hex(Theme::PRIMARY)); + lv_style_set_text_color(&s_screen, lv_color_hex(Theme::TEXT_PRIMARY)); lv_style_set_text_font(&s_screen, &lv_font_ratdeck_14); // Labels lv_style_init(&s_label); - lv_style_set_text_color(&s_label, lv_color_hex(Theme::PRIMARY)); + lv_style_set_text_color(&s_label, lv_color_hex(Theme::TEXT_PRIMARY)); lv_style_init(&s_labelMuted); - lv_style_set_text_color(&s_labelMuted, lv_color_hex(Theme::MUTED)); + lv_style_set_text_color(&s_labelMuted, lv_color_hex(Theme::TEXT_SECONDARY)); lv_style_init(&s_labelAccent); lv_style_set_text_color(&s_labelAccent, lv_color_hex(Theme::ACCENT)); // Buttons lv_style_init(&s_btn); - lv_style_set_bg_color(&s_btn, lv_color_hex(Theme::BG)); + lv_style_set_bg_color(&s_btn, lv_color_hex(Theme::BG_ELEVATED)); lv_style_set_bg_opa(&s_btn, LV_OPA_COVER); lv_style_set_border_color(&s_btn, lv_color_hex(Theme::BORDER)); lv_style_set_border_width(&s_btn, 1); - lv_style_set_text_color(&s_btn, lv_color_hex(Theme::PRIMARY)); - lv_style_set_radius(&s_btn, 3); - lv_style_set_pad_all(&s_btn, 6); + lv_style_set_text_color(&s_btn, lv_color_hex(Theme::TEXT_PRIMARY)); + lv_style_set_radius(&s_btn, 4); + lv_style_set_pad_all(&s_btn, 8); lv_style_init(&s_btnPressed); - lv_style_set_bg_color(&s_btnPressed, lv_color_hex(Theme::SELECTION_BG)); + lv_style_set_bg_color(&s_btnPressed, lv_color_hex(Theme::PRIMARY_SUBTLE)); lv_style_set_border_color(&s_btnPressed, lv_color_hex(Theme::PRIMARY)); + lv_style_set_border_width(&s_btnPressed, 1); + lv_style_set_text_color(&s_btnPressed, lv_color_hex(Theme::ACCENT)); + + lv_style_init(&s_btnFocused); + lv_style_set_border_color(&s_btnFocused, lv_color_hex(Theme::PRIMARY)); + lv_style_set_border_width(&s_btnFocused, 2); // Bar lv_style_init(&s_bar); lv_style_set_bg_color(&s_bar, lv_color_hex(Theme::BORDER)); lv_style_set_bg_opa(&s_bar, LV_OPA_COVER); - lv_style_set_radius(&s_bar, 2); + lv_style_set_radius(&s_bar, 3); lv_style_init(&s_barIndicator); lv_style_set_bg_color(&s_barIndicator, lv_color_hex(Theme::PRIMARY)); lv_style_set_bg_opa(&s_barIndicator, LV_OPA_COVER); - lv_style_set_radius(&s_barIndicator, 2); + lv_style_set_radius(&s_barIndicator, 3); - // Switch + // Switch (LV_USE_SWITCH currently 0; kept for future lv_menu rewrite) lv_style_init(&s_switch); - lv_style_set_bg_color(&s_switch, lv_color_hex(Theme::MUTED)); + lv_style_set_bg_color(&s_switch, lv_color_hex(Theme::BORDER)); lv_style_set_bg_opa(&s_switch, LV_OPA_COVER); lv_style_set_radius(&s_switch, LV_RADIUS_CIRCLE); @@ -76,15 +92,19 @@ void init(lv_disp_t* disp) { // Textarea lv_style_init(&s_textarea); - lv_style_set_bg_color(&s_textarea, lv_color_hex(Theme::BG)); + lv_style_set_bg_color(&s_textarea, lv_color_hex(Theme::BG_ELEVATED)); lv_style_set_bg_opa(&s_textarea, LV_OPA_COVER); lv_style_set_border_color(&s_textarea, lv_color_hex(Theme::BORDER)); lv_style_set_border_width(&s_textarea, 1); - lv_style_set_text_color(&s_textarea, lv_color_hex(Theme::PRIMARY)); - lv_style_set_radius(&s_textarea, 2); + lv_style_set_text_color(&s_textarea, lv_color_hex(Theme::TEXT_PRIMARY)); + lv_style_set_radius(&s_textarea, 4); lv_style_set_pad_all(&s_textarea, 6); - // List + lv_style_init(&s_textareaFocused); + lv_style_set_border_color(&s_textareaFocused, lv_color_hex(Theme::PRIMARY)); + lv_style_set_border_width(&s_textareaFocused, 1); + + // List container lv_style_init(&s_list); lv_style_set_bg_color(&s_list, lv_color_hex(Theme::BG)); lv_style_set_bg_opa(&s_list, LV_OPA_COVER); @@ -92,40 +112,84 @@ void init(lv_disp_t* disp) { lv_style_set_pad_row(&s_list, 0); lv_style_set_border_width(&s_list, 0); + // List items lv_style_init(&s_listBtn); lv_style_set_bg_color(&s_listBtn, lv_color_hex(Theme::BG)); lv_style_set_bg_opa(&s_listBtn, LV_OPA_COVER); - lv_style_set_text_color(&s_listBtn, lv_color_hex(Theme::PRIMARY)); - lv_style_set_border_color(&s_listBtn, lv_color_hex(Theme::BORDER)); - lv_style_set_border_width(&s_listBtn, 0); + lv_style_set_text_color(&s_listBtn, lv_color_hex(Theme::TEXT_PRIMARY)); + lv_style_set_border_color(&s_listBtn, lv_color_hex(Theme::DIVIDER)); + lv_style_set_border_width(&s_listBtn, 1); lv_style_set_border_side(&s_listBtn, LV_BORDER_SIDE_BOTTOM); - lv_style_set_pad_all(&s_listBtn, 5); + lv_style_set_pad_all(&s_listBtn, 6); + lv_style_set_pad_left(&s_listBtn, 8); lv_style_set_radius(&s_listBtn, 0); + // List item focused — left-bar indicator + hover background lv_style_init(&s_listBtnFocused); - lv_style_set_bg_color(&s_listBtnFocused, lv_color_hex(Theme::SELECTION_BG)); + lv_style_set_bg_color(&s_listBtnFocused, lv_color_hex(Theme::BG_HOVER)); lv_style_set_bg_opa(&s_listBtnFocused, LV_OPA_COVER); - lv_style_set_text_color(&s_listBtnFocused, lv_color_hex(Theme::ACCENT)); + lv_style_set_text_color(&s_listBtnFocused, lv_color_hex(Theme::TEXT_PRIMARY)); lv_style_set_border_color(&s_listBtnFocused, lv_color_hex(Theme::PRIMARY)); - lv_style_set_border_width(&s_listBtnFocused, 1); - // Override LVGL's default blue focus outline - lv_style_set_outline_width(&s_listBtnFocused, 0); - lv_style_set_outline_opa(&s_listBtnFocused, LV_OPA_TRANSP); + lv_style_set_border_width(&s_listBtnFocused, 2); + lv_style_set_border_side(&s_listBtnFocused, LV_BORDER_SIDE_LEFT); // Dropdown lv_style_init(&s_dropdown); - lv_style_set_bg_color(&s_dropdown, lv_color_hex(Theme::BG)); + lv_style_set_bg_color(&s_dropdown, lv_color_hex(Theme::BG_ELEVATED)); lv_style_set_bg_opa(&s_dropdown, LV_OPA_COVER); lv_style_set_border_color(&s_dropdown, lv_color_hex(Theme::BORDER)); lv_style_set_border_width(&s_dropdown, 1); - lv_style_set_text_color(&s_dropdown, lv_color_hex(Theme::PRIMARY)); - lv_style_set_radius(&s_dropdown, 2); + lv_style_set_text_color(&s_dropdown, lv_color_hex(Theme::TEXT_PRIMARY)); + lv_style_set_radius(&s_dropdown, 4); lv_style_set_pad_all(&s_dropdown, 4); // Slider lv_style_init(&s_slider); lv_style_set_bg_color(&s_slider, lv_color_hex(Theme::BORDER)); lv_style_set_bg_opa(&s_slider, LV_OPA_COVER); + lv_style_set_radius(&s_slider, 3); + + // Section header (for list section dividers like "Contacts (3)") + lv_style_init(&s_sectionHeader); + lv_style_set_bg_opa(&s_sectionHeader, LV_OPA_TRANSP); + lv_style_set_text_color(&s_sectionHeader, lv_color_hex(Theme::TEXT_SECONDARY)); + lv_style_set_text_font(&s_sectionHeader, &lv_font_ratdeck_10); + lv_style_set_text_letter_space(&s_sectionHeader, 1); + lv_style_set_border_color(&s_sectionHeader, lv_color_hex(Theme::DIVIDER)); + lv_style_set_border_width(&s_sectionHeader, 1); + lv_style_set_border_side(&s_sectionHeader, LV_BORDER_SIDE_BOTTOM); + lv_style_set_pad_top(&s_sectionHeader, 8); + lv_style_set_pad_bottom(&s_sectionHeader, 4); + lv_style_set_pad_left(&s_sectionHeader, 8); + + // Modal overlay + lv_style_init(&s_modal); + lv_style_set_bg_color(&s_modal, lv_color_hex(Theme::BG_SURFACE)); + lv_style_set_bg_opa(&s_modal, LV_OPA_COVER); + lv_style_set_border_color(&s_modal, lv_color_hex(Theme::BORDER)); + lv_style_set_border_width(&s_modal, 1); + lv_style_set_radius(&s_modal, 6); + lv_style_set_pad_all(&s_modal, 12); + lv_style_set_pad_row(&s_modal, 4); + lv_style_set_shadow_width(&s_modal, 8); + lv_style_set_shadow_color(&s_modal, lv_color_hex(0x000000)); + lv_style_set_shadow_opa(&s_modal, LV_OPA_50); + + // Scrollbar + lv_style_init(&s_scrollbar); + lv_style_set_bg_color(&s_scrollbar, lv_color_hex(Theme::TEXT_MUTED)); + lv_style_set_bg_opa(&s_scrollbar, LV_OPA_40); + lv_style_set_radius(&s_scrollbar, LV_RADIUS_CIRCLE); + lv_style_set_width(&s_scrollbar, 3); + lv_style_set_pad_right(&s_scrollbar, 2); + + // Roller — no border so selected row highlight extends edge-to-edge + lv_style_init(&s_roller); + lv_style_set_bg_color(&s_roller, lv_color_hex(Theme::BG)); + lv_style_set_bg_opa(&s_roller, LV_OPA_COVER); + lv_style_set_text_color(&s_roller, lv_color_hex(Theme::TEXT_SECONDARY)); + lv_style_set_text_font(&s_roller, &lv_font_ratdeck_14); + lv_style_set_border_width(&s_roller, 0); // Apply screen style to default theme lv_obj_add_style(lv_scr_act(), &s_screen, 0); @@ -133,21 +197,30 @@ void init(lv_disp_t* disp) { Serial.println("[LVGL] Ratspeak theme initialized"); } -lv_style_t* styleScreen() { return &s_screen; } -lv_style_t* styleLabel() { return &s_label; } -lv_style_t* styleLabelMuted() { return &s_labelMuted; } -lv_style_t* styleLabelAccent() { return &s_labelAccent; } -lv_style_t* styleBtn() { return &s_btn; } -lv_style_t* styleBtnPressed() { return &s_btnPressed; } -lv_style_t* styleBar() { return &s_bar; } -lv_style_t* styleBarIndicator() { return &s_barIndicator; } -lv_style_t* styleSwitch() { return &s_switch; } -lv_style_t* styleSwitchChecked() { return &s_switchChecked; } -lv_style_t* styleTextarea() { return &s_textarea; } -lv_style_t* styleList() { return &s_list; } -lv_style_t* styleListBtn() { return &s_listBtn; } -lv_style_t* styleListBtnFocused() { return &s_listBtnFocused; } -lv_style_t* styleDropdown() { return &s_dropdown; } -lv_style_t* styleSlider() { return &s_slider; } +// Existing accessors (16) +lv_style_t* styleScreen() { return &s_screen; } +lv_style_t* styleLabel() { return &s_label; } +lv_style_t* styleLabelMuted() { return &s_labelMuted; } +lv_style_t* styleLabelAccent() { return &s_labelAccent; } +lv_style_t* styleBtn() { return &s_btn; } +lv_style_t* styleBtnPressed() { return &s_btnPressed; } +lv_style_t* styleBar() { return &s_bar; } +lv_style_t* styleBarIndicator() { return &s_barIndicator; } +lv_style_t* styleSwitch() { return &s_switch; } +lv_style_t* styleSwitchChecked() { return &s_switchChecked; } +lv_style_t* styleTextarea() { return &s_textarea; } +lv_style_t* styleList() { return &s_list; } +lv_style_t* styleListBtn() { return &s_listBtn; } +lv_style_t* styleListBtnFocused() { return &s_listBtnFocused; } +lv_style_t* styleDropdown() { return &s_dropdown; } +lv_style_t* styleSlider() { return &s_slider; } + +// New accessors (6) +lv_style_t* styleBtnFocused() { return &s_btnFocused; } +lv_style_t* styleTextareaFocused() { return &s_textareaFocused; } +lv_style_t* styleSectionHeader() { return &s_sectionHeader; } +lv_style_t* styleModal() { return &s_modal; } +lv_style_t* styleScrollbar() { return &s_scrollbar; } +lv_style_t* styleRoller() { return &s_roller; } } // namespace LvTheme diff --git a/src/ui/LvTheme.h b/src/ui/LvTheme.h index c1add1c..ead80c5 100644 --- a/src/ui/LvTheme.h +++ b/src/ui/LvTheme.h @@ -2,12 +2,12 @@ #include -// Ratspeak LVGL theme — matrix green on black cyberpunk aesthetic +// Ratspeak LVGL theme — warm orange brand palette on dark neutrals namespace LvTheme { void init(lv_disp_t* disp); -// Style accessors for common elements +// Style accessors — existing (16) lv_style_t* styleScreen(); lv_style_t* styleLabel(); lv_style_t* styleLabelMuted(); @@ -25,4 +25,12 @@ lv_style_t* styleListBtnFocused(); lv_style_t* styleDropdown(); lv_style_t* styleSlider(); +// Style accessors — new (6) +lv_style_t* styleBtnFocused(); +lv_style_t* styleTextareaFocused(); +lv_style_t* styleSectionHeader(); +lv_style_t* styleModal(); +lv_style_t* styleScrollbar(); +lv_style_t* styleRoller(); + } // namespace LvTheme diff --git a/src/ui/StatusBar.cpp b/src/ui/StatusBar.cpp deleted file mode 100644 index f522856..0000000 --- a/src/ui/StatusBar.cpp +++ /dev/null @@ -1,105 +0,0 @@ -#include "StatusBar.h" -#include "Theme.h" -#include "hal/Display.h" -#include - -void StatusBar::update() { - bool wasFlashing = (_announceFlashEnd > 0); - if (wasFlashing && millis() >= _announceFlashEnd) { - _announceFlashEnd = 0; - markDirty(); - } - if (_toastEnd > 0 && millis() >= _toastEnd) { - _toastEnd = 0; - _toastMsg.clear(); - markDirty(); - } -} - -void StatusBar::draw(LGFX_TDeck& gfx) { - // Toast notification replaces status bar temporarily - if (_toastEnd > 0 && millis() < _toastEnd) { - gfx.fillRect(0, 0, Theme::SCREEN_W, Theme::STATUS_BAR_H, Theme::ACCENT); - gfx.setTextSize(1); - gfx.setTextColor(Theme::BG, Theme::ACCENT); - // Center the toast text - int textW = (int)_toastMsg.length() * 6; - int tx = (Theme::SCREEN_W - textW) / 2; - gfx.setCursor(tx, 3); - gfx.print(_toastMsg.c_str()); - gfx.drawFastHLine(0, Theme::STATUS_BAR_H - 1, Theme::SCREEN_W, Theme::BORDER); - return; - } - - // Clear status bar area - gfx.fillRect(0, 0, Theme::SCREEN_W, Theme::STATUS_BAR_H, Theme::BG); - gfx.setTextSize(1); - - int x = 2; - - // LoRa indicator - bool flashing = _announceFlashEnd > 0 && millis() < _announceFlashEnd; - if (_loraOnline) { - gfx.setTextColor(flashing ? Theme::ACCENT : Theme::PRIMARY, Theme::BG); - gfx.setCursor(x, 3); - gfx.print(flashing ? "LoRa TX" : "LoRa OK"); - } else { - gfx.setTextColor(Theme::MUTED, Theme::BG); - gfx.setCursor(x, 3); - gfx.print("LoRa --"); - } - x += 50; - - // BLE indicator - if (_bleActive) { - gfx.setTextColor(Theme::ACCENT, Theme::BG); - gfx.setCursor(x, 3); - gfx.print("BLE"); - x += 30; - } - - // WiFi indicator - if (_wifiActive) { - gfx.setTextColor(Theme::PRIMARY, Theme::BG); - gfx.setCursor(x, 3); - gfx.print("WiFi"); - } - - // Battery (right side) - if (_battPct >= 0) { - char buf[8]; - snprintf(buf, sizeof(buf), "%d%%", _battPct); - uint32_t col = Theme::PRIMARY; - if (_battPct <= 15) col = Theme::ERROR_CLR; - else if (_battPct <= 30) col = Theme::WARNING_CLR; - gfx.setTextColor(col, Theme::BG); - gfx.setCursor(Theme::SCREEN_W - 60, 3); - gfx.print(buf); - } - - // Uptime (far right) — minutes only to avoid per-second redraws - unsigned long mins = millis() / 60000; - char timeBuf[16]; - if (mins >= 60) { - snprintf(timeBuf, sizeof(timeBuf), "%luh%lum", mins / 60, mins % 60); - } else { - snprintf(timeBuf, sizeof(timeBuf), "%lum", mins); - } - gfx.setTextColor(Theme::SECONDARY, Theme::BG); - gfx.setCursor(Theme::SCREEN_W - 36, 3); - gfx.print(timeBuf); - - // Bottom border line - gfx.drawFastHLine(0, Theme::STATUS_BAR_H - 1, Theme::SCREEN_W, Theme::BORDER); -} - -void StatusBar::flashAnnounce() { - _announceFlashEnd = millis() + 1000; - markDirty(); -} - -void StatusBar::showToast(const char* msg, uint32_t durationMs) { - _toastMsg = msg; - _toastEnd = millis() + durationMs; - markDirty(); -} diff --git a/src/ui/StatusBar.h b/src/ui/StatusBar.h deleted file mode 100644 index 7a62172..0000000 --- a/src/ui/StatusBar.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include - -class LGFX_TDeck; - -class StatusBar { -public: - void setGfx(LGFX_TDeck* gfx) { _gfx = gfx; } - void draw(LGFX_TDeck& gfx); - void update(); - - void setLoRaOnline(bool online) { _loraOnline = online; markDirty(); } - void setBLEActive(bool active) { _bleActive = active; markDirty(); } - void setWiFiActive(bool active) { _wifiActive = active; markDirty(); } - void setBatteryPercent(int pct) { if (_battPct != pct) { _battPct = pct; markDirty(); } } - void setTransportMode(const char* mode) { _transportMode = mode; markDirty(); } - void flashAnnounce(); - void showToast(const char* msg, uint32_t durationMs = 1500); - - bool isDirty() const { return _dirty; } - void markDirty() { _dirty = true; } - void clearDirty() { _dirty = false; } - - friend class UIManager; - -private: - LGFX_TDeck* _gfx = nullptr; - bool _dirty = true; - - bool _loraOnline = false; - bool _bleActive = false; - bool _wifiActive = false; - int _battPct = -1; - std::string _transportMode; - unsigned long _announceFlashEnd = 0; - - // Toast notification - std::string _toastMsg; - unsigned long _toastEnd = 0; -}; diff --git a/src/ui/TabBar.cpp b/src/ui/TabBar.cpp deleted file mode 100644 index b8c7da6..0000000 --- a/src/ui/TabBar.cpp +++ /dev/null @@ -1,64 +0,0 @@ -#include "TabBar.h" -#include "Theme.h" -#include "hal/Display.h" -#include -#include - -constexpr const char* TabBar::TAB_NAMES[5]; - -void TabBar::draw(LGFX_TDeck& gfx) { - int y = Theme::SCREEN_H - Theme::TAB_BAR_H; - - // Clear tab bar area - gfx.fillRect(0, y, Theme::SCREEN_W, Theme::TAB_BAR_H, Theme::BG); - - // Top border - gfx.drawFastHLine(0, y, Theme::SCREEN_W, Theme::BORDER); - - gfx.setTextSize(1); - - for (int i = 0; i < 5; i++) { - int tx = i * Theme::TAB_W; - - // Active tab indicator - if (i == _activeTab) { - gfx.drawFastHLine(tx + 2, y, Theme::TAB_W - 4, Theme::ACCENT); - } - - // Tab label - uint32_t col = (i == _activeTab) ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE; - gfx.setTextColor(col, Theme::BG); - - char label[24]; - if (_unread[i] > 0) { - snprintf(label, sizeof(label), "%s(%d)", TAB_NAMES[i], _unread[i]); - } else { - snprintf(label, sizeof(label), "%s", TAB_NAMES[i]); - } - - // Center text in tab - int textW = strlen(label) * 6; // 6px per char at textSize 1 - int textX = tx + (Theme::TAB_W - textW) / 2; - gfx.setCursor(textX, y + 4); - gfx.print(label); - } -} - -void TabBar::setActiveTab(int tab) { - if (tab >= 0 && tab < 5) { - _activeTab = tab; - markDirty(); - } -} - -void TabBar::cycleTab(int direction) { - _activeTab = (_activeTab + direction + 5) % 5; - markDirty(); -} - -void TabBar::setUnreadCount(int tab, int count) { - if (tab >= 0 && tab < 5) { - _unread[tab] = count; - markDirty(); - } -} diff --git a/src/ui/TabBar.h b/src/ui/TabBar.h deleted file mode 100644 index 79210c1..0000000 --- a/src/ui/TabBar.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -class LGFX_TDeck; - -class TabBar { -public: - enum Tab { TAB_HOME = 0, TAB_MSGS, TAB_NODES, TAB_MAP, TAB_SETUP }; - - void setGfx(LGFX_TDeck* gfx) { _gfx = gfx; } - void draw(LGFX_TDeck& gfx); - - void setActiveTab(int tab); - int getActiveTab() const { return _activeTab; } - void cycleTab(int direction); - - void setUnreadCount(int tab, int count); - - using TabCallback = void(*)(int tab); - void setTabCallback(TabCallback cb) { _tabCb = cb; } - - bool isDirty() const { return _dirty; } - void markDirty() { _dirty = true; } - void clearDirty() { _dirty = false; } - - friend class UIManager; - -private: - LGFX_TDeck* _gfx = nullptr; - bool _dirty = true; - - int _activeTab = TAB_HOME; - int _unread[5] = {}; - TabCallback _tabCb = nullptr; - - static constexpr const char* TAB_NAMES[5] = {"Home", "Msgs", "Nodes", "Map", "Setup"}; -}; diff --git a/src/ui/Theme.cpp b/src/ui/Theme.cpp deleted file mode 100644 index e58a507..0000000 --- a/src/ui/Theme.cpp +++ /dev/null @@ -1,2 +0,0 @@ -#include "Theme.h" -// Theme is now compile-time constants only — no runtime init needed. diff --git a/src/ui/Theme.h b/src/ui/Theme.h index 77f7db2..4c58b34 100644 --- a/src/ui/Theme.h +++ b/src/ui/Theme.h @@ -3,32 +3,54 @@ #include // ============================================================================= -// RatDeck — Cyberpunk Theme Constants +// RatDeck — Design System Constants +// Brand orange palette with warm dark neutrals // ============================================================================= namespace Theme { -// --- Colors (RGB888 for LovyanGFX) --- -constexpr uint32_t BG = 0x000000; // Pure black -constexpr uint32_t PRIMARY = 0x00FF41; // Matrix green -constexpr uint32_t SECONDARY = 0x00DD44; // Dimmed green -constexpr uint32_t ACCENT = 0x00FFFF; // Cyan -constexpr uint32_t MUTED = 0x559955; // Readable muted green -constexpr uint32_t ERROR_CLR = 0xFF3333; // Red -constexpr uint32_t WARNING_CLR = 0xFFFF00; // Yellow -constexpr uint32_t BORDER = 0x007700; // Visible green border -constexpr uint32_t SELECTION_BG = 0x004400; // Highlight -constexpr uint32_t MSG_OUT_BG = 0x002200; // Outgoing bubble -constexpr uint32_t MSG_IN_BG = 0x1A1A2E; // Incoming bubble -constexpr uint32_t TAB_ACTIVE = 0x00FF41; -constexpr uint32_t TAB_INACTIVE = 0x559955; -constexpr uint32_t BADGE_BG = 0xFF3333; +// --- Backgrounds (true black base) --- +constexpr uint32_t BG = 0x000000; // Pure black (screen bg) +constexpr uint32_t BG_ELEVATED = 0x0E140E; // Cards, inputs, list items +constexpr uint32_t BG_SURFACE = 0x162016; // Modals, dropdowns, tooltips +constexpr uint32_t BG_HOVER = 0x1E2E1E; // Focus/hover background + +// --- Text Hierarchy (cool green-tinted for cohesion with brand) --- +constexpr uint32_t TEXT_PRIMARY = 0xCCE8CC; // Primary text (green-tinted white) +constexpr uint32_t TEXT_SECONDARY = 0x7A9A7A; // Timestamps, metadata (green-tinted gray) +constexpr uint32_t TEXT_MUTED = 0x4A5E4A; // Disabled, placeholder (green-tinted dark) + +// --- Brand / Interactive --- +constexpr uint32_t PRIMARY = 0x00CC66; // Emerald green (interactive, focus) +constexpr uint32_t PRIMARY_MUTED = 0x00AA55; // Pressed/active state +constexpr uint32_t PRIMARY_SUBTLE = 0x0A1F12; // Selection backgrounds, subtle highlight +constexpr uint32_t ACCENT = 0x40E880; // Headers, brand text (bright emerald) + +// --- Status --- +constexpr uint32_t SUCCESS = 0x00CC66; // Online, delivered, connected (= PRIMARY) +constexpr uint32_t WARNING_CLR = 0xE0B83E; // Queued, pending, caution (golden amber) +constexpr uint32_t ERROR_CLR = 0xF87171; // Failed, error, critical (soft red) + +// --- Structural --- +constexpr uint32_t BORDER = 0x1E2E1E; // Subtle structural borders (= BG_HOVER) +constexpr uint32_t BORDER_ACTIVE = 0x00CC66; // Focus borders (= PRIMARY) +constexpr uint32_t DIVIDER = 0x162016; // Hairline separators (= BG_SURFACE) + +// --- Messages --- +constexpr uint32_t MSG_OUT_BG = 0x0A1F12; // Outgoing bubble (= PRIMARY_SUBTLE) +constexpr uint32_t MSG_IN_BG = 0x0A120A; // Incoming bubble + +// --- Tab Bar --- +constexpr uint32_t TAB_ACTIVE = 0x00CC66; // Active tab (= PRIMARY) +constexpr uint32_t TAB_INACTIVE = 0x9A908A; // Inactive tabs (= TEXT_SECONDARY) +constexpr uint32_t BADGE_BG = 0xF87171; // Unread badge (= ERROR_CLR) + // --- Layout Metrics --- constexpr int SCREEN_W = 320; constexpr int SCREEN_H = 240; constexpr int STATUS_BAR_H = 20; -constexpr int TAB_BAR_H = 20; +constexpr int TAB_BAR_H = 24; constexpr int CONTENT_Y = STATUS_BAR_H; constexpr int CONTENT_H = SCREEN_H - STATUS_BAR_H - TAB_BAR_H; constexpr int CONTENT_W = SCREEN_W; @@ -37,6 +59,13 @@ constexpr int CONTENT_W = SCREEN_W; constexpr int TAB_COUNT = 5; constexpr int TAB_W = SCREEN_W / TAB_COUNT; +// --- Spacing (base unit: 4px) --- +constexpr int SP_1 = 4; +constexpr int SP_2 = 8; +constexpr int SP_3 = 12; +constexpr int SP_4 = 16; +constexpr int SP_6 = 24; + } // namespace Theme // LVGL color helper — available when LVGL is included diff --git a/src/ui/UIManager.cpp b/src/ui/UIManager.cpp index ae7c544..03986a7 100644 --- a/src/ui/UIManager.cpp +++ b/src/ui/UIManager.cpp @@ -1,7 +1,6 @@ #include "UIManager.h" #include "Theme.h" #include "LvTheme.h" -#include "hal/Display.h" // --- LvScreen base --- @@ -13,11 +12,7 @@ void LvScreen::destroyUI() { // --- UIManager --- -void UIManager::begin(LGFX_TDeck* gfx) { - _gfx = gfx; - _statusBar.setGfx(gfx); - _tabBar.setGfx(gfx); - +void UIManager::begin() { // Initialize LVGL theme and create persistent UI structure lv_obj_t* scr = lv_scr_act(); LvTheme::init(lv_disp_get_default()); @@ -41,47 +36,10 @@ void UIManager::begin(LGFX_TDeck* gfx) { lv_obj_set_style_pad_all(_lvContent, 0, 0); lv_obj_set_style_radius(_lvContent, 0, 0); - _lvglActive = true; - Serial.println("[UI] LVGL UI structure created"); } -void UIManager::setScreen(Screen* screen) { - // When setting a legacy screen, hide LVGL content and use legacy rendering - if (_currentLvScreen) { - _currentLvScreen->onExit(); - _currentLvScreen->destroyUI(); - _currentLvScreen = nullptr; - } - - if (_currentScreen) { - _currentScreen->onExit(); - } - - _currentScreen = screen; - - if (_currentScreen) { - _currentScreen->onEnter(); - _currentScreen->markDirty(); - } - - // Hide LVGL layers for legacy rendering - if (_lvglActive) { - lv_obj_add_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(_lvContent, LV_OBJ_FLAG_HIDDEN); - } - - forceRedraw(); -} - -void UIManager::setLvScreen(LvScreen* screen) { - // Transition from legacy screen - if (_currentScreen) { - _currentScreen->onExit(); - _currentScreen = nullptr; - } - +void UIManager::setScreen(LvScreen* screen) { // Transition from previous LVGL screen if (_currentLvScreen) { _currentLvScreen->onExit(); @@ -91,13 +49,11 @@ void UIManager::setLvScreen(LvScreen* screen) { _currentLvScreen = screen; // Show LVGL layers - if (_lvglActive) { - if (!_bootMode) { - lv_obj_clear_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); - } - lv_obj_clear_flag(_lvContent, LV_OBJ_FLAG_HIDDEN); + if (!_bootMode) { + lv_obj_clear_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); } + lv_obj_clear_flag(_lvContent, LV_OBJ_FLAG_HIDDEN); if (_currentLvScreen) { // Clean content area @@ -107,103 +63,35 @@ void UIManager::setLvScreen(LvScreen* screen) { } } -void UIManager::setOverlay(Screen* overlay) { - _overlay = overlay; - if (_overlay) { - _overlay->markDirty(); - } - forceRedraw(); -} - void UIManager::setBootMode(bool boot) { _bootMode = boot; - if (_lvglActive) { - if (boot) { - lv_obj_add_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); - // In boot mode, content area is full screen - lv_obj_set_pos(_lvContent, 0, 0); - lv_obj_set_size(_lvContent, Theme::SCREEN_W, Theme::SCREEN_H); - } else { - lv_obj_clear_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); - lv_obj_set_pos(_lvContent, 0, Theme::STATUS_BAR_H); - lv_obj_set_size(_lvContent, Theme::CONTENT_W, Theme::CONTENT_H); - } + if (boot) { + lv_obj_add_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); + // In boot mode, content area is full screen + lv_obj_set_pos(_lvContent, 0, 0); + lv_obj_set_size(_lvContent, Theme::SCREEN_W, Theme::SCREEN_H); + } else { + lv_obj_clear_flag(_lvStatusBar.obj(), LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_lvTabBar.obj(), LV_OBJ_FLAG_HIDDEN); + lv_obj_set_pos(_lvContent, 0, Theme::STATUS_BAR_H); + lv_obj_set_size(_lvContent, Theme::CONTENT_W, Theme::CONTENT_H); } - forceRedraw(); } void UIManager::update() { - _statusBar.update(); _lvStatusBar.update(); - if (_currentScreen) _currentScreen->update(); if (_currentLvScreen) _currentLvScreen->refreshUI(); } -void UIManager::render() { - if (!_gfx) return; - - // If an LVGL screen is active, don't do legacy rendering - if (_currentLvScreen) return; - - bool needStatusRedraw = _statusBar.isDirty(); - bool needTabRedraw = _tabBar.isDirty(); - bool needContentRedraw = (_currentScreen && _currentScreen->isDirty()); - bool needOverlayRedraw = (_overlay && _overlay->isDirty()); - - if (!needStatusRedraw && !needTabRedraw && !needContentRedraw && !needOverlayRedraw) - return; - - if (!_bootMode && needStatusRedraw) { - _statusBar.draw(*_gfx); - _statusBar.clearDirty(); - } - - if (needContentRedraw) { - if (_bootMode) { - _gfx->setClipRect(0, 0, Theme::SCREEN_W, Theme::SCREEN_H); - } else { - _gfx->setClipRect(0, Theme::CONTENT_Y, Theme::CONTENT_W, Theme::CONTENT_H); - } - _gfx->fillRect(0, _bootMode ? 0 : Theme::CONTENT_Y, - Theme::CONTENT_W, _bootMode ? Theme::SCREEN_H : Theme::CONTENT_H, - Theme::BG); - _currentScreen->draw(*_gfx); - _currentScreen->clearDirty(); - _gfx->clearClipRect(); - } - - if (needOverlayRedraw && _overlay) { - _gfx->setClipRect(0, Theme::CONTENT_Y, Theme::CONTENT_W, Theme::CONTENT_H); - _overlay->draw(*_gfx); - _overlay->clearDirty(); - _gfx->clearClipRect(); - } - - if (!_bootMode && needTabRedraw) { - _tabBar.draw(*_gfx); - _tabBar.clearDirty(); - } -} - void UIManager::forceRedraw() { - _statusBar.markDirty(); - _tabBar.markDirty(); - if (_currentScreen) _currentScreen->markDirty(); - if (_overlay) _overlay->markDirty(); + lv_obj_invalidate(lv_scr_act()); } bool UIManager::handleKey(const KeyEvent& event) { - if (_overlay) { - return _overlay->handleKey(event); - } if (_currentLvScreen) { return _currentLvScreen->handleKey(event); } - if (_currentScreen) { - return _currentScreen->handleKey(event); - } return false; } diff --git a/src/ui/UIManager.h b/src/ui/UIManager.h index 4a176f3..73a8772 100644 --- a/src/ui/UIManager.h +++ b/src/ui/UIManager.h @@ -3,31 +3,8 @@ #include #include "LvStatusBar.h" #include "LvTabBar.h" -#include "StatusBar.h" -#include "TabBar.h" #include "hal/Keyboard.h" -class LGFX_TDeck; - -// Legacy screen base class (LovyanGFX direct drawing) -class Screen { -public: - virtual ~Screen() = default; - virtual void onEnter() {} - virtual void onExit() {} - virtual void update() {} - virtual bool handleKey(const KeyEvent& event) { return false; } - virtual const char* title() const = 0; - virtual void draw(LGFX_TDeck& gfx) = 0; - - bool isDirty() const { return _dirty; } - void markDirty() { _dirty = true; } - void clearDirty() { _dirty = false; } - -protected: - bool _dirty = true; -}; - // LVGL screen base class class LvScreen { public: @@ -49,30 +26,19 @@ protected: class UIManager { public: - void begin(LGFX_TDeck* gfx); + void begin(); - // Legacy screen management (LovyanGFX) - void setScreen(Screen* screen); - Screen* getScreen() { return _currentScreen; } + // Screen management + void setScreen(LvScreen* screen); + LvScreen* getScreen() { return _currentLvScreen; } - // LVGL screen management - void setLvScreen(LvScreen* screen); - LvScreen* getLvScreen() { return _currentLvScreen; } - - // Component access — legacy (for old code compatibility) - StatusBar& statusBar() { return _statusBar; } - TabBar& tabBar() { return _tabBar; } - - // Component access — LVGL + // Component access LvStatusBar& lvStatusBar() { return _lvStatusBar; } LvTabBar& lvTabBar() { return _lvTabBar; } // Update data (called periodically) void update(); - // Render if dirty (called from loop — legacy screens only) - void render(); - // Force full redraw void forceRedraw(); @@ -84,21 +50,10 @@ public: void setBootMode(bool boot); bool isBootMode() const { return _bootMode; } - // Overlay support (legacy) - void setOverlay(Screen* overlay); - // LVGL content area parent (between status bar and tab bar) lv_obj_t* contentParent() { return _lvContent; } private: - LGFX_TDeck* _gfx = nullptr; - - // Legacy components - StatusBar _statusBar; - TabBar _tabBar; - Screen* _currentScreen = nullptr; - Screen* _overlay = nullptr; - // LVGL components LvStatusBar _lvStatusBar; LvTabBar _lvTabBar; @@ -106,5 +61,4 @@ private: lv_obj_t* _lvContent = nullptr; bool _bootMode = false; - bool _lvglActive = false; }; diff --git a/src/ui/screens/LvBootScreen.cpp b/src/ui/screens/LvBootScreen.cpp index 1d405b7..e6fd200 100644 --- a/src/ui/screens/LvBootScreen.cpp +++ b/src/ui/screens/LvBootScreen.cpp @@ -13,14 +13,14 @@ void LvBootScreen::createUI(lv_obj_t* parent) { // Title: "RATDECK" _lblTitle = lv_label_create(parent); lv_obj_set_style_text_font(_lblTitle, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(_lblTitle, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_text_color(_lblTitle, lv_color_hex(Theme::ACCENT), 0); lv_label_set_text(_lblTitle, "RATDECK"); lv_obj_align(_lblTitle, LV_ALIGN_TOP_MID, 0, 60); // Version _lblVersion = lv_label_create(parent); lv_obj_set_style_text_font(_lblVersion, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_lblVersion, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_color(_lblVersion, lv_color_hex(Theme::TEXT_SECONDARY), 0); char ver[32]; snprintf(ver, sizeof(ver), "v%s", RATDECK_VERSION_STRING); lv_label_set_text(_lblVersion, ver); @@ -38,7 +38,7 @@ void LvBootScreen::createUI(lv_obj_t* parent) { // Status text _lblStatus = lv_label_create(parent); lv_obj_set_style_text_font(_lblStatus, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(_lblStatus, "Starting..."); lv_obj_align(_lblStatus, LV_ALIGN_TOP_MID, 0, 128); } diff --git a/src/ui/screens/LvContactsScreen.cpp b/src/ui/screens/LvContactsScreen.cpp index b2cb96d..18625ee 100644 --- a/src/ui/screens/LvContactsScreen.cpp +++ b/src/ui/screens/LvContactsScreen.cpp @@ -14,7 +14,7 @@ void LvContactsScreen::createUI(lv_obj_t* parent) { _lblEmpty = lv_label_create(parent); lv_obj_set_style_text_font(_lblEmpty, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(_lblEmpty, "No saved contacts"); lv_obj_center(_lblEmpty); @@ -93,11 +93,20 @@ void LvContactsScreen::rebuildList() { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); + // Contact name lv_obj_t* lbl = lv_label_create(row); lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0); lv_label_set_text(lbl, node.name.c_str()); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); + + // Hash suffix (right side, muted) + lv_obj_t* lblHash = lv_label_create(row); + lv_obj_set_style_text_font(lblHash, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(lblHash, lv_color_hex(Theme::TEXT_MUTED), 0); + std::string shortHash = node.hash.toHex().substr(0, 8); + lv_label_set_text(lblHash, shortHash.c_str()); + lv_obj_align(lblHash, LV_ALIGN_RIGHT_MID, -8, 0); } } diff --git a/src/ui/screens/LvDataCleanScreen.cpp b/src/ui/screens/LvDataCleanScreen.cpp index 9041d27..5f6366e 100644 --- a/src/ui/screens/LvDataCleanScreen.cpp +++ b/src/ui/screens/LvDataCleanScreen.cpp @@ -26,14 +26,14 @@ void LvDataCleanScreen::createUI(lv_obj_t* parent) { // Message lv_obj_t* msg = lv_label_create(parent); lv_obj_set_style_text_font(msg, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(msg, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_color(msg, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(msg, "Old data found on SD card."); lv_obj_align(msg, LV_ALIGN_TOP_MID, 0, 75); // Prompt lv_obj_t* prompt = lv_label_create(parent); lv_obj_set_style_text_font(prompt, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(prompt, "Remove old data and start fresh?"); lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 95); @@ -69,7 +69,7 @@ void LvDataCleanScreen::createUI(lv_obj_t* parent) { // Version lv_obj_t* ver = lv_label_create(parent); lv_obj_set_style_text_font(ver, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(ver, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(ver, lv_color_hex(Theme::TEXT_MUTED), 0); char verBuf[32]; snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING); lv_label_set_text(ver, verBuf); @@ -79,9 +79,9 @@ void LvDataCleanScreen::createUI(lv_obj_t* parent) { void LvDataCleanScreen::updateSelection() { if (_selectedYes) { lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::ACCENT), 0); - lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::TEXT_MUTED), 0); } else { - lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::TEXT_MUTED), 0); lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::ACCENT), 0); } } diff --git a/src/ui/screens/LvHelpOverlay.cpp b/src/ui/screens/LvHelpOverlay.cpp index 44b871e..48ce379 100644 --- a/src/ui/screens/LvHelpOverlay.cpp +++ b/src/ui/screens/LvHelpOverlay.cpp @@ -1,5 +1,6 @@ #include "LvHelpOverlay.h" #include "ui/Theme.h" +#include "ui/LvTheme.h" #include #include "fonts/fonts.h" @@ -7,12 +8,7 @@ void LvHelpOverlay::create() { _overlay = lv_obj_create(lv_layer_top()); lv_obj_set_size(_overlay, Theme::CONTENT_W - 40, Theme::CONTENT_H - 20); lv_obj_center(_overlay); - lv_obj_set_style_bg_color(_overlay, lv_color_hex(Theme::BG), 0); - lv_obj_set_style_bg_opa(_overlay, 240, 0); - lv_obj_set_style_border_color(_overlay, lv_color_hex(Theme::PRIMARY), 0); - lv_obj_set_style_border_width(_overlay, 1, 0); - lv_obj_set_style_radius(_overlay, 4, 0); - lv_obj_set_style_pad_all(_overlay, 10, 0); + lv_obj_add_style(_overlay, LvTheme::styleModal(), 0); lv_obj_set_style_pad_row(_overlay, 3, 0); lv_obj_set_layout(_overlay, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_overlay, LV_FLEX_FLOW_COLUMN); @@ -27,18 +23,18 @@ void LvHelpOverlay::create() { }; mkLabel("HOTKEYS", Theme::ACCENT); - mkLabel("Ctrl+H Help", Theme::PRIMARY); - mkLabel("Ctrl+M Messages", Theme::PRIMARY); - mkLabel("Ctrl+N New Message", Theme::PRIMARY); - mkLabel("Ctrl+S Settings", Theme::PRIMARY); - mkLabel("Ctrl+A Announce", Theme::PRIMARY); - mkLabel("Ctrl+D Diagnostics", Theme::PRIMARY); - mkLabel("Ctrl+T Radio Test", Theme::PRIMARY); - mkLabel("Ctrl+R RSSI Monitor", Theme::PRIMARY); - mkLabel(", / Prev/Next Tab", Theme::PRIMARY); - mkLabel("Esc Back", Theme::PRIMARY); - mkLabel("", Theme::MUTED); - mkLabel("Any key to close", Theme::MUTED); + mkLabel("Ctrl+H Help", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+M Messages", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+N New Message", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+S Settings", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+A Announce", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+D Diagnostics", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+T Radio Test", Theme::TEXT_PRIMARY); + mkLabel("Ctrl+R RSSI Monitor", Theme::TEXT_PRIMARY); + mkLabel(", / Prev/Next Tab", Theme::TEXT_PRIMARY); + mkLabel("Esc Back", Theme::TEXT_PRIMARY); + mkLabel("", Theme::TEXT_MUTED); + mkLabel("Any key to close", Theme::TEXT_MUTED); lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); } diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp index d96a8bd..fc71476 100644 --- a/src/ui/screens/LvHomeScreen.cpp +++ b/src/ui/screens/LvHomeScreen.cpp @@ -1,5 +1,7 @@ #include "LvHomeScreen.h" #include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "ui/LvInput.h" #include "reticulum/ReticulumManager.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" @@ -14,23 +16,52 @@ void LvHomeScreen::createUI(lv_obj_t* parent) { _screen = parent; lv_obj_set_layout(parent, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(parent, 6, 0); - lv_obj_set_style_pad_row(parent, 4, 0); + lv_obj_set_style_pad_all(parent, 8, 0); + lv_obj_set_style_pad_row(parent, 6, 0); lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - const lv_font_t* font = &lv_font_ratdeck_14; - auto mkLabel = [&](const char* initial) -> lv_obj_t* { - lv_obj_t* lbl = lv_label_create(parent); - lv_obj_set_style_text_font(lbl, font, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0); - lv_label_set_text(lbl, initial); - return lbl; - }; + // Name — large, accent color + _lblName = lv_label_create(parent); + lv_obj_set_style_text_font(_lblName, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(_lblName, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(_lblName, "..."); - _lblName = mkLabel("Name: ..."); - _lblId = mkLabel("ID: ..."); - _lblStatus = mkLabel("Status: ..."); - _lblNodes = mkLabel("Online Nodes: ..."); + // ID hash — smaller, muted + _lblId = lv_label_create(parent); + lv_obj_set_style_text_font(_lblId, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(_lblId, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(_lblId, "ID: ..."); + + // Transport indicators row + _lblStatus = lv_label_create(parent); + lv_obj_set_style_text_font(_lblStatus, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(_lblStatus, "Status: ..."); + lv_obj_set_style_pad_top(_lblStatus, 4, 0); + + // Online nodes + _lblNodes = lv_label_create(parent); + lv_obj_set_style_text_font(_lblNodes, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(_lblNodes, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(_lblNodes, "Online Nodes: ..."); + + // Announce button — compact, understated + _btnAnnounce = lv_btn_create(parent); + lv_obj_set_size(_btnAnnounce, 90, 24); + lv_obj_add_style(_btnAnnounce, LvTheme::styleBtn(), 0); + lv_obj_add_style(_btnAnnounce, LvTheme::styleBtnFocused(), LV_STATE_FOCUSED); + lv_obj_add_style(_btnAnnounce, LvTheme::styleBtnPressed(), LV_STATE_PRESSED); + + lv_obj_t* btnLbl = lv_label_create(_btnAnnounce); + lv_obj_set_style_text_font(btnLbl, &lv_font_ratdeck_10, 0); + lv_label_set_text(btnLbl, "Announce"); + lv_obj_center(btnLbl); + + lv_group_add_obj(LvInput::group(), _btnAnnounce); + lv_obj_add_event_cb(_btnAnnounce, [](lv_event_t* e) { + auto* self = (LvHomeScreen*)lv_event_get_user_data(e); + if (self->_announceCb) self->_announceCb(); + }, LV_EVENT_CLICKED, this); // Force refresh on new UI _lastUptime = ULONG_MAX; @@ -55,25 +86,25 @@ void LvHomeScreen::refreshUI() { // Name if (_cfg && !_cfg->settings().displayName.isEmpty()) { - lv_label_set_text_fmt(_lblName, "Name: %s", _cfg->settings().displayName.c_str()); + lv_label_set_text(_lblName, _cfg->settings().displayName.c_str()); } else if (_rns) { String dh = _rns->destinationHashHex(); String fallback = "Ratspeak.org-" + dh.substring(0, 3); - lv_label_set_text_fmt(_lblName, "Name: %s", fallback.c_str()); + lv_label_set_text(_lblName, fallback.c_str()); } else { - lv_label_set_text(_lblName, "Name: ---"); + lv_label_set_text(_lblName, "---"); } // ID (LXMF destination hash, 12 chars) if (_rns) { String dh = _rns->destinationHashHex(); if (dh.length() > 12) dh = dh.substring(0, 12); - lv_label_set_text_fmt(_lblId, "ID: %s", dh.c_str()); + lv_label_set_text_fmt(_lblId, "LXMF ID: %s", dh.c_str()); } else { - lv_label_set_text(_lblId, "ID: ---"); + lv_label_set_text(_lblId, "LXMF ID: ---"); } - // Status — check actual TCP socket state, not just WiFi association + // Status with transport indicators bool loraUp = _radioOnline && _radio && _radio->isRadioOnline(); bool tcpUp = false; if (_tcpClients) { @@ -82,20 +113,21 @@ void LvHomeScreen::refreshUI() { } } bool wifiUp = WiFi.status() == WL_CONNECTED; + if (loraUp && tcpUp) { - lv_label_set_text(_lblStatus, "Status: Online (LoRa/TCP)"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_lblStatus, "LoRa + TCP"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); } else if (loraUp && wifiUp) { - lv_label_set_text(_lblStatus, "Status: Online (LoRa/WiFi)"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::WARNING_CLR), 0); + lv_label_set_text(_lblStatus, "LoRa + WiFi"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); } else if (loraUp) { - lv_label_set_text(_lblStatus, "Status: Online (LoRa)"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_lblStatus, "LoRa"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); } else if (tcpUp) { - lv_label_set_text(_lblStatus, "Status: Online (TCP)"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_lblStatus, "TCP"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); } else { - lv_label_set_text(_lblStatus, "Status: Offline"); + lv_label_set_text(_lblStatus, "Offline"); lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::ERROR_CLR), 0); } @@ -106,7 +138,6 @@ void LvHomeScreen::refreshUI() { } else { lv_label_set_text(_lblNodes, "Online Nodes: 0"); } - } bool LvHomeScreen::handleKey(const KeyEvent& event) { diff --git a/src/ui/screens/LvHomeScreen.h b/src/ui/screens/LvHomeScreen.h index 0c4cf23..1f889fd 100644 --- a/src/ui/screens/LvHomeScreen.h +++ b/src/ui/screens/LvHomeScreen.h @@ -45,4 +45,5 @@ private: lv_obj_t* _lblId = nullptr; lv_obj_t* _lblStatus = nullptr; lv_obj_t* _lblNodes = nullptr; + lv_obj_t* _btnAnnounce = nullptr; }; diff --git a/src/ui/screens/LvMapScreen.cpp b/src/ui/screens/LvMapScreen.cpp index 62ba949..1bcf6d6 100644 --- a/src/ui/screens/LvMapScreen.cpp +++ b/src/ui/screens/LvMapScreen.cpp @@ -8,7 +8,7 @@ void LvMapScreen::createUI(lv_obj_t* parent) { lv_obj_t* lbl = lv_label_create(parent); lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(lbl, "Map\n\nComing soon\n\nNode topology view\nwill appear here"); lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); lv_obj_center(lbl); diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index f5f12d8..f10d561 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -48,6 +48,13 @@ void LvMessageView::createUI(lv_obj_t* parent) { lv_obj_set_style_text_color(_lblHeader, lv_color_hex(Theme::ACCENT), 0); lv_obj_align(_lblHeader, LV_ALIGN_LEFT_MID, 4, 0); + // Make header tappable for back navigation + lv_obj_add_flag(_header, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(_header, [](lv_event_t* e) { + auto* self = (LvMessageView*)lv_event_get_user_data(e); + if (self->_onBack) self->_onBack(); + }, LV_EVENT_CLICKED, this); + // Message scroll area (middle, grows to fill) _msgScroll = lv_obj_create(parent); lv_obj_set_width(_msgScroll, lv_pct(100)); @@ -60,6 +67,7 @@ void LvMessageView::createUI(lv_obj_t* parent) { lv_obj_set_style_radius(_msgScroll, 0, 0); lv_obj_set_layout(_msgScroll, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_msgScroll, LV_FLEX_FLOW_COLUMN); + lv_obj_add_style(_msgScroll, LvTheme::styleScrollbar(), LV_PART_SCROLLBAR); // Input row (bottom, just above tab bar) _inputRow = lv_obj_create(parent); @@ -78,21 +86,18 @@ void LvMessageView::createUI(lv_obj_t* parent) { lv_obj_align(_textarea, LV_ALIGN_LEFT_MID, 0, 0); lv_textarea_set_one_line(_textarea, true); lv_textarea_set_placeholder_text(_textarea, "Type message..."); - lv_obj_set_style_bg_color(_textarea, lv_color_hex(Theme::BG), 0); + lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0); lv_obj_set_style_border_width(_textarea, 0, 0); - lv_obj_set_style_text_color(_textarea, lv_color_hex(Theme::PRIMARY), 0); lv_obj_set_style_text_font(_textarea, font, 0); lv_obj_set_style_pad_all(_textarea, 2, 0); _btnSend = lv_btn_create(_inputRow); lv_obj_set_size(_btnSend, 40, 22); lv_obj_align(_btnSend, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_set_style_bg_color(_btnSend, lv_color_hex(Theme::SELECTION_BG), 0); - lv_obj_set_style_radius(_btnSend, 3, 0); + lv_obj_add_style(_btnSend, LvTheme::styleBtn(), 0); lv_obj_set_style_pad_all(_btnSend, 0, 0); lv_obj_t* sendLbl = lv_label_create(_btnSend); lv_obj_set_style_text_font(sendLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(sendLbl, lv_color_hex(Theme::PRIMARY), 0); lv_label_set_text(sendLbl, "Send"); lv_obj_center(sendLbl); lv_obj_add_event_cb(_btnSend, [](lv_event_t* e) { @@ -218,20 +223,20 @@ void LvMessageView::appendMessage(const LXMFMessage& msg) { } lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); - // Message label with word wrap — status-based colors for outgoing - uint32_t textColor = Theme::ACCENT; // incoming default + // Message text color — incoming is plain text, outgoing reflects delivery status + uint32_t textColor = Theme::TEXT_PRIMARY; // incoming default if (!msg.incoming) { switch (msg.status) { case LXMFStatus::QUEUED: case LXMFStatus::SENDING: - textColor = Theme::WARNING_CLR; break; + textColor = Theme::TEXT_SECONDARY; break; case LXMFStatus::SENT: case LXMFStatus::DELIVERED: - textColor = Theme::PRIMARY; break; + textColor = Theme::TEXT_PRIMARY; break; case LXMFStatus::FAILED: textColor = Theme::ERROR_CLR; break; default: - textColor = Theme::PRIMARY; break; + textColor = Theme::TEXT_PRIMARY; break; } } lv_obj_t* lbl = lv_label_create(box); @@ -244,9 +249,9 @@ void LvMessageView::appendMessage(const LXMFMessage& msg) { // Status indicator for outgoing (tracked for partial updates) if (!msg.incoming) { const char* ind = "~"; - uint32_t indColor = Theme::MUTED; + uint32_t indColor = Theme::TEXT_MUTED; if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) { - ind = "*"; indColor = Theme::ACCENT; + ind = "*"; indColor = Theme::SUCCESS; } else if (msg.status == LXMFStatus::FAILED) { ind = "!"; indColor = Theme::ERROR_CLR; } @@ -271,7 +276,7 @@ void LvMessageView::appendMessage(const LXMFMessage& msg) { snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tm->tm_hour, tm->tm_min); lv_obj_t* timeLbl = lv_label_create(bubble); lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(timeLbl, timeBuf); if (msg.incoming) { lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_LEFT, 2, 1); @@ -311,9 +316,9 @@ void LvMessageView::updateMessageStatus(int msgIdx, LXMFStatus status) { // Update status indicator const char* ind = "~"; - uint32_t indColor = Theme::MUTED; + uint32_t indColor = Theme::TEXT_MUTED; if (status == LXMFStatus::SENT || status == LXMFStatus::DELIVERED) { - ind = "*"; indColor = Theme::ACCENT; + ind = "*"; indColor = Theme::SUCCESS; } else if (status == LXMFStatus::FAILED) { ind = "!"; indColor = Theme::ERROR_CLR; } @@ -322,9 +327,9 @@ void LvMessageView::updateMessageStatus(int msgIdx, LXMFStatus status) { // Update text color to match status if (textLbl) { - uint32_t textColor = Theme::PRIMARY; + uint32_t textColor = Theme::TEXT_PRIMARY; if (status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING) { - textColor = Theme::WARNING_CLR; + textColor = Theme::TEXT_SECONDARY; } else if (status == LXMFStatus::FAILED) { textColor = Theme::ERROR_CLR; } diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index 6571a74..2cf94b1 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -18,7 +18,7 @@ void LvMessagesScreen::createUI(lv_obj_t* parent) { _lblEmpty = lv_label_create(parent); lv_obj_set_style_text_font(_lblEmpty, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(_lblEmpty, "No conversations"); lv_obj_center(_lblEmpty); @@ -151,7 +151,7 @@ void LvMessagesScreen::rebuildList() { snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tm->tm_hour, tm->tm_min); lv_obj_t* timeLbl = lv_label_create(row); lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(timeLbl, timeBuf); lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 3); } @@ -161,7 +161,7 @@ void LvMessagesScreen::rebuildList() { if (!ci.preview.empty()) { lv_obj_t* prevLbl = lv_label_create(row); lv_obj_set_style_text_font(prevLbl, smallFont, 0); - lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_long_mode(prevLbl, LV_LABEL_LONG_CLIP); lv_obj_set_width(prevLbl, Theme::CONTENT_W - leftPad - 8); lv_label_set_text(prevLbl, ci.preview.c_str()); diff --git a/src/ui/screens/LvNameInputScreen.cpp b/src/ui/screens/LvNameInputScreen.cpp index a589fb5..a5a51cf 100644 --- a/src/ui/screens/LvNameInputScreen.cpp +++ b/src/ui/screens/LvNameInputScreen.cpp @@ -1,6 +1,7 @@ #include "LvNameInputScreen.h" #include "ui/Theme.h" #include "ui/LvTheme.h" +#include "ui/LvInput.h" #include "config/Config.h" #include "fonts/fonts.h" @@ -26,7 +27,7 @@ void LvNameInputScreen::createUI(lv_obj_t* parent) { // Prompt lv_obj_t* prompt = lv_label_create(parent); lv_obj_set_style_text_font(prompt, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(prompt, "Enter your display name:"); lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 70); @@ -38,7 +39,9 @@ void LvNameInputScreen::createUI(lv_obj_t* parent) { lv_textarea_set_one_line(_textarea, true); lv_textarea_set_placeholder_text(_textarea, "Optional"); lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0); + lv_obj_add_style(_textarea, LvTheme::styleTextareaFocused(), LV_STATE_FOCUSED); lv_obj_set_style_text_font(_textarea, &lv_font_ratdeck_14, 0); + lv_group_add_obj(LvInput::group(), _textarea); // Hint lv_obj_t* hint = lv_label_create(parent); @@ -50,7 +53,7 @@ void LvNameInputScreen::createUI(lv_obj_t* parent) { // Version lv_obj_t* ver = lv_label_create(parent); lv_obj_set_style_text_font(ver, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(ver, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(ver, lv_color_hex(Theme::TEXT_MUTED), 0); char verBuf[32]; snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING); lv_label_set_text(ver, verBuf); diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index bf66739..27a4542 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -16,7 +16,7 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { _lblEmpty = lv_label_create(parent); lv_obj_set_style_text_font(_lblEmpty, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(_lblEmpty, "No nodes discovered"); lv_obj_center(_lblEmpty); @@ -30,15 +30,10 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { // --- Action modal overlay (on top layer, centered) --- _overlay = lv_obj_create(lv_layer_top()); - lv_obj_set_size(_overlay, 180, 100); - lv_obj_set_pos(_overlay, (320 - 180) / 2, 20 + (Theme::CONTENT_H - 100) / 2); - lv_obj_set_style_bg_color(_overlay, lv_color_hex(0x001100), 0); - lv_obj_set_style_bg_opa(_overlay, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(_overlay, 1, 0); - lv_obj_set_style_border_color(_overlay, lv_color_hex(Theme::PRIMARY), 0); - lv_obj_set_style_radius(_overlay, 6, 0); - lv_obj_set_style_pad_all(_overlay, 6, 0); - lv_obj_set_style_pad_row(_overlay, 2, 0); + lv_obj_set_size(_overlay, 200, 110); + lv_obj_set_pos(_overlay, (Theme::SCREEN_W - 200) / 2, Theme::STATUS_BAR_H + (Theme::CONTENT_H - 110) / 2); + lv_obj_add_style(_overlay, LvTheme::styleModal(), 0); + lv_obj_set_style_pad_row(_overlay, 4, 0); lv_obj_set_layout(_overlay, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_overlay, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(_overlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); @@ -48,12 +43,12 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { const char* menuText[] = {"Add Contact", "Message", "Back"}; for (int i = 0; i < 3; i++) { lv_obj_t* btn = lv_obj_create(_overlay); - lv_obj_set_size(btn, 166, 26); - lv_obj_set_style_bg_color(btn, lv_color_hex(Theme::BG), 0); + lv_obj_set_size(btn, 176, 28); + lv_obj_set_style_bg_color(btn, lv_color_hex(Theme::BG_SURFACE), 0); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); lv_obj_set_style_border_width(btn, 0, 0); lv_obj_set_style_pad_all(btn, 0, 0); - lv_obj_set_style_radius(btn, 3, 0); + lv_obj_set_style_radius(btn, 4, 0); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_user_data(btn, (void*)(intptr_t)i); @@ -77,7 +72,7 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { // Nickname input widgets _nicknameBox = lv_obj_create(_overlay); - lv_obj_set_size(_nicknameBox, 166, LV_SIZE_CONTENT); + lv_obj_set_size(_nicknameBox, 176, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(_nicknameBox, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(_nicknameBox, 0, 0); lv_obj_set_style_pad_all(_nicknameBox, 0, 0); @@ -100,10 +95,18 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { _nicknameHint = lv_label_create(_nicknameBox); lv_obj_set_style_text_font(_nicknameHint, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(_nicknameHint, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(_nicknameHint, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(_nicknameHint, "Enter=Save Esc=Cancel"); } +void LvNodesScreen::destroyUI() { + if (_overlay) { lv_obj_del(_overlay); _overlay = nullptr; } + for (int i = 0; i < 3; i++) { _menuBtns[i] = nullptr; _menuLabels[i] = nullptr; } + _nicknameBox = nullptr; _nicknameLbl = nullptr; _nicknameHint = nullptr; + _list = nullptr; _lblEmpty = nullptr; + LvScreen::destroyUI(); +} + void LvNodesScreen::onEnter() { _lastNodeCount = -1; _lastContactCount = -1; @@ -128,6 +131,8 @@ void LvNodesScreen::refreshUI() { void LvNodesScreen::rebuildList() { if (!_am || !_list) return; + // Preserve scroll position across rebuilds + lv_coord_t scrollY = lv_obj_get_scroll_y(_list); lv_obj_clean(_list); _sortedContactIndices.clear(); _sortedOnlineIndices.clear(); @@ -160,15 +165,11 @@ void LvNodesScreen::rebuildList() { auto addHeader = [&](const char* text) { lv_obj_t* hdr = lv_obj_create(_list); lv_obj_set_size(hdr, Theme::CONTENT_W, 22); - lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_color(hdr, lv_color_hex(Theme::BORDER), 0); - lv_obj_set_style_border_width(hdr, 1, 0); - lv_obj_set_style_border_side(hdr, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_pad_all(hdr, 0, 0); + lv_obj_add_style(hdr, LvTheme::styleSectionHeader(), 0); lv_obj_set_style_radius(hdr, 0, 0); lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); lv_obj_t* lbl = lv_label_create(hdr); - lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); lv_label_set_text(lbl, text); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); @@ -227,7 +228,7 @@ void LvNodesScreen::rebuildList() { } lv_obj_t* infoLbl = lv_label_create(row); lv_obj_set_style_text_font(infoLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(infoLbl, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_color(infoLbl, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(infoLbl, infoBuf); lv_obj_align(infoLbl, LV_ALIGN_RIGHT_MID, -4, 0); }; @@ -246,6 +247,12 @@ void LvNodesScreen::rebuildList() { addHeader(hdrBuf); for (int idx : _sortedOnlineIndices) addNodeRow(idx); } + + // Restore scroll position so the list doesn't jump to top on refresh + if (scrollY > 0) { + lv_obj_update_layout(_list); + lv_obj_scroll_to_y(_list, scrollY, LV_ANIM_OFF); + } } int LvNodesScreen::getFocusedNodeIdx() const { @@ -290,9 +297,9 @@ void LvNodesScreen::updateMenuSelection() { for (int i = 0; i < 3; i++) { bool sel = (i == _menuIdx); lv_obj_set_style_text_color(_menuLabels[i], lv_color_hex( - sel ? Theme::BG : Theme::PRIMARY), 0); + sel ? Theme::TEXT_PRIMARY : Theme::TEXT_SECONDARY), 0); lv_obj_set_style_bg_color(_menuBtns[i], lv_color_hex( - sel ? Theme::PRIMARY : 0x001100), 0); + sel ? Theme::BG_HOVER : Theme::BG_SURFACE), 0); lv_obj_set_style_bg_opa(_menuBtns[i], LV_OPA_COVER, 0); } } diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h index 26d472c..dfdf237 100644 --- a/src/ui/screens/LvNodesScreen.h +++ b/src/ui/screens/LvNodesScreen.h @@ -13,6 +13,7 @@ public: using NodeSelectedCallback = std::function; void createUI(lv_obj_t* parent) override; + void destroyUI() override; void refreshUI() override; void onEnter() override; bool handleKey(const KeyEvent& event) override; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 21f2fbe..70b6708 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -734,7 +734,7 @@ void LvSettingsScreen::rebuildCategoryList() { bool selected = (i == _categoryIdx); lv_obj_t* row = lv_obj_create(_scrollContainer); - lv_obj_set_size(row, Theme::CONTENT_W, 34); + lv_obj_set_size(row, Theme::CONTENT_W, 38); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); lv_obj_set_style_pad_all(row, 0, 0); @@ -757,7 +757,7 @@ void LvSettingsScreen::rebuildCategoryList() { snprintf(buf, sizeof(buf), "%s (%d)", cat.name, cat.count); lv_obj_t* nameLbl = lv_label_create(row); lv_obj_set_style_text_font(nameLbl, font, 0); - lv_obj_set_style_text_color(nameLbl, lv_color_hex(selected ? Theme::ACCENT : Theme::PRIMARY), 0); + lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); lv_label_set_text(nameLbl, buf); lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 12, 4); @@ -765,15 +765,15 @@ void LvSettingsScreen::rebuildCategoryList() { if (cat.summary) { lv_obj_t* sumLbl = lv_label_create(row); lv_obj_set_style_text_font(sumLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(sumLbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(sumLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(sumLbl, cat.summary().c_str()); - lv_obj_align(sumLbl, LV_ALIGN_BOTTOM_LEFT, 20, -2); + lv_obj_align(sumLbl, LV_ALIGN_BOTTOM_LEFT, 20, -4); } // Arrow lv_obj_t* arrow = lv_label_create(row); lv_obj_set_style_text_font(arrow, font, 0); - lv_obj_set_style_text_color(arrow, lv_color_hex(selected ? Theme::ACCENT : Theme::MUTED), 0); + lv_obj_set_style_text_color(arrow, lv_color_hex(selected ? Theme::ACCENT : Theme::TEXT_MUTED), 0); lv_label_set_text(arrow, ">"); lv_obj_align(arrow, LV_ALIGN_RIGHT_MID, -8, 0); @@ -789,6 +789,7 @@ void LvSettingsScreen::rebuildCategoryList() { void LvSettingsScreen::rebuildItemList() { if (!_scrollContainer) return; _rowObjs.clear(); + _editValueLbl = nullptr; // Invalidate cached label before destroying widgets lv_obj_clean(_scrollContainer); const lv_font_t* font = &lv_font_ratdeck_12; @@ -823,7 +824,7 @@ void LvSettingsScreen::rebuildItemList() { bool editable = isEditable(i); lv_obj_t* row = lv_obj_create(_scrollContainer); - lv_obj_set_size(row, Theme::CONTENT_W, 22); + lv_obj_set_size(row, Theme::CONTENT_W, 26); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); if (editable) { lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); @@ -857,7 +858,8 @@ void LvSettingsScreen::rebuildItemList() { lv_obj_t* nameLbl = lv_label_create(row); lv_obj_set_style_text_font(nameLbl, font, 0); lv_obj_set_style_text_color(nameLbl, lv_color_hex( - item.type == SettingType::ACTION ? (selected ? Theme::ACCENT : Theme::PRIMARY) : Theme::SECONDARY), 0); + item.type == SettingType::ACTION ? Theme::TEXT_PRIMARY : + item.type == SettingType::READONLY ? Theme::TEXT_MUTED : Theme::TEXT_SECONDARY), 0); lv_label_set_text(nameLbl, item.label); lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 4, 0); @@ -885,12 +887,12 @@ void LvSettingsScreen::rebuildItemList() { switch (item.type) { case SettingType::READONLY: valStr = item.formatter ? item.formatter(0) : ""; - valColor = Theme::MUTED; + valColor = Theme::TEXT_MUTED; break; case SettingType::TEXT_INPUT: { String v = item.textGetter ? item.textGetter() : ""; valStr = v.isEmpty() ? "(not set)" : v; - valColor = v.isEmpty() ? Theme::MUTED : Theme::PRIMARY; + valColor = v.isEmpty() ? Theme::TEXT_MUTED : Theme::PRIMARY; break; } case SettingType::ENUM_CHOICE: @@ -901,7 +903,7 @@ void LvSettingsScreen::rebuildItemList() { break; case SettingType::ACTION: valStr = item.formatter ? item.formatter(0) : ""; - valColor = Theme::MUTED; + valColor = Theme::TEXT_MUTED; break; default: { int v = item.getter ? item.getter() : 0; @@ -917,6 +919,10 @@ void LvSettingsScreen::rebuildItemList() { lv_obj_set_style_text_color(valLbl, lv_color_hex(valColor), 0); lv_label_set_text(valLbl, valStr.c_str()); lv_obj_align(valLbl, LV_ALIGN_RIGHT_MID, -4, 0); + // Cache value label for the actively edited item (in-place updates) + if (i == _selectedIdx && (_textEditing || _freqEditing || _editing)) { + _editValueLbl = valLbl; + } } _rowObjs.push_back(row); @@ -955,7 +961,7 @@ void LvSettingsScreen::rebuildWifiList() { if (_wifiResults.empty()) { lv_obj_t* emptyLbl = lv_label_create(_scrollContainer); lv_obj_set_style_text_font(emptyLbl, font, 0); - lv_obj_set_style_text_color(emptyLbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(emptyLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(emptyLbl, "No networks found"); return; } @@ -1010,7 +1016,7 @@ void LvSettingsScreen::rebuildWifiList() { snprintf(sigBuf, sizeof(sigBuf), "%ddBm", net.rssi); lv_obj_t* sigLbl = lv_label_create(row); lv_obj_set_style_text_font(sigLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(sigLbl, lv_color_hex(Theme::MUTED), 0); + lv_obj_set_style_text_color(sigLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(sigLbl, sigBuf); lv_obj_align(sigLbl, LV_ALIGN_RIGHT_MID, -4, 0); @@ -1019,20 +1025,19 @@ void LvSettingsScreen::rebuildWifiList() { } void LvSettingsScreen::updateCategorySelection(int oldIdx, int newIdx) { - // _rowObjs maps directly to category indices (title row is NOT in _rowObjs) if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) { lv_obj_set_style_bg_color(_rowObjs[oldIdx], lv_color_hex(Theme::BG), 0); lv_obj_t* nameLbl = lv_obj_get_child(_rowObjs[oldIdx], 0); - if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0); + if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); lv_obj_t* arrow = lv_obj_get_child(_rowObjs[oldIdx], -1); - if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(Theme::MUTED), 0); + if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(Theme::TEXT_MUTED), 0); } if (newIdx >= 0 && newIdx < (int)_rowObjs.size()) { - lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); + lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::BG_HOVER), 0); lv_obj_t* nameLbl = lv_obj_get_child(_rowObjs[newIdx], 0); - if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::ACCENT), 0); + if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); lv_obj_t* arrow = lv_obj_get_child(_rowObjs[newIdx], -1); - if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(Theme::ACCENT), 0); + if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(Theme::PRIMARY), 0); lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF); } } @@ -1045,7 +1050,7 @@ void LvSettingsScreen::updateItemSelection(int oldIdx, int newIdx) { if (row < 0 || row >= (int)_rowObjs.size()) return; bool editable = isEditable(row + _catRangeStart); lv_obj_set_style_bg_color(_rowObjs[row], lv_color_hex( - (selected && editable) ? Theme::SELECTION_BG : Theme::BG), 0); + (selected && editable) ? Theme::BG_HOVER : Theme::BG), 0); }; setItemRowBg(oldRow, false); setItemRowBg(newRow, true); @@ -1055,16 +1060,15 @@ void LvSettingsScreen::updateItemSelection(int oldIdx, int newIdx) { } void LvSettingsScreen::updateWifiSelection(int oldIdx, int newIdx) { - // _rowObjs maps directly to wifi items (header row is NOT in _rowObjs) if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) { lv_obj_set_style_bg_color(_rowObjs[oldIdx], lv_color_hex(Theme::BG), 0); lv_obj_t* lbl = lv_obj_get_child(_rowObjs[oldIdx], 0); - if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0); + if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); } if (newIdx >= 0 && newIdx < (int)_rowObjs.size()) { - lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); + lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::BG_HOVER), 0); lv_obj_t* lbl = lv_obj_get_child(_rowObjs[newIdx], 0); - if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); + if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF); } } @@ -1109,60 +1113,77 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { case SettingsView::ITEM_LIST: { if (_items.empty()) return false; - // Text edit mode + // Text edit mode — in-place label updates for responsiveness if (_textEditing) { auto& item = _items[_selectedIdx]; + auto updateEditLabel = [this]() { + if (_editValueLbl) { + String display = _editText + "_"; + lv_label_set_text(_editValueLbl, display.c_str()); + lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -4, 0); + } + }; if (event.enter || event.character == '\n' || event.character == '\r') { if (item.textSetter) item.textSetter(_editText); _textEditing = false; + _editValueLbl = nullptr; applyAndSave(); rebuildItemList(); return true; } if (event.del || event.character == 8) { - if (_editText.length() > 0) { _editText.remove(_editText.length() - 1); rebuildItemList(); } + if (_editText.length() > 0) { _editText.remove(_editText.length() - 1); updateEditLabel(); } return true; } - if (event.character == 0x1B) { _textEditing = false; rebuildItemList(); return true; } + if (event.character == 0x1B) { _textEditing = false; _editValueLbl = nullptr; rebuildItemList(); return true; } if (event.character >= 0x20 && event.character <= 0x7E && (int)_editText.length() < item.maxTextLen) { - _editText += (char)event.character; rebuildItemList(); return true; + _editText += (char)event.character; updateEditLabel(); return true; } return true; } - // Frequency digit-cursor edit mode + // Frequency digit-cursor edit mode — in-place label updates if (_freqEditing) { + auto updateFreqLabel = [this]() { + if (_editValueLbl) { + String display = String("< ") + freqFormatWithCursor() + " >"; + lv_label_set_text(_editValueLbl, display.c_str()); + lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -4, 0); + } + }; if (event.left) { if (_freqCursor > 0) _freqCursor--; - rebuildItemList(); return true; + updateFreqLabel(); return true; } if (event.right) { if (_freqCursor < 8) _freqCursor++; - rebuildItemList(); return true; + updateFreqLabel(); return true; } if (event.character >= '0' && event.character <= '9') { _freqDigits[_freqCursor] = event.character - '0'; _editValue = freqRecompose(); if (_freqCursor < 8) _freqCursor++; - rebuildItemList(); return true; + updateFreqLabel(); return true; } if (event.enter || event.character == '\n' || event.character == '\r') { auto& item = _items[_selectedIdx]; _editValue = freqRecompose(); if (item.setter) item.setter(_editValue); _freqEditing = false; _editing = false; + _editValueLbl = nullptr; applyAndSave(); rebuildItemList(); return true; } if (event.del || event.character == 8) { if (_freqCursor > 0) _freqCursor--; - rebuildItemList(); return true; + updateFreqLabel(); return true; } if (event.character == 0x1B) { _editValue = _freqOriginal; _freqEditing = false; _editing = false; + _editValueLbl = nullptr; rebuildItemList(); return true; } - return true; // Consume all keys in freq mode + return true; } // Value edit mode diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index 511f88c..57662d5 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -169,4 +169,5 @@ private: // LVGL widgets lv_obj_t* _scrollContainer = nullptr; std::vector _rowObjs; + lv_obj_t* _editValueLbl = nullptr; // Cached for in-place updates during text/freq editing }; diff --git a/src/ui/screens/LvTimezoneScreen.cpp b/src/ui/screens/LvTimezoneScreen.cpp index 9d5e43e..f9ccd81 100644 --- a/src/ui/screens/LvTimezoneScreen.cpp +++ b/src/ui/screens/LvTimezoneScreen.cpp @@ -1,153 +1,104 @@ #include "LvTimezoneScreen.h" #include "ui/Theme.h" #include "ui/LvTheme.h" -#include "config/Config.h" +#include "ui/LvInput.h" #include "fonts/fonts.h" +#include void LvTimezoneScreen::createUI(lv_obj_t* parent) { _screen = parent; lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); - const lv_font_t* font12 = &lv_font_ratdeck_12; - const lv_font_t* font14 = &lv_font_ratdeck_14; - // Title lv_obj_t* title = lv_label_create(parent); lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(title, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_text_color(title, lv_color_hex(Theme::ACCENT), 0); lv_label_set_text(title, "SELECT TIMEZONE"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8); // Subtitle lv_obj_t* sub = lv_label_create(parent); - lv_obj_set_style_text_font(sub, font12, 0); - lv_obj_set_style_text_color(sub, lv_color_hex(Theme::SECONDARY), 0); + lv_obj_set_style_text_font(sub, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(sub, "Choose your nearest city:"); lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 30); - // Scrollable list container - int listY = 50; - int listH = VISIBLE_ROWS * ROW_H; - _scrollContainer = lv_obj_create(parent); - lv_obj_set_size(_scrollContainer, Theme::SCREEN_W - 20, listH); - lv_obj_set_pos(_scrollContainer, 10, listY); - lv_obj_set_style_bg_color(_scrollContainer, lv_color_hex(Theme::BG), 0); - lv_obj_set_style_bg_opa(_scrollContainer, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(_scrollContainer, 1, 0); - lv_obj_set_style_border_color(_scrollContainer, lv_color_hex(Theme::BORDER), 0); - lv_obj_set_style_pad_all(_scrollContainer, 0, 0); - lv_obj_set_style_radius(_scrollContainer, 4, 0); - lv_obj_set_layout(_scrollContainer, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(_scrollContainer, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scroll_snap_y(_scrollContainer, LV_SCROLL_SNAP_CENTER); - lv_obj_set_scrollbar_mode(_scrollContainer, LV_SCROLLBAR_MODE_OFF); - - // Create rows + // Build roller options string: "City UTC+X\nCity UTC-Y\n..." + static char opts[640]; + int pos = 0; for (int i = 0; i < TIMEZONE_COUNT; i++) { - lv_obj_t* row = lv_obj_create(_scrollContainer); - lv_obj_set_size(row, Theme::SCREEN_W - 24, ROW_H); - lv_obj_set_style_pad_left(row, 8, 0); - lv_obj_set_style_pad_right(row, 8, 0); - lv_obj_set_style_pad_top(row, 0, 0); - lv_obj_set_style_pad_bottom(row, 0, 0); - lv_obj_set_style_border_width(row, 0, 0); - lv_obj_set_style_radius(row, 2, 0); - lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); - - bool selected = (i == _selectedIdx); - lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::SELECTION_BG : Theme::BG), 0); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); - - // City name (left) - lv_obj_t* lblCity = lv_label_create(row); - lv_obj_set_style_text_font(lblCity, font14, 0); - lv_obj_set_style_text_color(lblCity, lv_color_hex(selected ? Theme::BG : Theme::PRIMARY), 0); - lv_label_set_text(lblCity, TIMEZONE_TABLE[i].label); - lv_obj_align(lblCity, LV_ALIGN_LEFT_MID, 0, 0); - - // UTC offset (right) - lv_obj_t* lblOffset = lv_label_create(row); - lv_obj_set_style_text_font(lblOffset, font12, 0); - lv_obj_set_style_text_color(lblOffset, lv_color_hex(selected ? Theme::BG : Theme::ACCENT), 0); - char buf[12]; - int8_t off = TIMEZONE_TABLE[i].baseOffset; - snprintf(buf, sizeof(buf), "UTC%+d", off); - lv_label_set_text(lblOffset, buf); - lv_obj_align(lblOffset, LV_ALIGN_RIGHT_MID, 0, 0); + if (i > 0 && pos < (int)sizeof(opts) - 1) opts[pos++] = '\n'; + pos += snprintf(opts + pos, sizeof(opts) - pos, "%-14s UTC%+d", + TIMEZONE_TABLE[i].label, TIMEZONE_TABLE[i].baseOffset); } - // Scroll initial selection into view - if (_selectedIdx >= 0 && _selectedIdx < TIMEZONE_COUNT) { - lv_obj_t* selRow = lv_obj_get_child(_scrollContainer, _selectedIdx); - if (selRow) lv_obj_scroll_to_view(selRow, LV_ANIM_OFF); - } + // Card container — green-bordered panel holding the roller + lv_obj_t* card = lv_obj_create(parent); + lv_obj_set_width(card, Theme::CONTENT_W - 20); + lv_obj_set_style_bg_color(card, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_radius(card, 4, 0); + lv_obj_set_style_pad_left(card, 6, 0); + lv_obj_set_style_pad_right(card, 6, 0); + lv_obj_set_style_pad_top(card, 0, 0); + lv_obj_set_style_pad_bottom(card, 0, 0); + lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_clip_corner(card, true, 0); + lv_obj_align_to(card, sub, LV_ALIGN_OUT_BOTTOM_MID, 0, 6); - // Hint at bottom + // Roller inside the card — borderless, fills the card + _roller = lv_roller_create(card); + lv_roller_set_options(_roller, opts, LV_ROLLER_MODE_NORMAL); + lv_roller_set_visible_row_count(_roller, 7); + lv_obj_set_width(_roller, lv_pct(100)); + lv_obj_center(_roller); + lv_roller_set_selected(_roller, _selectedIdx, LV_ANIM_OFF); + + // Style the roller — no border, highlight goes edge-to-edge within card + lv_obj_add_style(_roller, LvTheme::styleRoller(), LV_PART_MAIN); + lv_obj_set_style_text_font(_roller, &lv_font_ratdeck_14, LV_PART_MAIN); + // Selected row highlight + lv_obj_set_style_bg_color(_roller, lv_color_hex(Theme::BG_HOVER), LV_PART_SELECTED); + lv_obj_set_style_bg_opa(_roller, LV_OPA_COVER, LV_PART_SELECTED); + lv_obj_set_style_text_color(_roller, lv_color_hex(Theme::PRIMARY), LV_PART_SELECTED); + lv_obj_set_style_text_font(_roller, &lv_font_ratdeck_14, LV_PART_SELECTED); + + // Size the card to fit the roller + lv_obj_update_layout(parent); + lv_obj_set_height(card, lv_obj_get_height(_roller)); + + // Add to focus group for trackball navigation + lv_group_add_obj(LvInput::group(), _roller); + + // Hint — positioned below roller lv_obj_t* hint = lv_label_create(parent); - lv_obj_set_style_text_font(hint, font12, 0); - lv_obj_set_style_text_color(hint, lv_color_hex(Theme::ACCENT), 0); + lv_obj_set_style_text_font(hint, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(hint, lv_color_hex(Theme::TEXT_SECONDARY), 0); lv_label_set_text(hint, "[Enter] Select"); - lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10); -} - -void LvTimezoneScreen::updateSelection(int oldIdx, int newIdx) { - if (!_scrollContainer) return; - uint32_t count = lv_obj_get_child_cnt(_scrollContainer); - - const lv_font_t* font14 = &lv_font_ratdeck_14; - const lv_font_t* font12 = &lv_font_ratdeck_12; - - // Deselect old - if (oldIdx >= 0 && oldIdx < (int)count) { - lv_obj_t* row = lv_obj_get_child(_scrollContainer, oldIdx); - lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0); - // Update text colors - lv_obj_t* lblCity = lv_obj_get_child(row, 0); - lv_obj_t* lblOff = lv_obj_get_child(row, 1); - if (lblCity) lv_obj_set_style_text_color(lblCity, lv_color_hex(Theme::PRIMARY), 0); - if (lblOff) lv_obj_set_style_text_color(lblOff, lv_color_hex(Theme::ACCENT), 0); - } - - // Select new - if (newIdx >= 0 && newIdx < (int)count) { - lv_obj_t* row = lv_obj_get_child(_scrollContainer, newIdx); - lv_obj_set_style_bg_color(row, lv_color_hex(Theme::SELECTION_BG), 0); - lv_obj_t* lblCity = lv_obj_get_child(row, 0); - lv_obj_t* lblOff = lv_obj_get_child(row, 1); - if (lblCity) lv_obj_set_style_text_color(lblCity, lv_color_hex(Theme::BG), 0); - if (lblOff) lv_obj_set_style_text_color(lblOff, lv_color_hex(Theme::BG), 0); - - // Scroll to make visible - lv_obj_scroll_to_view(row, LV_ANIM_ON); - } + lv_obj_align_to(hint, card, LV_ALIGN_OUT_BOTTOM_MID, 0, 6); } bool LvTimezoneScreen::handleKey(const KeyEvent& event) { if (event.enter || event.character == '\n' || event.character == '\r') { - // Guard: ignore Enter for first 600ms to prevent stale keypress bleed if (millis() - _enterTime < ENTER_GUARD_MS) return true; + _selectedIdx = lv_roller_get_selected(_roller); if (_doneCb) _doneCb(_selectedIdx); return true; } - + // Trackball maps to PREV/NEXT (focus group nav), but roller needs + // explicit selection changes since it's the only focusable widget if (event.up) { - if (_selectedIdx > 0) { - int old = _selectedIdx; - _selectedIdx--; - updateSelection(old, _selectedIdx); - } + uint16_t sel = lv_roller_get_selected(_roller); + if (sel > 0) lv_roller_set_selected(_roller, sel - 1, LV_ANIM_ON); return true; } - if (event.down) { - if (_selectedIdx < TIMEZONE_COUNT - 1) { - int old = _selectedIdx; - _selectedIdx++; - updateSelection(old, _selectedIdx); - } + uint16_t sel = lv_roller_get_selected(_roller); + if (sel < TIMEZONE_COUNT - 1) lv_roller_set_selected(_roller, sel + 1, LV_ANIM_ON); return true; } - - return true; // Consume all keys + return true; // Consume all keys on this screen } diff --git a/src/ui/screens/LvTimezoneScreen.h b/src/ui/screens/LvTimezoneScreen.h index 83d6d2d..963df8a 100644 --- a/src/ui/screens/LvTimezoneScreen.h +++ b/src/ui/screens/LvTimezoneScreen.h @@ -51,15 +51,10 @@ public: void setSelectedIndex(int idx) { _selectedIdx = idx; } private: - void updateSelection(int oldIdx, int newIdx); - std::function _doneCb; int _selectedIdx = 6; // Default: New York (EST/EDT) unsigned long _enterTime = 0; static constexpr unsigned long ENTER_GUARD_MS = 600; - // LVGL widgets - lv_obj_t* _scrollContainer = nullptr; - static constexpr int VISIBLE_ROWS = 5; - static constexpr int ROW_H = 28; + lv_obj_t* _roller = nullptr; };