diff --git a/lv_conf.h b/lv_conf.h index 4a22ecd..dfcd5f9 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -6,6 +6,10 @@ // Color depth: 16-bit RGB565 #define LV_COLOR_DEPTH 16 +// CRITICAL: Byte-swap for LovyanGFX + ST7789V +// Without this, colors appear pixelated/glitchy +#define LV_COLOR_16_SWAP 1 + // Memory: use stdlib malloc (PSRAM-aware on ESP32-S3) #define LV_MEM_CUSTOM 1 #define LV_MEM_CUSTOM_INCLUDE @@ -23,16 +27,22 @@ #define LV_VER_RES_MAX 240 #define LV_DPI_DEF 130 -// Logging -#define LV_USE_LOG 0 +// Logging (enabled temporarily for debug) +#define LV_USE_LOG 1 +#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 // Fonts - built-in -#define LV_FONT_MONTSERRAT_8 1 #define LV_FONT_MONTSERRAT_10 1 #define LV_FONT_MONTSERRAT_12 1 #define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 #define LV_FONT_UNSCII_8 1 -#define LV_FONT_DEFAULT &lv_font_montserrat_12 +#define LV_FONT_DEFAULT &lv_font_montserrat_14 // Widgets #define LV_USE_LABEL 1 @@ -53,11 +63,16 @@ #define LV_USE_SPINNER 1 #define LV_USE_MSGBOX 1 #define LV_USE_KEYBOARD 1 +#define LV_USE_CHECKBOX 1 +#define LV_USE_CANVAS 0 -// Scroll +// Layouts #define LV_USE_FLEX 1 #define LV_USE_GRID 1 +// Animations +#define LV_USE_ANIMIMG 0 + // OS #define LV_USE_OS LV_OS_NONE diff --git a/platformio.ini b/platformio.ini index f7a5197..fe62f41 100644 --- a/platformio.ini +++ b/platformio.ini @@ -21,17 +21,20 @@ build_flags = -mfix-esp32-psram-cache-issue -DDISPLAY_WIDTH=320 -DDISPLAY_HEIGHT=240 + -DLV_CONF_INCLUDE_SIMPLE + "-I${PROJECT_DIR}" build_unflags = -fno-exceptions -std=gnu++11 lib_deps = - https://github.com/attermann/microReticulum.git + https://github.com/attermann/microReticulum.git#392363c https://github.com/attermann/Crypto.git bblanchon/ArduinoJson@^7.4.2 lovyan03/LovyanGFX@^1.1.16 h2zero/NimBLE-Arduino@^2.1 + lvgl/lvgl@^8.3.4 lib_archive = false diff --git a/src/config/Config.h b/src/config/Config.h index d4d736b..6a333e2 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -5,9 +5,9 @@ // ============================================================================= #define RATDECK_VERSION_MAJOR 1 -#define RATDECK_VERSION_MINOR 3 +#define RATDECK_VERSION_MINOR 4 #define RATDECK_VERSION_PATCH 0 -#define RATDECK_VERSION_STRING "1.3.0" +#define RATDECK_VERSION_STRING "1.4.0" // --- Feature Flags --- #define HAS_DISPLAY true @@ -43,8 +43,8 @@ // --- TCP Client --- #define MAX_TCP_CONNECTIONS 4 #define TCP_DEFAULT_PORT 4242 -#define TCP_RECONNECT_INTERVAL_MS 10000 -#define TCP_CONNECT_TIMEOUT_MS 5000 +#define TCP_RECONNECT_INTERVAL_MS 15000 +#define TCP_CONNECT_TIMEOUT_MS 500 // --- Limits --- #define RATDECK_MAX_NODES 200 // PSRAM allows more diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index 9ee01f7..58f3d25 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -46,7 +46,10 @@ bool UserConfig::parseJson(const String& json) { _settings.screenDimTimeout = doc["screen_dim"] | 30; _settings.screenOffTimeout = doc["screen_off"] | 60; - _settings.brightness = doc["brightness"] | 255; + // Brightness: stored as 1-100%. Migrate old 0-255 values. + int rawBri = doc["brightness"] | 100; + if (rawBri > 100) rawBri = rawBri * 100 / 255; // Migrate from PWM to percentage + _settings.brightness = constrain(rawBri, 1, 100); _settings.denseFontMode = doc["dense_font"] | false; _settings.trackballSpeed = doc["trackball_speed"] | 3; _settings.touchSensitivity = doc["touch_sens"] | 3; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index 66bf5d1..d671121 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -38,7 +38,7 @@ struct UserSettings { // Display uint16_t screenDimTimeout = 30; // seconds uint16_t screenOffTimeout = 60; // seconds - uint8_t brightness = 255; + uint8_t brightness = 100; // Percentage 1-100 bool denseFontMode = false; // T-Deck Plus: adaptive font toggle // Trackball diff --git a/src/hal/Display.cpp b/src/hal/Display.cpp index 023f248..e3c93ed 100644 --- a/src/hal/Display.cpp +++ b/src/hal/Display.cpp @@ -1,4 +1,20 @@ #include "Display.h" +#include + +// Double-buffered 10-line strips in PSRAM for DMA flush +static lv_color_t* s_buf1 = nullptr; +static lv_color_t* s_buf2 = nullptr; +static LGFX_TDeck* s_gfx = nullptr; + +static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p) { + uint32_t w = area->x2 - area->x1 + 1; + uint32_t h = area->y2 - area->y1 + 1; + s_gfx->startWrite(); + s_gfx->setAddrWindow(area->x1, area->y1, w, h); + s_gfx->pushPixelsDMA((lgfx::swap565_t*)&color_p->full, w * h); + s_gfx->endWrite(); + lv_disp_flush_ready(drv); +} bool Display::begin() { _gfx.init(); @@ -12,6 +28,39 @@ bool Display::begin() { return true; } +void Display::beginLVGL() { + s_gfx = &_gfx; + + lv_init(); + + // Allocate double-buffered 10-line strips in PSRAM + const uint32_t bufSize = 320 * 10; + s_buf1 = (lv_color_t*)heap_caps_malloc(bufSize * sizeof(lv_color_t), MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + s_buf2 = (lv_color_t*)heap_caps_malloc(bufSize * sizeof(lv_color_t), MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + + // Fall back to PSRAM if DMA-capable memory not available + if (!s_buf1) s_buf1 = (lv_color_t*)ps_malloc(bufSize * sizeof(lv_color_t)); + if (!s_buf2) s_buf2 = (lv_color_t*)ps_malloc(bufSize * sizeof(lv_color_t)); + + if (!s_buf1 || !s_buf2) { + Serial.println("[LVGL] FATAL: buffer allocation failed!"); + return; + } + + static lv_disp_draw_buf_t draw_buf; + lv_disp_draw_buf_init(&draw_buf, s_buf1, s_buf2, bufSize); + + static lv_disp_drv_t disp_drv; + lv_disp_drv_init(&disp_drv); + disp_drv.hor_res = 320; + disp_drv.ver_res = 240; + disp_drv.flush_cb = lvgl_flush_cb; + disp_drv.draw_buf = &draw_buf; + lv_disp_drv_register(&disp_drv); + + Serial.println("[LVGL] Display driver registered (320x240, double-buffered 10-line DMA)"); +} + void Display::setBrightness(uint8_t level) { _gfx.setBrightness(level); } diff --git a/src/hal/Display.h b/src/hal/Display.h index 489f48f..a1ee8ee 100644 --- a/src/hal/Display.h +++ b/src/hal/Display.h @@ -57,6 +57,9 @@ class Display { public: bool begin(); + // Initialize LVGL display driver with double-buffered DMA flush + void beginLVGL(); + // Backlight void setBrightness(uint8_t level); void sleep(); diff --git a/src/hal/Power.cpp b/src/hal/Power.cpp index 4114825..f379450 100644 --- a/src/hal/Power.cpp +++ b/src/hal/Power.cpp @@ -37,6 +37,13 @@ int Power::batteryPercent() const { return (int)((v - 3.0f) / 1.2f * 100.0f); } +uint8_t Power::percentToPWM(uint8_t pct) const { + if (pct == 0) return 0; + if (pct >= 100) return 255; + // Map 1-100 to ~6-255 (minimum visible PWM ~6) + return (uint8_t)(6 + (uint16_t)(pct - 1) * 249 / 99); +} + void Power::activity() { _lastActivity = millis(); if (_state != ACTIVE) { @@ -44,10 +51,18 @@ void Power::activity() { } } -void Power::setBrightness(uint8_t brightness) { - _fullBrightness = brightness; +void Power::weakActivity() { + _lastActivity = millis(); + // Trackball wakes from DIM but not from SCREEN_OFF + if (_state == DIMMED) { + setState(ACTIVE); + } +} + +void Power::setBrightness(uint8_t percent) { + _brightnessPct = constrain(percent, 1, 100); if (_state == ACTIVE) { - display.setBrightness(_fullBrightness); + display.setBrightness(percentToPWM(_brightnessPct)); } } @@ -76,15 +91,18 @@ void Power::loop() { void Power::setState(State newState) { if (newState == _state) return; + State oldState = _state; _state = newState; switch (_state) { case ACTIVE: - display.wakeup(); - display.setBrightness(_fullBrightness); + if (oldState == SCREEN_OFF) { + display.wakeup(); + } + display.setBrightness(percentToPWM(_brightnessPct)); break; case DIMMED: - display.setBrightness(DIM_BRIGHTNESS); + display.setBrightness(DIM_PWM); break; case SCREEN_OFF: display.setBrightness(0); diff --git a/src/hal/Power.h b/src/hal/Power.h index 00a3641..81dc656 100644 --- a/src/hal/Power.h +++ b/src/hal/Power.h @@ -11,15 +11,17 @@ public: void begin(); void loop(); - // Call on any user activity (keypress, touch, trackball) + // Strong activity = keyboard/touch — wakes from any state void activity(); + // Weak activity = trackball — wakes from DIM only, not SCREEN_OFF + void weakActivity(); // Battery float batteryVoltage() const; int batteryPercent() const; - // Backlight - void setBrightness(uint8_t brightness); + // Backlight — accepts percentage 1-100 + void setBrightness(uint8_t percent); void setDimTimeout(uint16_t seconds) { _dimTimeout = seconds * 1000UL; } void setOffTimeout(uint16_t seconds) { _offTimeout = seconds * 1000UL; } @@ -29,11 +31,12 @@ public: private: void setState(State newState); + uint8_t percentToPWM(uint8_t pct) const; State _state = ACTIVE; unsigned long _lastActivity = 0; unsigned long _dimTimeout = 30000; unsigned long _offTimeout = 60000; - uint8_t _fullBrightness = 255; - static constexpr uint8_t DIM_BRIGHTNESS = 64; + uint8_t _brightnessPct = 100; // User brightness as 1-100% + static constexpr uint8_t DIM_PWM = 40; // ~15% PWM when dimmed }; diff --git a/src/input/InputManager.cpp b/src/input/InputManager.cpp index d4361b4..81405a4 100644 --- a/src/input/InputManager.cpp +++ b/src/input/InputManager.cpp @@ -1,4 +1,5 @@ #include "InputManager.h" +#include "config/BoardConfig.h" void InputManager::begin(Keyboard* kb, Trackball* tb, TouchInput* touch) { _kb = kb; @@ -9,6 +10,8 @@ void InputManager::begin(Keyboard* kb, Trackball* tb, TouchInput* touch) { void InputManager::update() { _hasKey = false; _activity = false; + _strongActivity = false; + _longPress = false; // Poll keyboard if (_kb) { @@ -17,17 +20,18 @@ void InputManager::update() { _keyEvent = _kb->getEvent(); _hasKey = true; _activity = true; + _strongActivity = true; } } // Poll trackball — convert deltas to nav KeyEvents if (_tb) { _tb->update(); - if (_tb->hadMovement() || _tb->wasClicked()) { - _activity = true; + if (_tb->hadMovement()) { + _activity = true; // Movement is weak — only wakes from dim } - // Generate nav events from trackball when no keyboard key was pressed + // Generate nav events from trackball movement (click handled below via GPIO) if (!_hasKey) { unsigned long now = millis(); @@ -40,46 +44,87 @@ void InputManager::update() { if (_tbAccumY < -20) _tbAccumY = -20; if (now - _lastTbNavTime >= TB_NAV_RATE_MS) { - // Click → enter - if (_tb->wasClicked()) { + int8_t absX = _tbAccumX < 0 ? -_tbAccumX : _tbAccumX; + int8_t absY = _tbAccumY < 0 ? -_tbAccumY : _tbAccumY; + bool yDominant = absY >= absX; + + if (yDominant && absY >= TB_NAV_THRESHOLD) { _keyEvent = {}; - _keyEvent.enter = true; + if (_tbAccumY < 0) _keyEvent.up = true; + else _keyEvent.down = true; _hasKey = true; + _activity = true; + _strongActivity = true; _lastTbNavTime = now; _tbAccumX = 0; _tbAccumY = 0; } - // Pick dominant axis — whichever accumulated more wins - else { - int8_t absX = _tbAccumX < 0 ? -_tbAccumX : _tbAccumX; - int8_t absY = _tbAccumY < 0 ? -_tbAccumY : _tbAccumY; - bool yDominant = absY >= absX; - - if (yDominant && absY >= TB_NAV_THRESHOLD) { - _keyEvent = {}; - if (_tbAccumY < 0) _keyEvent.up = true; - else _keyEvent.down = true; - _hasKey = true; - _lastTbNavTime = now; - _tbAccumX = 0; - _tbAccumY = 0; - } - else if (!yDominant && absX >= TB_NAV_THRESHOLD) { - _keyEvent = {}; - if (_tbAccumX < 0) _keyEvent.left = true; - else _keyEvent.right = true; - _hasKey = true; - _lastTbNavTime = now; - _tbAccumX = 0; - _tbAccumY = 0; - } + else if (!yDominant && absX >= TB_NAV_THRESHOLD) { + _keyEvent = {}; + if (_tbAccumX < 0) _keyEvent.left = true; + else _keyEvent.right = true; + _hasKey = true; + _activity = true; + _strongActivity = true; + _lastTbNavTime = now; + _tbAccumX = 0; + _tbAccumY = 0; } } } + + // Click / long-press detection via GPIO (deferred click with debounce) + // Short click fires on button RELEASE; long press fires after hold threshold + // Debounce: require GPIO HIGH for CLICK_DEBOUNCE_MS before accepting release + bool clickDown = (digitalRead(TBALL_CLICK) == LOW); + + if (clickDown) { + _lastClickDownMs = millis(); // Track last time GPIO was LOW + if (!_clickPending) { + // Button just went down — start tracking, don't fire yet + _clickPending = true; + _longPressFired = false; + _clickStartMs = millis(); + _activity = true; + _strongActivity = true; // Click wakes from screen off + } else if (!_longPressFired && millis() - _clickStartMs >= LONG_PRESS_MS) { + // Long press threshold reached + _longPress = true; + _longPressFired = true; + _hasKey = false; // Suppress any concurrent events + _activity = true; + _strongActivity = true; + } + } else if (_clickPending) { + // GPIO is HIGH — only accept release after debounce period + if (millis() - _lastClickDownMs >= CLICK_DEBOUNCE_MS) { + _clickPending = false; + if (!_longPressFired && !_hasKey) { + // Short click — generate deferred enter event + _keyEvent = {}; + _keyEvent.enter = true; + _hasKey = true; + _activity = true; + _strongActivity = true; + _lastTbNavTime = millis(); + _tbAccumX = 0; + _tbAccumY = 0; + } + _longPressFired = false; + } + // If GPIO was LOW too recently, ignore — likely bounce + } } - // Touch is polled by LVGL indev read callback — just check for activity - if (_touch && _touch->isTouched()) { - _activity = true; + // Touch activity check — throttled to ~50Hz + if (_touch) { + unsigned long now = millis(); + if (now - _lastTouchPoll >= TOUCH_POLL_MS) { + _lastTouchPoll = now; + if (_touch->isTouched()) { + _activity = true; + _strongActivity = true; + } + } } } diff --git a/src/input/InputManager.h b/src/input/InputManager.h index 86b510d..341bc8e 100644 --- a/src/input/InputManager.h +++ b/src/input/InputManager.h @@ -15,6 +15,12 @@ public: // Any activity (for power wake) bool hadActivity() const { return _activity; } + // Strong activity = keyboard/touch (wakes from screen off) + // Weak activity = trackball only (wakes from dim, NOT from screen off) + bool hadStrongActivity() const { return _strongActivity; } + + // Long-press: trackball click held for >= threshold + bool hadLongPress() const { return _longPress; } private: Keyboard* _kb = nullptr; @@ -24,6 +30,14 @@ private: bool _hasKey = false; KeyEvent _keyEvent; bool _activity = false; + bool _strongActivity = false; + bool _longPress = false; + bool _clickPending = false; + bool _longPressFired = false; + unsigned long _clickStartMs = 0; + unsigned long _lastClickDownMs = 0; + static constexpr unsigned long LONG_PRESS_MS = 1200; + static constexpr unsigned long CLICK_DEBOUNCE_MS = 80; // Trackball navigation state int8_t _tbAccumX = 0; @@ -31,4 +45,8 @@ private: unsigned long _lastTbNavTime = 0; static constexpr int8_t TB_NAV_THRESHOLD = 3; static constexpr unsigned long TB_NAV_RATE_MS = 200; + + // Touch polling throttle + unsigned long _lastTouchPoll = 0; + static constexpr unsigned long TOUCH_POLL_MS = 20; // ~50Hz }; diff --git a/src/lv_conf.h b/src/lv_conf.h index 4a22ecd..4fb1523 100644 --- a/src/lv_conf.h +++ b/src/lv_conf.h @@ -1,64 +1,2 @@ -#ifndef LV_CONF_H -#define LV_CONF_H - -#include - -// Color depth: 16-bit RGB565 -#define LV_COLOR_DEPTH 16 - -// Memory: use stdlib malloc (PSRAM-aware on ESP32-S3) -#define LV_MEM_CUSTOM 1 -#define LV_MEM_CUSTOM_INCLUDE -#define LV_MEM_CUSTOM_ALLOC malloc -#define LV_MEM_CUSTOM_FREE free -#define LV_MEM_CUSTOM_REALLOC realloc - -// Tick: custom (provided by main loop) -#define LV_TICK_CUSTOM 1 -#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" -#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) - -// Display -#define LV_HOR_RES_MAX 320 -#define LV_VER_RES_MAX 240 -#define LV_DPI_DEF 130 - -// Logging -#define LV_USE_LOG 0 - -// Fonts - built-in -#define LV_FONT_MONTSERRAT_8 1 -#define LV_FONT_MONTSERRAT_10 1 -#define LV_FONT_MONTSERRAT_12 1 -#define LV_FONT_MONTSERRAT_14 1 -#define LV_FONT_UNSCII_8 1 -#define LV_FONT_DEFAULT &lv_font_montserrat_12 - -// Widgets -#define LV_USE_LABEL 1 -#define LV_USE_BTN 1 -#define LV_USE_BTNMATRIX 1 -#define LV_USE_TEXTAREA 1 -#define LV_USE_LIST 1 -#define LV_USE_BAR 1 -#define LV_USE_SLIDER 1 -#define LV_USE_SWITCH 1 -#define LV_USE_DROPDOWN 1 -#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 - -// Scroll -#define LV_USE_FLEX 1 -#define LV_USE_GRID 1 - -// OS -#define LV_USE_OS LV_OS_NONE - -#endif // LV_CONF_H +// Redirect to root lv_conf.h — LVGL expects it at project root +#include "../lv_conf.h" diff --git a/src/main.cpp b/src/main.cpp index 3332b94..3129179 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "config/BoardConfig.h" #include "config/Config.h" @@ -18,6 +19,8 @@ #include "input/InputManager.h" #include "input/HotkeyManager.h" #include "ui/UIManager.h" +#include "ui/LvTabBar.h" +#include "ui/LvInput.h" #include "ui/screens/BootScreen.h" #include "ui/screens/HomeScreen.h" #include "ui/screens/NodesScreen.h" @@ -25,8 +28,17 @@ #include "ui/screens/MessageView.h" #include "ui/screens/SettingsScreen.h" #include "ui/screens/HelpOverlay.h" -#include "ui/screens/MapScreen.h" +// MapScreen removed #include "ui/screens/NameInputScreen.h" +#include "ui/screens/LvBootScreen.h" +#include "ui/screens/LvHomeScreen.h" +#include "ui/screens/LvNodesScreen.h" +#include "ui/screens/LvMessagesScreen.h" +#include "ui/screens/LvMessageView.h" +#include "ui/screens/LvSettingsScreen.h" +#include "ui/screens/LvHelpOverlay.h" +// Map screen removed +#include "ui/screens/LvNameInputScreen.h" #include "storage/FlashStore.h" #include "storage/SDStore.h" #include "storage/MessageStore.h" @@ -85,7 +97,7 @@ Power powerMgr; AudioNotify audio; IdentityManager identityMgr; -// --- Screens --- +// --- Legacy Screens (kept for fallback during migration) --- BootScreen bootScreen; HomeScreen homeScreen; NodesScreen nodesScreen; @@ -93,11 +105,25 @@ MessagesScreen messagesScreen; MessageView messageView; SettingsScreen settingsScreen; HelpOverlay helpOverlay; -MapScreen mapScreen; +// MapScreen removed NameInputScreen nameInputScreen; -// Tab-screen mapping (5 tabs) -Screen* tabScreens[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; +// --- LVGL Screens --- +LvBootScreen lvBootScreen; +LvHomeScreen lvHomeScreen; +LvNodesScreen lvNodesScreen; +LvMessagesScreen lvMessagesScreen; +LvMessageView lvMessageView; +LvSettingsScreen lvSettingsScreen; +LvHelpOverlay lvHelpOverlay; +// LvMapScreen removed +LvNameInputScreen lvNameInputScreen; + +// Tab-screen mapping (4 tabs) — LVGL versions +LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {}; + +// Legacy tab mapping (kept for reference) +Screen* tabScreens[LvTabBar::TAB_COUNT] = {}; // --- State --- bool radioOnline = false; @@ -105,7 +131,6 @@ bool bootComplete = false; bool bootLoopRecovery = false; bool wifiSTAStarted = false; bool wifiSTAConnected = false; -bool tcpClientsCreated = false; unsigned long lastAutoAnnounce = 0; unsigned long lastStatusUpdate = 0; constexpr unsigned long ANNOUNCE_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -135,28 +160,60 @@ static void announceWithName() { rns.announce(appData); ui.statusBar().flashAnnounce(); ui.statusBar().showToast("Announce sent!"); + ui.lvStatusBar().flashAnnounce(); + ui.lvStatusBar().showToast("Announce sent!"); Serial.println("[ANNOUNCE] Sent with display name"); } +// ============================================================================= +// TCP client management — stop old clients, create new from config +// ============================================================================= + +static void reloadTCPClients() { + // Stop existing clients (don't delete — Transport holds interface references) + for (auto* tcp : tcpClients) tcp->stop(); + tcpClients.clear(); + + // Create new clients from current config + if (WiFi.status() == WL_CONNECTED) { + for (auto& ep : userConfig.settings().tcpConnections) { + if (ep.autoConnect && !ep.host.isEmpty()) { + char name[32]; + snprintf(name, sizeof(name), "TCP.%s", ep.host.c_str()); + auto* tcp = new TCPClientInterface(ep.host.c_str(), ep.port, name); + tcpIfaces.emplace_back(tcp); + tcpIfaces.back().mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(tcpIfaces.back()); + tcp->start(); + tcpClients.push_back(tcp); + Serial.printf("[TCP] Created client: %s:%d\n", ep.host.c_str(), ep.port); + } + } + } + + if (tcpClients.empty()) { + Serial.println("[TCP] No active TCP connections"); + } +} + // ============================================================================= // Hotkey callbacks // ============================================================================= void onHotkeyHelp() { - helpOverlay.toggle(); - ui.setOverlay(helpOverlay.isVisible() ? &helpOverlay : nullptr); + lvHelpOverlay.toggle(); } void onHotkeyMessages() { - ui.tabBar().setActiveTab(TabBar::TAB_MSGS); - ui.setScreen(&messagesScreen); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); + ui.setLvScreen(&lvMessagesScreen); } void onHotkeyNewMsg() { - ui.tabBar().setActiveTab(TabBar::TAB_MSGS); - ui.setScreen(&messagesScreen); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); + ui.setLvScreen(&lvMessagesScreen); } void onHotkeySettings() { - ui.tabBar().setActiveTab(TabBar::TAB_SETUP); - ui.setScreen(&settingsScreen); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETUP); + ui.setLvScreen(&lvSettingsScreen); } void onHotkeyAnnounce() { announceWithName(); @@ -225,7 +282,8 @@ void onHotkeyRadioTest() { // Helper: render boot screen immediately // ============================================================================= static void bootRender() { - ui.render(); + // LVGL boot screen calls lv_timer_handler() internally via setProgress() + // Legacy render kept as fallback } // ============================================================================= @@ -320,6 +378,10 @@ void setup() { display.begin(); Serial.println("[BOOT] Display initialized (LovyanGFX direct)"); + // Step 5.5: Initialize LVGL display driver + display.beginLVGL(); + Serial.println("[BOOT] LVGL initialized"); + // Verify radio SPI survives display init if (radioOnline) { uint8_t sw_msb = radio.readRegister(0x0740); @@ -328,33 +390,37 @@ void setup() { sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK"); } - // Step 6: UI manager + // Step 6: UI manager (initializes both legacy and LVGL UI layers) ui.begin(&display.gfx()); ui.setBootMode(true); - ui.setScreen(&bootScreen); + ui.setLvScreen(&lvBootScreen); ui.statusBar().setLoRaOnline(radioOnline); - bootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED"); - bootRender(); + ui.lvStatusBar().setLoRaOnline(radioOnline); + lvBootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED"); // Step 7: Touch HAL — GT911 I2C touch.begin(); - bootScreen.setProgress(0.50f, "Touch ready"); - bootRender(); + lvBootScreen.setProgress(0.50f, "Touch ready"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 8: Keyboard HAL — ESP32-C3 I2C keyboard.begin(); - bootScreen.setProgress(0.52f, "Keyboard ready"); - bootRender(); + lvBootScreen.setProgress(0.52f, "Keyboard ready"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 9: Trackball HAL — GPIO interrupts trackball.begin(); - bootScreen.setProgress(0.54f, "Trackball ready"); - bootRender(); + lvBootScreen.setProgress(0.54f, "Trackball ready"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 10: Input manager inputManager.begin(&keyboard, &trackball, &touch); - bootScreen.setProgress(0.55f, "Input ready"); - bootRender(); + + // Step 10.5: LVGL input drivers + LvInput::init(&keyboard, &trackball, &touch); + + lvBootScreen.setProgress(0.55f, "Input ready"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 11: Register hotkeys hotkeys.registerHotkey('h', "Help", onHotkeyHelp); @@ -366,16 +432,16 @@ void setup() { hotkeys.registerHotkey('t', "Radio Test", onHotkeyRadioTest); hotkeys.registerHotkey('r', "RSSI Monitor", onHotkeyRssiMonitor); hotkeys.setTabCycleCallback([](int dir) { - ui.tabBar().cycleTab(dir); - int tab = ui.tabBar().getActiveTab(); - if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + ui.lvTabBar().cycleTab(dir); + int tab = ui.lvTabBar().getActiveTab(); + if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); }); - bootScreen.setProgress(0.58f, "Hotkeys registered"); - bootRender(); + lvBootScreen.setProgress(0.58f, "Hotkeys registered"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 12: Mount LittleFS - bootScreen.setProgress(0.60f, "Mounting flash..."); - bootRender(); + lvBootScreen.setProgress(0.60f, "Mounting flash..."); + // (LVGL boot renders via lv_timer_handler in setProgress) if (!flash.begin()) { Serial.println("[BOOT] Flash init failed, formatting..."); if (flash.format()) { @@ -401,8 +467,8 @@ void setup() { } } - bootScreen.setProgress(0.65f, "Starting Reticulum..."); - bootRender(); + lvBootScreen.setProgress(0.65f, "Starting Reticulum..."); + // (LVGL boot renders via lv_timer_handler in setProgress) rns.setSDStore(&sdStore); // Transport mode is loaded later from config; default is endpoint (no rebroadcast) // We load a quick peek at the config to get the transport setting before RNS init @@ -420,19 +486,19 @@ void setup() { } if (rns.begin(&radio, &flash)) { Serial.printf("[BOOT] Identity: %s\n", rns.identityHash().c_str()); - bootScreen.setProgress(0.72f, "Reticulum active"); + lvBootScreen.setProgress(0.72f, "Reticulum active"); } else { Serial.println("[BOOT] Reticulum init failed!"); - bootScreen.setProgress(0.72f, "RNS: FAILED"); + lvBootScreen.setProgress(0.72f, "RNS: FAILED"); } - bootRender(); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 15.5: Identity manager identityMgr.begin(&flash, &sdStore); // Step 16: Message store - bootScreen.setProgress(0.72f, "Starting messaging..."); - bootRender(); + lvBootScreen.setProgress(0.72f, "Starting messaging..."); + // (LVGL boot renders via lv_timer_handler in setProgress) messageStore.begin(&flash, &sdStore); // Step 17: LXMF init @@ -440,24 +506,28 @@ void setup() { 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(); }); - bootScreen.setProgress(0.75f, "LXMF ready"); - bootRender(); + // Pre-cache unread counts so first tab switch to Messages is instant + lxmf.unreadCount(); + lvBootScreen.setProgress(0.75f, "LXMF ready"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 18: Announce manager - bootScreen.setProgress(0.78f, "Loading contacts..."); - bootRender(); + lvBootScreen.setProgress(0.78f, "Loading contacts..."); + // (LVGL boot renders via lv_timer_handler in setProgress) announceManager = new AnnounceManager(); announceManager->setStorage(&sdStore, &flash); announceManager->setLocalDestHash(rns.destination().hash()); announceManager->loadContacts(); + announceManager->loadNameCache(); announceHandler = RNS::HAnnounceHandler(announceManager); RNS::Transport::register_announce_handler(announceHandler); // Step 19: User config load - bootScreen.setProgress(0.82f, "Loading config..."); - bootRender(); + lvBootScreen.setProgress(0.82f, "Loading config..."); + // (LVGL boot renders via lv_timer_handler in setProgress) userConfig.load(sdStore, flash); // Step 20: Boot loop recovery @@ -465,8 +535,8 @@ void setup() { userConfig.settings().wifiMode = RAT_WIFI_OFF; Serial.println("[BOOT] WiFi forced OFF (boot loop recovery)"); } - bootScreen.setProgress(0.83f, "Config loaded"); - bootRender(); + lvBootScreen.setProgress(0.83f, "Config loaded"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 21: Apply radio config if (radioOnline) { @@ -482,14 +552,15 @@ void setup() { (unsigned long)s.loraFrequency, s.loraSF, (unsigned long)s.loraBW, s.loraCR, s.loraTxPower, s.loraPreamble); } - bootScreen.setProgress(0.84f, "Radio configured"); - bootRender(); + lvBootScreen.setProgress(0.84f, "Radio configured"); + // (LVGL boot renders via lv_timer_handler in setProgress) // Step 22: WiFi start RatWiFiMode wifiMode = userConfig.settings().wifiMode; + ui.lvStatusBar().setWiFiEnabled(wifiMode != RAT_WIFI_OFF); if (wifiMode == RAT_WIFI_AP) { - bootScreen.setProgress(0.87f, "Starting WiFi AP..."); - bootRender(); + lvBootScreen.setProgress(0.87f, "Starting WiFi AP..."); + // (LVGL boot renders via lv_timer_handler in setProgress) wifiImpl = new WiFiInterface("WiFi.AP"); if (!userConfig.settings().wifiAPSSID.isEmpty()) { wifiImpl->setAPCredentials( @@ -501,9 +572,10 @@ void setup() { RNS::Transport::register_interface(wifiIface); wifiImpl->start(); ui.statusBar().setWiFiActive(true); + ui.lvStatusBar().setWiFiActive(true); } else if (wifiMode == RAT_WIFI_STA) { - bootScreen.setProgress(0.87f, "WiFi STA starting..."); - bootRender(); + lvBootScreen.setProgress(0.87f, "WiFi STA starting..."); + // WiFi is enabled but not yet connected — indicator will be yellow if (!userConfig.settings().wifiSTASSID.isEmpty()) { WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); @@ -513,13 +585,14 @@ void setup() { Serial.printf("[WIFI] STA: %s\n", userConfig.settings().wifiSTASSID.c_str()); } } else { - bootScreen.setProgress(0.87f, "WiFi disabled"); - bootRender(); + lvBootScreen.setProgress(0.87f, "WiFi disabled"); + // (LVGL boot renders via lv_timer_handler in setProgress) } // Step 23: BLE start - bootScreen.setProgress(0.90f, "BLE..."); - bootRender(); + lvBootScreen.setProgress(0.90f, "BLE..."); + // (LVGL boot renders via lv_timer_handler in setProgress) + ui.lvStatusBar().setBLEEnabled(userConfig.settings().bleEnabled); if (userConfig.settings().bleEnabled) { bleInterface.setSideband(&bleSideband); @@ -535,6 +608,7 @@ void setup() { }); ui.statusBar().setBLEActive(true); + ui.lvStatusBar().setBLEActive(true); Serial.println("[BLE] Transport + Sideband ready"); } } else { @@ -542,115 +616,139 @@ void setup() { } // Step 24: Power manager - bootScreen.setProgress(0.92f, "Power manager..."); - bootRender(); + lvBootScreen.setProgress(0.92f, "Power manager..."); + // (LVGL boot renders via lv_timer_handler in setProgress) powerMgr.begin(); powerMgr.setDimTimeout(userConfig.settings().screenDimTimeout); powerMgr.setOffTimeout(userConfig.settings().screenOffTimeout); powerMgr.setBrightness(userConfig.settings().brightness); // Step 25: Audio init - bootScreen.setProgress(0.94f, "Audio..."); - bootRender(); + lvBootScreen.setProgress(0.94f, "Audio..."); + // (LVGL boot renders via lv_timer_handler in setProgress) audio.setEnabled(userConfig.settings().audioEnabled); audio.setVolume(userConfig.settings().audioVolume); audio.begin(); // Boot complete — transition to Home screen delay(200); - bootScreen.setProgress(1.0f, "Ready"); - bootRender(); + lvBootScreen.setProgress(1.0f, "Ready"); + // (LVGL boot renders via lv_timer_handler in setProgress) audio.playBoot(); delay(400); bootComplete = true; ui.statusBar().setTransportMode("Ratdeck"); + ui.lvStatusBar().setTransportMode("Ratdeck"); - // Wire up screen dependencies - homeScreen.setReticulumManager(&rns); - homeScreen.setRadio(&radio); - homeScreen.setUserConfig(&userConfig); - homeScreen.setAnnounceCallback([]() { + // Keep UI alive during blocking radio TX (endPacket wait loop) + // Re-entrancy guard prevents nested lv_timer_handler() calls + radio.setYieldCallback([]() { + static bool inYield = false; + if (inYield) return; + inYield = true; + powerMgr.activity(); // Keep screen alive during TX + if (powerMgr.isScreenOn()) { + lv_timer_handler(); + } + inYield = false; + }); + + // Wire up LVGL screen dependencies + lvHomeScreen.setReticulumManager(&rns); + lvHomeScreen.setRadio(&radio); + lvHomeScreen.setUserConfig(&userConfig); + lvHomeScreen.setAnnounceCallback([]() { announceWithName(); }); - nodesScreen.setAnnounceManager(announceManager); - nodesScreen.setNodeSelectedCallback([](const std::string& peerHex) { - messageView.setPeerHex(peerHex); - ui.tabBar().setActiveTab(TabBar::TAB_MSGS); - ui.setScreen(&messageView); + lvNodesScreen.setAnnounceManager(announceManager); + lvNodesScreen.setUIManager(&ui); + lvNodesScreen.setNodeSelectedCallback([](const std::string& peerHex) { + lvMessageView.setPeerHex(peerHex); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); + ui.setLvScreen(&lvMessageView); }); - messagesScreen.setLXMFManager(&lxmf); - messagesScreen.setAnnounceManager(announceManager); - messagesScreen.setOpenCallback([](const std::string& peerHex) { - messageView.setPeerHex(peerHex); - ui.setScreen(&messageView); + lvMessagesScreen.setLXMFManager(&lxmf); + lvMessagesScreen.setAnnounceManager(announceManager); + lvMessagesScreen.setUIManager(&ui); + lvMessagesScreen.setOpenCallback([](const std::string& peerHex) { + lvMessageView.setPeerHex(peerHex); + ui.setLvScreen(&lvMessageView); }); - messageView.setLXMFManager(&lxmf); - messageView.setAnnounceManager(announceManager); - messageView.setBackCallback([]() { - ui.setScreen(&messagesScreen); + lvMessageView.setLXMFManager(&lxmf); + lvMessageView.setAnnounceManager(announceManager); + lvMessageView.setBackCallback([]() { + ui.setLvScreen(&lvMessagesScreen); }); - settingsScreen.setUserConfig(&userConfig); - settingsScreen.setFlashStore(&flash); - settingsScreen.setSDStore(&sdStore); - settingsScreen.setRadio(&radio); - settingsScreen.setAudio(&audio); - settingsScreen.setPower(&powerMgr); - settingsScreen.setWiFi(wifiImpl); - settingsScreen.setTCPClients(&tcpClients); - settingsScreen.setRNS(&rns); - settingsScreen.setIdentityManager(&identityMgr); - settingsScreen.setUIManager(&ui); - settingsScreen.setIdentityHash(rns.identityHash()); - settingsScreen.setSaveCallback([]() -> bool { + lvSettingsScreen.setUserConfig(&userConfig); + lvSettingsScreen.setFlashStore(&flash); + lvSettingsScreen.setSDStore(&sdStore); + lvSettingsScreen.setRadio(&radio); + lvSettingsScreen.setAudio(&audio); + lvSettingsScreen.setPower(&powerMgr); + lvSettingsScreen.setWiFi(wifiImpl); + lvSettingsScreen.setTCPClients(&tcpClients); + lvSettingsScreen.setRNS(&rns); + lvSettingsScreen.setIdentityManager(&identityMgr); + lvSettingsScreen.setUIManager(&ui); + lvSettingsScreen.setIdentityHash(rns.identityHash()); + lvSettingsScreen.setSaveCallback([]() -> bool { bool ok = userConfig.save(sdStore, flash); Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED"); return ok; }); + lvSettingsScreen.setTCPChangeCallback([]() { + Serial.println("[TCP] Settings changed, reloading..."); + reloadTCPClients(); + if (announceManager) announceManager->clearTransientNodes(); + }); - // Tab bar callbacks - tabScreens[TabBar::TAB_HOME] = &homeScreen; - tabScreens[TabBar::TAB_MSGS] = &messagesScreen; - tabScreens[TabBar::TAB_NODES] = &nodesScreen; - tabScreens[TabBar::TAB_MAP] = &mapScreen; - tabScreens[TabBar::TAB_SETUP] = &settingsScreen; + // LVGL help overlay + lvHelpOverlay.create(); - ui.tabBar().setTabCallback([](int tab) { - if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + // Tab bar callbacks — LVGL + lvTabScreens[LvTabBar::TAB_HOME] = &lvHomeScreen; + lvTabScreens[LvTabBar::TAB_MSGS] = &lvMessagesScreen; + lvTabScreens[LvTabBar::TAB_NODES] = &lvNodesScreen; + lvTabScreens[LvTabBar::TAB_SETUP] = &lvSettingsScreen; + + ui.lvTabBar().setTabCallback([](int tab) { + if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); }); // Name input screen (first boot only — when no display name is set) - nameInputScreen.setDoneCallback([](const String& name) { + lvNameInputScreen.setDoneCallback([](const String& name) { userConfig.settings().displayName = name; userConfig.save(sdStore, flash); Serial.printf("[BOOT] Display name set: '%s'\n", name.c_str()); // Transition to home screen ui.setBootMode(false); - ui.setScreen(&homeScreen); - ui.tabBar().setActiveTab(TabBar::TAB_HOME); + ui.setLvScreen(&lvHomeScreen); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); // Initial announce with name RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); rns.announce(appData); lastAutoAnnounce = millis(); ui.statusBar().flashAnnounce(); + ui.lvStatusBar().flashAnnounce(); Serial.println("[BOOT] Initial announce sent"); }); if (userConfig.settings().displayName.isEmpty()) { // Show name input screen (boot mode keeps status/tab bars hidden) - ui.setScreen(&nameInputScreen); + ui.setLvScreen(&lvNameInputScreen); Serial.println("[BOOT] Showing name input screen"); } else { // Name already set — go straight to home ui.setBootMode(false); - ui.setScreen(&homeScreen); - ui.tabBar().setActiveTab(TabBar::TAB_HOME); + ui.setLvScreen(&lvHomeScreen); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); // Initial announce with name RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); @@ -682,21 +780,30 @@ void setup() { void loop() { // 1. Input polling inputManager.update(); - if (inputManager.hadActivity()) { - powerMgr.activity(); + if (inputManager.hadStrongActivity()) { + powerMgr.activity(); // Keyboard/touch: wake from any state + } else if (inputManager.hadActivity()) { + powerMgr.weakActivity(); // Trackball: wake from dim only } - // 2. Key event dispatch + // 2. Long-press dispatch + if (inputManager.hadLongPress()) { + ui.handleLongPress(); + } + + // 3. Key event dispatch if (inputManager.hasKeyEvent()) { const KeyEvent& evt = inputManager.getKeyEvent(); // Help overlay intercepts all keys when visible - if (helpOverlay.isVisible()) { - helpOverlay.handleKey(evt); - ui.setOverlay(helpOverlay.isVisible() ? &helpOverlay : nullptr); + if (lvHelpOverlay.isVisible()) { + lvHelpOverlay.handleKey(evt); } // Ctrl+hotkeys first else if (!hotkeys.process(evt)) { + // Feed key to LVGL input system + LvInput::feedKey(evt); + // Screen gets the key next bool consumed = ui.handleKey(evt); @@ -705,93 +812,99 @@ void loop() { bool tabLeft = (evt.character == ',') || evt.left; bool tabRight = (evt.character == '/') || evt.right; if (tabLeft) { - ui.tabBar().cycleTab(-1); - int tab = ui.tabBar().getActiveTab(); - if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + ui.lvTabBar().cycleTab(-1); + int tab = ui.lvTabBar().getActiveTab(); + if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); } if (tabRight) { - ui.tabBar().cycleTab(1); - int tab = ui.tabBar().getActiveTab(); - if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + ui.lvTabBar().cycleTab(1); + int tab = ui.lvTabBar().getActiveTab(); + if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); } } } } - // 3. Reticulum loop (radio RX via LoRaInterface) - rns.loop(); + // 3. LVGL timer handler — run FIRST after input for responsive UI + if (powerMgr.isScreenOn()) { + lv_timer_handler(); + } - // 4. Auto-announce every 5 minutes + // 4. Reticulum loop (radio RX via LoRaInterface) — throttle to ~200Hz + { + static unsigned long lastRNS = 0; + unsigned long now = millis(); + if (now - lastRNS >= 5) { + lastRNS = now; + rns.loop(); + } + } + + // 5. Auto-announce every 5 minutes if (bootComplete && millis() - lastAutoAnnounce >= ANNOUNCE_INTERVAL_MS) { lastAutoAnnounce = millis(); RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); rns.announce(appData); ui.statusBar().flashAnnounce(); + ui.lvStatusBar().flashAnnounce(); Serial.println("[AUTO] Periodic announce"); } - // 5. LXMF outgoing queue + // 6. LXMF outgoing queue + announce manager deferred saves lxmf.loop(); + if (announceManager) announceManager->loop(); - // 6. WiFi STA connection handler + // 7. WiFi STA connection handler if (wifiSTAStarted) { 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()); - if (!tcpClientsCreated) { - tcpClientsCreated = true; - for (auto& ep : userConfig.settings().tcpConnections) { - if (ep.autoConnect) { - char name[32]; - snprintf(name, sizeof(name), "TCP.%s", ep.host.c_str()); - auto* tcp = new TCPClientInterface(ep.host.c_str(), ep.port, name); - tcpIfaces.emplace_back(tcp); - tcpIfaces.back().mode(RNS::Type::Interface::MODE_GATEWAY); - RNS::Transport::register_interface(tcpIfaces.back()); - tcp->start(); - tcpClients.push_back(tcp); - } - } + // Create TCP clients (safe to call multiple times) + if (tcpClients.empty()) { + reloadTCPClients(); } } else if (!connected && wifiSTAConnected) { wifiSTAConnected = false; ui.statusBar().setWiFiActive(false); + ui.lvStatusBar().setWiFiActive(false); Serial.println("[WIFI] STA disconnected"); } } - // 7. WiFi + TCP loops + // 8. WiFi + TCP loops if (wifiImpl) wifiImpl->loop(); for (auto* tcp : tcpClients) { tcp->loop(); yield(); } - // 8. BLE loops + // 9. BLE loops bleInterface.loop(); bleSideband.loop(); - // 9. Power management + // 10. Power management powerMgr.loop(); - // 10. Periodic status bar update (1 Hz) + render + // 11. Periodic status bar update (1 Hz) + render if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) { lastStatusUpdate = millis(); if (powerMgr.isScreenOn()) { ui.statusBar().setBatteryPercent(powerMgr.batteryPercent()); + ui.lvStatusBar().setBatteryPercent(powerMgr.batteryPercent()); ui.update(); } } - // 11. Render any dirty regions + // 12. Render any dirty regions if (powerMgr.isScreenOn()) { ui.render(); } - // 12. Heartbeat for crash diagnosis + // 13. Heartbeat for crash diagnosis { unsigned long cycleTime = millis() - loopCycleStart; if (cycleTime > maxLoopTime) maxLoopTime = cycleTime; @@ -817,5 +930,4 @@ void loop() { loopCycleStart = millis(); yield(); - delay(5); } diff --git a/src/radio/SX1262.cpp b/src/radio/SX1262.cpp index 167f7ae..4870669 100644 --- a/src/radio/SX1262.cpp +++ b/src/radio/SX1262.cpp @@ -144,6 +144,7 @@ void SX1262::waitOnBusy() { if (_busy != -1) { while (digitalRead(_busy) == HIGH) { if (millis() >= (t + 100)) break; + if (_yieldCb) _yieldCb(); } } } @@ -293,25 +294,33 @@ int SX1262::beginPacket(int implicitHeader) { return 1; } -int SX1262::endPacket() { +int SX1262::endPacket(bool async) { setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); uint8_t timeout[3] = {0}; - uint32_t txStart = millis(); + _txStartMs = millis(); + _txTimeoutMs = _txStartMs + (uint32_t)(getAirtime(_payloadLength) * MODEM_TIMEOUT_MULT) + 2000; executeOpcode(OP_TX_6X, timeout, 3); + if (async) { + _txActive = true; + Serial.printf("[SX1262] TX ASYNC: payload=%d calc=%.0fms\n", + _payloadLength, getAirtime(_payloadLength)); + return 1; + } + + // Blocking mode: wait for TX completion uint8_t buf[2] = {0}; executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); - bool timed_out = false; - uint32_t w_timeout = txStart + (uint32_t)(getAirtime(_payloadLength) * MODEM_TIMEOUT_MULT) + 2000; - while ((millis() < w_timeout) && ((buf[1] & IRQ_TX_DONE_MASK_6X) == 0)) { + while ((millis() < _txTimeoutMs) && ((buf[1] & IRQ_TX_DONE_MASK_6X) == 0)) { buf[0] = 0x00; buf[1] = 0x00; executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); yield(); + if (_yieldCb) _yieldCb(); } - uint32_t txActual = millis() - txStart; - if (millis() > w_timeout) { timed_out = true; } + uint32_t txActual = millis() - _txStartMs; + bool timed_out = millis() > _txTimeoutMs; if (timed_out) { Serial.printf("[SX1262] TX TIMEOUT: payload=%d actual=%dms calc=%.0fms\n", @@ -326,6 +335,34 @@ int SX1262::endPacket() { return !timed_out; } +bool SX1262::isTxBusy() { + if (!_txActive) return false; + + // Check for timeout + if (millis() > _txTimeoutMs) { + Serial.printf("[SX1262] TX ASYNC TIMEOUT after %dms\n", + (int)(millis() - _txStartMs)); + uint8_t mask[2] = {0x00, IRQ_TX_DONE_MASK_6X}; + executeOpcode(OP_CLEAR_IRQ_STATUS_6X, mask, 2); + _txActive = false; + return false; + } + + // Poll IRQ status + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); + if (buf[1] & IRQ_TX_DONE_MASK_6X) { + Serial.printf("[SX1262] TX ASYNC OK: %dms\n", + (int)(millis() - _txStartMs)); + uint8_t mask[2] = {0x00, IRQ_TX_DONE_MASK_6X}; + executeOpcode(OP_CLEAR_IRQ_STATUS_6X, mask, 2); + _txActive = false; + return false; + } + + return true; // Still transmitting +} + size_t SX1262::write(uint8_t byte) { return write(&byte, 1); } size_t SX1262::write(const uint8_t* buffer, size_t size) { diff --git a/src/radio/SX1262.h b/src/radio/SX1262.h index f18770f..f3816c0 100644 --- a/src/radio/SX1262.h +++ b/src/radio/SX1262.h @@ -22,7 +22,8 @@ public: // --- TX --- int beginPacket(int implicitHeader = 0); - int endPacket(); + int endPacket(bool async = false); + bool isTxBusy(); size_t write(uint8_t byte); size_t write(const uint8_t* buffer, size_t size); @@ -68,6 +69,10 @@ public: // --- Interrupt-driven RX --- void onReceive(void(*callback)(int)); + // --- Yield callback (called during blocking TX wait) --- + using YieldCallback = void(*)(); + void setYieldCallback(YieldCallback cb) { _yieldCb = cb; } + // --- Power --- void standby(); void sleep(); @@ -127,9 +132,13 @@ private: bool _radioOnline = false; bool _tcxo = false; bool _dio2_as_rf_switch = false; + bool _txActive = false; + uint32_t _txStartMs = 0; + uint32_t _txTimeoutMs = 0; uint8_t _packet[MAX_PACKET_SIZE] = {}; void (*_onReceive)(int) = nullptr; + YieldCallback _yieldCb = nullptr; unsigned long _preambleDetectedAt = 0; long _loraPreambleTimeMs = 0; diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index 47837e0..b4543fc 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -58,17 +58,30 @@ void AnnounceManager::received_announce( // Filter out own announces if (_localDestHash.size() > 0 && destination_hash == _localDestHash) return; - Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destination_hash.toHex().c_str(), name.c_str()); + std::string destHex = destination_hash.toHex(); + Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destHex.c_str(), name.c_str()); + + // Update name cache for persistence across reboots + if (!name.empty()) { + auto it = _nameCache.find(destHex); + if (it == _nameCache.end() || it->second != name) { + _nameCache[destHex] = name; + _nameCacheDirty = true; + } + } std::string idHex = announced_identity.hexhash(); + unsigned long now = millis(); for (auto& node : _nodes) { if (node.hash == destination_hash) { + // Rate-limit updates for existing nodes + if (now - node.lastSeen < ANNOUNCE_MIN_INTERVAL_MS) return; if (!name.empty()) node.name = name; if (!idHex.empty()) node.identityHex = idHex; - node.lastSeen = millis(); + node.lastSeen = now; node.hops = RNS::Transport::hops_to(destination_hash); - if (node.saved) saveContact(node); + if (node.saved) _contactsDirty = true; return; } } @@ -98,13 +111,29 @@ void AnnounceManager::received_announce( _nodes.push_back(node); } +void AnnounceManager::loop() { + unsigned long now = millis(); + if (_contactsDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) { + _contactsDirty = false; + _lastContactSave = now; + saveContacts(); + Serial.println("[ANNOUNCE] Deferred contact save complete"); + } + if (_nameCacheDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) { + _nameCacheDirty = false; + saveNameCache(); + } +} + const DiscoveredNode* AnnounceManager::findNode(const RNS::Bytes& hash) const { for (const auto& n : _nodes) { if (n.hash == hash) return &n; } return nullptr; } const DiscoveredNode* AnnounceManager::findNodeByHex(const std::string& hexHash) const { - for (const auto& n : _nodes) { if (n.hash.toHex() == hexHash) return &n; } + RNS::Bytes target; + target.assignHex(hexHash.c_str()); + for (const auto& n : _nodes) { if (n.hash == target) return &n; } return nullptr; } @@ -137,6 +166,16 @@ void AnnounceManager::evictStale(unsigned long maxAgeMs) { }), _nodes.end()); } +void AnnounceManager::clearTransientNodes() { + int before = _nodes.size(); + _nodes.erase(std::remove_if(_nodes.begin(), _nodes.end(), + [](const DiscoveredNode& n) { return !n.saved; }), _nodes.end()); + int removed = before - (int)_nodes.size(); + if (removed > 0) { + Serial.printf("[ANNOUNCE] Cleared %d transient nodes\n", removed); + } +} + void AnnounceManager::saveContact(const DiscoveredNode& node) { std::string hexHash = node.hash.toHex(); JsonDocument doc; @@ -202,3 +241,46 @@ void AnnounceManager::loadContacts() { void AnnounceManager::saveContacts() { for (const auto& n : _nodes) { if (n.saved) saveContact(n); } } + +std::string AnnounceManager::lookupName(const std::string& hexHash) const { + // Check live nodes first + const DiscoveredNode* node = findNodeByHex(hexHash); + if (node && !node->name.empty()) return node->name; + // Fall back to cached names + auto it = _nameCache.find(hexHash); + if (it != _nameCache.end()) return it->second; + return ""; +} + +void AnnounceManager::saveNameCache() { + JsonDocument doc; + for (auto& kv : _nameCache) { + doc[kv.first] = kv.second; + } + String json; + serializeJson(doc, json); + if (_sd && _sd->isReady()) { + _sd->writeString("/ratputer/config/names.json", json); + } + if (_flash) { + _flash->writeString("/config/names.json", json); + } + Serial.printf("[ANNOUNCE] Name cache saved (%d entries)\n", (int)_nameCache.size()); +} + +void AnnounceManager::loadNameCache() { + String json; + if (_sd && _sd->isReady()) { + json = _sd->readString("/ratputer/config/names.json"); + } + if (json.isEmpty() && _flash) { + json = _flash->readString("/config/names.json"); + } + if (json.isEmpty()) return; + JsonDocument doc; + if (deserializeJson(doc, json)) return; + for (JsonPair kv : doc.as()) { + _nameCache[kv.key().c_str()] = kv.value().as(); + } + Serial.printf("[ANNOUNCE] Name cache loaded (%d entries)\n", (int)_nameCache.size()); +} diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index 7cf4cb4..9e1ed62 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -5,6 +5,7 @@ #include #include #include +#include class SDStore; class FlashStore; @@ -34,6 +35,12 @@ public: void setLocalDestHash(const RNS::Bytes& hash) { _localDestHash = hash; } void saveContacts(); void loadContacts(); + void loop(); // Call from main loop — handles deferred saves + + // Name cache: persists hash→name mappings so names survive reboots + std::string lookupName(const std::string& hexHash) const; + void saveNameCache(); + void loadNameCache(); const std::vector& nodes() const { return _nodes; } int nodeCount() const { return _nodes.size(); } @@ -41,6 +48,7 @@ public: const DiscoveredNode* findNodeByHex(const std::string& hexHash) const; void addManualContact(const std::string& hexHash, const std::string& name); void evictStale(unsigned long maxAgeMs = 3600000); + void clearTransientNodes(); private: void saveContact(const DiscoveredNode& node); @@ -50,5 +58,12 @@ private: SDStore* _sd = nullptr; FlashStore* _flash = nullptr; RNS::Bytes _localDestHash; - static constexpr int MAX_NODES = 200; // PSRAM allows more + bool _contactsDirty = false; + bool _nameCacheDirty = false; + unsigned long _lastContactSave = 0; + unsigned long _lastAnnounceProcessed = 0; + std::map _nameCache; // hexHash → displayName + static constexpr int MAX_NODES = 30; + static constexpr unsigned long CONTACT_SAVE_INTERVAL_MS = 30000; + static constexpr unsigned long ANNOUNCE_MIN_INTERVAL_MS = 200; // Rate-limit announce processing }; diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index a8589fc..644c02c 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -38,12 +38,17 @@ bool LittleFSFileSystem::directory_exists(const char* p) { return LittleFS.exist bool LittleFSFileSystem::create_directory(const char* p) { return LittleFS.mkdir(p); } bool LittleFSFileSystem::remove_directory(const char* p) { return LittleFS.rmdir(p); } -std::list LittleFSFileSystem::list_directory(const char* p) { +std::list LittleFSFileSystem::list_directory(const char* p, Callbacks::DirectoryListing callback) { std::list entries; File dir = LittleFS.open(p); if (!dir || !dir.isDirectory()) return entries; File f = dir.openNextFile(); - while (f) { entries.push_back(f.name()); f = dir.openNextFile(); } + while (f) { + const char* name = f.name(); + entries.push_back(name); + if (callback) callback(name); + f = dir.openNextFile(); + } return entries; } diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 5e2d094..350aee1 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -24,7 +24,7 @@ public: virtual bool directory_exists(const char* directory_path) override; virtual bool create_directory(const char* directory_path) override; virtual bool remove_directory(const char* directory_path) override; - virtual std::list list_directory(const char* directory_path) override; + virtual std::list list_directory(const char* directory_path, Callbacks::DirectoryListing callback = nullptr) override; virtual size_t storage_size() override; virtual size_t storage_available() override; }; diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp index 39c9cdf..79ca5ca 100644 --- a/src/transport/LoRaInterface.cpp +++ b/src/transport/LoRaInterface.cpp @@ -40,6 +40,11 @@ void LoRaInterface::stop() { void LoRaInterface::send_outgoing(const RNS::Bytes& data) { if (!_online || !_radio) return; + if (_txPending) { + Serial.println("[LORA_IF] TX busy, dropping packet"); + return; + } + // Build RNode-compatible 1-byte header: // Upper nibble: random sequence number (for split-packet tracking) // Lower nibble: flags (FLAG_SPLIT=0x01 if packet won't fit in single frame) @@ -57,22 +62,27 @@ void LoRaInterface::send_outgoing(const RNS::Bytes& data) { _radio->beginPacket(); _radio->write(header); // 1-byte RNode header _radio->write(data.data(), data.size()); // Reticulum packet payload - bool sent = _radio->endPacket(); + _radio->endPacket(true); // Async: start TX and return immediately - if (sent) { - Serial.printf("[LORA_IF] TX %d+1 bytes (hdr=0x%02X)\n", data.size(), header); - InterfaceImpl::handle_outgoing(data); - } else { - Serial.println("[LORA_IF] TX failed (timeout)"); - } - - // Return to RX mode - _radio->receive(); + _txPending = true; + _txData = data; + InterfaceImpl::handle_outgoing(data); + Serial.printf("[LORA_IF] TX %d+1 bytes queued (hdr=0x%02X)\n", data.size(), header); } void LoRaInterface::loop() { if (!_online || !_radio) return; + // Handle async TX completion + if (_txPending) { + if (!_radio->isTxBusy()) { + _txPending = false; + _txData = RNS::Bytes(); + _radio->receive(); + } + return; // Don't process RX while TX is active + } + // Periodic RX debug: dump RSSI + chip status every 30 seconds static unsigned long lastRxDebug = 0; if (millis() - lastRxDebug > 30000) { diff --git a/src/transport/LoRaInterface.h b/src/transport/LoRaInterface.h index 87efe42..b476c86 100644 --- a/src/transport/LoRaInterface.h +++ b/src/transport/LoRaInterface.h @@ -21,4 +21,6 @@ protected: private: SX1262* _radio; + bool _txPending = false; + RNS::Bytes _txData; }; diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index 778481b..c476279 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -42,8 +42,9 @@ void TCPClientInterface::tryConnect() { void TCPClientInterface::loop() { if (!_online) return; - // Auto-reconnect + // Auto-reconnect (only if WiFi is connected) if (!_client.connected()) { + if (WiFi.status() != WL_CONNECTED) return; if (millis() - _lastAttempt >= TCP_RECONNECT_INTERVAL_MS) { tryConnect(); } diff --git a/src/ui/LvInput.cpp b/src/ui/LvInput.cpp new file mode 100644 index 0000000..455f507 --- /dev/null +++ b/src/ui/LvInput.cpp @@ -0,0 +1,73 @@ +#include "LvInput.h" +#include "hal/Keyboard.h" +#include "hal/Trackball.h" +#include "hal/TouchInput.h" + +namespace LvInput { + +static Keyboard* s_kb = nullptr; +static Trackball* s_tb = nullptr; +static TouchInput* s_touch = nullptr; +static lv_group_t* s_group = nullptr; + +// Keypad indev state +static uint32_t s_lastKey = 0; +static lv_indev_state_t s_keyState = LV_INDEV_STATE_RELEASED; +static bool s_keyReady = false; + +// Touch disabled — GT911 coordinate mapping needs calibration + +static void keypad_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) { + if (s_keyReady) { + data->key = s_lastKey; + data->state = s_keyState; + s_keyReady = false; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } +} + +void init(Keyboard* kb, Trackball* tb, TouchInput* touch) { + s_kb = kb; + s_tb = tb; + s_touch = touch; + + // Create input group + s_group = lv_group_create(); + lv_group_set_default(s_group); + + // Register keypad indev + static lv_indev_drv_t keyDrv; + lv_indev_drv_init(&keyDrv); + keyDrv.type = LV_INDEV_TYPE_KEYPAD; + keyDrv.read_cb = keypad_read_cb; + lv_indev_t* keyIndev = lv_indev_drv_register(&keyDrv); + lv_indev_set_group(keyIndev, s_group); + + // Touch indev disabled — GT911 coordinate mapping needs calibration + Serial.println("[LVGL] Input drivers registered (touch disabled)"); +} + +void feedKey(const KeyEvent& evt) { + uint32_t key = 0; + + if (evt.enter) key = LV_KEY_ENTER; + else if (evt.up) key = LV_KEY_UP; + else if (evt.down) key = LV_KEY_DOWN; + else if (evt.left) key = LV_KEY_LEFT; + else if (evt.right) key = LV_KEY_RIGHT; + else if (evt.del) key = LV_KEY_BACKSPACE; + else if (evt.tab) key = LV_KEY_NEXT; + else if (evt.character == 0x1B) key = LV_KEY_ESC; + else return; // Don't feed printable chars — screens handle them via handleKey() + + s_lastKey = key; + s_keyState = LV_INDEV_STATE_PRESSED; + s_keyReady = true; +} + +lv_group_t* group() { + return s_group; +} + +} // namespace LvInput diff --git a/src/ui/LvInput.h b/src/ui/LvInput.h new file mode 100644 index 0000000..4ef0d3a --- /dev/null +++ b/src/ui/LvInput.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +class Keyboard; +class Trackball; +class TouchInput; +struct KeyEvent; + +// LVGL input device drivers for T-Deck Plus hardware +namespace LvInput { + +void init(Keyboard* kb, Trackball* tb, TouchInput* touch); + +// Feed a KeyEvent into the LVGL keypad indev (called from main loop) +void feedKey(const KeyEvent& evt); + +// Get the LVGL input group (for focusing widgets) +lv_group_t* group(); + +} // namespace LvInput diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp new file mode 100644 index 0000000..7924467 --- /dev/null +++ b/src/ui/LvStatusBar.cpp @@ -0,0 +1,157 @@ +#include "LvStatusBar.h" +#include "Theme.h" +#include + +void LvStatusBar::create(lv_obj_t* parent) { + _bar = lv_obj_create(parent); + lv_obj_set_size(_bar, Theme::SCREEN_W, Theme::STATUS_BAR_H); + 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_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); + lv_obj_set_style_radius(_bar, 0, 0); + lv_obj_clear_flag(_bar, LV_OBJ_FLAG_SCROLLABLE); + + const lv_font_t* font = &lv_font_montserrat_12; + + // Left side: LoRa BLE WiFi indicators + _lblLora = lv_label_create(_bar); + lv_obj_set_style_text_font(_lblLora, font, 0); + lv_label_set_text(_lblLora, "LoRa"); + lv_obj_align(_lblLora, LV_ALIGN_LEFT_MID, 4, 0); + + _lblBle = lv_label_create(_bar); + lv_obj_set_style_text_font(_lblBle, font, 0); + lv_label_set_text(_lblBle, "BLE"); + lv_obj_align(_lblBle, LV_ALIGN_LEFT_MID, 50, 0); + + _lblWifi = lv_label_create(_bar); + lv_obj_set_style_text_font(_lblWifi, font, 0); + lv_label_set_text(_lblWifi, "WiFi"); + lv_obj_align(_lblWifi, LV_ALIGN_LEFT_MID, 84, 0); + + // Center: "Ratspeak" + _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); + lv_label_set_text(_lblBrand, "Ratspeak"); + lv_obj_align(_lblBrand, LV_ALIGN_CENTER, 0, 0); + + // Right: Battery % + _lblBatt = lv_label_create(_bar); + lv_obj_set_style_text_font(_lblBatt, font, 0); + lv_label_set_text(_lblBatt, ""); + lv_obj_align(_lblBatt, LV_ALIGN_RIGHT_MID, -4, 0); + + // Toast overlay (hidden by default) + _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_opa(_toast, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_toast, 0, 0); + lv_obj_set_style_radius(_toast, 0, 0); + lv_obj_clear_flag(_toast, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN); + + _lblToast = lv_label_create(_toast); + lv_obj_set_style_text_font(_lblToast, font, 0); + lv_obj_set_style_text_color(_lblToast, lv_color_hex(Theme::BG), 0); + lv_obj_center(_lblToast); + lv_label_set_text(_lblToast, ""); + + // Set initial indicator colors + refreshIndicators(); +} + +void LvStatusBar::update() { + // Handle announce flash timeout + if (_announceFlashEnd > 0 && millis() >= _announceFlashEnd) { + _announceFlashEnd = 0; + refreshIndicators(); + } + + // Handle toast timeout + if (_toastEnd > 0 && millis() >= _toastEnd) { + _toastEnd = 0; + lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN); + } +} + +void LvStatusBar::setLoRaOnline(bool online) { + _loraOnline = online; + refreshIndicators(); +} + +void LvStatusBar::setBLEActive(bool active) { + _bleActive = active; + refreshIndicators(); +} + +void LvStatusBar::setWiFiActive(bool active) { + _wifiActive = active; + refreshIndicators(); +} + +void LvStatusBar::setBatteryPercent(int pct) { + if (_battPct == pct) return; + _battPct = pct; + if (pct >= 0) { + char buf[8]; + snprintf(buf, sizeof(buf), "%d%%", pct); + lv_label_set_text(_lblBatt, buf); + uint32_t col = Theme::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; + refreshIndicators(); +} + +void LvStatusBar::showToast(const char* msg, uint32_t durationMs) { + lv_label_set_text(_lblToast, msg); + _toastEnd = millis() + durationMs; + lv_obj_clear_flag(_toast, LV_OBJ_FLAG_HIDDEN); +} + +void LvStatusBar::refreshIndicators() { + bool flashing = _announceFlashEnd > 0 && millis() < _announceFlashEnd; + + // LoRa: green=online, red=offline, cyan if TX flash + if (flashing) { + lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ACCENT), 0); + } else if (_loraOnline) { + lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::PRIMARY), 0); + } else { + lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ERROR_CLR), 0); + } + + // BLE: green=active, yellow=enabled-not-connected, red=disabled + if (_bleActive) { + lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::PRIMARY), 0); + } else if (_bleEnabled) { + lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::WARNING_CLR), 0); + } else { + lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::ERROR_CLR), 0); + } + + // WiFi: green=connected, yellow=enabled-not-connected, red=disabled + if (_wifiActive) { + lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::PRIMARY), 0); + } else if (_wifiEnabled) { + lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::WARNING_CLR), 0); + } else { + lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::ERROR_CLR), 0); + } +} diff --git a/src/ui/LvStatusBar.h b/src/ui/LvStatusBar.h new file mode 100644 index 0000000..6fb4f7f --- /dev/null +++ b/src/ui/LvStatusBar.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +class LvStatusBar { +public: + void create(lv_obj_t* parent); + void update(); + + // Status indicators: green=active, yellow=enabled-but-unconfigured, red=off + void setLoRaOnline(bool online); + void setBLEActive(bool active); + void setBLEEnabled(bool enabled) { _bleEnabled = enabled; refreshIndicators(); } + void setWiFiActive(bool active); + void setWiFiEnabled(bool enabled) { _wifiEnabled = enabled; refreshIndicators(); } + void setBatteryPercent(int pct); + void setTransportMode(const char* mode); + void flashAnnounce(); + void showToast(const char* msg, uint32_t durationMs = 1500); + + lv_obj_t* obj() { return _bar; } + +private: + void refreshIndicators(); + + lv_obj_t* _bar = nullptr; + lv_obj_t* _lblLora = nullptr; + lv_obj_t* _lblBle = nullptr; + lv_obj_t* _lblWifi = nullptr; + lv_obj_t* _lblBrand = nullptr; + lv_obj_t* _lblBatt = nullptr; + lv_obj_t* _toast = nullptr; + lv_obj_t* _lblToast = nullptr; + + bool _loraOnline = false; + bool _bleActive = false; + bool _bleEnabled = false; + bool _wifiActive = false; + bool _wifiEnabled = false; + int _battPct = -1; + unsigned long _announceFlashEnd = 0; + unsigned long _toastEnd = 0; +}; diff --git a/src/ui/LvTabBar.cpp b/src/ui/LvTabBar.cpp new file mode 100644 index 0000000..790d127 --- /dev/null +++ b/src/ui/LvTabBar.cpp @@ -0,0 +1,81 @@ +#include "LvTabBar.h" +#include "Theme.h" +#include + +constexpr const char* LvTabBar::TAB_NAMES[TAB_COUNT]; + +static void tab_click_cb(lv_event_t* e) { + LvTabBar* bar = (LvTabBar*)lv_event_get_user_data(e); + lv_obj_t* target = lv_event_get_target(e); + for (int i = 0; i < LvTabBar::TAB_COUNT; i++) { + lv_obj_t* parent = lv_obj_get_parent(target); + if (target == lv_obj_get_child(bar->obj(), i) || + (parent && parent == lv_obj_get_child(bar->obj(), i))) { + bar->setActiveTab(i); + break; + } + } +} + +void LvTabBar::create(lv_obj_t* parent) { + _bar = lv_obj_create(parent); + lv_obj_set_size(_bar, Theme::SCREEN_W, Theme::TAB_BAR_H); + 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_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); + lv_obj_set_style_radius(_bar, 0, 0); + lv_obj_clear_flag(_bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_layout(_bar, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_bar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + const lv_font_t* font = &lv_font_montserrat_12; + + 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_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); + } + + refreshTabs(); +} + +void LvTabBar::setActiveTab(int tab) { + if (tab < 0 || tab >= TAB_COUNT) return; + _activeTab = tab; + refreshTabs(); + if (_tabCb) _tabCb(tab); +} + +void LvTabBar::cycleTab(int direction) { + int next = (_activeTab + direction + TAB_COUNT) % TAB_COUNT; + setActiveTab(next); +} + +void LvTabBar::setUnreadCount(int tab, int count) { + if (tab < 0 || tab >= TAB_COUNT) return; + _unread[tab] = count; + refreshTabs(); +} + +void LvTabBar::refreshTabs() { + for (int i = 0; i < TAB_COUNT; i++) { + bool active = (i == _activeTab); + lv_obj_set_style_text_color(_tabs[i], + lv_color_hex(active ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE), 0); + + char buf[24]; + if (_unread[i] > 0) { + snprintf(buf, sizeof(buf), "%s(%d)", TAB_NAMES[i], _unread[i]); + } else { + snprintf(buf, sizeof(buf), "%s", TAB_NAMES[i]); + } + lv_label_set_text(_tabs[i], buf); + } +} diff --git a/src/ui/LvTabBar.h b/src/ui/LvTabBar.h new file mode 100644 index 0000000..55bff57 --- /dev/null +++ b/src/ui/LvTabBar.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +class LvTabBar { +public: + enum Tab { TAB_HOME = 0, TAB_MSGS, TAB_NODES, TAB_SETUP, TAB_COUNT = 4 }; + + void create(lv_obj_t* parent); + + 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; } + + lv_obj_t* obj() { return _bar; } + +private: + void refreshTabs(); + + lv_obj_t* _bar = nullptr; + lv_obj_t* _tabs[TAB_COUNT] = {}; + int _activeTab = TAB_HOME; + int _unread[TAB_COUNT] = {}; + TabCallback _tabCb = nullptr; + + static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Msgs", "Nodes", "Setup"}; +}; diff --git a/src/ui/LvTheme.cpp b/src/ui/LvTheme.cpp new file mode 100644 index 0000000..5a4c113 --- /dev/null +++ b/src/ui/LvTheme.cpp @@ -0,0 +1,147 @@ +#include "LvTheme.h" +#include "Theme.h" +#include + +namespace LvTheme { + +static lv_style_t s_screen; +static lv_style_t s_label; +static lv_style_t s_labelMuted; +static lv_style_t s_labelAccent; +static lv_style_t s_btn; +static lv_style_t s_btnPressed; +static lv_style_t s_bar; +static lv_style_t s_barIndicator; +static lv_style_t s_switch; +static lv_style_t s_switchChecked; +static lv_style_t s_textarea; +static lv_style_t s_list; +static lv_style_t s_listBtn; +static lv_style_t s_listBtnFocused; +static lv_style_t s_dropdown; +static lv_style_t s_slider; + +void init(lv_disp_t* disp) { + // Screen background + 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_font(&s_screen, &lv_font_montserrat_14); + + // Labels + lv_style_init(&s_label); + lv_style_set_text_color(&s_label, lv_color_hex(Theme::PRIMARY)); + + lv_style_init(&s_labelMuted); + lv_style_set_text_color(&s_labelMuted, lv_color_hex(Theme::MUTED)); + + 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_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_init(&s_btnPressed); + lv_style_set_bg_color(&s_btnPressed, lv_color_hex(Theme::SELECTION_BG)); + lv_style_set_border_color(&s_btnPressed, lv_color_hex(Theme::PRIMARY)); + + // 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_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); + + // Switch + lv_style_init(&s_switch); + lv_style_set_bg_color(&s_switch, lv_color_hex(Theme::MUTED)); + lv_style_set_bg_opa(&s_switch, LV_OPA_COVER); + lv_style_set_radius(&s_switch, LV_RADIUS_CIRCLE); + + lv_style_init(&s_switchChecked); + lv_style_set_bg_color(&s_switchChecked, lv_color_hex(Theme::PRIMARY)); + + // Textarea + lv_style_init(&s_textarea); + lv_style_set_bg_color(&s_textarea, lv_color_hex(Theme::BG)); + 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_pad_all(&s_textarea, 6); + + // List + 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); + lv_style_set_pad_all(&s_list, 0); + lv_style_set_pad_row(&s_list, 0); + lv_style_set_border_width(&s_list, 0); + + 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_border_side(&s_listBtn, LV_BORDER_SIDE_BOTTOM); + lv_style_set_pad_all(&s_listBtn, 5); + lv_style_set_radius(&s_listBtn, 0); + + lv_style_init(&s_listBtnFocused); + lv_style_set_bg_color(&s_listBtnFocused, lv_color_hex(Theme::SELECTION_BG)); + lv_style_set_border_color(&s_listBtnFocused, lv_color_hex(Theme::PRIMARY)); + lv_style_set_border_width(&s_listBtnFocused, 1); + + // Dropdown + lv_style_init(&s_dropdown); + lv_style_set_bg_color(&s_dropdown, lv_color_hex(Theme::BG)); + 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_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); + + // Apply screen style to default theme + lv_obj_add_style(lv_scr_act(), &s_screen, 0); + + 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; } + +} // namespace LvTheme diff --git a/src/ui/LvTheme.h b/src/ui/LvTheme.h new file mode 100644 index 0000000..c1add1c --- /dev/null +++ b/src/ui/LvTheme.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// Ratspeak LVGL theme — matrix green on black cyberpunk aesthetic +namespace LvTheme { + +void init(lv_disp_t* disp); + +// Style accessors for common elements +lv_style_t* styleScreen(); +lv_style_t* styleLabel(); +lv_style_t* styleLabelMuted(); +lv_style_t* styleLabelAccent(); +lv_style_t* styleBtn(); +lv_style_t* styleBtnPressed(); +lv_style_t* styleBar(); +lv_style_t* styleBarIndicator(); +lv_style_t* styleSwitch(); +lv_style_t* styleSwitchChecked(); +lv_style_t* styleTextarea(); +lv_style_t* styleList(); +lv_style_t* styleListBtn(); +lv_style_t* styleListBtnFocused(); +lv_style_t* styleDropdown(); +lv_style_t* styleSlider(); + +} // namespace LvTheme diff --git a/src/ui/Theme.h b/src/ui/Theme.h index 6f79838..a33e8b2 100644 --- a/src/ui/Theme.h +++ b/src/ui/Theme.h @@ -3,7 +3,7 @@ #include // ============================================================================= -// Ratdeck — Cyberpunk Theme Constants (LovyanGFX direct drawing) +// Ratdeck — Cyberpunk Theme Constants // ============================================================================= namespace Theme { @@ -11,24 +11,24 @@ namespace Theme { // --- Colors (RGB888 for LovyanGFX) --- constexpr uint32_t BG = 0x000000; // Pure black constexpr uint32_t PRIMARY = 0x00FF41; // Matrix green -constexpr uint32_t SECONDARY = 0x00CC33; // Dimmed green +constexpr uint32_t SECONDARY = 0x00DD44; // Dimmed green constexpr uint32_t ACCENT = 0x00FFFF; // Cyan -constexpr uint32_t MUTED = 0x336633; // Dark green +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 = 0x004400; // Subtle green -constexpr uint32_t SELECTION_BG = 0x003300; // Highlight +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 = 0x336633; +constexpr uint32_t TAB_INACTIVE = 0x559955; constexpr uint32_t BADGE_BG = 0xFF3333; // --- Layout Metrics --- constexpr int SCREEN_W = 320; constexpr int SCREEN_H = 240; -constexpr int STATUS_BAR_H = 14; -constexpr int TAB_BAR_H = 14; +constexpr int STATUS_BAR_H = 20; +constexpr int TAB_BAR_H = 20; 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; @@ -38,3 +38,9 @@ constexpr int TAB_COUNT = 5; constexpr int TAB_W = SCREEN_W / TAB_COUNT; } // namespace Theme + +// LVGL color helper — available when LVGL is included +#ifdef LV_CONF_H +#include +inline lv_color_t lvColor(uint32_t rgb888) { return lv_color_hex(rgb888); } +#endif diff --git a/src/ui/UIManager.cpp b/src/ui/UIManager.cpp index fe275a4..ae7c544 100644 --- a/src/ui/UIManager.cpp +++ b/src/ui/UIManager.cpp @@ -1,14 +1,59 @@ #include "UIManager.h" #include "Theme.h" +#include "LvTheme.h" #include "hal/Display.h" +// --- LvScreen base --- + +void LvScreen::destroyUI() { + // Content is owned by UIManager's _lvContent — just clear our pointers. + // The UIManager calls lv_obj_clean(_lvContent) before creating the next screen. + _screen = nullptr; +} + +// --- UIManager --- + void UIManager::begin(LGFX_TDeck* gfx) { _gfx = gfx; _statusBar.setGfx(gfx); _tabBar.setGfx(gfx); + + // Initialize LVGL theme and create persistent UI structure + lv_obj_t* scr = lv_scr_act(); + LvTheme::init(lv_disp_get_default()); + + // Apply screen background style + lv_obj_add_style(scr, LvTheme::styleScreen(), 0); + + // Create LVGL status bar (top) + _lvStatusBar.create(scr); + + // Create LVGL tab bar (bottom) + _lvTabBar.create(scr); + + // Create content area between status bar and tab bar + _lvContent = lv_obj_create(scr); + lv_obj_set_pos(_lvContent, 0, Theme::STATUS_BAR_H); + lv_obj_set_size(_lvContent, Theme::CONTENT_W, Theme::CONTENT_H); + lv_obj_set_style_bg_color(_lvContent, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_lvContent, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_lvContent, 0, 0); + 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(); } @@ -20,9 +65,48 @@ void UIManager::setScreen(Screen* screen) { _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; + } + + // Transition from previous LVGL screen + if (_currentLvScreen) { + _currentLvScreen->onExit(); + _currentLvScreen->destroyUI(); + } + + _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 (_currentLvScreen) { + // Clean content area + lv_obj_clean(_lvContent); + _currentLvScreen->createUI(_lvContent); + _currentLvScreen->onEnter(); + } +} + void UIManager::setOverlay(Screen* overlay) { _overlay = overlay; if (_overlay) { @@ -33,17 +117,36 @@ void UIManager::setOverlay(Screen* overlay) { 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); + } + } 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()); @@ -59,7 +162,6 @@ void UIManager::render() { if (needContentRedraw) { if (_bootMode) { - // In boot mode, content area is full screen _gfx->setClipRect(0, 0, Theme::SCREEN_W, Theme::SCREEN_H); } else { _gfx->setClipRect(0, Theme::CONTENT_Y, Theme::CONTENT_W, Theme::CONTENT_H); @@ -96,8 +198,18 @@ 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; } + +bool UIManager::handleLongPress() { + if (_currentLvScreen) { + return _currentLvScreen->handleLongPress(); + } + return false; +} diff --git a/src/ui/UIManager.h b/src/ui/UIManager.h index 57fc469..4a176f3 100644 --- a/src/ui/UIManager.h +++ b/src/ui/UIManager.h @@ -1,11 +1,15 @@ #pragma once +#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; @@ -24,22 +28,49 @@ protected: bool _dirty = true; }; +// LVGL screen base class +class LvScreen { +public: + virtual ~LvScreen() = default; + virtual void createUI(lv_obj_t* parent) = 0; + virtual void destroyUI(); + virtual void refreshUI() {} + virtual void onEnter() {} + virtual void onExit() {} + virtual bool handleKey(const KeyEvent& event) { return false; } + virtual bool handleLongPress() { return false; } + virtual const char* title() const = 0; + + lv_obj_t* screen() const { return _screen; } + +protected: + lv_obj_t* _screen = nullptr; +}; + class UIManager { public: void begin(LGFX_TDeck* gfx); - // Screen management + // Legacy screen management (LovyanGFX) void setScreen(Screen* screen); Screen* getScreen() { return _currentScreen; } - // Component access + // 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 + LvStatusBar& lvStatusBar() { return _lvStatusBar; } + LvTabBar& lvTabBar() { return _lvTabBar; } + // Update data (called periodically) void update(); - // Render if dirty (called from loop) + // Render if dirty (called from loop — legacy screens only) void render(); // Force full redraw @@ -47,19 +78,33 @@ public: // Handle key event — routes to current screen bool handleKey(const KeyEvent& event); + bool handleLongPress(); // Boot mode — hides status bar and tab bar void setBootMode(bool boot); bool isBootMode() const { return _bootMode; } - // Overlay support + // 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; + LvScreen* _currentLvScreen = nullptr; + 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 new file mode 100644 index 0000000..6cf6172 --- /dev/null +++ b/src/ui/screens/LvBootScreen.cpp @@ -0,0 +1,54 @@ +#include "LvBootScreen.h" +#include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "config/Config.h" + +void LvBootScreen::createUI(lv_obj_t* parent) { + _screen = parent; + lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0); + lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + + // 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_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_montserrat_12, 0); + lv_obj_set_style_text_color(_lblVersion, lv_color_hex(Theme::SECONDARY), 0); + char ver[32]; + snprintf(ver, sizeof(ver), "v%s", RATDECK_VERSION_STRING); + lv_label_set_text(_lblVersion, ver); + lv_obj_align(_lblVersion, LV_ALIGN_TOP_MID, 0, 82); + + // Progress bar + _bar = lv_bar_create(parent); + lv_obj_set_size(_bar, 200, 10); + lv_obj_align(_bar, LV_ALIGN_TOP_MID, 0, 105); + lv_bar_set_range(_bar, 0, 100); + lv_bar_set_value(_bar, 0, LV_ANIM_OFF); + lv_obj_add_style(_bar, LvTheme::styleBar(), LV_PART_MAIN); + lv_obj_add_style(_bar, LvTheme::styleBarIndicator(), LV_PART_INDICATOR); + + // Status text + _lblStatus = lv_label_create(parent); + lv_obj_set_style_text_font(_lblStatus, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SECONDARY), 0); + lv_label_set_text(_lblStatus, "Starting..."); + lv_obj_align(_lblStatus, LV_ALIGN_TOP_MID, 0, 128); +} + +void LvBootScreen::setProgress(float progress, const char* status) { + if (_bar) { + lv_bar_set_value(_bar, (int)(progress * 100), LV_ANIM_OFF); + } + if (_lblStatus) { + lv_label_set_text(_lblStatus, status); + } + // Force LVGL to flush during boot (before main loop) + lv_timer_handler(); +} diff --git a/src/ui/screens/LvBootScreen.h b/src/ui/screens/LvBootScreen.h new file mode 100644 index 0000000..fab92ad --- /dev/null +++ b/src/ui/screens/LvBootScreen.h @@ -0,0 +1,17 @@ +#pragma once + +#include "ui/UIManager.h" + +class LvBootScreen : public LvScreen { +public: + void createUI(lv_obj_t* parent) override; + const char* title() const override { return "Boot"; } + + void setProgress(float progress, const char* status); + +private: + lv_obj_t* _lblTitle = nullptr; + lv_obj_t* _lblVersion = nullptr; + lv_obj_t* _bar = nullptr; + lv_obj_t* _lblStatus = nullptr; +}; diff --git a/src/ui/screens/LvHelpOverlay.cpp b/src/ui/screens/LvHelpOverlay.cpp new file mode 100644 index 0000000..2faf321 --- /dev/null +++ b/src/ui/screens/LvHelpOverlay.cpp @@ -0,0 +1,66 @@ +#include "LvHelpOverlay.h" +#include "ui/Theme.h" +#include + +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_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); + + const lv_font_t* font = &lv_font_montserrat_12; + + auto mkLabel = [&](const char* text, uint32_t color) { + lv_obj_t* lbl = lv_label_create(_overlay); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_label_set_text(lbl, text); + }; + + 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); + + lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); +} + +void LvHelpOverlay::show() { + if (!_overlay) create(); + lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_HIDDEN); + _visible = true; +} + +void LvHelpOverlay::hide() { + if (_overlay) lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); + _visible = false; +} + +void LvHelpOverlay::toggle() { + if (_visible) hide(); else show(); +} + +bool LvHelpOverlay::handleKey(const KeyEvent& event) { + if (_visible) { + hide(); + return true; + } + return false; +} diff --git a/src/ui/screens/LvHelpOverlay.h b/src/ui/screens/LvHelpOverlay.h new file mode 100644 index 0000000..247e8d4 --- /dev/null +++ b/src/ui/screens/LvHelpOverlay.h @@ -0,0 +1,17 @@ +#pragma once + +#include "ui/UIManager.h" + +class LvHelpOverlay { +public: + void create(); + void show(); + void hide(); + bool isVisible() const { return _visible; } + void toggle(); + bool handleKey(const KeyEvent& event); + +private: + lv_obj_t* _overlay = nullptr; + bool _visible = false; +}; diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp new file mode 100644 index 0000000..b22a3c1 --- /dev/null +++ b/src/ui/screens/LvHomeScreen.cpp @@ -0,0 +1,97 @@ +#include "LvHomeScreen.h" +#include "ui/Theme.h" +#include "reticulum/ReticulumManager.h" +#include "radio/SX1262.h" +#include "config/UserConfig.h" +#include +#include + +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_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + + const lv_font_t* font = &lv_font_montserrat_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; + }; + + _lblId = mkLabel("Identity: ..."); + _lblTransport = mkLabel("Transport: ..."); + _lblPaths = mkLabel("Paths: ..."); + _lblLora = mkLabel("Radio: ..."); + _lblHeap = mkLabel("Heap: ..."); + _lblPsram = mkLabel("PSRAM: ..."); + _lblUptime = mkLabel("Uptime: 0m"); + + // Force refresh on new UI — invalidate cache + _lastUptime = ULONG_MAX; + _lastHeap = UINT32_MAX; + refreshUI(); +} + +void LvHomeScreen::onEnter() { + // Invalidate cache so refreshUI() always updates after screen transition + _lastUptime = ULONG_MAX; + _lastHeap = UINT32_MAX; + refreshUI(); +} + +void LvHomeScreen::refreshUI() { + if (!_lblId) return; + + unsigned long upMins = millis() / 60000; + uint32_t heap = ESP.getFreeHeap() / 1024; + if (upMins == _lastUptime && heap == _lastHeap) return; + _lastUptime = upMins; + _lastHeap = heap; + + if (_rns) { + lv_label_set_text_fmt(_lblId, "ID: %s", _rns->identityHash().c_str()); + lv_label_set_text_fmt(_lblTransport, "Transport: %s", + _rns->isTransportActive() ? "ACTIVE" : "OFFLINE"); + lv_label_set_text_fmt(_lblPaths, "Paths: %d Links: %d", + (int)_rns->pathCount(), (int)_rns->linkCount()); + } else { + lv_label_set_text(_lblId, "Identity: ---"); + lv_label_set_text(_lblTransport, "Transport: OFFLINE"); + lv_label_set_text(_lblPaths, "Paths: 0 Links: 0"); + } + + if (_radio && _radio->isRadioOnline()) { + lv_label_set_text_fmt(_lblLora, "LoRa: SF%d BW%luk %ddBm", + _radio->getSpreadingFactor(), + (unsigned long)(_radio->getSignalBandwidth() / 1000), + _radio->getTxPower()); + lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::PRIMARY), 0); + } else { + lv_label_set_text(_lblLora, "Radio: OFFLINE"); + lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ERROR_CLR), 0); + } + + lv_label_set_text_fmt(_lblHeap, "Heap: %lukB free", + (unsigned long)(ESP.getFreeHeap() / 1024)); + lv_label_set_text_fmt(_lblPsram, "PSRAM: %lukB free", + (unsigned long)(ESP.getFreePsram() / 1024)); + + if (upMins >= 60) { + lv_label_set_text_fmt(_lblUptime, "Uptime: %luh %lum", upMins / 60, upMins % 60); + } else { + lv_label_set_text_fmt(_lblUptime, "Uptime: %lum", upMins); + } +} + +bool LvHomeScreen::handleKey(const KeyEvent& event) { + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_announceCb) _announceCb(); + return true; + } + return false; +} diff --git a/src/ui/screens/LvHomeScreen.h b/src/ui/screens/LvHomeScreen.h new file mode 100644 index 0000000..5b20122 --- /dev/null +++ b/src/ui/screens/LvHomeScreen.h @@ -0,0 +1,39 @@ +#pragma once + +#include "ui/UIManager.h" +#include + +class ReticulumManager; +class SX1262; +class UserConfig; + +class LvHomeScreen : public LvScreen { +public: + void createUI(lv_obj_t* parent) override; + void refreshUI() override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setReticulumManager(ReticulumManager* rns) { _rns = rns; } + void setRadio(SX1262* radio) { _radio = radio; } + void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + void setAnnounceCallback(std::function cb) { _announceCb = cb; } + + const char* title() const override { return "Home"; } + +private: + ReticulumManager* _rns = nullptr; + SX1262* _radio = nullptr; + UserConfig* _cfg = nullptr; + std::function _announceCb; + unsigned long _lastUptime = 0; + uint32_t _lastHeap = 0; + + lv_obj_t* _lblId = nullptr; + lv_obj_t* _lblTransport = nullptr; + lv_obj_t* _lblPaths = nullptr; + lv_obj_t* _lblLora = nullptr; + lv_obj_t* _lblHeap = nullptr; + lv_obj_t* _lblPsram = nullptr; + lv_obj_t* _lblUptime = nullptr; +}; diff --git a/src/ui/screens/LvMapScreen.cpp b/src/ui/screens/LvMapScreen.cpp new file mode 100644 index 0000000..27581c0 --- /dev/null +++ b/src/ui/screens/LvMapScreen.cpp @@ -0,0 +1,14 @@ +#include "LvMapScreen.h" +#include "ui/Theme.h" + +void LvMapScreen::createUI(lv_obj_t* parent) { + _screen = parent; + lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::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/LvMapScreen.h b/src/ui/screens/LvMapScreen.h new file mode 100644 index 0000000..ad0b2ba --- /dev/null +++ b/src/ui/screens/LvMapScreen.h @@ -0,0 +1,9 @@ +#pragma once + +#include "ui/UIManager.h" + +class LvMapScreen : public LvScreen { +public: + void createUI(lv_obj_t* parent) override; + const char* title() const override { return "Map"; } +}; diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp new file mode 100644 index 0000000..e16c598 --- /dev/null +++ b/src/ui/screens/LvMessageView.cpp @@ -0,0 +1,283 @@ +#include "LvMessageView.h" +#include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "reticulum/LXMFManager.h" +#include "reticulum/AnnounceManager.h" +#include +#include + +std::string LvMessageView::getPeerName() { + if (_am) { + std::string name = _am->lookupName(_peerHex); + if (!name.empty()) return name; + } + return _peerHex.substr(0, 12); +} + +void LvMessageView::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); + lv_obj_set_style_pad_all(parent, 0, 0); + + // Use flex column layout: header, messages (grows), input + lv_obj_set_layout(parent, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, 0); + + const lv_font_t* font = &lv_font_montserrat_12; + int headerH = 22; + int inputH = 28; + + // Header bar (top) + _header = lv_obj_create(parent); + lv_obj_set_size(_header, lv_pct(100), headerH); + lv_obj_set_style_bg_color(_header, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_header, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(_header, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(_header, 1, 0); + lv_obj_set_style_border_side(_header, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_clear_flag(_header, LV_OBJ_FLAG_SCROLLABLE); + + _lblHeader = lv_label_create(_header); + lv_obj_set_style_text_font(_lblHeader, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(_lblHeader, lv_color_hex(Theme::ACCENT), 0); + lv_obj_align(_lblHeader, LV_ALIGN_LEFT_MID, 4, 0); + + // Message scroll area (middle, grows to fill) + _msgScroll = lv_obj_create(parent); + lv_obj_set_width(_msgScroll, lv_pct(100)); + lv_obj_set_flex_grow(_msgScroll, 1); + lv_obj_set_style_bg_color(_msgScroll, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_msgScroll, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_msgScroll, 0, 0); + lv_obj_set_style_pad_all(_msgScroll, 4, 0); + lv_obj_set_style_pad_row(_msgScroll, 6, 0); + 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); + + // Input row (bottom, just above tab bar) + _inputRow = lv_obj_create(parent); + lv_obj_set_size(_inputRow, lv_pct(100), inputH); + lv_obj_set_style_bg_color(_inputRow, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_inputRow, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(_inputRow, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(_inputRow, 1, 0); + lv_obj_set_style_border_side(_inputRow, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_pad_all(_inputRow, 3, 0); + lv_obj_set_style_radius(_inputRow, 0, 0); + lv_obj_clear_flag(_inputRow, LV_OBJ_FLAG_SCROLLABLE); + + _textarea = lv_textarea_create(_inputRow); + lv_obj_set_size(_textarea, Theme::CONTENT_W - 50, 22); + 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_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_set_style_pad_all(_btnSend, 0, 0); + lv_obj_t* sendLbl = lv_label_create(_btnSend); + lv_obj_set_style_text_font(sendLbl, &lv_font_montserrat_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); +} + +void LvMessageView::destroyUI() { + _header = nullptr; + _lblHeader = nullptr; + _msgScroll = nullptr; + _inputRow = nullptr; + _textarea = nullptr; + _btnSend = nullptr; + LvScreen::destroyUI(); +} + +void LvMessageView::onEnter() { + if (_lxmf) _lxmf->markRead(_peerHex); + _lastMsgCount = -1; + _lastRefreshMs = 0; + _inputText.clear(); + + if (_lblHeader) { + char header[48]; + snprintf(header, sizeof(header), "< %s", getPeerName().c_str()); + lv_label_set_text(_lblHeader, header); + } + if (_textarea) { + lv_textarea_set_text(_textarea, ""); + } + rebuildMessages(); +} + +void LvMessageView::onExit() { + _inputText.clear(); + _cachedMsgs.clear(); +} + +void LvMessageView::refreshUI() { + if (!_lxmf) return; + unsigned long now = millis(); + if (now - _lastRefreshMs >= REFRESH_INTERVAL_MS) { + int oldCount = (int)_cachedMsgs.size(); + _cachedMsgs = _lxmf->getMessages(_peerHex); + _lastRefreshMs = now; + if ((int)_cachedMsgs.size() != oldCount) { + _lastMsgCount = (int)_cachedMsgs.size(); + rebuildMessages(); + } + } +} + +void LvMessageView::rebuildMessages() { + if (!_lxmf || !_msgScroll) return; + + _cachedMsgs = _lxmf->getMessages(_peerHex); + _lastRefreshMs = millis(); + lv_obj_clean(_msgScroll); + + const lv_font_t* font = &lv_font_montserrat_12; + int maxBubbleW = Theme::CONTENT_W * 3 / 4; + + for (const auto& msg : _cachedMsgs) { + // Bubble container + lv_obj_t* bubble = lv_obj_create(_msgScroll); + lv_obj_set_width(bubble, Theme::CONTENT_W - 12); + lv_obj_set_style_pad_all(bubble, 0, 0); + lv_obj_set_style_bg_opa(bubble, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(bubble, 0, 0); + lv_obj_clear_flag(bubble, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(bubble, LV_SIZE_CONTENT); + + // Message text in a rounded box + lv_obj_t* box = lv_obj_create(bubble); + lv_obj_set_style_radius(box, 4, 0); + lv_obj_set_style_pad_all(box, 5, 0); + lv_obj_set_style_border_width(box, 0, 0); + lv_obj_set_width(box, LV_SIZE_CONTENT); + lv_obj_set_height(box, LV_SIZE_CONTENT); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + + if (msg.incoming) { + lv_obj_set_style_bg_color(box, lv_color_hex(Theme::MSG_IN_BG), 0); + lv_obj_align(box, LV_ALIGN_LEFT_MID, 0, 0); + } else { + lv_obj_set_style_bg_color(box, lv_color_hex(Theme::MSG_OUT_BG), 0); + lv_obj_align(box, LV_ALIGN_RIGHT_MID, 0, 0); + } + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + + // Message label with word wrap + lv_obj_t* lbl = lv_label_create(box); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex( + msg.incoming ? Theme::ACCENT : Theme::PRIMARY), 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_width(lbl, maxBubbleW - 14); + lv_label_set_text(lbl, msg.content.c_str()); + + // Status indicator for outgoing + if (!msg.incoming) { + const char* ind = "~"; + uint32_t indColor = Theme::MUTED; + if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) { + ind = "*"; indColor = Theme::ACCENT; + } else if (msg.status == LXMFStatus::FAILED) { + ind = "!"; indColor = Theme::ERROR_CLR; + } + lv_obj_t* statusLbl = lv_label_create(box); + lv_obj_set_style_text_font(statusLbl, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(statusLbl, lv_color_hex(indColor), 0); + lv_label_set_text(statusLbl, ind); + lv_obj_align(statusLbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + } + + // Timestamp below bubble + if (msg.timestamp > 1000000) { + time_t t = (time_t)msg.timestamp; + struct tm* tm = localtime(&t); + if (tm) { + char timeBuf[8]; + 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_montserrat_10, 0); + lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(timeLbl, timeBuf); + if (msg.incoming) { + lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_LEFT, 2, 1); + } else { + lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_RIGHT, -2, 1); + } + } + } + } + + // Auto-scroll to bottom + lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF); +} + +void LvMessageView::sendCurrentMessage() { + if (!_lxmf || _peerHex.empty() || _inputText.empty()) return; + + RNS::Bytes destHash; + destHash.assignHex(_peerHex.c_str()); + _lxmf->sendMessage(destHash, _inputText.c_str()); + + _inputText.clear(); + if (_textarea) lv_textarea_set_text(_textarea, ""); + rebuildMessages(); +} + +bool LvMessageView::handleKey(const KeyEvent& event) { + if (event.character == 0x1B) { + if (_onBack) _onBack(); + return true; + } + + if (event.del || event.character == 0x08) { + if (!_inputText.empty()) { + _inputText.pop_back(); + if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str()); + } else { + if (_onBack) _onBack(); + } + return true; + } + + if (event.enter || event.character == '\n' || event.character == '\r') { + sendCurrentMessage(); + return true; + } + + // Scroll + if (event.up) { + if (_msgScroll) lv_obj_scroll_to_y(_msgScroll, + lv_obj_get_scroll_y(_msgScroll) - 30, LV_ANIM_OFF); + return true; + } + if (event.down) { + if (_msgScroll) lv_obj_scroll_to_y(_msgScroll, + lv_obj_get_scroll_y(_msgScroll) + 30, LV_ANIM_OFF); + return true; + } + + if (event.character >= 0x20 && event.character < 0x7F) { + _inputText += (char)event.character; + if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str()); + return true; + } + + return false; +} diff --git a/src/ui/screens/LvMessageView.h b/src/ui/screens/LvMessageView.h new file mode 100644 index 0000000..a64e063 --- /dev/null +++ b/src/ui/screens/LvMessageView.h @@ -0,0 +1,53 @@ +#pragma once + +#include "ui/UIManager.h" +#include "reticulum/LXMFMessage.h" +#include +#include +#include + +class LXMFManager; +class AnnounceManager; + +class LvMessageView : public LvScreen { +public: + using BackCallback = std::function; + + void createUI(lv_obj_t* parent) override; + void destroyUI() override; + void refreshUI() override; + void onEnter() override; + void onExit() override; + bool handleKey(const KeyEvent& event) override; + + void setPeerHex(const std::string& hex) { _peerHex = hex; } + void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } + void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setBackCallback(BackCallback cb) { _onBack = cb; } + + const char* title() const override { return "Chat"; } + +private: + void sendCurrentMessage(); + void rebuildMessages(); + std::string getPeerName(); + + LXMFManager* _lxmf = nullptr; + AnnounceManager* _am = nullptr; + BackCallback _onBack; + std::string _peerHex; + std::string _inputText; + int _lastMsgCount = -1; + unsigned long _lastRefreshMs = 0; + std::vector _cachedMsgs; + + // LVGL widgets + lv_obj_t* _header = nullptr; + lv_obj_t* _lblHeader = nullptr; + lv_obj_t* _msgScroll = nullptr; + lv_obj_t* _inputRow = nullptr; + lv_obj_t* _textarea = nullptr; + lv_obj_t* _btnSend = nullptr; + + static constexpr unsigned long REFRESH_INTERVAL_MS = 2000; // Check for new messages every 2s +}; diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp new file mode 100644 index 0000000..c531d62 --- /dev/null +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -0,0 +1,188 @@ +#include "LvMessagesScreen.h" +#include "ui/Theme.h" +#include "ui/UIManager.h" +#include "reticulum/LXMFManager.h" +#include "reticulum/AnnounceManager.h" +#include "storage/MessageStore.h" +#include + +void LvMessagesScreen::createUI(lv_obj_t* parent) { + _screen = parent; + lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_pad_all(parent, 0, 0); + + _lblEmpty = lv_label_create(parent); + lv_obj_set_style_text_font(_lblEmpty, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(_lblEmpty, "No conversations"); + lv_obj_center(_lblEmpty); + + _list = lv_obj_create(parent); + lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); + lv_obj_set_pos(_list, 0, 0); + lv_obj_set_flex_grow(_list, 1); + lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_list, 0, 0); + lv_obj_set_style_pad_all(_list, 0, 0); + lv_obj_set_style_pad_row(_list, 0, 0); + lv_obj_set_style_radius(_list, 0, 0); + lv_obj_set_layout(_list, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + + _lastConvCount = -1; + rebuildList(); +} + +void LvMessagesScreen::onEnter() { + _lastConvCount = -1; + _selectedIdx = 0; + rebuildList(); +} + +void LvMessagesScreen::refreshUI() { + if (!_lxmf) return; + int count = (int)_lxmf->conversations().size(); + if (count != _lastConvCount) { + rebuildList(); + } +} + +// Update only the selection highlight without rebuilding widgets +void LvMessagesScreen::updateSelection(int oldIdx, int newIdx) { + if (oldIdx >= 0 && oldIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[oldIdx], lv_color_hex(Theme::BG), 0); + } + if (newIdx >= 0 && newIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); + lv_obj_scroll_to_view(_rows[newIdx], LV_ANIM_OFF); + } +} + +void LvMessagesScreen::rebuildList() { + if (!_lxmf || !_list) return; + int count = (int)_lxmf->conversations().size(); + _lastConvCount = count; + _rows.clear(); + lv_obj_clean(_list); + + if (count == 0) { + lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); + + if (_selectedIdx >= count) _selectedIdx = count - 1; + if (_selectedIdx < 0) _selectedIdx = 0; + + const auto& convs = _lxmf->conversations(); + const lv_font_t* font = &lv_font_montserrat_14; + + for (int i = 0; i < count; i++) { + const auto& peerHex = convs[i]; + int unread = _lxmf->unreadCount(peerHex); + + lv_obj_t* row = lv_obj_create(_list); + lv_obj_set_size(row, Theme::CONTENT_W, 28); + lv_obj_set_style_bg_color(row, lv_color_hex( + i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Peer name — check name cache (survives reboots) + std::string displayName; + if (_am) displayName = _am->lookupName(peerHex); + if (displayName.empty()) displayName = peerHex.substr(0, 16); + + lv_obj_t* lbl = lv_label_create(row); + 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, displayName.c_str()); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + + // Unread badge + if (unread > 0) { + char badge[8]; + snprintf(badge, sizeof(badge), "(%d)", unread); + lv_obj_t* badgeLbl = lv_label_create(row); + lv_obj_set_style_text_font(badgeLbl, font, 0); + lv_obj_set_style_text_color(badgeLbl, lv_color_hex(Theme::BADGE_BG), 0); + lv_label_set_text(badgeLbl, badge); + lv_obj_align(badgeLbl, LV_ALIGN_RIGHT_MID, -4, 0); + } + + _rows.push_back(row); + } +} + +bool LvMessagesScreen::handleLongPress() { + if (!_lxmf) return false; + int count = (int)_lxmf->conversations().size(); + if (count == 0 || _selectedIdx >= count) return false; + _confirmDelete = true; + if (_ui) _ui->lvStatusBar().showToast("Delete chat? Enter=Yes Esc=No", 5000); + return true; +} + +bool LvMessagesScreen::handleKey(const KeyEvent& event) { + if (!_lxmf) return false; + + // Confirm delete mode + if (_confirmDelete) { + if (event.enter || event.character == '\n' || event.character == '\r') { + int count = (int)_lxmf->conversations().size(); + if (_selectedIdx < count) { + const auto& peerHex = _lxmf->conversations()[_selectedIdx]; + _lxmf->markRead(peerHex); // Clear unread + // Delete via MessageStore + extern MessageStore messageStore; + messageStore.deleteConversation(peerHex); + messageStore.refreshConversations(); + if (_ui) _ui->lvStatusBar().showToast("Chat deleted", 1200); + _selectedIdx = 0; + _lastConvCount = -1; + rebuildList(); + } + _confirmDelete = false; + return true; + } + _confirmDelete = false; + if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + return true; + } + + int count = (int)_lxmf->conversations().size(); + if (count == 0) return false; + + if (event.up) { + if (_selectedIdx > 0) { + int prev = _selectedIdx; + _selectedIdx--; + updateSelection(prev, _selectedIdx); + } + return true; + } + if (event.down) { + if (_selectedIdx < count - 1) { + int prev = _selectedIdx; + _selectedIdx++; + updateSelection(prev, _selectedIdx); + } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx < count && _onOpen) { + _onOpen(_lxmf->conversations()[_selectedIdx]); + } + return true; + } + return false; +} diff --git a/src/ui/screens/LvMessagesScreen.h b/src/ui/screens/LvMessagesScreen.h new file mode 100644 index 0000000..b7ae558 --- /dev/null +++ b/src/ui/screens/LvMessagesScreen.h @@ -0,0 +1,43 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include +#include + +class LXMFManager; +class AnnounceManager; + +class LvMessagesScreen : public LvScreen { +public: + using OpenCallback = std::function; + + void createUI(lv_obj_t* parent) override; + void refreshUI() override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } + void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setOpenCallback(OpenCallback cb) { _onOpen = cb; } + void setUIManager(class UIManager* ui) { _ui = ui; } + bool handleLongPress() override; + + const char* title() const override { return "Messages"; } + +private: + void rebuildList(); + void updateSelection(int oldIdx, int newIdx); + + LXMFManager* _lxmf = nullptr; + AnnounceManager* _am = nullptr; + class UIManager* _ui = nullptr; + OpenCallback _onOpen; + int _lastConvCount = -1; + int _selectedIdx = 0; + bool _confirmDelete = false; + + lv_obj_t* _list = nullptr; + lv_obj_t* _lblEmpty = nullptr; + std::vector _rows; +}; diff --git a/src/ui/screens/LvNameInputScreen.cpp b/src/ui/screens/LvNameInputScreen.cpp new file mode 100644 index 0000000..1b63734 --- /dev/null +++ b/src/ui/screens/LvNameInputScreen.cpp @@ -0,0 +1,79 @@ +#include "LvNameInputScreen.h" +#include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "config/Config.h" + +void LvNameInputScreen::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); + + // Title: "RATSPEAK" + 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_label_set_text(title, "RATSPEAK"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20); + + // Subtitle + lv_obj_t* sub = lv_label_create(parent); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(sub, "ratspeak.org"); + lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 42); + + // Prompt + lv_obj_t* prompt = lv_label_create(parent); + lv_obj_set_style_text_font(prompt, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::SECONDARY), 0); + lv_label_set_text(prompt, "Enter your display name:"); + lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 70); + + // Text area + _textarea = lv_textarea_create(parent); + lv_obj_set_size(_textarea, 220, 36); + lv_obj_align(_textarea, LV_ALIGN_TOP_MID, 0, 95); + lv_textarea_set_max_length(_textarea, MAX_NAME_LEN); + lv_textarea_set_one_line(_textarea, true); + lv_textarea_set_placeholder_text(_textarea, "Name"); + lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0); + lv_obj_set_style_text_font(_textarea, &lv_font_montserrat_14, 0); + + // Hint + lv_obj_t* hint = lv_label_create(parent); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(hint, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(hint, "[Enter] OK"); + lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 140); + + // Version + lv_obj_t* ver = lv_label_create(parent); + lv_obj_set_style_text_font(ver, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(ver, lv_color_hex(Theme::MUTED), 0); + char verBuf[32]; + snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING); + lv_label_set_text(ver, verBuf); + lv_obj_align(ver, LV_ALIGN_BOTTOM_MID, 0, -10); +} + +bool LvNameInputScreen::handleKey(const KeyEvent& event) { + if (!_textarea) return false; + + if (event.enter || event.character == '\n' || event.character == '\r') { + const char* text = lv_textarea_get_text(_textarea); + if (text && strlen(text) > 0 && _doneCb) { + _doneCb(String(text)); + } + return true; + } + if (event.del || event.character == 8) { + lv_textarea_del_char(_textarea); + return true; + } + if (event.character >= 0x20 && event.character <= 0x7E) { + char buf[2] = {event.character, 0}; + lv_textarea_add_text(_textarea, buf); + return true; + } + return true; // Consume all keys +} diff --git a/src/ui/screens/LvNameInputScreen.h b/src/ui/screens/LvNameInputScreen.h new file mode 100644 index 0000000..9ab37d6 --- /dev/null +++ b/src/ui/screens/LvNameInputScreen.h @@ -0,0 +1,19 @@ +#pragma once + +#include "ui/UIManager.h" +#include + +class LvNameInputScreen : public LvScreen { +public: + void createUI(lv_obj_t* parent) override; + bool handleKey(const KeyEvent& event) override; + const char* title() const override { return "Setup"; } + + void setDoneCallback(std::function cb) { _doneCb = cb; } + + static constexpr int MAX_NAME_LEN = 16; + +private: + lv_obj_t* _textarea = nullptr; + std::function _doneCb; +}; diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp new file mode 100644 index 0000000..a47c2a9 --- /dev/null +++ b/src/ui/screens/LvNodesScreen.cpp @@ -0,0 +1,290 @@ +#include "LvNodesScreen.h" +#include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "ui/UIManager.h" +#include "reticulum/AnnounceManager.h" +#include +#include + +void LvNodesScreen::createUI(lv_obj_t* parent) { + _screen = parent; + lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_pad_all(parent, 0, 0); + + // Empty state label + _lblEmpty = lv_label_create(parent); + lv_obj_set_style_text_font(_lblEmpty, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(_lblEmpty, "No nodes discovered"); + lv_obj_center(_lblEmpty); + + // Scrollable list container + _list = lv_obj_create(parent); + lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); + lv_obj_set_pos(_list, 0, 0); + lv_obj_set_flex_grow(_list, 1); + lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_list, 0, 0); + lv_obj_set_style_pad_all(_list, 0, 0); + lv_obj_set_style_pad_row(_list, 0, 0); + lv_obj_set_style_radius(_list, 0, 0); + lv_obj_set_layout(_list, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + + _lastNodeCount = -1; + _lastContactCount = -1; + rebuildList(); +} + +void LvNodesScreen::onEnter() { + _lastNodeCount = -1; + _lastContactCount = -1; + _selectedIdx = 0; + rebuildList(); +} + +void LvNodesScreen::refreshUI() { + if (!_am) return; + int contacts = 0; + for (const auto& n : _am->nodes()) { if (n.saved) contacts++; } + if (_am->nodeCount() != _lastNodeCount || contacts != _lastContactCount) { + rebuildList(); + } +} + +// Update only the selection highlight without rebuilding widgets +void LvNodesScreen::updateSelection(int oldIdx, int newIdx) { + if (oldIdx >= 0 && oldIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[oldIdx], lv_color_hex(Theme::BG), 0); + } + if (newIdx >= 0 && newIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); + } + scrollToSelected(); +} + +void LvNodesScreen::rebuildList() { + if (!_am || !_list) return; + int count = _am->nodeCount(); + _lastNodeCount = count; + _rows.clear(); + _rowToNodeIdx.clear(); + lv_obj_clean(_list); + + // Separate contacts and online nodes + std::vector contactIndices; + std::vector onlineIndices; + const auto& nodes = _am->nodes(); + for (int i = 0; i < count; i++) { + if (nodes[i].saved) contactIndices.push_back(i); + else onlineIndices.push_back(i); + } + _lastContactCount = contactIndices.size(); + + // Sort online nodes: most recently seen first + std::sort(onlineIndices.begin(), onlineIndices.end(), [&nodes](int a, int b) { + return nodes[a].lastSeen > nodes[b].lastSeen; + }); + + if (count == 0) { + lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); + _totalRows = 0; + return; + } + + lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); + + const lv_font_t* font = &lv_font_montserrat_14; + const lv_font_t* smallFont = &lv_font_montserrat_10; + int rowIdx = 0; + + // Helper to create a section header + auto makeSectionHeader = [&](const char* title, int itemCount) { + lv_obj_t* row = lv_obj_create(_list); + lv_obj_set_size(row, Theme::CONTENT_W, 22); + lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + char buf[32]; + snprintf(buf, sizeof(buf), "%s (%d)", title, itemCount); + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(lbl, buf); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + + _rows.push_back(row); + _rowToNodeIdx.push_back(-1); // header, not selectable + return rowIdx++; + }; + + // Helper to create a node row + auto makeNodeRow = [&](int nodeIdx) { + const auto& node = nodes[nodeIdx]; + bool selected = (rowIdx == _selectedIdx); + + lv_obj_t* row = lv_obj_create(_list); + lv_obj_set_size(row, Theme::CONTENT_W, 24); + 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); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Name + hash + std::string displayHash; + if (!node.identityHex.empty() && node.identityHex.size() >= 12) { + displayHash = node.identityHex.substr(0, 4) + ":" + + node.identityHex.substr(4, 4) + ":" + + node.identityHex.substr(8, 4); + } else { + displayHash = node.hash.toHex().substr(0, 8); + } + char buf[64]; + snprintf(buf, sizeof(buf), "%s [%s]", node.name.c_str(), displayHash.c_str()); + + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex( + node.saved ? Theme::ACCENT : Theme::PRIMARY), 0); + lv_label_set_text(lbl, buf); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); + + // Hops + age + unsigned long ageSec = (millis() - node.lastSeen) / 1000; + char infoBuf[24]; + if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%dhop %lus", node.hops, ageSec); + else snprintf(infoBuf, sizeof(infoBuf), "%dhop %lum", node.hops, ageSec / 60); + + lv_obj_t* info = lv_label_create(row); + lv_obj_set_style_text_font(info, smallFont, 0); + lv_obj_set_style_text_color(info, lv_color_hex(Theme::SECONDARY), 0); + lv_label_set_text(info, infoBuf); + lv_obj_align(info, LV_ALIGN_RIGHT_MID, -4, 0); + + _rows.push_back(row); + _rowToNodeIdx.push_back(nodeIdx); + rowIdx++; + }; + + // Contacts section (if any) + if (!contactIndices.empty()) { + _contactHeaderIdx = makeSectionHeader("Contacts", contactIndices.size()); + for (int ni : contactIndices) makeNodeRow(ni); + } else { + _contactHeaderIdx = -1; + } + + // Online section + _onlineHeaderIdx = makeSectionHeader("Online", onlineIndices.size()); + for (int ni : onlineIndices) makeNodeRow(ni); + + _totalRows = rowIdx; + + // Clamp selection to valid selectable row + if (_selectedIdx >= _totalRows) _selectedIdx = _totalRows - 1; + if (_selectedIdx < 0) _selectedIdx = 0; + // Skip headers + while (_selectedIdx < _totalRows && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx++; + if (_selectedIdx >= _totalRows) _selectedIdx = _totalRows - 1; + + scrollToSelected(); +} + +void LvNodesScreen::scrollToSelected() { + if (_selectedIdx >= 0 && _selectedIdx < (int)_rows.size()) { + lv_obj_scroll_to_view(_rows[_selectedIdx], LV_ANIM_OFF); + } +} + + +bool LvNodesScreen::handleLongPress() { + if (!_am || _totalRows == 0) return false; + if (_selectedIdx < 0 || _selectedIdx >= (int)_rowToNodeIdx.size()) return false; + int nodeIdx = _rowToNodeIdx[_selectedIdx]; + if (nodeIdx < 0 || nodeIdx >= (int)_am->nodes().size()) return false; + const auto& node = _am->nodes()[nodeIdx]; + if (!node.saved) return false; // Only contacts can be deleted + _confirmDelete = true; + if (_ui) _ui->lvStatusBar().showToast("Delete contact? Enter=Yes Esc=No", 5000); + return true; +} + +bool LvNodesScreen::handleKey(const KeyEvent& event) { + if (!_am || _totalRows == 0) return false; + + // Confirm delete mode + if (_confirmDelete) { + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx >= 0 && _selectedIdx < (int)_rowToNodeIdx.size()) { + int nodeIdx = _rowToNodeIdx[_selectedIdx]; + if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { + auto& nodes = const_cast&>(_am->nodes()); + nodes.erase(nodes.begin() + nodeIdx); + _am->saveContacts(); + if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200); + _selectedIdx = 0; + rebuildList(); + } + } + _confirmDelete = false; + return true; + } + _confirmDelete = false; + if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + return true; + } + + if (event.up) { + int prev = _selectedIdx; + _selectedIdx--; + while (_selectedIdx >= 0 && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx--; + if (_selectedIdx < 0) _selectedIdx = prev; + if (_selectedIdx != prev) updateSelection(prev, _selectedIdx); + return true; + } + if (event.down) { + int prev = _selectedIdx; + _selectedIdx++; + while (_selectedIdx < _totalRows && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx++; + if (_selectedIdx >= _totalRows) _selectedIdx = prev; + if (_selectedIdx != prev) updateSelection(prev, _selectedIdx); + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx >= 0 && _selectedIdx < (int)_rowToNodeIdx.size()) { + int nodeIdx = _rowToNodeIdx[_selectedIdx]; + if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size() && _onSelect) { + _onSelect(_am->nodes()[nodeIdx].hash.toHex()); + } + } + return true; + } + // 's' or 'S' to save/unsave contact + if (event.character == 's' || event.character == 'S') { + if (_selectedIdx >= 0 && _selectedIdx < (int)_rowToNodeIdx.size()) { + int nodeIdx = _rowToNodeIdx[_selectedIdx]; + if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { + auto& node = const_cast(_am->nodes()[nodeIdx]); + node.saved = !node.saved; + if (node.saved) { + _am->saveContacts(); + } + rebuildList(); + } + } + return true; + } + return false; +} diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h new file mode 100644 index 0000000..4ae8604 --- /dev/null +++ b/src/ui/screens/LvNodesScreen.h @@ -0,0 +1,49 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include +#include + +class AnnounceManager; + +class LvNodesScreen : public LvScreen { +public: + using NodeSelectedCallback = std::function; + + void createUI(lv_obj_t* parent) override; + void refreshUI() override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setNodeSelectedCallback(NodeSelectedCallback cb) { _onSelect = cb; } + void setUIManager(class UIManager* ui) { _ui = ui; } + bool handleLongPress() override; + + const char* title() const override { return "Nodes"; } + +private: + void rebuildList(); + void updateSelection(int oldIdx, int newIdx); + void scrollToSelected(); + + AnnounceManager* _am = nullptr; + class UIManager* _ui = nullptr; + NodeSelectedCallback _onSelect; + bool _confirmDelete = false; + int _lastNodeCount = -1; + int _lastContactCount = -1; + int _selectedIdx = 0; + int _totalRows = 0; + + // Section tracking + bool _contactsCollapsed = false; + int _contactHeaderIdx = -1; // Row index of "Contacts" header + int _onlineHeaderIdx = -1; // Row index of "Online" header + std::vector _rowToNodeIdx; // Maps row index -> node index in _am->nodes(), -1 for headers + + lv_obj_t* _list = nullptr; + lv_obj_t* _lblEmpty = nullptr; + std::vector _rows; +}; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp new file mode 100644 index 0000000..648fa0b --- /dev/null +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -0,0 +1,1033 @@ +#include "LvSettingsScreen.h" +#include "ui/Theme.h" +#include "ui/LvTheme.h" +#include "config/Config.h" +#include "config/UserConfig.h" +#include "storage/FlashStore.h" +#include "storage/SDStore.h" +#include "radio/SX1262.h" +#include "audio/AudioNotify.h" +#include "hal/Power.h" +#include "transport/WiFiInterface.h" +#include "reticulum/ReticulumManager.h" +#include "reticulum/IdentityManager.h" +#include +#include +#include + +struct RadioPresetLv { + const char* name; + uint8_t sf; uint32_t bw; uint8_t cr; int8_t txPower; long preamble; +}; +static const RadioPresetLv LV_PRESETS[] = { + {"Balanced", 9, 250000, 5, 14, 18}, + {"Long Range", 12, 125000, 8, 17, 18}, + {"Fast", 7, 500000, 5, 10, 18}, +}; +static constexpr int LV_NUM_PRESETS = 3; + +int LvSettingsScreen::detectPreset() const { + if (!_cfg) return -1; + auto& s = _cfg->settings(); + for (int i = 0; i < LV_NUM_PRESETS; i++) { + if (s.loraSF == LV_PRESETS[i].sf && s.loraBW == LV_PRESETS[i].bw + && s.loraCR == LV_PRESETS[i].cr && s.loraTxPower == LV_PRESETS[i].txPower) + return i; + } + return -1; +} + +void LvSettingsScreen::applyPreset(int presetIdx) { + if (!_cfg || presetIdx < 0 || presetIdx >= LV_NUM_PRESETS) return; + auto& s = _cfg->settings(); + const auto& p = LV_PRESETS[presetIdx]; + s.loraSF = p.sf; s.loraBW = p.bw; s.loraCR = p.cr; + s.loraTxPower = p.txPower; s.loraPreamble = p.preamble; +} + +bool LvSettingsScreen::isEditable(int idx) const { + if (idx < 0 || idx >= (int)_items.size()) return false; + auto t = _items[idx].type; + return t == SettingType::INTEGER || t == SettingType::TOGGLE + || t == SettingType::ENUM_CHOICE || t == SettingType::ACTION + || t == SettingType::TEXT_INPUT; +} + +void LvSettingsScreen::skipToNextEditable(int dir) { + int n = _catRangeEnd; + int start = _selectedIdx; + for (int i = 0; i < (n - _catRangeStart); i++) { + _selectedIdx += dir; + if (_selectedIdx < _catRangeStart) _selectedIdx = _catRangeStart; + if (_selectedIdx >= n) _selectedIdx = n - 1; + if (isEditable(_selectedIdx)) return; + if (_selectedIdx == _catRangeStart && dir < 0) return; + if (_selectedIdx == n - 1 && dir > 0) return; + } + _selectedIdx = start; +} + +// buildItems() — identical logic to SettingsScreen::buildItems() +void LvSettingsScreen::buildItems() { + _items.clear(); + _categories.clear(); + if (!_cfg) return; + auto& s = _cfg->settings(); + int idx = 0; + + // Device + int devStart = idx; + _items.push_back({"Version", SettingType::READONLY, nullptr, nullptr, + [](int) { return String(RATDECK_VERSION_STRING); }}); + idx++; + _items.push_back({"Identity", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _identityHash.substring(0, 16); }}); + idx++; + { + SettingItem nameItem; + nameItem.label = "Display Name"; + nameItem.type = SettingType::TEXT_INPUT; + nameItem.textGetter = [&s]() { return s.displayName; }; + nameItem.textSetter = [&s](const String& v) { s.displayName = v; }; + nameItem.maxTextLen = 16; + _items.push_back(nameItem); + idx++; + } + if (_idMgr && _idMgr->count() > 0) { + SettingItem idSwitch; + idSwitch.label = "Active Identity"; + idSwitch.type = SettingType::ENUM_CHOICE; + idSwitch.getter = [this]() { return _idMgr->activeIndex(); }; + idSwitch.setter = [this](int v) { + if (v == _idMgr->activeIndex()) return; + RNS::Identity newId; + if (_idMgr->switchTo(v, newId)) { + if (_ui) _ui->lvStatusBar().showToast("Identity switched! Rebooting...", 2000); + applyAndSave(); + delay(1000); + ESP.restart(); + } else { + if (_ui) _ui->lvStatusBar().showToast("Switch failed!", 1500); + } + }; + idSwitch.minVal = 0; + idSwitch.maxVal = _idMgr->count() - 1; + idSwitch.step = 1; + for (int i = 0; i < _idMgr->count(); i++) { + auto& slot = _idMgr->identities()[i]; + static char labelBufs[8][32]; + if (!slot.displayName.isEmpty()) { + snprintf(labelBufs[i], sizeof(labelBufs[i]), "%s", slot.displayName.c_str()); + } else { + snprintf(labelBufs[i], sizeof(labelBufs[i]), "%.12s", slot.hash.c_str()); + } + idSwitch.enumLabels.push_back(labelBufs[i]); + } + _items.push_back(idSwitch); + idx++; + } + { + SettingItem newId; + newId.label = "New Identity"; + newId.type = SettingType::ACTION; + newId.formatter = [](int) { return String("[Enter]"); }; + newId.action = [this, &s]() { + if (!_idMgr) { if (_ui) _ui->lvStatusBar().showToast("Not available", 1200); return; } + if (_idMgr->count() >= 8) { if (_ui) _ui->lvStatusBar().showToast("Max 8 identities!", 1200); return; } + int idx = _idMgr->createIdentity(s.displayName); + if (idx >= 0) { + if (_ui) _ui->lvStatusBar().showToast("Identity created!", 1200); + buildItems(); + rebuildCategoryList(); + } + }; + _items.push_back(newId); + idx++; + } + _categories.push_back({"Device", devStart, idx - devStart, + [&s]() { return s.displayName.isEmpty() ? String("(unnamed)") : s.displayName; }}); + + // Display & Input + int dispStart = idx; + _items.push_back({"Brightness", SettingType::INTEGER, + [&s]() { return s.brightness; }, [&s](int v) { s.brightness = v; }, + [](int v) { return String(v) + "%"; }, 5, 100, 5}); + idx++; + _items.push_back({"Dim Timeout", SettingType::INTEGER, + [&s]() { return s.screenDimTimeout; }, [&s](int v) { s.screenDimTimeout = v; }, + [](int v) { return String(v) + "s"; }, 5, 300, 5}); + idx++; + _items.push_back({"Off Timeout", SettingType::INTEGER, + [&s]() { return s.screenOffTimeout; }, [&s](int v) { s.screenOffTimeout = v; }, + [](int v) { return String(v) + "s"; }, 10, 600, 10}); + idx++; + _items.push_back({"Trackball Speed", SettingType::INTEGER, + [&s]() { return s.trackballSpeed; }, [&s](int v) { s.trackballSpeed = v; }, + [](int v) { return String(v); }, 1, 5, 1}); + idx++; + _categories.push_back({"Display & Input", dispStart, idx - dispStart, + [&s]() { return String(s.brightness) + "%"; }}); + + // Radio + int radioStart = idx; + { + SettingItem presetItem; + presetItem.label = "Preset"; + presetItem.type = SettingType::ENUM_CHOICE; + presetItem.getter = [this]() { int p = detectPreset(); return (p >= 0) ? p : LV_NUM_PRESETS; }; + presetItem.setter = [this](int v) { if (v >= 0 && v < LV_NUM_PRESETS) applyPreset(v); }; + presetItem.minVal = 0; presetItem.maxVal = LV_NUM_PRESETS; presetItem.step = 1; + presetItem.enumLabels = {"Balanced", "Long Range", "Fast", "Custom"}; + _items.push_back(presetItem); + idx++; + } + _items.push_back({"Frequency", SettingType::ENUM_CHOICE, + [&s]() { + if (s.loraFrequency <= 868000000) return 0; + if (s.loraFrequency <= 906000000) return 1; + if (s.loraFrequency <= 915000000) return 2; + return 3; + }, + [&s](int v) { + static const uint32_t freqs[] = {868000000, 906000000, 915000000, 923000000}; + s.loraFrequency = freqs[constrain(v, 0, 3)]; + }, + nullptr, 0, 3, 1, {"868 MHz", "906 MHz", "915 MHz", "923 MHz"}}); + idx++; + _items.push_back({"TX Power", SettingType::INTEGER, + [&s]() { return s.loraTxPower; }, [&s](int v) { s.loraTxPower = v; }, + [](int v) { return String(v) + " dBm"; }, -9, 22, 1}); + idx++; + _items.push_back({"Spread Factor", SettingType::INTEGER, + [&s]() { return s.loraSF; }, [&s](int v) { s.loraSF = v; }, + [](int v) { return String("SF") + String(v); }, 5, 12, 1}); + idx++; + _items.push_back({"Bandwidth", SettingType::ENUM_CHOICE, + [&s]() { + if (s.loraBW <= 62500) return 0; + if (s.loraBW <= 125000) return 1; + if (s.loraBW <= 250000) return 2; + return 3; + }, + [&s](int v) { + static const uint32_t bws[] = {62500, 125000, 250000, 500000}; + s.loraBW = bws[constrain(v, 0, 3)]; + }, + nullptr, 0, 3, 1, {"62.5k", "125k", "250k", "500k"}}); + idx++; + _items.push_back({"Coding Rate", SettingType::INTEGER, + [&s]() { return s.loraCR; }, [&s](int v) { s.loraCR = v; }, + [](int v) { return String("4/") + String(v); }, 5, 8, 1}); + idx++; + _items.push_back({"Preamble", SettingType::INTEGER, + [&s]() { return (int)s.loraPreamble; }, [&s](int v) { s.loraPreamble = v; }, + [](int v) { return String(v); }, 6, 65, 1}); + idx++; + _categories.push_back({"Radio", radioStart, idx - radioStart, + [this]() { int p = detectPreset(); return (p >= 0) ? String(LV_PRESETS[p].name) : String("Custom"); }}); + + // Network + int netStart = idx; + _items.push_back({"WiFi Mode", SettingType::ENUM_CHOICE, + [&s]() { return (int)s.wifiMode; }, + [&s](int v) { s.wifiMode = (RatWiFiMode)v; }, + nullptr, 0, 2, 1, {"OFF", "AP", "STA"}}); + idx++; + { + SettingItem ssidItem; + ssidItem.label = "WiFi SSID"; + ssidItem.type = SettingType::TEXT_INPUT; + ssidItem.textGetter = [&s]() { return s.wifiSTASSID; }; + ssidItem.textSetter = [&s](const String& v) { s.wifiSTASSID = v; }; + ssidItem.maxTextLen = 32; + _items.push_back(ssidItem); + idx++; + } + { + SettingItem passItem; + passItem.label = "WiFi Password"; + passItem.type = SettingType::TEXT_INPUT; + passItem.textGetter = [&s]() { return s.wifiSTAPassword; }; + passItem.textSetter = [&s](const String& v) { s.wifiSTAPassword = v; }; + passItem.maxTextLen = 32; + _items.push_back(passItem); + idx++; + } + { + SettingItem tcpPreset; + tcpPreset.label = "TCP Server"; + tcpPreset.type = SettingType::ENUM_CHOICE; + tcpPreset.getter = [&s]() { + for (auto& ep : s.tcpConnections) { if (ep.host == "rns.ratspeak.org") return 1; } + if (!s.tcpConnections.empty()) return 2; + return 0; + }; + tcpPreset.setter = [&s](int v) { + if (v == 0) { s.tcpConnections.clear(); } + else if (v == 1) { + s.tcpConnections.clear(); + TCPEndpoint ep; ep.host = "rns.ratspeak.org"; ep.port = TCP_DEFAULT_PORT; ep.autoConnect = true; + s.tcpConnections.push_back(ep); + } + }; + tcpPreset.minVal = 0; tcpPreset.maxVal = 2; tcpPreset.step = 1; + tcpPreset.enumLabels = {"None", "Ratspeak Hub", "Custom"}; + _items.push_back(tcpPreset); + idx++; + } + { + SettingItem tcpHost; + tcpHost.label = "TCP Host"; + tcpHost.type = SettingType::TEXT_INPUT; + tcpHost.textGetter = [&s]() { return s.tcpConnections.empty() ? String("") : s.tcpConnections[0].host; }; + tcpHost.textSetter = [&s](const String& v) { + if (s.tcpConnections.empty()) { + TCPEndpoint ep; ep.host = v; ep.port = TCP_DEFAULT_PORT; ep.autoConnect = true; + s.tcpConnections.push_back(ep); + } else { s.tcpConnections[0].host = v; } + }; + tcpHost.maxTextLen = 40; + _items.push_back(tcpHost); + idx++; + } + _items.push_back({"TCP Port", SettingType::INTEGER, + [&s]() { return s.tcpConnections.empty() ? TCP_DEFAULT_PORT : (int)s.tcpConnections[0].port; }, + [&s](int v) { + if (s.tcpConnections.empty()) { + TCPEndpoint ep; ep.port = v; ep.autoConnect = true; + s.tcpConnections.push_back(ep); + } else { s.tcpConnections[0].port = v; } + }, + [](int v) { return String(v); }, 1, 65535, 1}); + idx++; + _items.push_back({"Transport Node", SettingType::TOGGLE, + [&s]() { return s.transportEnabled ? 1 : 0; }, + [&s](int v) { s.transportEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; + _items.push_back({"BLE", SettingType::TOGGLE, + [&s]() { return s.bleEnabled ? 1 : 0; }, + [&s](int v) { s.bleEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; + _categories.push_back({"Network", netStart, idx - netStart, + [&s]() { + const char* modes[] = {"OFF", "AP", "STA"}; + return String(modes[constrain((int)s.wifiMode, 0, 2)]); + }}); + + // Audio + int audioStart = idx; + _items.push_back({"Audio", SettingType::TOGGLE, + [&s]() { return s.audioEnabled ? 1 : 0; }, + [&s](int v) { s.audioEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; + _items.push_back({"Volume", SettingType::INTEGER, + [&s]() { return s.audioVolume; }, [&s](int v) { s.audioVolume = v; }, + [](int v) { return String(v) + "%"; }, 0, 100, 10}); + idx++; + _categories.push_back({"Audio", audioStart, idx - audioStart, + [&s]() { return s.audioEnabled ? (String(s.audioVolume) + "%") : String("OFF"); }}); + + // System + int sysStart = idx; + _items.push_back({"Free Heap", SettingType::READONLY, nullptr, nullptr, + [](int) { return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB"; }}); + idx++; + _items.push_back({"Free PSRAM", SettingType::READONLY, nullptr, nullptr, + [](int) { return String((unsigned long)(ESP.getFreePsram() / 1024)) + " KB"; }}); + idx++; + _items.push_back({"Flash", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _flash && _flash->exists("/ratputer") ? String("Mounted") : String("Error"); }}); + idx++; + _items.push_back({"SD Card", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _sd && _sd->isReady() ? String("Ready") : String("Not Found"); }}); + idx++; + { + SettingItem announceItem; + announceItem.label = "Send Announce"; + announceItem.type = SettingType::ACTION; + announceItem.formatter = [](int) { return String("[Enter]"); }; + announceItem.action = [this]() { + if (_rns && _cfg) { + const String& name = _cfg->settings().displayName; + RNS::Bytes appData; + if (!name.isEmpty()) { + size_t len = name.length(); if (len > 31) len = 31; + uint8_t buf[2 + 31]; buf[0] = 0x91; buf[1] = 0xA0 | (uint8_t)len; + memcpy(buf + 2, name.c_str(), len); + appData = RNS::Bytes(buf, 2 + len); + } + _rns->announce(appData); + if (_ui) { _ui->lvStatusBar().flashAnnounce(); _ui->lvStatusBar().showToast("Announce sent!"); } + } else { + if (_ui) _ui->lvStatusBar().showToast("RNS not ready"); + } + }; + _items.push_back(announceItem); + idx++; + } + { + SettingItem initSD; + initSD.label = "Init SD Card"; + initSD.type = SettingType::ACTION; + initSD.formatter = [this](int) { return (_sd && _sd->isReady()) ? String("[Enter]") : String("No Card"); }; + initSD.action = [this]() { + if (!_sd || !_sd->isReady()) { if (_ui) _ui->lvStatusBar().showToast("No SD card!", 1200); return; } + if (_ui) _ui->lvStatusBar().showToast("Initializing SD...", 2000); + bool ok = _sd->formatForRatputer(); + if (_ui) _ui->lvStatusBar().showToast(ok ? "SD initialized!" : "SD init failed!", 1500); + }; + _items.push_back(initSD); + idx++; + } + { + SettingItem wipeSD; + wipeSD.label = "Wipe SD Data"; + wipeSD.type = SettingType::ACTION; + wipeSD.formatter = [this](int) { return (_sd && _sd->isReady()) ? String("[Enter]") : String("No Card"); }; + wipeSD.action = [this]() { + if (!_sd || !_sd->isReady()) { if (_ui) _ui->lvStatusBar().showToast("No SD card!", 1200); return; } + if (_ui) _ui->lvStatusBar().showToast("Wiping SD data...", 2000); + bool ok = _sd->wipeRatputer(); + if (_ui) _ui->lvStatusBar().showToast(ok ? "SD wiped & reinit!" : "Wipe failed!", 1500); + }; + _items.push_back(wipeSD); + idx++; + } + { + SettingItem factoryReset; + factoryReset.label = "Factory Reset"; + factoryReset.type = SettingType::ACTION; + factoryReset.formatter = [this](int) { return _confirmingReset ? String("[Confirm?]") : String("[Enter]"); }; + factoryReset.action = [this]() { + if (!_confirmingReset) { + _confirmingReset = true; + if (_ui) _ui->lvStatusBar().showToast("Press again to confirm!", 2000); + rebuildItemList(); + return; + } + _confirmingReset = false; + if (_ui) _ui->lvStatusBar().showToast("Factory resetting...", 3000); + if (_sd && _sd->isReady()) _sd->wipeRatputer(); + if (_flash) _flash->format(); + nvs_flash_erase(); + delay(500); + ESP.restart(); + }; + _items.push_back(factoryReset); + idx++; + } + { + SettingItem rebootItem; + rebootItem.label = "Reboot Device"; + rebootItem.type = SettingType::ACTION; + rebootItem.formatter = [](int) { return String("[Enter]"); }; + rebootItem.action = [this]() { + if (_ui) _ui->lvStatusBar().showToast("Rebooting...", 1500); + delay(500); + ESP.restart(); + }; + _items.push_back(rebootItem); + idx++; + } + _categories.push_back({"System", sysStart, idx - sysStart, + [](){ return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB free"; }}); +} + +void LvSettingsScreen::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); + lv_obj_set_style_pad_all(parent, 0, 0); + + _scrollContainer = lv_obj_create(parent); + lv_obj_set_size(_scrollContainer, lv_pct(100), lv_pct(100)); + lv_obj_set_pos(_scrollContainer, 0, 0); + 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, 0, 0); + lv_obj_set_style_pad_all(_scrollContainer, 0, 0); + lv_obj_set_style_pad_row(_scrollContainer, 0, 0); + lv_obj_set_style_radius(_scrollContainer, 0, 0); + lv_obj_set_layout(_scrollContainer, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_scrollContainer, LV_FLEX_FLOW_COLUMN); +} + +void LvSettingsScreen::onEnter() { + buildItems(); + snapshotRebootSettings(); + snapshotTCPSettings(); + _rebootNeeded = false; + _view = SettingsView::CATEGORY_LIST; + _categoryIdx = 0; + _selectedIdx = 0; + _editing = false; + _textEditing = false; + _confirmingReset = false; + rebuildCategoryList(); +} + +void LvSettingsScreen::rebuildCategoryList() { + if (!_scrollContainer) return; + _rowObjs.clear(); + lv_obj_clean(_scrollContainer); + + const lv_font_t* font = &lv_font_montserrat_12; + + // Title + lv_obj_t* titleRow = lv_obj_create(_scrollContainer); + lv_obj_set_size(titleRow, Theme::CONTENT_W, 24); + lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_color(titleRow, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(titleRow, 1, 0); + lv_obj_set_style_border_side(titleRow, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(titleRow, 0, 0); + lv_obj_set_style_radius(titleRow, 0, 0); + lv_obj_clear_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* titleLbl = lv_label_create(titleRow); + lv_obj_set_style_text_font(titleLbl, font, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(titleLbl, "SETTINGS"); + lv_obj_align(titleLbl, LV_ALIGN_LEFT_MID, 4, 0); + + for (int i = 0; i < (int)_categories.size(); i++) { + auto& cat = _categories[i]; + bool selected = (i == _categoryIdx); + + lv_obj_t* row = lv_obj_create(_scrollContainer); + lv_obj_set_size(row, Theme::CONTENT_W, 34); + 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); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Category name + count + char buf[48]; + 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_label_set_text(nameLbl, buf); + lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 12, 4); + + // Summary + if (cat.summary) { + lv_obj_t* sumLbl = lv_label_create(row); + lv_obj_set_style_text_font(sumLbl, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(sumLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(sumLbl, cat.summary().c_str()); + lv_obj_align(sumLbl, LV_ALIGN_BOTTOM_LEFT, 20, -2); + } + + // 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_label_set_text(arrow, ">"); + lv_obj_align(arrow, LV_ALIGN_RIGHT_MID, -8, 0); + + _rowObjs.push_back(row); + } +} + +void LvSettingsScreen::rebuildItemList() { + if (!_scrollContainer) return; + _rowObjs.clear(); + lv_obj_clean(_scrollContainer); + + const lv_font_t* font = &lv_font_montserrat_12; + + // Category header + lv_obj_t* headerRow = lv_obj_create(_scrollContainer); + lv_obj_set_size(headerRow, Theme::CONTENT_W, 22); + lv_obj_set_style_bg_opa(headerRow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_color(headerRow, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(headerRow, 1, 0); + lv_obj_set_style_border_side(headerRow, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(headerRow, 0, 0); + lv_obj_set_style_radius(headerRow, 0, 0); + lv_obj_clear_flag(headerRow, LV_OBJ_FLAG_SCROLLABLE); + char headerBuf[48]; + snprintf(headerBuf, sizeof(headerBuf), "< %s", _categories[_categoryIdx].name); + lv_obj_t* headerLbl = lv_label_create(headerRow); + lv_obj_set_style_text_font(headerLbl, font, 0); + lv_obj_set_style_text_color(headerLbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(headerLbl, headerBuf); + lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 4, 0); + + for (int i = _catRangeStart; i < _catRangeEnd; i++) { + const auto& item = _items[i]; + bool selected = (i == _selectedIdx); + bool editable = isEditable(i); + + lv_obj_t* row = lv_obj_create(_scrollContainer); + lv_obj_set_size(row, Theme::CONTENT_W, 22); + lv_obj_set_style_bg_color(row, lv_color_hex( + (selected && editable) ? Theme::SELECTION_BG : Theme::BG), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Label + 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); + lv_label_set_text(nameLbl, item.label); + lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 4, 0); + + // Value + String valStr; + uint32_t valColor = Theme::PRIMARY; + + if (_editing && selected) { + if (item.type == SettingType::ENUM_CHOICE && !item.enumLabels.empty()) { + int vi = constrain(_editValue, 0, (int)item.enumLabels.size() - 1); + valStr = String("< ") + item.enumLabels[vi] + " >"; + } else if (item.formatter) { + valStr = String("< ") + item.formatter(_editValue) + " >"; + } else { + valStr = String("< ") + String(_editValue) + " >"; + } + valColor = Theme::WARNING_CLR; + } else if (_textEditing && selected) { + valStr = _editText + "_"; + valColor = Theme::WARNING_CLR; + } else { + switch (item.type) { + case SettingType::READONLY: + valStr = item.formatter ? item.formatter(0) : ""; + valColor = Theme::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; + break; + } + case SettingType::ENUM_CHOICE: + if (!item.enumLabels.empty()) { + int vi = item.getter ? constrain(item.getter(), 0, (int)item.enumLabels.size() - 1) : 0; + valStr = item.enumLabels[vi]; + } + break; + case SettingType::ACTION: + valStr = item.formatter ? item.formatter(0) : ""; + valColor = Theme::MUTED; + break; + default: { + int v = item.getter ? item.getter() : 0; + valStr = item.formatter ? item.formatter(v) : String(v); + break; + } + } + } + + if (!valStr.isEmpty()) { + lv_obj_t* valLbl = lv_label_create(row); + lv_obj_set_style_text_font(valLbl, font, 0); + 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); + } + + _rowObjs.push_back(row); + } +} + +void LvSettingsScreen::rebuildWifiList() { + if (!_scrollContainer) return; + _rowObjs.clear(); + lv_obj_clean(_scrollContainer); + + const lv_font_t* font = &lv_font_montserrat_12; + + // Header + lv_obj_t* headerRow = lv_obj_create(_scrollContainer); + lv_obj_set_size(headerRow, Theme::CONTENT_W, 22); + lv_obj_set_style_bg_opa(headerRow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_color(headerRow, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(headerRow, 1, 0); + lv_obj_set_style_border_side(headerRow, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(headerRow, 0, 0); + lv_obj_set_style_radius(headerRow, 0, 0); + lv_obj_clear_flag(headerRow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* headerLbl = lv_label_create(headerRow); + lv_obj_set_style_text_font(headerLbl, font, 0); + lv_obj_set_style_text_color(headerLbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(headerLbl, "< Select WiFi Network"); + lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 4, 0); + + 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_label_set_text(emptyLbl, "No networks found"); + return; + } + + for (int i = 0; i < (int)_wifiResults.size(); i++) { + auto& net = _wifiResults[i]; + bool selected = (i == _wifiPickerIdx); + + lv_obj_t* row = lv_obj_create(_scrollContainer); + lv_obj_set_size(row, Theme::CONTENT_W, 22); + 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); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Lock + SSID + char buf[48]; + snprintf(buf, sizeof(buf), "%s %s", net.encrypted ? "*" : " ", net.ssid.c_str()); + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(selected ? Theme::ACCENT : Theme::PRIMARY), 0); + lv_label_set_text(lbl, buf); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + + // Signal + char sigBuf[12]; + snprintf(sigBuf, sizeof(sigBuf), "%ddBm", net.rssi); + lv_obj_t* sigLbl = lv_label_create(row); + lv_obj_set_style_text_font(sigLbl, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(sigLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(sigLbl, sigBuf); + lv_obj_align(sigLbl, LV_ALIGN_RIGHT_MID, -4, 0); + + _rowObjs.push_back(row); + } +} + +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); + 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 (newIdx >= 0 && newIdx < (int)_rowObjs.size()) { + lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::SELECTION_BG), 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); + 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); + lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF); + } +} + +void LvSettingsScreen::updateItemSelection(int oldIdx, int newIdx) { + // _rowObjs maps directly to items (header row is NOT in _rowObjs) + int oldRow = oldIdx - _catRangeStart; + int newRow = newIdx - _catRangeStart; + auto setItemRowBg = [&](int row, bool selected) { + 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); + }; + setItemRowBg(oldRow, false); + setItemRowBg(newRow, true); + if (newRow >= 0 && newRow < (int)_rowObjs.size()) { + lv_obj_scroll_to_view(_rowObjs[newRow], LV_ANIM_OFF); + } +} + +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 (newIdx >= 0 && newIdx < (int)_rowObjs.size()) { + lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::SELECTION_BG), 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); + lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF); + } +} + +void LvSettingsScreen::enterCategory(int catIdx) { + if (catIdx < 0 || catIdx >= (int)_categories.size()) return; + _categoryIdx = catIdx; + auto& cat = _categories[catIdx]; + _catRangeStart = cat.startIdx; + _catRangeEnd = cat.startIdx + cat.count; + _selectedIdx = _catRangeStart; + _editing = false; + _textEditing = false; + if (!isEditable(_selectedIdx)) skipToNextEditable(1); + _view = SettingsView::ITEM_LIST; + rebuildItemList(); +} + +void LvSettingsScreen::exitToCategories() { + _view = SettingsView::CATEGORY_LIST; + _editing = false; + _textEditing = false; + _confirmingReset = false; + rebuildCategoryList(); +} + +bool LvSettingsScreen::handleKey(const KeyEvent& event) { + switch (_view) { + case SettingsView::CATEGORY_LIST: { + if (event.up) { + if (_categoryIdx > 0) { + int prev = _categoryIdx; + _categoryIdx--; + updateCategorySelection(prev, _categoryIdx); + } + return true; + } + if (event.down) { + if (_categoryIdx < (int)_categories.size() - 1) { + int prev = _categoryIdx; + _categoryIdx++; + updateCategorySelection(prev, _categoryIdx); + } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + enterCategory(_categoryIdx); + return true; + } + return false; + } + + case SettingsView::ITEM_LIST: { + if (_items.empty()) return false; + + // Text edit mode + if (_textEditing) { + auto& item = _items[_selectedIdx]; + if (event.enter || event.character == '\n' || event.character == '\r') { + if (item.textSetter) item.textSetter(_editText); + _textEditing = false; + applyAndSave(); + rebuildItemList(); + return true; + } + if (event.del || event.character == 8) { + if (_editText.length() > 0) { _editText.remove(_editText.length() - 1); rebuildItemList(); } + return true; + } + if (event.character == 0x1B) { _textEditing = false; rebuildItemList(); return true; } + if (event.character >= 0x20 && event.character <= 0x7E && (int)_editText.length() < item.maxTextLen) { + _editText += (char)event.character; rebuildItemList(); return true; + } + return true; + } + + // Value edit mode + if (_editing) { + auto& item = _items[_selectedIdx]; + if (event.left) { + _editValue -= item.step; + if (_editValue < item.minVal) _editValue = item.minVal; + rebuildItemList(); return true; + } + if (event.right) { + _editValue += item.step; + if (_editValue > item.maxVal) _editValue = item.maxVal; + rebuildItemList(); return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (item.setter) item.setter(_editValue); + _editing = false; + applyAndSave(); + rebuildItemList(); return true; + } + if (event.del || event.character == 8 || event.character == 0x1B) { + _editing = false; rebuildItemList(); return true; + } + return true; + } + + // Browse mode + if (event.up) { + int prev = _selectedIdx; + skipToNextEditable(-1); + if (_selectedIdx != prev) updateItemSelection(prev, _selectedIdx); + return true; + } + if (event.down) { + int prev = _selectedIdx; + skipToNextEditable(1); + if (_selectedIdx != prev) updateItemSelection(prev, _selectedIdx); + return true; + } + if (event.del || event.character == 8 || event.character == 0x1B) { + exitToCategories(); return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (!isEditable(_selectedIdx)) return true; + auto& item = _items[_selectedIdx]; + if (item.type == SettingType::ACTION) { + if (item.action) item.action(); + rebuildItemList(); + } else if (item.type == SettingType::TEXT_INPUT) { + if (strcmp(item.label, "WiFi SSID") == 0) { + _wifiResults.clear(); + _wifiPickerIdx = 0; + _wifiResults = WiFiInterface::scanNetworks(); + if (_wifiResults.empty()) { + if (_ui) _ui->lvStatusBar().showToast("No networks found", 1500); + } else { + _view = SettingsView::WIFI_PICKER; + rebuildWifiList(); + } + return true; + } + _textEditing = true; + _editText = item.textGetter ? item.textGetter() : ""; + rebuildItemList(); + } else if (item.type == SettingType::TOGGLE) { + int val = item.getter ? item.getter() : 0; + if (item.setter) item.setter(val ? 0 : 1); + applyAndSave(); + rebuildItemList(); + } else { + _editing = true; + _editValue = item.getter ? item.getter() : 0; + rebuildItemList(); + } + return true; + } + return false; + } + + case SettingsView::WIFI_PICKER: { + if (event.up) { + if (_wifiPickerIdx > 0) { + int prev = _wifiPickerIdx; + _wifiPickerIdx--; + updateWifiSelection(prev, _wifiPickerIdx); + } + return true; + } + if (event.down) { + if (_wifiPickerIdx < (int)_wifiResults.size() - 1) { + int prev = _wifiPickerIdx; + _wifiPickerIdx++; + updateWifiSelection(prev, _wifiPickerIdx); + } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_wifiPickerIdx < (int)_wifiResults.size()) { + auto& net = _wifiResults[_wifiPickerIdx]; + if (_cfg) { _cfg->settings().wifiSTASSID = net.ssid; applyAndSave(); } + } + _view = SettingsView::ITEM_LIST; + rebuildItemList(); + return true; + } + if (event.del || event.character == 8 || event.character == 0x1B) { + _view = SettingsView::ITEM_LIST; + rebuildItemList(); + return true; + } + return false; + } + } + return false; +} + +void LvSettingsScreen::snapshotRebootSettings() { + if (!_cfg) return; + auto& s = _cfg->settings(); + _rebootSnap.wifiMode = s.wifiMode; + _rebootSnap.wifiSTASSID = s.wifiSTASSID; + _rebootSnap.wifiSTAPassword = s.wifiSTAPassword; + _rebootSnap.bleEnabled = s.bleEnabled; + _rebootSnap.transportEnabled = s.transportEnabled; +} + +bool LvSettingsScreen::rebootSettingsChanged() const { + if (!_cfg) return false; + auto& s = _cfg->settings(); + return s.wifiMode != _rebootSnap.wifiMode + || s.wifiSTASSID != _rebootSnap.wifiSTASSID + || s.wifiSTAPassword != _rebootSnap.wifiSTAPassword + || s.bleEnabled != _rebootSnap.bleEnabled + || s.transportEnabled != _rebootSnap.transportEnabled; +} + +void LvSettingsScreen::snapshotTCPSettings() { + if (!_cfg) return; + auto& s = _cfg->settings(); + _tcpSnapHost = s.tcpConnections.empty() ? "" : s.tcpConnections[0].host; + _tcpSnapPort = s.tcpConnections.empty() ? 0 : s.tcpConnections[0].port; +} + +bool LvSettingsScreen::tcpSettingsChanged() const { + if (!_cfg) return false; + auto& s = _cfg->settings(); + String curHost = s.tcpConnections.empty() ? "" : s.tcpConnections[0].host; + uint16_t curPort = s.tcpConnections.empty() ? 0 : s.tcpConnections[0].port; + return curHost != _tcpSnapHost || curPort != _tcpSnapPort; +} + +void LvSettingsScreen::applyAndSave() { + if (!_cfg) return; + auto& s = _cfg->settings(); + if (_power) { + _power->setBrightness(s.brightness); + _power->setDimTimeout(s.screenDimTimeout); + _power->setOffTimeout(s.screenOffTimeout); + } + if (_radio && _radio->isRadioOnline()) { + _radio->setFrequency(s.loraFrequency); + _radio->setTxPower(s.loraTxPower); + _radio->setSpreadingFactor(s.loraSF); + _radio->setSignalBandwidth(s.loraBW); + _radio->setCodingRate4(s.loraCR); + _radio->setPreambleLength(s.loraPreamble); + _radio->receive(); + } + if (_audio) { + _audio->setEnabled(s.audioEnabled); + _audio->setVolume(s.audioVolume); + } + + // Detect TCP server change before saving + bool tcpChanged = tcpSettingsChanged(); + + bool saved = false; + if (_saveCallback) { saved = _saveCallback(); } + else if (_sd && _flash) { saved = _cfg->save(*_sd, *_flash); } + else if (_flash) { saved = _cfg->save(*_flash); } + + // Apply TCP changes live (stop old clients, create new ones, clear transient nodes) + if (tcpChanged) { + snapshotTCPSettings(); + if (_tcpChangeCb) _tcpChangeCb(); + } + + // Check if reboot-required settings changed + if (rebootSettingsChanged()) { + _rebootNeeded = true; + } + + if (_ui) { + if (_rebootNeeded) { + _ui->lvStatusBar().showToast("Reboot Required!", 2000); + } else if (tcpChanged) { + _ui->lvStatusBar().showToast("TCP Updated", 1200); + } else { + _ui->lvStatusBar().showToast(saved ? "Saved" : "Applied", 800); + } + } +} diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h new file mode 100644 index 0000000..7e0d2c7 --- /dev/null +++ b/src/ui/screens/LvSettingsScreen.h @@ -0,0 +1,122 @@ +#pragma once + +#include "ui/UIManager.h" +#include "transport/WiFiInterface.h" +#include "config/UserConfig.h" +#include +#include +#include +class FlashStore; +class SDStore; +class SX1262; +class AudioNotify; +class Power; +class WiFiInterface; +class TCPClientInterface; +class ReticulumManager; +class IdentityManager; + +// Reuse existing SettingType, SettingItem, SettingsCategory from SettingsScreen.h +#include "SettingsScreen.h" + +class LvSettingsScreen : public LvScreen { +public: + void createUI(lv_obj_t* parent) override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + void setFlashStore(FlashStore* fs) { _flash = fs; } + void setSDStore(SDStore* sd) { _sd = sd; } + void setRadio(SX1262* radio) { _radio = radio; } + void setAudio(AudioNotify* audio) { _audio = audio; } + void setPower(Power* power) { _power = power; } + void setWiFi(WiFiInterface* wifi) { _wifi = wifi; } + void setTCPClients(std::vector* tcp) { _tcp = tcp; } + void setRNS(ReticulumManager* rns) { _rns = rns; } + void setIdentityManager(IdentityManager* idm) { _idMgr = idm; } + void setUIManager(UIManager* ui) { _ui = ui; } + void setIdentityHash(const String& hash) { _identityHash = hash; } + void setSaveCallback(std::function cb) { _saveCallback = cb; } + void setTCPChangeCallback(std::function cb) { _tcpChangeCb = cb; } + + const char* title() const override { return "Settings"; } + +private: + void buildItems(); + void applyAndSave(); + void applyPreset(int presetIdx); + int detectPreset() const; + bool isEditable(int idx) const; + void skipToNextEditable(int dir); + + void showCategoryList(); + void showItemList(int catIdx); + void showWifiPicker(); + void rebuildCategoryList(); + void rebuildItemList(); + void rebuildWifiList(); + + void enterCategory(int catIdx); + void exitToCategories(); + void updateCategorySelection(int oldIdx, int newIdx); + void updateItemSelection(int oldIdx, int newIdx); + void updateWifiSelection(int oldIdx, int newIdx); + + UserConfig* _cfg = nullptr; + FlashStore* _flash = nullptr; + SDStore* _sd = nullptr; + SX1262* _radio = nullptr; + AudioNotify* _audio = nullptr; + Power* _power = nullptr; + WiFiInterface* _wifi = nullptr; + std::vector* _tcp = nullptr; + ReticulumManager* _rns = nullptr; + IdentityManager* _idMgr = nullptr; + UIManager* _ui = nullptr; + String _identityHash; + std::function _saveCallback; + std::function _tcpChangeCb; + + SettingsView _view = SettingsView::CATEGORY_LIST; + std::vector _categories; + std::vector _items; + int _categoryIdx = 0; + int _selectedIdx = 0; + int _catRangeStart = 0; + int _catRangeEnd = 0; + + // Edit state + bool _editing = false; + int _editValue = 0; + bool _textEditing = false; + String _editText; + bool _confirmingReset = false; + + // WiFi picker + std::vector _wifiResults; + int _wifiPickerIdx = 0; + + // Reboot-required tracking + bool _rebootNeeded = false; + struct RebootSnapshot { + RatWiFiMode wifiMode; + String wifiSTASSID; + String wifiSTAPassword; + bool bleEnabled; + bool transportEnabled; + }; + RebootSnapshot _rebootSnap; + void snapshotRebootSettings(); + bool rebootSettingsChanged() const; + + // TCP change detection + String _tcpSnapHost; + uint16_t _tcpSnapPort = 0; + void snapshotTCPSettings(); + bool tcpSettingsChanged() const; + + // LVGL widgets + lv_obj_t* _scrollContainer = nullptr; + std::vector _rowObjs; +};