diff --git a/.gitignore b/.gitignore index 727429c..1f75cac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .pio +.platformio-core/ .vscode/.browse.c_cpp.db* .vscode/c_cpp_properties.json .vscode/launch.json @@ -9,7 +10,6 @@ build/ __pycache__ *.pyc .DS_Store -CLAUDE.md MANUAL.md RESOURCES.md test_lxmf_compat.py diff --git a/lv_conf.h b/lv_conf.h index ead33c6..f2bf047 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -63,14 +63,14 @@ extern const lv_font_t lv_font_ratdeck_14; #define LV_USE_ROLLER 1 #define LV_USE_TABLE 0 #define LV_USE_TABVIEW 0 -#define LV_USE_IMG 0 +#define LV_USE_IMG 1 #define LV_USE_LINE 0 #define LV_USE_ARC 0 #define LV_USE_SPINNER 0 #define LV_USE_MSGBOX 0 #define LV_USE_KEYBOARD 0 #define LV_USE_CHECKBOX 0 -#define LV_USE_CANVAS 0 +#define LV_USE_CANVAS 1 // Layouts #define LV_USE_FLEX 1 diff --git a/merge_firmware.py b/merge_firmware.py index 1432102..c90561c 100644 --- a/merge_firmware.py +++ b/merge_firmware.py @@ -4,6 +4,7 @@ into a single .bin for M5Burner and one-step flashing.""" Import("env") import os +import shlex def merge_bin(source, target, env): @@ -16,14 +17,15 @@ def merge_bin(source, target, env): output = os.path.join(project_dir, "ratdeck-merged.bin") + python = env.subst("$PYTHONEXE") env.Execute( - "esptool.py --chip esp32s3 merge_bin " + f"{shlex.quote(python)} -m esptool --chip esp32s3 merge_bin " "--flash_mode qio --flash_size 16MB " - f"-o {output} " - f"0x0000 {build_dir}/bootloader.bin " - f"0x8000 {build_dir}/partitions.bin " - f"0xe000 {boot_app0} " - f"0x10000 {build_dir}/firmware.bin" + f"-o {shlex.quote(output)} " + f"0x0000 {shlex.quote(os.path.join(build_dir, 'bootloader.bin'))} " + f"0x8000 {shlex.quote(os.path.join(build_dir, 'partitions.bin'))} " + f"0xe000 {shlex.quote(boot_app0)} " + f"0x10000 {shlex.quote(os.path.join(build_dir, 'firmware.bin'))}" ) print(f"\n** Merged firmware written to: {output}") diff --git a/platformio.ini b/platformio.ini index 76fc6c7..ea5e825 100644 --- a/platformio.ini +++ b/platformio.ini @@ -49,10 +49,9 @@ build_unflags = -std=gnu++11 lib_deps = - https://github.com/ratspeak/microReticulum.git + https://github.com/ratspeak/microReticulum.git#858010cee03f0b94f645726a516f8827ba02075f 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/audio/AudioNotify.cpp b/src/audio/AudioNotify.cpp index 8000ea0..8a98399 100644 --- a/src/audio/AudioNotify.cpp +++ b/src/audio/AudioNotify.cpp @@ -95,11 +95,59 @@ void AudioNotify::writeSilence(uint16_t durationMs) { } void AudioNotify::playMessage() { + if (!_enabled || !_i2sReady) return; + + const int sr = AUDIO_SAMPLE_RATE; + const int toneMs = 34; + const int gapMs = 28; + const int tailMs = 16; + const int totalMs = toneMs + gapMs + toneMs + tailMs; + const int totalSamples = sr * totalMs / 1000; + + int16_t* buf = (int16_t*)ps_malloc(totalSamples * sizeof(int16_t)); + if (!buf) buf = (int16_t*)malloc(totalSamples * sizeof(int16_t)); + if (!buf) return; + memset(buf, 0, totalSamples * sizeof(int16_t)); + + float vol = (_volume / 100.0f) * 12000.0f; + int pos = 0; + + auto addTone = [&](float freq, int ms) { + int n = sr * ms / 1000; + int fadeN = sr * 5 / 1000; + if (fadeN < 1) fadeN = 1; + for (int i = 0; i < n && (pos + i) < totalSamples; i++) { + float t = (float)i / sr; + float s = sinf(2.0f * M_PI * freq * t) * 0.80f + + sinf(2.0f * M_PI * freq * 2.0f * t) * 0.15f + + sinf(2.0f * M_PI * freq * 3.0f * t) * 0.05f; + float env = 1.0f; + if (i < fadeN) env = (float)i / fadeN; + if (i > n - fadeN) env = (float)(n - i) / fadeN; + buf[pos + i] = (int16_t)(s * env * vol); + } + pos += n; + }; + + addTone(1000.0f, toneMs); + pos += sr * gapMs / 1000; + addTone(1000.0f, toneMs); + pos += sr * tailMs / 1000; + + size_t written = 0; + i2s_write(I2S_PORT, buf, totalSamples * sizeof(int16_t), &written, pdMS_TO_TICKS(150)); + free(buf); +} + +void AudioNotify::requestMessage() { if (!_enabled) return; - writeTone(1000, 50); - writeSilence(50); - writeTone(1000, 50); - writeSilence(30); + _messagePending = true; +} + +void AudioNotify::loop() { + if (!_messagePending) return; + _messagePending = false; + playMessage(); } void AudioNotify::playAnnounce() { diff --git a/src/audio/AudioNotify.h b/src/audio/AudioNotify.h index baf2677..350d3da 100644 --- a/src/audio/AudioNotify.h +++ b/src/audio/AudioNotify.h @@ -12,6 +12,8 @@ public: void playAnnounce(); void playError(); void playBoot(); // Sci-fi boot sequence + void requestMessage(); // Defer short alert out of packet callbacks + void loop(); // Settings void setEnabled(bool enabled) { _enabled = enabled; } @@ -25,5 +27,6 @@ private: bool _enabled = true; bool _i2sReady = false; + volatile bool _messagePending = false; uint8_t _volume = 80; // 0-100 }; diff --git a/src/config/BoardConfig.h b/src/config/BoardConfig.h index 7455d91..24fc265 100644 --- a/src/config/BoardConfig.h +++ b/src/config/BoardConfig.h @@ -1,7 +1,7 @@ #pragma once // ============================================================================= -// RatDeck — LilyGo T-Deck Plus Pin Definitions +// Ratdeck — LilyGo T-Deck Plus Pin Definitions // ============================================================================= // --- Power Control --- diff --git a/src/config/Config.h b/src/config/Config.h index 4635048..b525f42 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -1,7 +1,7 @@ #pragma once // ============================================================================= -// RatDeck — Compile-Time Configuration +// Ratdeck — Compile-Time Configuration // ============================================================================= #define RATDECK_VERSION_MAJOR 1 @@ -16,7 +16,13 @@ #define HAS_TRACKBALL true #define HAS_LORA true #define HAS_WIFI true -#define HAS_BLE true +// TODO(BLE): Revisit as a dedicated Ratspeak BLE Peer transport based on the +// rsReticulum GATT/fragmentation/anti-loop design, after T-Deck Plus memory, +// flash, power, and interop behavior are tested end to end. +#ifndef RATDECK_EXPERIMENTAL_BLE +#define RATDECK_EXPERIMENTAL_BLE 0 +#endif +#define HAS_BLE RATDECK_EXPERIMENTAL_BLE #define HAS_SD true #define HAS_AUDIO true #define HAS_GPS true // UBlox MIA-M10Q UART GPS @@ -47,7 +53,7 @@ #define MAX_TCP_CONNECTIONS 4 #define TCP_DEFAULT_PORT 4242 #define TCP_RECONNECT_INTERVAL_MS 15000 -#define TCP_CONNECT_TIMEOUT_MS 5000 +#define TCP_CONNECT_TIMEOUT_MS 500 // --- Announce Flood Defense --- #define RATDECK_MAX_ANNOUNCES_PER_SEC 5 // Transport-level rate limit (before Ed25519 verify) @@ -57,6 +63,7 @@ #define RATDECK_MAX_MESSAGES_PER_CONV 100 #define FLASH_MSG_CACHE_LIMIT 20 #define RATDECK_MAX_OUTQUEUE 20 +#define RATDECK_LXMF_SINGLE_FRAME_MAX 254 // T-Deck-safe payload cap until resource transfers are fixed #define PATH_PERSIST_INTERVAL_MS 60000 // --- Power Management --- diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index bbf8956..63c6699 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -2,7 +2,7 @@ #include "config/BoardConfig.h" bool UserConfig::parseJson(const String& json) { - Serial.printf("[CONFIG] Raw JSON (%d bytes): %s\n", json.length(), json.c_str()); + Serial.printf("[CONFIG] Parsing config (%d bytes)\n", json.length()); JsonDocument doc; DeserializationError err = deserializeJson(doc, json); @@ -28,8 +28,25 @@ bool UserConfig::parseJson(const String& json) { } _settings.wifiAPSSID = doc["wifi_ap_ssid"] | ""; _settings.wifiAPPassword = doc["wifi_ap_pass"] | WIFI_AP_PASSWORD; - _settings.wifiSTASSID = doc["wifi_sta_ssid"] | ""; - _settings.wifiSTAPassword = doc["wifi_sta_pass"] | ""; + _settings.wifiSTASelected = constrain((int)(doc["wifi_sta_selected"] | 0), 0, (int)WIFI_STA_MAX_NETWORKS - 1); + + // Migrate legacy single-network config into the multi-network list. + _settings.wifiSTANetworks.clear(); + JsonArray staArr = doc["wifi_sta_networks"]; + if (staArr) { + for (JsonObject obj : staArr) { + if (_settings.wifiSTANetworks.size() >= WIFI_STA_MAX_NETWORKS) break; + WiFiNetwork n; + n.ssid = obj["ssid"] | ""; + n.password = obj["pass"] | ""; + _settings.wifiSTANetworks.push_back(n); + } + } else { + WiFiNetwork legacy; + legacy.ssid = doc["wifi_sta_ssid"] | ""; + legacy.password = doc["wifi_sta_pass"] | ""; + if (!legacy.ssid.isEmpty()) _settings.wifiSTANetworks.push_back(legacy); + } // AutoInterface (LAN auto-discovery) _settings.autoIfaceEnabled = doc["autoiface_en"] | false; @@ -57,12 +74,12 @@ bool UserConfig::parseJson(const String& json) { 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.keyboardBrightness = constrain(doc["kb_brightness"] | 100, 1, 100); + _settings.keyboardBrightness = constrain(doc["kb_brightness"] | 100, 0, 100); _settings.keyboardAutoOn = doc["kb_auto_on"] | false; _settings.keyboardAutoOff = doc["kb_auto_off"] | false; _settings.trackballSpeed = doc["trackball_speed"] | 3; _settings.touchSensitivity = doc["touch_sens"] | 3; - _settings.bleEnabled = doc["ble_enabled"] | false; + _settings.bleEnabled = false; _settings.gpsTimeEnabled = doc["gps_time"] | true; _settings.gpsLocationEnabled = doc["gps_location"] | false; @@ -74,7 +91,10 @@ bool UserConfig::parseJson(const String& json) { _settings.audioVolume = doc["audio_vol"] | 80; _settings.displayName = doc["display_name"] | ""; - _settings.announceInterval = doc["announce_int"] | 5; + _settings.sdStorageEnabled = doc["sd_storage"] | _settings.sdStorageEnabled; + _settings.announceInterval = doc["announce_int"] | 30; + if (_settings.announceInterval < 30) _settings.announceInterval = 30; + if (_settings.announceInterval > 360) _settings.announceInterval = 360; _settings.devMode = doc["dev_mode"] | false; Serial.println("[CONFIG] Settings loaded"); @@ -95,8 +115,18 @@ String UserConfig::serializeToJson() const { doc["wifi_mode"] = (int)_settings.wifiMode; doc["wifi_ap_ssid"] = _settings.wifiAPSSID; doc["wifi_ap_pass"] = _settings.wifiAPPassword; - doc["wifi_sta_ssid"] = _settings.wifiSTASSID; - doc["wifi_sta_pass"] = _settings.wifiSTAPassword; + doc["wifi_sta_selected"] = (int)constrain((int)_settings.wifiSTASelected, 0, (int)WIFI_STA_MAX_NETWORKS - 1); + JsonArray staArr = doc["wifi_sta_networks"].to(); + for (size_t slot = 0; slot < WIFI_STA_MAX_NETWORKS; slot++) { + JsonObject obj = staArr.add(); + if (slot < _settings.wifiSTANetworks.size()) { + obj["ssid"] = _settings.wifiSTANetworks[slot].ssid; + obj["pass"] = _settings.wifiSTANetworks[slot].password; + } else { + obj["ssid"] = ""; + obj["pass"] = ""; + } + } doc["autoiface_en"] = _settings.autoIfaceEnabled; doc["autoiface_group"] = _settings.autoIfaceGroupId; @@ -119,7 +149,7 @@ String UserConfig::serializeToJson() const { doc["kb_auto_off"] = _settings.keyboardAutoOff; doc["trackball_speed"] = _settings.trackballSpeed; doc["touch_sens"] = _settings.touchSensitivity; - doc["ble_enabled"] = _settings.bleEnabled; + doc["ble_enabled"] = false; doc["gps_time"] = _settings.gpsTimeEnabled; doc["gps_location"] = _settings.gpsLocationEnabled; @@ -131,6 +161,7 @@ String UserConfig::serializeToJson() const { doc["audio_vol"] = _settings.audioVolume; doc["display_name"] = _settings.displayName; + doc["sd_storage"] = _settings.sdStorageEnabled; doc["announce_int"] = _settings.announceInterval; doc["dev_mode"] = _settings.devMode; @@ -156,16 +187,6 @@ bool UserConfig::save(FlashStore& flash) { } bool UserConfig::load(SDStore& sd, FlashStore& flash) { - // Try SD card first - if (sd.isReady()) { - String json = sd.readString(SD_PATH_USER_CONFIG); - if (!json.isEmpty()) { - Serial.println("[CONFIG] Loading from SD card"); - return parseJson(json); - } - } - - // Fall back to flash String json = flash.readString(PATH_USER_CONFIG); if (json.isEmpty()) { Serial.println("[CONFIG] No saved config, using defaults"); @@ -174,14 +195,13 @@ bool UserConfig::load(SDStore& sd, FlashStore& flash) { bool ok = parseJson(json); - // Auto-migrate: flash had config but SD didn't — copy to SD - if (ok && sd.isReady()) { - Serial.println("[CONFIG] Migrating config from flash to SD..."); - sd.ensureDir("/ratdeck"); - sd.ensureDir("/ratdeck/config"); - String migrateJson = serializeToJson(); - if (sd.writeString(SD_PATH_USER_CONFIG, migrateJson)) { - Serial.println("[CONFIG] Migration complete"); + if (ok && _settings.sdStorageEnabled && sd.isReady()) { + String sdJson = sd.readString(SD_PATH_USER_CONFIG); + if (!sdJson.isEmpty()) { + Serial.println("[CONFIG] Loading opt-in SD config"); + bool sdOk = parseJson(sdJson); + _settings.sdStorageEnabled = true; + return sdOk; } } @@ -192,8 +212,7 @@ bool UserConfig::save(SDStore& sd, FlashStore& flash) { String json = serializeToJson(); bool ok = false; - // Write to SD (primary) - if (sd.isReady()) { + if (_settings.sdStorageEnabled && sd.isReady()) { sd.ensureDir("/ratdeck"); sd.ensureDir("/ratdeck/config"); if (sd.writeString(SD_PATH_USER_CONFIG, json)) { diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index 66c647a..fcceee3 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -10,6 +10,13 @@ enum RatWiFiMode : uint8_t { RAT_WIFI_OFF = 0, RAT_WIFI_AP = 1, RAT_WIFI_STA = 2 }; +struct WiFiNetwork { + String ssid; + String password; +}; + +constexpr size_t WIFI_STA_MAX_NETWORKS = 3; + struct TCPEndpoint { String host; uint16_t port = TCP_DEFAULT_PORT; @@ -30,8 +37,8 @@ struct UserSettings { RatWiFiMode wifiMode = RAT_WIFI_STA; String wifiAPSSID; String wifiAPPassword = WIFI_AP_PASSWORD; - String wifiSTASSID; - String wifiSTAPassword; + std::vector wifiSTANetworks; + uint8_t wifiSTASelected = 0; // AutoInterface (Reticulum LAN auto-discovery via IPv6 multicast). // Active only in STA mode; opt-in until proven stable on real APs. @@ -49,7 +56,7 @@ struct UserSettings { bool denseFontMode = false; // T-Deck Plus: adaptive font toggle // Keyboard - uint8_t keyboardBrightness = 100; // Percentage 1-100 + uint8_t keyboardBrightness = 100; // Percentage 0-100 (0 = off) bool keyboardAutoOn = false; // Backlight ON when switching to ACTIVE power state bool keyboardAutoOff = false; // Backlight OFF when switching from ACTIVE power state @@ -76,8 +83,11 @@ struct UserSettings { // Identity String displayName; + // Storage + bool sdStorageEnabled = false; // Removable SD stores plaintext unless explicitly enabled + // Announce - uint16_t announceInterval = 5; // minutes, 5-360 + uint16_t announceInterval = 30; // minutes, 30-360 // Developer mode — unlocks custom radio parameters bool devMode = false; diff --git a/src/hal/Display.cpp b/src/hal/Display.cpp index fff6649..f254e88 100644 --- a/src/hal/Display.cpp +++ b/src/hal/Display.cpp @@ -29,7 +29,7 @@ bool Display::begin() { return true; } -void Display::beginLVGL() { +bool Display::beginLVGL() { s_gfx = &_gfx; lv_init(); @@ -45,7 +45,7 @@ void Display::beginLVGL() { if (!s_buf1 || !s_buf2) { Serial.println("[LVGL] FATAL: buffer allocation failed!"); - return; + return false; } static lv_disp_draw_buf_t draw_buf; @@ -60,6 +60,7 @@ void Display::beginLVGL() { lv_disp_drv_register(&disp_drv); Serial.println("[LVGL] Display driver registered (320x240, double-buffered 20-line DMA)"); + return true; } void Display::setBrightness(uint8_t level) { diff --git a/src/hal/Display.h b/src/hal/Display.h index a1ee8ee..169e7e7 100644 --- a/src/hal/Display.h +++ b/src/hal/Display.h @@ -58,7 +58,7 @@ public: bool begin(); // Initialize LVGL display driver with double-buffered DMA flush - void beginLVGL(); + bool beginLVGL(); // Backlight void setBrightness(uint8_t level); diff --git a/src/hal/Keyboard.cpp b/src/hal/Keyboard.cpp index 03e7bba..27f5bbe 100644 --- a/src/hal/Keyboard.cpp +++ b/src/hal/Keyboard.cpp @@ -1,4 +1,5 @@ #include "Keyboard.h" +#include Keyboard* Keyboard::_instance = nullptr; int Keyboard::_debugCount = 0; @@ -25,11 +26,11 @@ bool Keyboard::begin() { uint8_t Keyboard::readKey(uint8_t* modOut) { *modOut = 0; - // Read 2 bytes: byte 1 = key, byte 2 = potential modifier flags - Wire.requestFrom((uint8_t)KB_I2C_ADDR, (uint8_t)2); + // The stock LilyGO keyboard firmware returns one translated key byte. + // Requesting extra bytes can read undefined data and create phantom modifiers. + Wire.requestFrom((uint8_t)KB_I2C_ADDR, (uint8_t)1); uint8_t key = 0; if (Wire.available()) key = Wire.read(); - if (Wire.available()) *modOut = Wire.read(); return key; } @@ -54,8 +55,11 @@ void Keyboard::update() { _event = {}; // Check for Alt in modifier byte (try common bit positions) + // Optional custom keyboard firmwares may expose modifier bits, but the + // default T-Deck path above intentionally reads only the translated key. // BBQ-style keyboards: bit 1=Alt, bit 2=Sym, bit 0=Ctrl // Also try bit 3, bit 4 as some firmwares use those + bool ctrlFromMod = (mod & 0x01); bool altFromMod = (mod & 0x02) || (mod & 0x08); // Track Alt state: if the keyboard sends Alt as a standalone keypress, @@ -63,6 +67,9 @@ void Keyboard::update() { // 0x1B = Esc (unlikely to be Alt), but some controllers use high bytes // The T-Deck Alt key might send no byte at all when pressed alone. + if (ctrlFromMod) { + _event.ctrl = true; + } if (altFromMod) { _event.alt = true; } @@ -81,19 +88,19 @@ void Keyboard::update() { } else if (key == ' ') { _event.space = true; _event.character = ' '; - } else if (key >= 0x01 && key <= 0x1A) { - // Ctrl+A through Ctrl+Z - _event.ctrl = true; - _event.character = key + 'a' - 1; } else if (key >= 0x20 && key <= 0x7E) { - _event.character = key; + _event.character = ctrlFromMod ? (char)tolower(key) : key; } _hasEvent = true; } bool Keyboard::setBacklightBrightness(uint8_t percent) { - percent = constrain(percent, 1, 100); + percent = constrain(percent, 0, 100); + if (percent == 0) { + _backlightBrightness = 0; + return true; + } // [1, 100] % -> [31, 255] PWM constexpr uint16_t SCALE = 255 - 31; constexpr uint16_t DIV = 100 - 1; diff --git a/src/hal/Keyboard.h b/src/hal/Keyboard.h index 74afd38..c31943d 100644 --- a/src/hal/Keyboard.h +++ b/src/hal/Keyboard.h @@ -52,7 +52,7 @@ public: // - The ESP32-C3 F/W uses 2 brightness settings, one for + (which doesn't // change the current brightness), the other one for the current brightness. // The range for the 1st one is limited to [31, 255], we use it for the 2nd one, too. - bool setBacklightBrightness(uint8_t percent); // doesn't change the current brightness + bool setBacklightBrightness(uint8_t percent); // 0 stores off; non-zero doesn't change current brightness bool backlightOn(); bool backlightOff(); diff --git a/src/hal/Power.cpp b/src/hal/Power.cpp index e9b2859..f01e8e1 100644 --- a/src/hal/Power.cpp +++ b/src/hal/Power.cpp @@ -80,8 +80,11 @@ void Power::setBrightness(uint8_t percent) { } void Power::setKbBrightness(uint8_t percent, bool apply) { - keyboard.setBacklightBrightness(constrain(percent, 1, 100)); - if (apply) { // Show the new brightness + percent = constrain(percent, 0, 100); + keyboard.setBacklightBrightness(percent); + if (percent == 0) { + keyboard.backlightOff(); + } else if (apply) { // Show the new brightness keyboard.backlightOn(); } } diff --git a/src/hal/Power.h b/src/hal/Power.h index 2eb4740..780ec91 100644 --- a/src/hal/Power.h +++ b/src/hal/Power.h @@ -38,7 +38,7 @@ public: } } - // Keyboard backlight — accepts percentage 1-100 + // Keyboard backlight — accepts percentage 0-100 (0 = off) void setKbBrightness(uint8_t percent, bool apply=false); void setKbAutoOn(bool enable) { _kbAutoOn = enable; } void setKbAutoOff(bool enable) { _kbAutoOff = enable; } diff --git a/src/input/InputManager.cpp b/src/input/InputManager.cpp index 94b0b41..1872ed1 100644 --- a/src/input/InputManager.cpp +++ b/src/input/InputManager.cpp @@ -7,6 +7,22 @@ void InputManager::begin(Keyboard* kb, Trackball* tb, TouchInput* touch) { _touch = touch; } +void InputManager::setTrackballSpeed(uint8_t speed) { + if (speed < 1) speed = 1; + if (speed > 5) speed = 5; + _trackballSpeed = speed; +} + +int8_t InputManager::trackballThreshold() const { + static const int8_t thresholds[] = {5, 4, 3, 2, 1}; + return thresholds[_trackballSpeed - 1]; +} + +unsigned long InputManager::trackballRateMs() const { + static const unsigned long rates[] = {260, 220, 180, 140, 100}; + return rates[_trackballSpeed - 1]; +} + void InputManager::update() { _hasKey = false; _activity = false; @@ -46,12 +62,13 @@ void InputManager::update() { if (_tbAccumY > 20) _tbAccumY = 20; if (_tbAccumY < -20) _tbAccumY = -20; - if (now - _lastTbNavTime >= TB_NAV_RATE_MS) { + if (now - _lastTbNavTime >= trackballRateMs()) { int8_t absX = _tbAccumX < 0 ? -_tbAccumX : _tbAccumX; int8_t absY = _tbAccumY < 0 ? -_tbAccumY : _tbAccumY; bool yDominant = absY >= absX; + int8_t threshold = trackballThreshold(); - if (yDominant && absY >= TB_NAV_THRESHOLD) { + if (yDominant && absY >= threshold) { _keyEvent = {}; if (_tbAccumY < 0) _keyEvent.up = true; else _keyEvent.down = true; @@ -62,7 +79,7 @@ void InputManager::update() { _tbAccumX = 0; _tbAccumY = 0; } - else if (!yDominant && absX >= TB_NAV_THRESHOLD) { + else if (!yDominant && absX >= threshold) { _keyEvent = {}; if (_tbAccumX < 0) _keyEvent.left = true; else _keyEvent.right = true; diff --git a/src/input/InputManager.h b/src/input/InputManager.h index 923c6b3..3bc627a 100644 --- a/src/input/InputManager.h +++ b/src/input/InputManager.h @@ -9,6 +9,7 @@ class InputManager { public: void begin(Keyboard* kb, Trackball* tb, TouchInput* touch); void setPowerMgr(Power* pm) { _powerMgr = pm; } + void setTrackballSpeed(uint8_t speed); void update(); // Keyboard events @@ -44,11 +45,12 @@ private: static constexpr unsigned long CLICK_DEBOUNCE_MS = 80; // Trackball navigation state + uint8_t _trackballSpeed = 3; int8_t _tbAccumX = 0; int8_t _tbAccumY = 0; unsigned long _lastTbNavTime = 0; - static constexpr int8_t TB_NAV_THRESHOLD = 3; - static constexpr unsigned long TB_NAV_RATE_MS = 200; + int8_t trackballThreshold() const; + unsigned long trackballRateMs() const; // Touch polling throttle unsigned long _lastTouchPoll = 0; diff --git a/src/main.cpp b/src/main.cpp index 8c2dfd8..c3af41e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,5 @@ // ============================================================================= -// RatDeck v1.5 — Main Entry Point +// Ratdeck — Main Entry Point // LilyGo T-Deck Plus: LovyanGFX Direct UI + microReticulum + LXMF Messaging // ============================================================================= @@ -46,16 +46,20 @@ #include "reticulum/IdentityManager.h" #include "transport/LoRaInterface.h" #include "transport/WiFiInterface.h" +#include #include "transport/TCPClientInterface.h" -#include "transport/BLEInterface.h" #include "transport/AutoInterfaceWrapper.h" +#if HAS_BLE +#include "transport/BLEInterface.h" #include "transport/BLESideband.h" +#endif #include "config/UserConfig.h" #include "audio/AudioNotify.h" #include #include #include #include +#include #include // --- Hardware --- @@ -89,8 +93,12 @@ WiFiInterface* wifiImpl = nullptr; RNS::Interface wifiIface({RNS::Type::NONE}); std::vector tcpClients; std::list tcpIfaces; // Must persist — Transport stores references (list: no realloc) +std::list retiredTcpClients; +bool tcpReloadRequested = false; +#if HAS_BLE BLEInterface bleInterface; BLESideband bleSideband; +#endif UserConfig userConfig; Power powerMgr; AudioNotify audio; @@ -120,7 +128,9 @@ LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {}; bool radioOnline = false; bool bootComplete = false; bool bootLoopRecovery = false; +bool sdHadExistingData = false; bool wifiSTAStarted = false; +WiFiMulti wifiMulti; bool wifiSTAConnected = false; unsigned long lastAutoAnnounce = 0; unsigned long lastStatusUpdate = 0; @@ -132,8 +142,6 @@ unsigned long maxLoopTime = 0; unsigned long lastLvglTime = 0; constexpr unsigned long LVGL_INTERVAL_MS = 33; // ~30 FPS constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 35; // Max cumulative TCP time per loop -bool wifiDeferredAnnounce = false; -unsigned long wifiConnectedAt = 0; AutoInterfaceWrapper autoIface; bool autoIfaceDeferredStart = false; @@ -157,11 +165,11 @@ static const char* currentPosixTZ() { // Announce with display name (MessagePack-encoded app_data) // ============================================================================= -// LXMF announce app_data, current upstream format: -// [display_name(bin), stamp_cost(uint), supported_functionality(array)] +// LXMF announce app_data: +// [display_name(bin), stamp_cost(nil|uint), supported_functionality(array)] // Always emit fixarray(3) so Python LXMF doesn't default auto_compress=True for -// our destinations. Empty supported_functionality list = we do NOT support -// SF_COMPRESSION (bz2) — see microReticulum/src/Compression/BZ2.cpp. +// our destinations. stamp_cost=nil means no inbound stamp is required. Empty +// supported_functionality list = we do NOT support SF_COMPRESSION (bz2). RNS::Bytes encodeAnnounceName(const String& name) { size_t nameLen = name.length(); if (nameLen > 31) nameLen = 31; @@ -171,12 +179,31 @@ RNS::Bytes encodeAnnounceName(const String& name) { buf[i++] = 0xC4; // bin 8 buf[i++] = (uint8_t)nameLen; if (nameLen) { memcpy(buf + i, name.c_str(), nameLen); i += nameLen; } - buf[i++] = 0x00; // stamp_cost = 0 + buf[i++] = 0xC0; // stamp_cost = nil (no stamp required) buf[i++] = 0x90; // empty fixarray (no SF_* supported) return RNS::Bytes(buf, i); } -static void announceWithName(bool silent = false) { +static bool hasUsableAnnounceTransport() { + if (!rns.isTransportActive()) return false; + if (radioOnline) return true; + if (wifiImpl && wifiImpl->isAPActive() && wifiImpl->getClientCount() > 0) return true; + for (auto* tcp : tcpClients) { + if (tcp && tcp->isConnected()) return true; + } +#if HAS_BLE + if (bleInterface.isClientConnected()) return true; +#endif + if (autoIface.isOnline() && autoIface.peerCount() > 0) return true; + return false; +} + +static bool announceWithName(bool silent = false) { + if (!hasUsableAnnounceTransport()) { + if (!silent) ui.lvStatusBar().showToast("No active transport", 1500); + Serial.println("[ANNOUNCE-TX] skipped: no active transport"); + return false; + } RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); Serial.printf("[ANNOUNCE-TX] name=\"%s\" appData=%d bytes silent=%s\n", userConfig.settings().displayName.c_str(), (int)appData.size(), @@ -186,20 +213,50 @@ static void announceWithName(bool silent = false) { ui.lvStatusBar().flashAnnounce(); ui.lvStatusBar().showToast("Announce sent!"); } + return true; +} + +static void manualAnnounce() { + if (announceWithName()) Serial.println("[ANNOUNCE] Manual announce sent"); } // ============================================================================= // TCP client management — stop old clients, create new from config // ============================================================================= +static void drainRetiredTCPClients() { + for (auto it = retiredTcpClients.begin(); it != retiredTcpClients.end(); ) { + TCPClientInterface* tcp = *it; + if (!tcp || tcp->canDestroy()) { + if (tcp) delete tcp; + it = retiredTcpClients.erase(it); + } else { + ++it; + } + } +} + +static void retireTCPClient(TCPClientInterface* tcp) { + if (!tcp) return; + tcp->stop(); + if (tcp->canDestroy()) { + delete tcp; + } else { + retiredTcpClients.push_back(tcp); + } +} + static void reloadTCPClients() { // Stop and deregister existing clients - for (auto* tcp : tcpClients) tcp->stop(); for (auto& iface : tcpIfaces) { RNS::Transport::deregister_interface(iface); } + for (auto* tcp : tcpClients) { + retireTCPClient(tcp); + } tcpClients.clear(); tcpIfaces.clear(); + drainRetiredTCPClients(); // Create new clients from current config if (WiFi.status() == WL_CONNECTED) { @@ -224,6 +281,10 @@ static void reloadTCPClients() { } } +static void requestTCPClientsReload() { + tcpReloadRequested = true; +} + // ============================================================================= // Hotkey callbacks // ============================================================================= @@ -236,15 +297,27 @@ void onHotkeyMessages() { ui.setScreen(&lvMessagesScreen); } void onHotkeyNewMsg() { - ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); - ui.setScreen(&lvMessagesScreen); + bool hasContacts = false; + if (announceManager) { + for (const auto& node : announceManager->nodes()) { + if (node.saved) { hasContacts = true; break; } + } + } + if (hasContacts) { + ui.lvTabBar().setActiveTab(LvTabBar::TAB_CONTACTS); + ui.setScreen(&lvContactsScreen); + } else { + ui.lvTabBar().setActiveTab(LvTabBar::TAB_NODES); + ui.setScreen(&lvNodesScreen); + ui.lvStatusBar().showToast("Pick a peer to message", 1200); + } } void onHotkeySettings() { ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETTINGS); ui.setScreen(&lvSettingsScreen); } void onHotkeyAnnounce() { - announceWithName(); + manualAnnounce(); } void onHotkeyAutoIface() { Serial.println("=== AUTOIFACE DUMP ==="); @@ -263,7 +336,7 @@ void onHotkeyAutoIface() { } void onHotkeyDiag() { Serial.println("=== DIAGNOSTIC DUMP ==="); - Serial.printf("Device: RatDeck T-Deck Plus\n"); + Serial.printf("Device: Ratdeck T-Deck Plus\n"); Serial.printf("Identity: %s\n", rns.identityHash().c_str()); Serial.printf("Transport: %s\n", rns.isTransportActive() ? "ACTIVE" : "OFFLINE"); Serial.printf("Paths: %d Links: %d\n", (int)rns.pathCount(), (int)rns.linkCount()); @@ -346,7 +419,7 @@ void setup() { delay(100); Serial.println(); Serial.println("================================="); - Serial.printf(" RatDeck v%s\n", RATDECK_VERSION_STRING); + Serial.printf(" Ratdeck v%s\n", RATDECK_VERSION_STRING); Serial.println(" LilyGo T-Deck Plus"); Serial.println("================================="); @@ -366,10 +439,16 @@ void setup() { Serial.printf("[BOOT] Reset: %s (%d)\n", reasonStr, (int)reason); Serial.printf("[BOOT] Heap: %lu PSRAM: %lu\n", (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getPsramSize()); + if (!psramFound() || heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < 1024 * 1024) { + Serial.printf("[BOOT] FATAL: PSRAM unavailable or too fragmented (largest=%lu)\n", + (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)); + while (true) delay(1000); + } // Step 3: Initialize I2C bus (shared by keyboard + touchscreen) Wire.begin(I2C_SDA, I2C_SCL); Wire.setClock(400000); + Wire.setTimeOut(20); // Step 3.5: Initialize shared SPI bus sharedSPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI); @@ -399,6 +478,7 @@ void setup() { digitalWrite(LORA_CS, HIGH); delay(10); if (sdStore.begin(&sharedSPI, SD_CS)) { + sdHadExistingData = sdStore.hasExistingData(); sdStore.formatForRatdeck(); Serial.println("[SD] Card ready"); } else { @@ -422,7 +502,13 @@ void setup() { Serial.println("[BOOT] Display initialized (LovyanGFX direct)"); // Step 5.5: Initialize LVGL display driver - display.beginLVGL(); + if (!display.beginLVGL()) { + display.gfx().fillScreen(TFT_BLACK); + display.gfx().setTextColor(TFT_RED, TFT_BLACK); + display.gfx().drawString("LVGL/PSRAM failed", 24, 106); + display.setBrightness(160); + while (true) delay(1000); + } Serial.println("[BOOT] LVGL initialized"); // Verify radio SPI survives display init @@ -487,12 +573,8 @@ void setup() { 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()) { - Serial.println("[BOOT] LittleFS formatted and mounted"); - } else { - Serial.println("[BOOT] LittleFS format failed!"); - } + Serial.println("[BOOT] Flash init failed; automatic formatting disabled"); + lvBootScreen.setProgress(0.62f, "Flash mount failed"); } else { Serial.println("[BOOT] LittleFS mounted OK"); } @@ -511,6 +593,10 @@ void setup() { } } + lvBootScreen.setProgress(0.64f, "Loading config..."); + userConfig.load(sdStore, flash); + inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed); + lvBootScreen.setProgress(0.65f, "Starting Reticulum..."); // (LVGL boot renders via lv_timer_handler in setProgress) rns.setSDStore(&sdStore); @@ -529,14 +615,14 @@ void setup() { // Step 16: Message store lvBootScreen.setProgress(0.72f, "Starting messaging..."); // (LVGL boot renders via lv_timer_handler in setProgress) - messageStore.begin(&flash, &sdStore); + messageStore.begin(&flash, &sdStore, userConfig.settings().sdStorageEnabled); // Step 17: LXMF init lxmf.begin(&rns, &messageStore); lxmf.setMessageCallback([](const LXMFMessage& msg) { Serial.printf("[LXMF] Message from %s\n", msg.sourceHash.toHex().substr(0, 8).c_str()); ui.lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, lxmf.unreadCount()); - audio.playMessage(); + audio.requestMessage(); }); // Pre-cache unread counts so first tab switch to Messages is instant lxmf.unreadCount(); @@ -557,11 +643,6 @@ void setup() { announceHandler = RNS::HAnnounceHandler(announceManager); RNS::Transport::register_announce_handler(announceHandler); - // Step 19: User config load - lvBootScreen.setProgress(0.82f, "Loading config..."); - // (LVGL boot renders via lv_timer_handler in setProgress) - userConfig.load(sdStore, flash); - // No default TCP hub. Users opt in via Settings → TCP Server → // "Ratspeak Hub" (seeds rns.ratspeak.org) or "Custom" (host/port). @@ -631,8 +712,22 @@ void setup() { ui.lvStatusBar().setWiFiActive(true); } else if (wifiMode == RAT_WIFI_STA) { lvBootScreen.setProgress(0.87f, "WiFi STA starting..."); + auto& settings = userConfig.settings(); + auto& nets = settings.wifiSTANetworks; + size_t selectedSlot = settings.wifiSTASelected < WIFI_STA_MAX_NETWORKS ? settings.wifiSTASelected : 0; + int registered = 0; + if (selectedSlot < nets.size() && !nets[selectedSlot].ssid.isEmpty()) { + const auto& n = nets[selectedSlot]; + wifiMulti.addAP(n.ssid.c_str(), n.password.c_str()); + registered++; + Serial.printf("[WIFI] STA: using profile %u (%s)\n", + (unsigned)(selectedSlot + 1), n.ssid.c_str()); + } else { + Serial.printf("[WIFI] STA: selected profile %u is empty\n", + (unsigned)(selectedSlot + 1)); + } // WiFi is enabled but not yet connected — indicator will be yellow - if (!userConfig.settings().wifiSTASSID.isEmpty()) { + if (registered > 0) { WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); // AutoInterface needs an IPv6 link-local address. Must be enabled @@ -641,19 +736,19 @@ void setup() { WiFi.enableIpV6(); Serial.println("[WIFI] IPv6 enabled (AutoInterface ON)"); } - WiFi.begin(userConfig.settings().wifiSTASSID.c_str(), - userConfig.settings().wifiSTAPassword.c_str()); + wifiMulti.run(5000); wifiSTAStarted = true; - Serial.printf("[WIFI] STA: %s\n", userConfig.settings().wifiSTASSID.c_str()); + Serial.printf("[WIFI] STA: %d selected profile registered\n", registered); } } else { lvBootScreen.setProgress(0.87f, "WiFi disabled"); // (LVGL boot renders via lv_timer_handler in setProgress) } - // Step 23: BLE start - lvBootScreen.setProgress(0.90f, "BLE..."); + // Step 23: BLE stays disabled in default builds. + lvBootScreen.setProgress(0.90f, "Links ready"); // (LVGL boot renders via lv_timer_handler in setProgress) +#if HAS_BLE ui.lvStatusBar().setBLEEnabled(userConfig.settings().bleEnabled); if (userConfig.settings().bleEnabled) { bleInterface.setSideband(&bleSideband); @@ -675,6 +770,11 @@ void setup() { } else { Serial.println("[BLE] Disabled by config"); } +#else + ui.lvStatusBar().setBLEEnabled(false); + ui.lvStatusBar().setBLEActive(false); + Serial.println("[BLE] Disabled in default firmware build"); +#endif // Step 24: Power manager lvBootScreen.setProgress(0.92f, "Power manager..."); @@ -735,9 +835,18 @@ void setup() { lvHomeScreen.setRadioOnline(radioOnline); lvHomeScreen.setTCPClients(&tcpClients); lvHomeScreen.setAnnounceCallback([]() { - announceWithName(); + manualAnnounce(); Serial.println("[HOME] Announce triggered via Enter"); }); + lvHomeScreen.setAudioToggleCallback([]() { + userConfig.settings().audioEnabled = !userConfig.settings().audioEnabled; + audio.setEnabled(userConfig.settings().audioEnabled); + bool ok = userConfig.save(sdStore, flash); + ui.lvStatusBar().showToast(userConfig.settings().audioEnabled ? "Audio ON" : "Audio OFF", 1000); + Serial.printf("[AUDIO] Notifications %s (save %s)\n", + userConfig.settings().audioEnabled ? "ON" : "OFF", + ok ? "OK" : "FAILED"); + }); lvContactsScreen.setAnnounceManager(announceManager); lvContactsScreen.setUIManager(&ui); @@ -785,14 +894,14 @@ void setup() { lvSettingsScreen.setIdentityHash(rns.destinationHashStr()); lvSettingsScreen.setDestinationHash(rns.destinationHashHex()); lvSettingsScreen.setSaveCallback([]() -> bool { + inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed); 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(); + Serial.println("[TCP] Settings changed, scheduling reload..."); + requestTCPClientsReload(); }); #if HAS_GPS lvSettingsScreen.setGPSChangeCallback([](bool timeEnabled) { @@ -836,7 +945,11 @@ void setup() { ESP.restart(); } else { Serial.println("[BOOT] User chose to keep old data"); - ui.setScreen(&lvNameInputScreen); + userConfig.settings().sdStorageEnabled = true; + userConfig.save(sdStore, flash); + lvDataCleanScreen.showStatus("SD storage enabled. Rebooting..."); + delay(1500); + ESP.restart(); } }); @@ -846,9 +959,7 @@ void setup() { ui.setBootMode(false); ui.setScreen(&lvHomeScreen); ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); - announceWithName(); - lastAutoAnnounce = millis(); - Serial.println("[BOOT] Initial announce sent"); + Serial.println("[BOOT] Home ready; first auto announce after configured interval"); }; // Show timezone screen, then go home @@ -910,7 +1021,10 @@ void setup() { showTimezone(); }); - if (userConfig.settings().displayName.isEmpty()) { + if (sdHadExistingData && !userConfig.settings().sdStorageEnabled) { + ui.setScreen(&lvDataCleanScreen); + Serial.println("[BOOT] Existing SD data found; waiting for user choice"); + } else if (userConfig.settings().displayName.isEmpty()) { // First boot — go to name input ui.setScreen(&lvNameInputScreen); Serial.println("[BOOT] Showing name input screen"); @@ -938,7 +1052,7 @@ void setup() { keyboard.backlightOn(); } - Serial.println("[BOOT] RatDeck ready"); + Serial.println("[BOOT] Ratdeck ready"); Serial.printf("[BOOT] Summary: radio=%s flash=%s sd=%s\n", radioOnline ? "ONLINE" : "OFFLINE", flash.isReady() ? "OK" : "FAIL", @@ -973,27 +1087,33 @@ void loop() { if (lvHelpOverlay.isVisible()) { lvHelpOverlay.handleKey(evt); } - // Ctrl+hotkeys first - else if (!hotkeys.process(evt)) { - // Screen gets the key first + else { + // Screen-local input owns the keyboard. This keeps message and + // settings text entry from being preempted by global shortcuts. bool consumed = ui.handleKey(evt); + if (!consumed) { + bool hotkeyAllowed = !ui.isBootMode() || (evt.ctrl && evt.character == 'h'); + bool hotkeyConsumed = hotkeyAllowed && hotkeys.process(evt); + if (!hotkeyConsumed) { - // Feed to LVGL input system only if the screen didn't consume it - if (!consumed) LvInput::feedKey(evt); + // Feed to LVGL input system only if the screen didn't consume it + LvInput::feedKey(evt); - // Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume) - if (!consumed && !evt.ctrl) { - bool tabLeft = (evt.character == ',') || evt.left; - bool tabRight = (evt.character == '/') || evt.right; - if (tabLeft) { - ui.lvTabBar().cycleTab(-1); - int tab = ui.lvTabBar().getActiveTab(); - if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); - } - if (tabRight) { - ui.lvTabBar().cycleTab(1); - int tab = ui.lvTabBar().getActiveTab(); - if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); + // Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume) + if (!evt.ctrl && !ui.isBootMode()) { + bool tabLeft = (evt.character == ',') || evt.left; + bool tabRight = (evt.character == '/') || evt.right; + if (tabLeft) { + ui.lvTabBar().cycleTab(-1); + int tab = ui.lvTabBar().getActiveTab(); + if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); + } + if (tabRight) { + ui.lvTabBar().cycleTab(1); + int tab = ui.lvTabBar().getActiveTab(); + if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]); + } + } } } } @@ -1027,7 +1147,8 @@ void loop() { lv_timer_handler(); } - // 5. Auto-announce every 5-360 minutes (user configured) + // 5. Auto-announce every 30-360 minutes from boot. Manual announces do + // not reset this schedule. const unsigned long announceInterval = (unsigned long)userConfig.settings().announceInterval * 60000; // m -> ms if (bootComplete && millis() - lastAutoAnnounce >= announceInterval) { lastAutoAnnounce = millis(); @@ -1042,6 +1163,7 @@ void loop() { // 6. LXMF outgoing queue + announce manager deferred saves lxmf.loop(); if (announceManager) announceManager->loop(); + audio.loop(); // 7. WiFi STA connection handler if (wifiSTAStarted) { @@ -1060,9 +1182,6 @@ void loop() { // Recreate TCP clients on every WiFi connect (old clients may have stale sockets) reloadTCPClients(); - // Defer announce to let VPS register the connection (non-blocking) - wifiDeferredAnnounce = true; - wifiConnectedAt = millis(); // Arm AutoInterface deferred-start; SLAAC needs ~1.5–10s to assign // a link-local IPv6 address, so we don't start the interface here. // Trigger link-local creation AFTER association (calling @@ -1078,10 +1197,12 @@ void loop() { ui.lvStatusBar().setWiFiActive(false); ui.lvStatusBar().setTCPConnected(false); // Stop and deregister TCP clients cleanly - for (auto* tcp : tcpClients) tcp->stop(); for (auto& iface : tcpIfaces) { RNS::Transport::deregister_interface(iface); } + for (auto* tcp : tcpClients) { + retireTCPClient(tcp); + } tcpClients.clear(); tcpIfaces.clear(); Serial.println("[WIFI] STA disconnected, TCP interfaces deregistered"); @@ -1090,22 +1211,6 @@ void loop() { } } - // 7.5. Deferred WiFi announce (non-blocking replacement for delay(1500)) - if (wifiDeferredAnnounce && millis() - wifiConnectedAt >= 1500) { - wifiDeferredAnnounce = false; - bool anyTcpConnected = false; - for (auto* tcp : tcpClients) { - if (tcp->isConnected()) { anyTcpConnected = true; break; } - } - if (anyTcpConnected) { - Serial.println("[TCP] Sending announce over new TCP connection..."); - announceWithName(!powerMgr.isScreenOn()); - lastAutoAnnounce = millis(); - } else { - Serial.println("[TCP] No TCP clients connected, skipping announce"); - } - } - // 7.6. AutoInterface deferred start — fire once SLAAC assigns a link-local // IPv6 address. Arduino's IPv6Address::toString returns the expanded // form ("0000:0000:..." for unset; "fe80:0000:..." once SLAAC completes), @@ -1147,8 +1252,18 @@ void loop() { } } + // 7.8. Deferred TCP reload from Settings. Avoid tearing down/recreating + // Transport interfaces inside the LVGL key event path. + if (tcpReloadRequested) { + tcpReloadRequested = false; + Serial.println("[TCP] Applying deferred settings reload..."); + reloadTCPClients(); + if (announceManager) announceManager->clearTransientNodes(); + } + // 8. WiFi + TCP loops (with global budget) — skip only if RNS severely overloaded { + drainRetiredTCPClients(); bool skipTcp = (rnsDuration > 500); if (skipTcp) diagTcpSkipEvents++; if (!skipTcp && wifiImpl) wifiImpl->loop(); @@ -1168,8 +1283,10 @@ void loop() { } // 9. BLE loops +#if HAS_BLE bleInterface.loop(); bleSideband.loop(); +#endif // 9.5. GPS poll (non-blocking, reads available UART bytes) #if HAS_GPS diff --git a/src/reticulum/IdentityManager.cpp b/src/reticulum/IdentityManager.cpp index 13e5068..8caa58a 100644 --- a/src/reticulum/IdentityManager.cpp +++ b/src/reticulum/IdentityManager.cpp @@ -1,4 +1,5 @@ #include "IdentityManager.h" +#include "config/Config.h" #include #include #include @@ -11,28 +12,46 @@ bool IdentityManager::begin(FlashStore* flash, SDStore* sd) { // If no slots exist, import the current active identity as slot 0 if (_slots.empty()) { - if (_flash->exists("/identity/identity.key")) { + if (_flash->exists(PATH_IDENTITY)) { IdentitySlot slot; - slot.keyPath = "/identity/identity.key"; + slot.keyPath = slotKeyPath(0); slot.active = true; // Load the key to get its hash RNS::Bytes keyData; - if (RNS::Utilities::OS::read_file(slot.keyPath.c_str(), keyData) > 0) { + if (RNS::Utilities::OS::read_file(PATH_IDENTITY, keyData) > 0) { RNS::Identity id(false); if (id.load_private_key(keyData)) { slot.hash = id.hexhash(); } } - _slots.push_back(slot); - _activeIdx = 0; - saveSlotMeta(); + if (!slot.hash.empty() + && _flash->writeAtomic(slot.keyPath.c_str(), keyData.data(), keyData.size())) { + _slots.push_back(slot); + _activeIdx = 0; + saveSlotMeta(); + } } } else { - // Find active slot + int activeIdx = -1; for (int i = 0; i < (int)_slots.size(); i++) { - if (_slots[i].active) { _activeIdx = i; break; } + if (_slots[i].keyPath == PATH_IDENTITY) { + String migratedPath = slotKeyPath(i); + RNS::Bytes keyData; + if (RNS::Utilities::OS::read_file(PATH_IDENTITY, keyData) > 0) { + RNS::Identity id(false); + if (id.load_private_key(keyData) + && (_slots[i].hash.empty() || _slots[i].hash == id.hexhash()) + && _flash->writeAtomic(migratedPath.c_str(), keyData.data(), keyData.size())) { + _slots[i].keyPath = migratedPath; + if (_slots[i].hash.empty()) _slots[i].hash = id.hexhash(); + } + } + } + if (_slots[i].active && activeIdx < 0) activeIdx = i; } + _activeIdx = activeIdx; + saveSlotMeta(); } Serial.printf("[IDMGR] %d identities, active=%d\n", (int)_slots.size(), _activeIdx); @@ -56,8 +75,10 @@ int IdentityManager::createIdentity(const String& displayName) { int slotNum = (int)_slots.size(); String keyPath = slotKeyPath(slotNum); - // Save key file - _flash->writeAtomic(keyPath.c_str(), privKey.data(), privKey.size()); + if (!_flash->writeAtomic(keyPath.c_str(), privKey.data(), privKey.size())) { + Serial.println("[IDMGR] Failed to save new identity key"); + return -1; + } IdentitySlot slot; slot.hash = newId.hexhash(); @@ -105,6 +126,16 @@ bool IdentityManager::switchTo(int index, RNS::Identity& outIdentity) { Serial.printf("[IDMGR] Failed to load key for slot %d\n", index); return false; } + if (!_slots[index].hash.empty() && _slots[index].hash != id.hexhash()) { + Serial.printf("[IDMGR] Slot %d key hash mismatch; refusing switch\n", index); + return false; + } + + // Copy the active key to the standard internal identity path for ReticulumManager. + if (!_flash->writeAtomic(PATH_IDENTITY, keyData.data(), keyData.size())) { + Serial.printf("[IDMGR] Failed to write active identity for slot %d\n", index); + return false; + } // Mark old active as inactive if (_activeIdx >= 0 && _activeIdx < (int)_slots.size()) { @@ -114,13 +145,6 @@ bool IdentityManager::switchTo(int index, RNS::Identity& outIdentity) { _slots[index].active = true; _activeIdx = index; - // Copy the active key to the standard identity path for ReticulumManager - _flash->writeAtomic("/identity/identity.key", keyData.data(), keyData.size()); - if (_sd && _sd->isReady()) { - _sd->ensureDir("/ratdeck/identity"); - _sd->writeAtomic("/ratdeck/identity/identity.key", keyData.data(), keyData.size()); - } - outIdentity = id; saveSlotMeta(); Serial.printf("[IDMGR] Switched to identity %d: %s\n", index, _slots[index].hash.substr(0, 16).c_str()); diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index ed0f83d..9d266de 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -3,22 +3,64 @@ #include "config/Config.h" #include #include +#include LXMFManager* LXMFManager::_instance = nullptr; std::map LXMFManager::_pendingProofs; +namespace { +constexpr unsigned long LXMF_DISCOVERY_RETRY_INTERVAL_MS = 10000; +constexpr int LXMF_DISCOVERY_MAX_ATTEMPTS = 7; // immediate attempt + six 10s retries ~= 60s +constexpr unsigned long LXMF_LINK_ESTABLISH_TIMEOUT_MS = 30000; +constexpr uint8_t LXMF_LINK_MAX_WAIT_ATTEMPTS = 6; +} + bool LXMFManager::begin(ReticulumManager* rns, MessageStore* store) { _rns = rns; _store = store; _instance = this; RNS::Destination& dest = _rns->destination(); dest.set_packet_callback(onPacketReceived); dest.set_link_established_callback(onLinkEstablished); + if (_store) { + for (const auto& id : _store->loadRecentMessageIds(MAX_SEEN_IDS)) { + rememberMessageId(id); + } + std::vector pending = _store->loadPendingOutgoing(); + for (auto& msg : pending) { + if ((int)_outQueue.size() >= RATDECK_MAX_OUTQUEUE) break; + msg.lastRetryMs = 0; + _outQueue.push_back(msg); + } + if (!pending.empty()) { + Serial.printf("[LXMF] Restored %d pending outgoing messages\n", (int)_outQueue.size()); + } + } Serial.println("[LXMF] Manager started"); return true; } +void LXMFManager::rememberMessageId(const std::string& msgIdHex) { + if (msgIdHex.empty() || _seenMessageIds.count(msgIdHex)) return; + _seenMessageIds.insert(msgIdHex); + _seenMessageOrder.push_back(msgIdHex); + while ((int)_seenMessageOrder.size() > MAX_SEEN_IDS) { + _seenMessageIds.erase(_seenMessageOrder.front()); + _seenMessageOrder.pop_front(); + } +} + void LXMFManager::loop() { - if (_outQueue.empty()) return; unsigned long now = millis(); + for (auto it = _pendingProofs.begin(); it != _pendingProofs.end(); ) { + if ((now - it->second.createdMs) > 65000UL) { + std::string rh = it->first; + ++it; + handleProofTimeoutHash(rh); + } else { + ++it; + } + } + + if (_outQueue.empty()) return; int processed = 0; for (auto it = _outQueue.begin(); it != _outQueue.end(); ) { @@ -27,8 +69,13 @@ void LXMFManager::loop() { LXMFMessage& msg = *it; - // Per-message retry cooldown: 2 seconds between attempts - if (msg.retries > 0 && (millis() - msg.lastRetryMs) < 2000) { ++it; continue; } + // Keep unresolved peers from churning the UI loop or LoRa airtime. + // The first attempt is immediate; later path/identity retries happen + // every 10s and are capped in sendDirect(). + if (msg.retries > 0 && (millis() - msg.lastRetryMs) < LXMF_DISCOVERY_RETRY_INTERVAL_MS) { + ++it; + continue; + } msg.lastRetryMs = millis(); @@ -46,8 +93,9 @@ void LXMFManager::loop() { } if (_statusCb) { - _statusCb(peerHex, msg.timestamp, msg.status); + _statusCb(peerHex, msg.timestamp, msg.savedCounter, msg.status); } + clearDeliveryPreference(msg); it = _outQueue.erase(it); } else { // sendDirect returned false — message stays in queue, try next @@ -56,7 +104,8 @@ void LXMFManager::loop() { } } -bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& content, const std::string& title) { +bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& content, const std::string& title, + DeliveryPreference preference) { LXMFMessage msg; msg.sourceHash = _rns->destination().hash(); msg.destHash = destHash; @@ -71,21 +120,79 @@ bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& con msg.title = title; msg.incoming = false; msg.status = LXMFStatus::QUEUED; - if ((int)_outQueue.size() >= RATDECK_MAX_OUTQUEUE) { _outQueue.pop_front(); } + + if ((int)_outQueue.size() >= RATDECK_MAX_OUTQUEUE) { + Serial.println("[LXMF] Outbound queue full; refusing new message"); + return false; + } + + std::vector payload = msg.packFull(_rns->identity()); + if (payload.empty()) { + Serial.println("[LXMF] Message pack failed"); + return false; + } + if (payload.size() > RATDECK_LXMF_SINGLE_FRAME_MAX) { + msg.status = LXMFStatus::FAILED; + if (_store) _store->saveMessage(msg); + Serial.printf("[LXMF] Message too large for T-Deck safe path (%d > %d); resource transfer disabled\n", + (int)payload.size(), RATDECK_LXMF_SINGLE_FRAME_MAX); + return true; + } + if (preference == DeliveryPreference::Link) { + _linkRequiredIds.insert(msg.messageId.toHex()); + Serial.printf("[LXMF] Message %s queued for link delivery\n", + msg.messageId.toHex().substr(0, 8).c_str()); + } + _outQueue.push_back(msg); // Immediately save with QUEUED status so it appears in getMessages() right away // Save the queue copy so savedCounter propagates back to the queued message if (_store) { _store->saveMessage(_outQueue.back()); } - // Proactively request path so it's ready when sendDirect runs if (!RNS::Transport::has_path(destHash)) { - RNS::Transport::request_path(destHash); - Serial.printf("[LXMF] Message queued for %s — requesting path\n", + Serial.printf("[LXMF] Message queued for %s; path discovery will retry every 10s\n", destHash.toHex().substr(0, 8).c_str()); } return true; } +bool LXMFManager::sendMessageViaLink(const RNS::Bytes& destHash, const std::string& content, + const std::string& title) { + return sendMessage(destHash, content, title, DeliveryPreference::Link); +} + +void LXMFManager::clearDeliveryPreference(const LXMFMessage& msg) { + std::string msgId = msg.messageId.toHex(); + if (msgId.empty()) return; + _linkRequiredIds.erase(msgId); + _linkWaitAttempts.erase(msgId); +} + +bool LXMFManager::ensureOutboundLink(const RNS::Destination& dest, const RNS::Bytes& destHash, + const char* reason) { + if (_outLink && _outLinkDestHash == destHash + && _outLink.status() == RNS::Type::Link::ACTIVE) { + return true; + } + + unsigned long now = millis(); + bool samePending = _outLinkPending && _outLinkPendingHash == destHash; + bool pendingAlive = samePending && _outLink + && _outLink.status() != RNS::Type::Link::CLOSED + && (now - _outLinkPendingSinceMs) < LXMF_LINK_ESTABLISH_TIMEOUT_MS; + if (pendingAlive) return false; + + _outLinkPendingHash = destHash; + _outLinkDestHash = destHash; + _outLinkPending = true; + _outLinkPendingSinceMs = now; + Serial.printf("[LXMF] Establishing link to %s for %s\n", + destHash.toHex().substr(0, 8).c_str(), reason ? reason : "delivery"); + RNS::Link newLink(dest, onOutLinkEstablished, onOutLinkClosed); + _outLink = newLink; + return false; +} + bool LXMFManager::sendDirect(LXMFMessage& msg) { Serial.printf("[LXMF] sendDirect: dest=%s link=%s pending=%s\n", msg.destHash.toHex().substr(0, 12).c_str(), @@ -96,30 +203,34 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { // allow a new link attempt. Covers: link object destroyed (NONE), timed out, or failed. if (_outLinkPending) { bool linkReady = _outLink && _outLink.status() == RNS::Type::Link::ACTIVE; - if (!linkReady) { + bool linkClosed = !_outLink || _outLink.status() == RNS::Type::Link::CLOSED; + bool linkTimedOut = _outLinkPendingSinceMs > 0 + && (millis() - _outLinkPendingSinceMs) >= LXMF_LINK_ESTABLISH_TIMEOUT_MS; + if (linkReady) { + _outLinkPending = false; + } else if (linkClosed || linkTimedOut) { _outLinkPending = false; _outLink = {RNS::Type::NONE}; - Serial.println("[LXMF] Clearing stale link-pending (link not active)"); + _outLinkPendingSinceMs = 0; + Serial.println("[LXMF] Clearing stale link-pending"); } } RNS::Identity recipientId = RNS::Identity::recall(msg.destHash); if (!recipientId) { msg.retries++; - // Request path on first retry and every 10 retries - if (msg.retries == 1 || msg.retries % 10 == 0) { - Serial.printf("[LXMF] Requesting path for %s\n", - msg.destHash.toHex().substr(0, 8).c_str()); - RNS::Transport::request_path(msg.destHash); - } - if (msg.retries >= 30) { - Serial.printf("[LXMF] recall FAILED for %s after %d retries — marking FAILED\n", + Serial.printf("[LXMF] Requesting path/identity for %s (attempt %d/%d)\n", + msg.destHash.toHex().substr(0, 8).c_str(), + msg.retries, LXMF_DISCOVERY_MAX_ATTEMPTS); + RNS::Transport::request_path(msg.destHash); + if (msg.retries >= LXMF_DISCOVERY_MAX_ATTEMPTS) { + Serial.printf("[LXMF] No identity/path for %s after ~60s — marking FAILED\n", msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); msg.status = LXMFStatus::FAILED; return true; } - Serial.printf("[LXMF] recall pending for %s (retry %d/30) — identity not known yet\n", - msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); + Serial.printf("[LXMF] recall pending for %s — identity not known yet\n", + msg.destHash.toHex().substr(0, 8).c_str()); return false; // keep in queue, retry next loop } Serial.printf("[LXMF] recall OK: identity=%s\n", recipientId.hexhash().c_str()); @@ -128,13 +239,12 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { // Header1 which the Python hub silently drops if (!RNS::Transport::has_path(msg.destHash)) { msg.retries++; - if (msg.retries == 1 || msg.retries % 5 == 0) { - Serial.printf("[LXMF] No path for %s, requesting (retry %d)\n", - msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); - RNS::Transport::request_path(msg.destHash); - } - if (msg.retries >= 30) { - Serial.printf("[LXMF] No path for %s after %d retries — FAILED\n", + Serial.printf("[LXMF] No path for %s, requesting (attempt %d/%d)\n", + msg.destHash.toHex().substr(0, 8).c_str(), + msg.retries, LXMF_DISCOVERY_MAX_ATTEMPTS); + RNS::Transport::request_path(msg.destHash); + if (msg.retries >= LXMF_DISCOVERY_MAX_ATTEMPTS) { + Serial.printf("[LXMF] No path for %s after ~60s — FAILED\n", msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); msg.status = LXMFStatus::FAILED; return true; @@ -155,6 +265,14 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { // packFull returns opportunistic format: [src:16][sig:64][msgpack] std::vector payload = msg.packFull(_rns->identity()); if (payload.empty()) { Serial.println("[LXMF] packFull returned empty!"); msg.status = LXMFStatus::FAILED; return true; } + const std::string msgIdHex = msg.messageId.toHex(); + const bool requireLink = _linkRequiredIds.count(msgIdHex) > 0; + if (payload.size() > RATDECK_LXMF_SINGLE_FRAME_MAX) { + Serial.printf("[LXMF] Refusing resource-sized message (%d bytes); T-Deck safe cap is %d\n", + (int)payload.size(), RATDECK_LXMF_SINGLE_FRAME_MAX); + msg.status = LXMFStatus::FAILED; + return true; + } msg.status = LXMFStatus::SENDING; bool sent = false; @@ -174,7 +292,12 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { (int)linkBytes.size(), msg.destHash.toHex().substr(0, 8).c_str()); RNS::Packet packet(_outLink, linkBytes); RNS::PacketReceipt receipt = packet.send(); - if (receipt) { sent = true; registerProofTracking(receipt, msg); } + if (receipt) { + // microReticulum currently does not validate link-packet delivery + // proofs, so keep link sends at SENT until upstream proof callbacks + // are protocol-correct. + sent = true; + } } else { // Too large for single packet — use Resource transfer (chunked). // No DELIVERED transition for this path: microReticulum's Transport @@ -192,15 +315,30 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { } } - // Fallback: opportunistic or queue for link-based resource transfer + // Fallback: opportunistic, or wait for a link if the UI explicitly requested it. if (!sent) { RNS::Bytes payloadBytes(payload.data(), payload.size()); + if (requireLink) { + ensureOutboundLink(outDest, msg.destHash, "requested link send"); + uint8_t attempts = ++_linkWaitAttempts[msgIdHex]; + msg.retries++; + if (attempts >= LXMF_LINK_MAX_WAIT_ATTEMPTS) { + Serial.printf("[LXMF] Link for %s not established after %u attempts — FAILED\n", + msg.destHash.toHex().substr(0, 8).c_str(), (unsigned)attempts); + msg.status = LXMFStatus::FAILED; + return true; + } + Serial.printf("[LXMF] Waiting for link to %s before send (%u/%u)\n", + msg.destHash.toHex().substr(0, 8).c_str(), + (unsigned)attempts, (unsigned)LXMF_LINK_MAX_WAIT_ATTEMPTS); + return false; + } + // Use opportunistic only for packets that fit in a single LoRa frame (254 bytes). // Larger packets require split-frame over LoRa, which is unreliable — any single // frame loss (CRC error, collision, half-duplex timing) kills the entire transfer // with no recovery. Link-based delivery handles retransmission at the protocol level. - static constexpr size_t SINGLE_FRAME_MAX = 254; - if (payloadBytes.size() <= SINGLE_FRAME_MAX) { + if (payloadBytes.size() <= RATDECK_LXMF_SINGLE_FRAME_MAX) { // Fits in single LoRa frame — send opportunistic Serial.printf("[LXMF] sending opportunistic: %d bytes to %s\n", (int)payloadBytes.size(), outDest.hash().toHex().substr(0, 12).c_str()); @@ -210,14 +348,10 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { } else { // Too large for single frame — need link + resource transfer Serial.printf("[LXMF] Message needs link delivery (%d bytes > %d single-frame), retry %d\n", - (int)payloadBytes.size(), (int)SINGLE_FRAME_MAX, msg.retries); + (int)payloadBytes.size(), RATDECK_LXMF_SINGLE_FRAME_MAX, msg.retries); if (msg.retries % 3 == 0 && (!_outLink || _outLinkDestHash != msg.destHash || _outLink.status() != RNS::Type::Link::ACTIVE)) { - _outLinkPendingHash = msg.destHash; - _outLinkPending = true; - Serial.printf("[LXMF] Establishing link to %s for resource transfer\n", - msg.destHash.toHex().substr(0, 8).c_str()); - RNS::Link newLink(outDest, onOutLinkEstablished, onOutLinkClosed); + ensureOutboundLink(outDest, msg.destHash, "resource transfer"); } msg.retries++; if (msg.retries >= 30) { @@ -263,6 +397,7 @@ void LXMFManager::onOutLinkEstablished(RNS::Link& link) { _instance->_outLink = link; _instance->_outLinkDestHash = _instance->_outLinkPendingHash; _instance->_outLinkPending = false; + _instance->_outLinkPendingSinceMs = 0; Serial.printf("[LXMF] Outbound link established to %s\n", _instance->_outLinkDestHash.toHex().substr(0, 8).c_str()); } @@ -271,6 +406,7 @@ void LXMFManager::onOutLinkClosed(RNS::Link& link) { if (!_instance) return; _instance->_outLink = {RNS::Type::NONE}; _instance->_outLinkPending = false; + _instance->_outLinkPendingSinceMs = 0; Serial.println("[LXMF] Outbound link closed"); } @@ -310,16 +446,18 @@ void LXMFManager::processIncoming(const uint8_t* data, size_t len, const RNS::By msg.sourceHash.toHex().substr(0, 8).c_str()); return; } - _seenMessageIds.insert(msgIdHex); - if ((int)_seenMessageIds.size() > MAX_SEEN_IDS) { - _seenMessageIds.erase(_seenMessageIds.begin()); - } + rememberMessageId(msgIdHex); // Only overwrite destHash if caller provided a real one (non-link delivery). // For link delivery, unpackFull already parsed the correct destHash from the payload. if (destHash.size() > 0) { msg.destHash = destHash; } + if (_rns && msg.destHash != _rns->destination().hash()) { + Serial.printf("[LXMF] Dropping message for wrong destination %s\n", + msg.destHash.toHex().substr(0, 8).c_str()); + return; + } // Use local receive time for incoming messages so all timestamps in // the conversation reflect THIS device's clock, not the sender's. // Prevents confusing display when a peer's clock is wrong/unsynced. @@ -362,10 +500,30 @@ void LXMFManager::markRead(const std::string& peerHex) { if (_store) { _store->markConversationRead(peerHex); } } +bool LXMFManager::deleteConversation(const std::string& peerHex) { + for (auto it = _outQueue.begin(); it != _outQueue.end(); ) { + if (it->destHash.toHex() == peerHex) it = _outQueue.erase(it); + else ++it; + } + for (auto it = _pendingProofs.begin(); it != _pendingProofs.end(); ) { + if (it->second.peerHex == peerHex) it = _pendingProofs.erase(it); + else ++it; + } + return _store ? _store->deleteConversation(peerHex) : false; +} + void LXMFManager::registerProofTracking(RNS::PacketReceipt& receipt, const LXMFMessage& msg) { if (!receipt) return; std::string rh = receipt.hash().toHex(); - _pendingProofs[rh] = { msg.destHash.toHex(), msg.timestamp }; + PendingProof pending; + pending.peerHex = msg.destHash.toHex(); + pending.timestamp = msg.timestamp; + pending.savedCounter = msg.savedCounter; + pending.createdMs = millis(); + pending.proofAttempts = (uint8_t)std::min(msg.retries, 255); + pending.msg = msg; + pending.msg.status = LXMFStatus::SENT; + _pendingProofs[rh] = pending; receipt.set_delivery_callback(&LXMFManager::onProofDelivered); receipt.set_timeout(60); receipt.set_timeout_callback(&LXMFManager::onProofTimeout); @@ -380,19 +538,54 @@ void LXMFManager::onProofDelivered(const RNS::PacketReceipt& r) { _pendingProofs.erase(it); if (_instance->_store) { - _instance->_store->updateMessageStatus(p.peerHex, p.timestamp, false, LXMFStatus::DELIVERED); + if (p.savedCounter > 0) { + _instance->_store->updateMessageStatusByCounter(p.peerHex, p.savedCounter, false, LXMFStatus::DELIVERED); + } else { + _instance->_store->updateMessageStatus(p.peerHex, p.timestamp, false, LXMFStatus::DELIVERED); + } } if (_instance->_statusCb) { - _instance->_statusCb(p.peerHex, p.timestamp, LXMFStatus::DELIVERED); + _instance->_statusCb(p.peerHex, p.timestamp, p.savedCounter, LXMFStatus::DELIVERED); } Serial.printf("[LXMF] DELIVERED proof for %s @ %.0f\n", p.peerHex.substr(0, 12).c_str(), p.timestamp); } void LXMFManager::onProofTimeout(const RNS::PacketReceipt& r) { - // No proof within window — leave status as SENT (over LoRa, lack of - // proof doesn't mean undelivered). Just clear the pending entry so the - // map doesn't leak. - if (!_instance) return; - _pendingProofs.erase(r.hash().toHex()); + handleProofTimeoutHash(r.hash().toHex()); +} + +void LXMFManager::handleProofTimeoutHash(const std::string& receiptHash) { + if (!_instance) return; + auto it = _pendingProofs.find(receiptHash); + if (it == _pendingProofs.end()) return; + PendingProof p = it->second; + _pendingProofs.erase(it); + + p.proofAttempts++; + bool retry = p.proofAttempts < 3 && (int)_instance->_outQueue.size() < RATDECK_MAX_OUTQUEUE; + LXMFStatus nextStatus = retry ? LXMFStatus::QUEUED : LXMFStatus::FAILED; + + if (_instance->_store) { + if (p.savedCounter > 0) { + _instance->_store->updateMessageStatusByCounter(p.peerHex, p.savedCounter, false, nextStatus); + } else { + _instance->_store->updateMessageStatus(p.peerHex, p.timestamp, false, nextStatus); + } + } + if (_instance->_statusCb) { + _instance->_statusCb(p.peerHex, p.timestamp, p.savedCounter, nextStatus); + } + + if (retry) { + p.msg.status = LXMFStatus::QUEUED; + p.msg.retries = p.proofAttempts; + p.msg.lastRetryMs = 0; + _instance->_outQueue.push_back(p.msg); + Serial.printf("[LXMF] Proof timeout; requeued %s attempt %u/3\n", + p.peerHex.substr(0, 8).c_str(), (unsigned)p.proofAttempts + 1); + } else { + Serial.printf("[LXMF] Proof timeout; marking FAILED for %s\n", + p.peerHex.substr(0, 8).c_str()); + } } diff --git a/src/reticulum/LXMFManager.h b/src/reticulum/LXMFManager.h index 99b622e..52be37a 100644 --- a/src/reticulum/LXMFManager.h +++ b/src/reticulum/LXMFManager.h @@ -14,24 +14,36 @@ class LXMFManager { public: + enum class DeliveryPreference : uint8_t { + Opportunistic = 0, + Link = 1, + }; + using MessageCallback = std::function; - using StatusCallback = std::function; + using StatusCallback = std::function; void setStatusCallback(StatusCallback cb) { _statusCb = cb; } bool begin(ReticulumManager* rns, MessageStore* store); void loop(); - bool sendMessage(const RNS::Bytes& destHash, const std::string& content, const std::string& title = ""); + bool sendMessage(const RNS::Bytes& destHash, const std::string& content, const std::string& title = "", + DeliveryPreference preference = DeliveryPreference::Opportunistic); + bool sendMessageViaLink(const RNS::Bytes& destHash, const std::string& content, + const std::string& title = ""); void setMessageCallback(MessageCallback cb) { _onMessage = cb; } int queuedCount() const { return _outQueue.size(); } + uint32_t storeRevision() const { return _store ? _store->revision() : 0; } const std::vector& conversations() const; std::vector getMessages(const std::string& peerHex) const; int unreadCount(const std::string& peerHex = "") const; void markRead(const std::string& peerHex); + bool deleteConversation(const std::string& peerHex); const ConversationSummary* getConversationSummary(const std::string& peerHex) const; private: bool sendDirect(LXMFMessage& msg); + bool ensureOutboundLink(const RNS::Destination& dest, const RNS::Bytes& destHash, const char* reason); + void clearDeliveryPreference(const LXMFMessage& msg); void processIncoming(const uint8_t* data, size_t len, const RNS::Bytes& destHash); static void onPacketReceived(const RNS::Bytes& data, const RNS::Packet& packet); static void onLinkEstablished(RNS::Link& link); @@ -49,10 +61,15 @@ private: RNS::Bytes _outLinkDestHash; // Destination the ACTIVE _outLink is for RNS::Bytes _outLinkPendingHash; // Destination being connected to (not yet established) bool _outLinkPending = false; + unsigned long _outLinkPendingSinceMs = 0; + std::set _linkRequiredIds; + std::map _linkWaitAttempts; // Deduplication: recently seen message IDs std::set _seenMessageIds; + std::deque _seenMessageOrder; static constexpr int MAX_SEEN_IDS = 100; + void rememberMessageId(const std::string& msgIdHex); // Outstanding delivery proofs — keyed by receipt hash hex. The recipient // returns a PROOF packet over RNS for opportunistic and link single @@ -60,10 +77,15 @@ private: struct PendingProof { std::string peerHex; double timestamp; + uint32_t savedCounter = 0; + unsigned long createdMs = 0; + uint8_t proofAttempts = 0; + LXMFMessage msg; }; static std::map _pendingProofs; static void onProofDelivered(const RNS::PacketReceipt& r); static void onProofTimeout(const RNS::PacketReceipt& r); + static void handleProofTimeoutHash(const std::string& receiptHash); void registerProofTracking(RNS::PacketReceipt& receipt, const LXMFMessage& msg); static LXMFManager* _instance; diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 49ba7bf..30cfea2 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -40,6 +40,80 @@ size_t LittleFSFileSystem::write_file(const char* p, const RNS::Bytes& data) { // Without this, write_path_table always logs "serialize failed" because the // default stub returns NONE, and the path table never persists across reboots. namespace { +constexpr size_t RNS_COPY_CHUNK = 512; + +bool copySDToFlash(const char* sdPath, const char* flashPath) { + File in = SD.open(sdPath, FILE_READ); + if (!in) return false; + + String path = String(flashPath); + int lastSlash = path.lastIndexOf('/'); + if (lastSlash > 0) { + String dir = path.substring(0, lastSlash); + if (!LittleFS.exists(dir.c_str())) LittleFS.mkdir(dir.c_str()); + } + + File out = LittleFS.open(flashPath, "w"); + if (!out) { in.close(); return false; } + + uint8_t buf[RNS_COPY_CHUNK]; + bool ok = true; + while (in.available()) { + size_t n = in.read(buf, sizeof(buf)); + if (n == 0) break; + if (out.write(buf, n) != n) { ok = false; break; } + RNS::Utilities::OS::reset_watchdog(); + } + in.close(); + out.close(); + if (!ok) LittleFS.remove(flashPath); + return ok; +} + +bool copyFlashToSD(SDStore* sd, const char* flashPath, const char* sdPath) { + if (!sd || !sd->isReady()) return false; + File in = LittleFS.open(flashPath, "r"); + if (!in || in.size() == 0) { if (in) in.close(); return false; } + + String path = String(sdPath); + int lastSlash = path.lastIndexOf('/'); + if (lastSlash > 0) { + String dir = path.substring(0, lastSlash); + if (!sd->ensureDir(dir.c_str())) { in.close(); return false; } + } + + String tmpPath = path + ".tmp"; + String bakPath = path + ".bak"; + SD.remove(tmpPath.c_str()); + File out = SD.open(tmpPath.c_str(), FILE_WRITE); + if (!out) { in.close(); return false; } + + uint8_t buf[RNS_COPY_CHUNK]; + bool ok = true; + while (in.available()) { + size_t n = in.read(buf, sizeof(buf)); + if (n == 0) break; + if (out.write(buf, n) != n) { ok = false; break; } + RNS::Utilities::OS::reset_watchdog(); + } + in.close(); + out.close(); + + if (!ok) { SD.remove(tmpPath.c_str()); return false; } + if (SD.exists(sdPath)) { + SD.remove(bakPath.c_str()); + SD.rename(sdPath, bakPath.c_str()); + } + SD.remove(sdPath); + if (!SD.rename(tmpPath.c_str(), sdPath)) { + if (SD.exists(bakPath.c_str())) SD.rename(bakPath.c_str(), sdPath); + SD.remove(tmpPath.c_str()); + return false; + } + SD.remove(bakPath.c_str()); + return true; +} + class LittleFSStreamImpl : public RNS::FileStreamImpl { public: LittleFSStreamImpl(File&& f) : _f(std::move(f)) {} @@ -110,18 +184,23 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { RNS::Utilities::OS::register_filesystem(fs); Serial.println("[RNS] Filesystem registered"); - // Restore routing tables and known destinations from SD if missing on flash + // Endpoint mode learns paths live from announces/path requests. Upstream + // Reticulum only restores destination_table in transport mode, so do not + // resurrect stale endpoint path caches from SD or flash. + if (LittleFS.exists("/destination_table")) LittleFS.remove("/destination_table"); + if (LittleFS.exists("/packet_hashlist")) LittleFS.remove("/packet_hashlist"); + + // Restore known destination identities from SD if missing on flash. if (_sd && _sd->isReady()) { - static const char* files[] = {"/destination_table", "/packet_hashlist", "/known_destinations"}; + static const char* files[] = {"/known_destinations"}; for (const char* name : files) { if (!LittleFS.exists(name)) { char sdPath[64]; snprintf(sdPath, sizeof(sdPath), "/ratdeck/transport%s", name); - uint8_t buf[4096]; - size_t len = 0; - if (_sd->readFile(sdPath, buf, sizeof(buf), len) && len > 0) { - File f = LittleFS.open(name, "w"); - if (f) { f.write(buf, len); f.close(); } + if (copySDToFlash(sdPath, name)) { + File restored = LittleFS.open(name, "r"); + size_t len = restored ? restored.size() : 0; + if (restored) restored.close(); Serial.printf("[RNS] Restored %s from SD (%d bytes)\n", name, (int)len); } } @@ -158,32 +237,12 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { static unsigned int count = 0; if (now - windowStart >= 1000) { windowStart = now; count = 0; } + if (packet.context() == RNS::Type::Packet::PATH_RESPONSE) return true; + // Adaptive rate: tighter during first 60s boot flood, then normal unsigned int maxRate = (now < 60000) ? 3 : RATDECK_MAX_ANNOUNCES_PER_SEC; if (++count > maxRate) { ReticulumManager::_announceFilterCount++; return false; } - // Skip re-validation of known paths (saves ~100ms Ed25519 per announce) - // Allow through if better path discovered, or once per 5 min for name/ratchet updates. - // Uses raw hash bytes as key (avoids expensive toHex per packet). - if (RNS::Transport::has_path(packet.destination_hash())) { - // Always allow announces with a better (shorter) path through - uint8_t existingHops = RNS::Transport::hops_to(packet.destination_hash()); - if (packet.hops() < existingHops) return true; - - static std::unordered_map lastRevalidate; - std::string key((const char*)packet.destination_hash().data(), - packet.destination_hash().size()); - - auto it = lastRevalidate.find(key); - if (it != lastRevalidate.end() && (now - it->second) < 300000) { - ReticulumManager::_announceFilterCount++; - return false; - } - lastRevalidate[key] = now; - - if (lastRevalidate.size() > 300) lastRevalidate.clear(); - } - return true; }); @@ -225,22 +284,11 @@ bool ReticulumManager::loadOrCreateIdentity() { } } - // Tier 2: SD card if (_sd && _sd->isReady() && _sd->exists(SD_PATH_IDENTITY)) { - uint8_t keyBuf[128]; - size_t keyLen = 0; - if (_sd->readFile(SD_PATH_IDENTITY, keyBuf, sizeof(keyBuf), keyLen) && keyLen > 0) { - RNS::Bytes keyData(keyBuf, keyLen); - _identity = RNS::Identity(false); - if (_identity.load_private_key(keyData)) { - Serial.printf("[RNS] Identity restored from SD: %s\n", _identity.hexhash().c_str()); - saveIdentityToAll(keyData); - return true; - } - } + Serial.println("[RNS] SD identity backup present; automatic import disabled"); } - // Tier 3: NVS (ESP32 Preferences — always available) + // Tier 2: NVS (ESP32 Preferences — always available) { Preferences prefs; if (prefs.begin("ratdeck_id", true)) { @@ -276,11 +324,6 @@ bool ReticulumManager::loadOrCreateIdentity() { void ReticulumManager::saveIdentityToAll(const RNS::Bytes& keyData) { // Flash _flash->writeAtomic(PATH_IDENTITY, keyData.data(), keyData.size()); - // SD - if (_sd && _sd->isReady()) { - _sd->ensureDir("/ratdeck/identity"); - _sd->writeAtomic(SD_PATH_IDENTITY, keyData.data(), keyData.size()); - } // NVS (always available, survives flash/SD failures) Preferences prefs; if (prefs.begin("ratdeck_id", false)) { @@ -293,7 +336,6 @@ void ReticulumManager::saveIdentityToAll(const RNS::Bytes& keyData) { void ReticulumManager::loop() { if (!_transportActive) return; _reticulum.loop(); - if (_loraImpl) { _loraImpl->loop(); } unsigned long now = millis(); if (now - _lastPersist >= PATH_PERSIST_INTERVAL_MS) { _lastPersist = now; @@ -303,34 +345,27 @@ void ReticulumManager::loop() { // Synchronous persist — one cycle per call to spread I/O across intervals. // Runs on core 1 (main loop) to avoid data races with microReticulum's -// single-threaded transport state. +// single-threaded transport state. Ratdeck is an endpoint, not a transport +// node, so path tables are intentionally not persisted across boots. void ReticulumManager::persistData() { unsigned long start = millis(); switch (_persistCycle) { case 0: - RNS::Transport::persist_data(); - break; - case 1: RNS::Identity::persist_data(); break; - case 2: + case 1: if (_sd && _sd->isReady()) { - static const char* files[] = {"/destination_table", "/packet_hashlist", "/known_destinations"}; + static const char* files[] = {"/known_destinations"}; for (const char* name : files) { File f = LittleFS.open(name, "r"); if (f && f.size() > 0) { - size_t sz = f.size(); - uint8_t* buf = (uint8_t*)malloc(sz); - if (buf) { - f.readBytes((char*)buf, sz); - char sdPath[64]; - snprintf(sdPath, sizeof(sdPath), "/ratdeck/transport%s", name); - _sd->ensureDir("/ratdeck/transport"); - _sd->writeSimple(sdPath, buf, sz); - free(buf); - } + f.close(); + char sdPath[64]; + snprintf(sdPath, sizeof(sdPath), "/ratdeck/transport%s", name); + copyFlashToSD(_sd, name, sdPath); + } else { + if (f) f.close(); } - if (f) f.close(); } } break; @@ -340,7 +375,7 @@ void ReticulumManager::persistData() { if (dur > 500) { Serial.printf("[PERSIST] WARNING: Cycle %d blocked for %lums!\n", _persistCycle, dur); } - _persistCycle = (_persistCycle + 1) % 3; + _persistCycle = (_persistCycle + 1) % 2; } String ReticulumManager::identityHash() const { diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 0c19cee..fd8a310 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -71,6 +71,6 @@ private: bool _transportActive = false; unsigned long _lastPersist = 0; unsigned long _lastAnnounceTime = 0; - uint8_t _persistCycle = 0; // Rotating: 0=Transport, 1=Identity, 2=SD backup + uint8_t _persistCycle = 0; // Rotating: 0=known destinations, 1=SD backup static uint32_t _announceFilterCount; }; diff --git a/src/storage/FlashStore.cpp b/src/storage/FlashStore.cpp index b93dcac..7a4f392 100644 --- a/src/storage/FlashStore.cpp +++ b/src/storage/FlashStore.cpp @@ -6,7 +6,7 @@ bool FlashStore::begin() { const char* labels[] = { "littlefs", "spiffs" }; bool mounted = false; for (const char* label : labels) { - if (LittleFS.begin(true, "/littlefs", 10, label)) { + if (LittleFS.begin(false, "/littlefs", 10, label)) { Serial.printf("[FLASH] LittleFS mounted on partition '%s'\n", label); mounted = true; break; diff --git a/src/storage/MessageStore.cpp b/src/storage/MessageStore.cpp index ac96a29..3e71b6f 100644 --- a/src/storage/MessageStore.cpp +++ b/src/storage/MessageStore.cpp @@ -10,21 +10,54 @@ static bool isJsonFile(const char* name) { return len > 5 && strcmp(name + len - 5, ".json") == 0; } -bool MessageStore::begin(FlashStore* flash, SDStore* sd) { +static bool hasDirectionSuffix(const String& name, char suffix) { + int len = name.length(); + return len >= 7 && name[len - 6] == suffix; +} + +static bool isPendingStatus(LXMFStatus status) { + return status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING; +} + +static uint32_t counterFromFilename(const String& name) { + return (uint32_t)strtoul(name.c_str(), nullptr, 10); +} + +bool MessageStore::begin(FlashStore* flash, SDStore* sd, bool externalStorageEnabled) { _flash = flash; _sd = sd; + _externalStorageEnabled = externalStorageEnabled; + unsigned long beginMs = millis(); _flash->ensureDir(PATH_MESSAGES); - if (_sd && _sd->isReady()) { + unsigned long phaseMs = millis(); + if (_externalStorageEnabled && _sd && _sd->isReady()) { _sd->ensureDir("/ratdeck"); _sd->ensureDir("/ratdeck/messages"); migrateFlashToSD(); } + unsigned long migrateMs = millis() - phaseMs; + phaseMs = millis(); migrateTruncatedDirs(); + unsigned long truncMs = millis() - phaseMs; + + phaseMs = millis(); initReceiveCounter(); + unsigned long counterMs = millis() - phaseMs; + + phaseMs = millis(); refreshConversations(); + unsigned long refreshMs = millis() - phaseMs; + + phaseMs = millis(); buildSummaries(); + unsigned long summaryMs = millis() - phaseMs; + unsigned long totalMs = millis() - beginMs; + if (totalMs > 100 || summaryMs > 50) { + Serial.printf("[PERF] MSG begin: total=%lums migrate=%lums trunc=%lums counter=%lums refresh=%lums summaries=%lums\n", + totalMs, migrateMs, truncMs, counterMs, refreshMs, summaryMs); + } Serial.printf("[MSGSTORE] %d conversations found, receive counter=%lu\n", (int)_conversations.size(), (unsigned long)_nextReceiveCounter); return true; @@ -98,7 +131,7 @@ void MessageStore::initReceiveCounter() { }; // Scan SD conversations - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { File dir = _sd->openDir(SD_PATH_MESSAGES); if (dir && dir.isDirectory()) { File peerDir = dir.openNextFile(); @@ -203,7 +236,7 @@ void MessageStore::migrateTruncatedDirs() { ); // Migrate SD directories - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { migrateInDir( [this](const char* p) { return _sd->openDir(p); }, [](const char* a, const char* b) { return SD.rename(a, b); }, @@ -216,7 +249,7 @@ void MessageStore::migrateTruncatedDirs() { void MessageStore::refreshConversations() { _conversations.clear(); - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { File dir = _sd->openDir(SD_PATH_MESSAGES); if (dir && dir.isDirectory()) { File entry = dir.openNextFile(); @@ -248,6 +281,7 @@ void MessageStore::refreshConversations() { bool MessageStore::saveMessage(LXMFMessage& msg) { if (!_flash) return false; + unsigned long startMs = millis(); std::string peerHex = msg.incoming ? msg.sourceHash.toHex() : msg.destHash.toHex(); @@ -286,7 +320,7 @@ bool MessageStore::saveMessage(LXMFMessage& msg) { bool sdOk = false; bool flashOk = false; - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); _sd->ensureDir(sdDir.c_str()); String sdPath = sdDir + "/" + filename; @@ -297,6 +331,7 @@ bool MessageStore::saveMessage(LXMFMessage& msg) { _flash->ensureDir(flashDir.c_str()); String flashPath = flashDir + "/" + filename; flashOk = _flash->writeString(flashPath.c_str(), json); + bool saved = sdOk || flashOk; bool found = false; for (auto& c : _conversations) { @@ -318,16 +353,38 @@ bool MessageStore::saveMessage(LXMFMessage& msg) { s.lastPreview = prefix + content; s.totalCount++; if (msg.incoming && !msg.read) s.unreadCount++; + if (!msg.incoming) { + s.hasOutgoing = true; + s.lastOutgoingStatus = msg.status; + s.lastOutgoingCounter = counter; + if (isPendingStatus(msg.status)) s.hasPending = true; + if (msg.status == LXMFStatus::FAILED) s.hasFailed = true; + if (isPendingStatus(msg.status) && s.pendingCount < UINT16_MAX) s.pendingCount++; + if (msg.status == LXMFStatus::FAILED && s.failedCount < UINT16_MAX) s.failedCount++; + } + int limit = sdOk ? RATDECK_MAX_MESSAGES_PER_CONV + : ((_externalStorageEnabled && _sd && _sd->isReady()) ? FLASH_MSG_CACHE_LIMIT : RATDECK_MAX_MESSAGES_PER_CONV); + if (s.totalCount > limit) { + rebuildSummary(peerHex); + } } - return sdOk || flashOk; + if (saved) bumpRevision(); + unsigned long elapsed = millis() - startMs; + if (elapsed > 30) { + Serial.printf("[PERF] MSG save: %s dir=%c sd=%s flash=%s in %lums\n", + peerHex.substr(0, 8).c_str(), msg.incoming ? 'i' : 'o', + sdOk ? "ok" : "no", flashOk ? "ok" : "no", + (unsigned long)elapsed); + } + return saved; } std::vector MessageStore::loadConversation(const std::string& peerHex) const { std::vector messages; auto loadFromDir = [&](File& d, auto readFileFn) { - // Collect filenames first, then sort alphabetically (counter prefix → insertion order) + // Collect filenames first, then sort alphabetically (counter prefix = insertion order) std::vector filenames; File entry = d.openNextFile(); while (entry) { @@ -360,13 +417,19 @@ std::vector MessageStore::loadConversation(const std::string& peerH msg.incoming = doc["incoming"] | false; msg.status = (LXMFStatus)(doc["status"] | 0); msg.read = doc["read"] | false; + msg.savedCounter = counterFromFilename(fname); + std::string msgIdHex = doc["msgid"] | ""; + if (!msgIdHex.empty()) { + msg.messageId = RNS::Bytes(); + msg.messageId.assignHex(msgIdHex.c_str()); + } messages.push_back(msg); } } }; bool loadedFromSD = false; - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -397,8 +460,50 @@ std::vector MessageStore::loadConversation(const std::string& peerH return messages; } +std::vector MessageStore::loadPendingOutgoing() const { + std::vector pending; + for (const auto& peerHex : _conversations) { + std::vector messages = loadConversation(peerHex); + for (auto& msg : messages) { + if (msg.incoming) continue; + if (msg.status == LXMFStatus::QUEUED || msg.status == LXMFStatus::SENDING) { + msg.status = LXMFStatus::QUEUED; + pending.push_back(msg); + } + } + } + std::sort(pending.begin(), pending.end(), [](const LXMFMessage& a, const LXMFMessage& b) { + return a.savedCounter < b.savedCounter; + }); + return pending; +} + +std::vector MessageStore::loadRecentMessageIds(size_t maxIds) const { + std::vector> ordered; + for (const auto& peerHex : _conversations) { + std::vector messages = loadConversation(peerHex); + for (const auto& msg : messages) { + if (msg.messageId.size() == 0) continue; + ordered.push_back({msg.savedCounter, msg.messageId.toHex()}); + } + } + std::sort(ordered.begin(), ordered.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + if (ordered.size() > maxIds) { + ordered.erase(ordered.begin(), ordered.end() - maxIds); + } + + std::set seen; + std::vector ids; + ids.reserve(ordered.size()); + for (const auto& item : ordered) { + if (seen.insert(item.second).second) ids.push_back(item.second); + } + return ids; +} + int MessageStore::messageCount(const std::string& peerHex) const { - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -424,7 +529,7 @@ int MessageStore::messageCount(const std::string& peerHex) const { } bool MessageStore::deleteConversation(const std::string& peerHex) { - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); File d = _sd->openDir(sdDir.c_str()); if (d && d.isDirectory()) { @@ -456,10 +561,14 @@ bool MessageStore::deleteConversation(const std::string& peerHex) { std::remove(_conversations.begin(), _conversations.end(), peerHex), _conversations.end()); _summaries.erase(peerHex); + bumpRevision(); return true; } void MessageStore::markConversationRead(const std::string& peerHex) { + unsigned long startMs = millis(); + int scannedFiles = 0; + int rewrittenFiles = 0; auto markInDir = [&](auto openFn, auto writeFn, const String& dir) { // Collect only incoming (_i.json) filenames std::vector incomingFiles; @@ -469,9 +578,8 @@ void MessageStore::markConversationRead(const std::string& peerHex) { while (entry) { if (!entry.isDirectory() && isJsonFile(entry.name())) { String name = entry.name(); - int len = name.length(); // Check for _i.json suffix (incoming) - if (len >= 7 && name[len - 6] == 'i') { + if (hasDirectionSuffix(name, 'i')) { incomingFiles.push_back(name); } } @@ -493,6 +601,7 @@ void MessageStore::markConversationRead(const std::string& peerHex) { f.close(); } if (json.length() == 0) continue; + scannedFiles++; JsonDocument doc; if (deserializeJson(doc, json)) continue; @@ -502,10 +611,11 @@ void MessageStore::markConversationRead(const std::string& peerHex) { String updated; serializeJson(doc, updated); writeFn(path.c_str(), updated); + rewrittenFiles++; } }; - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); markInDir([&](const char* p) { return _sd->openDir(p); }, [&](const char* p, const String& d) { _sd->writeString(p, d); return true; }, @@ -519,11 +629,29 @@ void MessageStore::markConversationRead(const std::string& peerHex) { dir); } - _summaries[peerHex].unreadCount = 0; + bool changed = false; + auto it = _summaries.find(peerHex); + if (it != _summaries.end()) { + changed = it->second.unreadCount > 0; + it->second.unreadCount = 0; + } else { + rebuildSummary(peerHex); + changed = true; + } + if (changed) bumpRevision(); + unsigned long elapsed = millis() - startMs; + if (elapsed > 25) { + Serial.printf("[PERF] MSG markRead: %s scanned=%d wrote=%d in %lums\n", + peerHex.substr(0, 8).c_str(), scannedFiles, rewrittenFiles, + (unsigned long)elapsed); + } } bool MessageStore::updateMessageStatus(const std::string& peerHex, double timestamp, bool incoming, LXMFStatus newStatus) { char suffix = incoming ? 'i' : 'o'; + LXMFStatus oldStatus = LXMFStatus::DRAFT; + uint32_t updatedCounter = 0; + bool capturedOldStatus = false; auto updateInDir = [&](auto openFn, auto readFn, auto writeFn, const String& dir) -> bool { File d = openFn(dir.c_str()); @@ -535,8 +663,7 @@ bool MessageStore::updateMessageStatus(const std::string& peerHex, double timest while (entry) { if (!entry.isDirectory() && isJsonFile(entry.name())) { String name = entry.name(); - int len = name.length(); - if (len >= 7 && name[len - 6] == suffix) { + if (hasDirectionSuffix(name, suffix)) { candidates.push_back(name); } } @@ -556,6 +683,12 @@ bool MessageStore::updateMessageStatus(const std::string& peerHex, double timest double ts = doc["ts"] | 0.0; if (ts == timestamp) { + LXMFStatus before = (LXMFStatus)(doc["status"] | 0); + if (!capturedOldStatus) { + oldStatus = before; + updatedCounter = counterFromFilename(fname); + capturedOldStatus = true; + } doc["status"] = (int)newStatus; String updated; serializeJson(doc, updated); @@ -568,7 +701,7 @@ bool MessageStore::updateMessageStatus(const std::string& peerHex, double timest bool updated = false; - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); updated = updateInDir( [&](const char* p) { return _sd->openDir(p); }, @@ -587,6 +720,8 @@ bool MessageStore::updateMessageStatus(const std::string& peerHex, double timest updated = updated || flashUpdated; } + if (updated && !incoming) updateSummaryStatus(peerHex, updatedCounter, oldStatus, newStatus); + else if (updated) bumpRevision(); return updated; } @@ -596,12 +731,20 @@ bool MessageStore::updateMessageStatusByCounter(const std::string& peerHex, uint snprintf(filename, sizeof(filename), "%013lu_%c.json", (unsigned long)counter, incoming ? 'i' : 'o'); + LXMFStatus oldStatus = LXMFStatus::DRAFT; + bool capturedOldStatus = false; + auto readModifyWrite = [&](auto readFn, auto writeFn, const String& dir) -> bool { String path = dir + "/" + filename; String json = readFn(path.c_str()); if (json.length() == 0) return false; JsonDocument doc; if (deserializeJson(doc, json)) return false; + LXMFStatus before = (LXMFStatus)(doc["status"] | 0); + if (!capturedOldStatus) { + oldStatus = before; + capturedOldStatus = true; + } doc["status"] = (int)newStatus; String updated; serializeJson(doc, updated); @@ -610,7 +753,7 @@ bool MessageStore::updateMessageStatusByCounter(const std::string& peerHex, uint }; bool updated = false; - if (_sd && _sd->isReady()) { + if (_externalStorageEnabled && _sd && _sd->isReady()) { String sdDir = sdConversationDir(peerHex); updated = readModifyWrite( [&](const char* p) { return _sd->readString(p); }, @@ -625,102 +768,156 @@ bool MessageStore::updateMessageStatusByCounter(const std::string& peerHex, uint dir); updated = updated || flashUpdated; } + if (updated && !incoming) updateSummaryStatus(peerHex, counter, oldStatus, newStatus); + else if (updated) bumpRevision(); return updated; } +ConversationSummary MessageStore::buildSummaryForPeer(const std::string& peerHex) const { + unsigned long startMs = millis(); + ConversationSummary summary; + + std::vector files; + auto collectFiles = [&](File& d) { + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory() && isJsonFile(entry.name())) { + files.push_back(entry.name()); + } + entry = d.openNextFile(); + } + }; + + bool loadedFromSD = false; + if (_externalStorageEnabled && _sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + File d = _sd->openDir(sdDir.c_str()); + if (d && d.isDirectory()) { + collectFiles(d); + loadedFromSD = true; + } + } + if (!loadedFromSD && _flash) { + String dir = conversationDir(peerHex); + File d = LittleFS.open(dir); + if (d && d.isDirectory()) { + collectFiles(d); + } + } + + summary.totalCount = (int)files.size(); + if (files.empty()) return summary; + + std::sort(files.begin(), files.end()); + String basePath = loadedFromSD ? sdConversationDir(peerHex) : conversationDir(peerHex); + + auto readJsonFile = [&](const String& path) -> String { + if (loadedFromSD && _sd && _sd->isReady()) return _sd->readString(path.c_str()); + if (_flash) return _flash->readString(path.c_str()); + return String(""); + }; + + String lastPath = basePath + "/" + files.back(); + String json = readJsonFile(lastPath); + if (json.length() > 0) { + JsonDocument doc; + if (!deserializeJson(doc, json)) { + summary.lastTimestamp = doc["ts"] | 0.0; + std::string content = doc["content"] | ""; + summary.lastIncoming = doc["incoming"] | false; + std::string prefix = summary.lastIncoming ? "Them: " : "You: "; + if (content.size() > 15) content = content.substr(0, 15) + "..."; + summary.lastPreview = prefix + content; + } + } + + bool unreadDone = false; + for (int i = (int)files.size() - 1; i >= 0; i--) { + const String& fname = files[i]; + const bool incomingFile = hasDirectionSuffix(fname, 'i'); + const bool outgoingFile = hasDirectionSuffix(fname, 'o'); + const bool needIncoming = incomingFile && !unreadDone; + const bool needOutgoing = outgoingFile; + if (!needIncoming && !needOutgoing) continue; + + String fjson = readJsonFile(basePath + "/" + fname); + if (fjson.length() == 0) continue; + + JsonDocument fdoc; + if (deserializeJson(fdoc, fjson)) continue; + + if (needIncoming) { + bool isRead = fdoc["read"] | false; + if (isRead) unreadDone = true; + else summary.unreadCount++; + } + + if (needOutgoing) { + LXMFStatus status = (LXMFStatus)(fdoc["status"] | 0); + if (!summary.hasOutgoing) { + summary.hasOutgoing = true; + summary.lastOutgoingStatus = status; + summary.lastOutgoingCounter = counterFromFilename(fname); + } + if (isPendingStatus(status)) { + summary.hasPending = true; + if (summary.pendingCount < UINT16_MAX) summary.pendingCount++; + } + if (status == LXMFStatus::FAILED) { + summary.hasFailed = true; + if (summary.failedCount < UINT16_MAX) summary.failedCount++; + } + } + } + + unsigned long elapsed = millis() - startMs; + if (elapsed > 25) { + Serial.printf("[PERF] MSG summary: %s files=%d unread=%d pending=%u failed=%u in %lums\n", + peerHex.substr(0, 8).c_str(), (int)files.size(), summary.unreadCount, + (unsigned)summary.pendingCount, (unsigned)summary.failedCount, + (unsigned long)elapsed); + } + return summary; +} + +void MessageStore::rebuildSummary(const std::string& peerHex) { + _summaries[peerHex] = buildSummaryForPeer(peerHex); +} + +void MessageStore::updateSummaryStatus(const std::string& peerHex, uint32_t counter, + LXMFStatus oldStatus, LXMFStatus newStatus) { + if (oldStatus == newStatus) { + return; + } + + auto it = _summaries.find(peerHex); + if (it == _summaries.end() || counter == 0) { + rebuildSummary(peerHex); + bumpRevision(); + return; + } + + ConversationSummary& s = it->second; + if (isPendingStatus(oldStatus) && s.pendingCount > 0) s.pendingCount--; + if (oldStatus == LXMFStatus::FAILED && s.failedCount > 0) s.failedCount--; + if (isPendingStatus(newStatus) && s.pendingCount < UINT16_MAX) s.pendingCount++; + if (newStatus == LXMFStatus::FAILED && s.failedCount < UINT16_MAX) s.failedCount++; + + s.hasPending = s.pendingCount > 0; + s.hasFailed = s.failedCount > 0; + if (counter >= s.lastOutgoingCounter) { + s.hasOutgoing = true; + s.lastOutgoingCounter = counter; + s.lastOutgoingStatus = newStatus; + } + bumpRevision(); +} + void MessageStore::buildSummaries() { _summaries.clear(); - for (const auto& peerHex : _conversations) { - ConversationSummary summary; - - // Collect filenames from the conversation directory - std::vector files; - auto collectFiles = [&](File& d) { - File entry = d.openNextFile(); - while (entry) { - if (!entry.isDirectory() && isJsonFile(entry.name())) { - files.push_back(entry.name()); - } - entry = d.openNextFile(); - } - }; - - bool loadedFromSD = false; - if (_sd && _sd->isReady()) { - String sdDir = sdConversationDir(peerHex); - File d = _sd->openDir(sdDir.c_str()); - if (d && d.isDirectory()) { - collectFiles(d); - loadedFromSD = true; - } - } - if (!loadedFromSD && _flash) { - String dir = conversationDir(peerHex); - File d = LittleFS.open(dir); - if (d && d.isDirectory()) { - collectFiles(d); - } - } - - summary.totalCount = (int)files.size(); - - if (files.empty()) { - _summaries[peerHex] = summary; - continue; - } - - // Sort filenames — highest counter = most recent (last element) - std::sort(files.begin(), files.end()); - - // Read the last file for preview/timestamp - String lastFilename = files.back(); - String basePath = loadedFromSD ? sdConversationDir(peerHex) : conversationDir(peerHex); - String lastPath = basePath + "/" + lastFilename; - - auto readJsonFile = [&](const String& path) -> String { - if (loadedFromSD && _sd && _sd->isReady()) { - return _sd->readString(path.c_str()); - } else if (_flash) { - return _flash->readString(path.c_str()); - } - return String(""); - }; - - String json = readJsonFile(lastPath); - if (json.length() > 0) { - JsonDocument doc; - if (!deserializeJson(doc, json)) { - summary.lastTimestamp = doc["ts"] | 0.0; - std::string content = doc["content"] | ""; - summary.lastIncoming = doc["incoming"] | false; - std::string prefix = summary.lastIncoming ? "Them: " : "You: "; - if (content.size() > 15) content = content.substr(0, 15) + "..."; - summary.lastPreview = prefix + content; - } - } - - // Count unreads: scan _i.json files (incoming), newest first, stop at first already-read - int unread = 0; - for (int i = (int)files.size() - 1; i >= 0; i--) { - const String& fname = files[i]; - // Only check incoming files (suffix _i.json) - int flen = fname.length(); - if (flen < 7 || fname[flen - 6] != 'i') continue; // not _i.json - - String fpath = basePath + "/" + fname; - String fjson = readJsonFile(fpath); - if (fjson.length() > 0) { - JsonDocument fdoc; - if (!deserializeJson(fdoc, fjson)) { - bool isRead = fdoc["read"] | false; - if (isRead) break; // all older messages must be read too - unread++; - } - } - } - summary.unreadCount = unread; - _summaries[peerHex] = summary; + _summaries[peerHex] = buildSummaryForPeer(peerHex); + yield(); } Serial.printf("[MSGSTORE] Built summaries for %d conversations\n", (int)_summaries.size()); @@ -737,6 +934,11 @@ int MessageStore::totalUnreadCount() const { return total; } +void MessageStore::bumpRevision() { + _revision++; + if (_revision == 0) _revision = 1; +} + String MessageStore::conversationDir(const std::string& peerHex) const { return String(PATH_MESSAGES) + "/" + peerHex.c_str(); } @@ -763,7 +965,7 @@ void MessageStore::enforceFlashLimit(const std::string& peerHex) { } entry = d.openNextFile(); } - int limit = (_sd && _sd->isReady()) ? FLASH_MSG_CACHE_LIMIT : RATDECK_MAX_MESSAGES_PER_CONV; + int limit = (_externalStorageEnabled && _sd && _sd->isReady()) ? FLASH_MSG_CACHE_LIMIT : RATDECK_MAX_MESSAGES_PER_CONV; if ((int)files.size() <= limit) return; std::sort(files.begin(), files.end()); int excess = files.size() - limit; @@ -773,7 +975,7 @@ void MessageStore::enforceFlashLimit(const std::string& peerHex) { } void MessageStore::enforceSDLimit(const std::string& peerHex) { - if (!_sd || !_sd->isReady()) return; + if (!_externalStorageEnabled || !_sd || !_sd->isReady()) return; String dir = sdConversationDir(peerHex); std::vector files; File d = _sd->openDir(dir.c_str()); diff --git a/src/storage/MessageStore.h b/src/storage/MessageStore.h index bb91949..676bb21 100644 --- a/src/storage/MessageStore.h +++ b/src/storage/MessageStore.h @@ -7,6 +7,7 @@ #include #include #include +#include struct ConversationSummary { double lastTimestamp = 0; @@ -14,11 +15,20 @@ struct ConversationSummary { bool lastIncoming = false; int unreadCount = 0; int totalCount = 0; + bool hasOutgoing = false; + bool hasPending = false; + bool hasFailed = false; + LXMFStatus lastOutgoingStatus = LXMFStatus::DRAFT; + uint32_t lastOutgoingCounter = 0; + uint16_t pendingCount = 0; + uint16_t failedCount = 0; }; class MessageStore { public: - bool begin(FlashStore* flash, SDStore* sd = nullptr); + bool begin(FlashStore* flash, SDStore* sd = nullptr, bool externalStorageEnabled = false); + void setExternalStorageEnabled(bool enabled) { _externalStorageEnabled = enabled; } + bool externalStorageEnabled() const { return _externalStorageEnabled; } bool saveMessage(LXMFMessage& msg); std::vector loadConversation(const std::string& peerHex) const; @@ -26,12 +36,15 @@ public: void refreshConversations(); int messageCount(const std::string& peerHex) const; bool deleteConversation(const std::string& peerHex); + std::vector loadPendingOutgoing() const; + std::vector loadRecentMessageIds(size_t maxIds) const; void markConversationRead(const std::string& peerHex); bool updateMessageStatus(const std::string& peerHex, double timestamp, bool incoming, LXMFStatus newStatus); bool updateMessageStatusByCounter(const std::string& peerHex, uint32_t counter, bool incoming, LXMFStatus newStatus); const ConversationSummary* getSummary(const std::string& peerHex) const; int totalUnreadCount() const; + uint32_t revision() const { return _revision; } private: String conversationDir(const std::string& peerHex) const; @@ -42,10 +55,16 @@ private: void migrateTruncatedDirs(); void initReceiveCounter(); void buildSummaries(); + void rebuildSummary(const std::string& peerHex); + ConversationSummary buildSummaryForPeer(const std::string& peerHex) const; + void updateSummaryStatus(const std::string& peerHex, uint32_t counter, LXMFStatus oldStatus, LXMFStatus newStatus); + void bumpRevision(); FlashStore* _flash = nullptr; SDStore* _sd = nullptr; + bool _externalStorageEnabled = false; std::vector _conversations; std::map _summaries; uint32_t _nextReceiveCounter = 0; + uint32_t _revision = 0; }; diff --git a/src/storage/SDStore.cpp b/src/storage/SDStore.cpp index 28d5146..eb96432 100644 --- a/src/storage/SDStore.cpp +++ b/src/storage/SDStore.cpp @@ -38,9 +38,15 @@ bool SDStore::begin(SPIClass* spi, int csPin) { SD.end(); if (!SD.begin(csPin, *spi, 16000000)) { // 16MHz failed, fall back to 4MHz - SD.begin(csPin, *spi, 4000000); + if (!SD.begin(csPin, *spi, 4000000)) { + Serial.println("[SD] 16MHz failed and 4MHz fallback failed"); + _ready = false; + return false; + } + _ready = true; Serial.println("[SD] 16MHz failed, using 4MHz"); } else { + _ready = true; Serial.println("[SD] SPI speed: 16MHz"); } @@ -128,6 +134,7 @@ bool SDStore::writeAtomic(const char* path, const uint8_t* data, size_t len) { if (SD.exists(bakPath.c_str())) { SD.rename(bakPath.c_str(), path); } return false; } + SD.remove(bakPath.c_str()); return true; } @@ -201,8 +208,8 @@ void SDStore::wipeDir(const char* path) { bool SDStore::hasExistingData() { if (!_ready) return false; - if (SD.exists("/ratdeck/config.json")) return true; - if (SD.exists("/ratdeck/identity/identity")) return true; + if (SD.exists(SD_PATH_USER_CONFIG)) return true; + if (SD.exists(SD_PATH_IDENTITY)) return true; File dir = SD.open("/ratdeck/messages"); if (dir && dir.isDirectory()) { File entry = dir.openNextFile(); diff --git a/src/transport/AutoInterfaceWrapper.cpp b/src/transport/AutoInterfaceWrapper.cpp index 89bbc6c..54922bc 100644 --- a/src/transport/AutoInterfaceWrapper.cpp +++ b/src/transport/AutoInterfaceWrapper.cpp @@ -11,6 +11,8 @@ bool AutoInterfaceWrapper::start(const char* group_id, Serial.println("[AUTOIFACE] start: no link-local address"); return false; } + _groupId = group_id ? group_id : ""; + _maxPeers = max_peers; auto* impl = new RNS::AutoInterface( "AutoInterface", @@ -35,7 +37,9 @@ bool AutoInterfaceWrapper::start(const char* group_id, } RNS::Transport::register_interface(_wrapper); _started = true; - Serial.printf("[AUTOIFACE] online — mcast=%s ll=%s scope=%u\n", + _linkLocalAddr = link_local_addr; + _scopeId = scope_id; + Serial.printf("[AUTOIFACE] online - mcast=%s ll=%s scope=%u\n", _impl->multicast_address().c_str(), _impl->link_local_address().c_str(), (unsigned)scope_id); @@ -62,7 +66,14 @@ void AutoInterfaceWrapper::notifyLinkChange(const String& link_local_addr, uint32_t scope_id) { if (!_started || !_impl) return; if (link_local_addr.length() == 0) return; - _impl->notify_link_change(link_local_addr.c_str(), scope_id); + if (link_local_addr == _linkLocalAddr && scope_id == _scopeId) return; + + Serial.printf("[AUTOIFACE] link-local changed - restarting ll=%s scope=%u\n", + link_local_addr.c_str(), (unsigned)scope_id); + String group = _groupId; + uint8_t maxPeers = _maxPeers; + stop(); + start(group.c_str(), maxPeers, link_local_addr, scope_id); } bool AutoInterfaceWrapper::isOnline() const { diff --git a/src/transport/AutoInterfaceWrapper.h b/src/transport/AutoInterfaceWrapper.h index a3df174..860cd29 100644 --- a/src/transport/AutoInterfaceWrapper.h +++ b/src/transport/AutoInterfaceWrapper.h @@ -11,13 +11,13 @@ * (peer_count, multicast_address, online flag) for the UI / heartbeat. * * Use: - * 1. wifi STA connects → resolve link-local IPv6 + scope id + * 1. wifi STA connects -> resolve link-local IPv6 + scope id * 2. wrapper.start(group_id, max_peers, link_local, scope_id) * 3. wrapper.loop() each main-loop tick - * 4. periodically poll WiFi.localIPv6() and call notifyLinkChange() — + * 4. periodically poll WiFi.localIPv6() and call notifyLinkChange() - * catches SLAAC privacy-address rotation while STA stays associated; * idempotent at the library layer (no socket churn unless changed) - * 5. on STA disconnect → wrapper.stop() + * 5. on STA disconnect -> wrapper.stop() */ class AutoInterfaceWrapper { public: @@ -37,5 +37,9 @@ private: std::shared_ptr _impl; RNS::Interface _wrapper{RNS::Type::NONE}; std::string _empty_addr; // sentinel returned when not started + String _groupId; + String _linkLocalAddr; + uint32_t _scopeId = 0; + uint8_t _maxPeers = 0; bool _started = false; }; diff --git a/src/transport/BLEInterface.cpp b/src/transport/BLEInterface.cpp index 6fcd266..cababff 100644 --- a/src/transport/BLEInterface.cpp +++ b/src/transport/BLEInterface.cpp @@ -1,3 +1,8 @@ +#include +#include "config/Config.h" + +#if HAS_BLE + #include "BLEInterface.h" #include "BLESideband.h" @@ -92,7 +97,9 @@ void BLEInterface::processRxByte(uint8_t b) { if (_rxActive && !_rxFrame.empty()) { // Complete frame received — queue it (mutex: called from NimBLE task) if (_framesMutex && xSemaphoreTake(_framesMutex, pdMS_TO_TICKS(5)) == pdTRUE) { - _incomingFrames.push_back(std::move(_rxFrame)); + if (_incomingFrames.size() < MAX_INCOMING_FRAMES) { + _incomingFrames.push_back(std::move(_rxFrame)); + } xSemaphoreGive(_framesMutex); } _rxFrame.clear(); @@ -180,3 +187,5 @@ void BLEInterface::sendFrame(const uint8_t* data, size_t len) { _pTxChar->notify(frame.data() + offset, chunk); } } + +#endif diff --git a/src/transport/BLEInterface.h b/src/transport/BLEInterface.h index 7f69297..ad3c4cc 100644 --- a/src/transport/BLEInterface.h +++ b/src/transport/BLEInterface.h @@ -1,5 +1,9 @@ #pragma once +#include "config/Config.h" + +#if HAS_BLE + #include #include #include @@ -57,6 +61,7 @@ private: bool _connected = false; unsigned long _lastActivityMs = 0; static constexpr unsigned long BLE_IDLE_TIMEOUT_MS = 300000; // 5 minutes + static constexpr size_t MAX_INCOMING_FRAMES = 12; // HDLC rx state std::vector _rxFrame; @@ -79,3 +84,5 @@ private: static constexpr const char* TX_CHAR_UUID = "e2f0a5b2-c3d4-4e56-8f90-1a2b3c4d5e6f"; static constexpr const char* RX_CHAR_UUID = "e2f0a5b3-c3d4-4e56-8f90-1a2b3c4d5e6f"; }; + +#endif diff --git a/src/transport/BLESideband.cpp b/src/transport/BLESideband.cpp index 118faad..121634e 100644 --- a/src/transport/BLESideband.cpp +++ b/src/transport/BLESideband.cpp @@ -1,6 +1,12 @@ +#include +#include "config/Config.h" + +#if HAS_BLE + #include "BLESideband.h" bool BLESideband::begin(NimBLEServer* existingServer) { + if (!_packetsMutex) _packetsMutex = xSemaphoreCreateMutex(); if (existingServer) { _pServer = existingServer; _ownServer = false; @@ -76,7 +82,10 @@ void BLESideband::processRxByte(uint8_t b) { // First byte is KISS command; 0x00 = data frame if (_rxFrame[0] == KISS_CMD_DATA && _rxFrame.size() > 1) { std::vector pkt(_rxFrame.begin() + 1, _rxFrame.end()); - _incomingPackets.push_back(std::move(pkt)); + if (_packetsMutex && xSemaphoreTake(_packetsMutex, pdMS_TO_TICKS(5)) == pdTRUE) { + if (_incomingPackets.size() < 12) _incomingPackets.push_back(std::move(pkt)); + xSemaphoreGive(_packetsMutex); + } } _rxFrame.clear(); } @@ -104,10 +113,12 @@ void BLESideband::processRxByte(uint8_t b) { } void BLESideband::loop() { - while (!_incomingPackets.empty()) { - auto pkt = std::move(_incomingPackets.front()); - _incomingPackets.erase(_incomingPackets.begin()); - + std::vector> localPackets; + if (_packetsMutex && xSemaphoreTake(_packetsMutex, pdMS_TO_TICKS(2)) == pdTRUE) { + localPackets.swap(_incomingPackets); + xSemaphoreGive(_packetsMutex); + } + for (auto& pkt : localPackets) { if (_packetCb && !pkt.empty()) { _packetCb(pkt.data(), pkt.size()); } @@ -146,3 +157,5 @@ void BLESideband::sendPacket(const uint8_t* data, size_t len) { _pTxChar->notify(frame.data() + offset, chunk); } } + +#endif diff --git a/src/transport/BLESideband.h b/src/transport/BLESideband.h index a9c1475..10866bf 100644 --- a/src/transport/BLESideband.h +++ b/src/transport/BLESideband.h @@ -1,5 +1,9 @@ #pragma once +#include "config/Config.h" + +#if HAS_BLE + #include #include #include @@ -49,6 +53,7 @@ private: // Queued incoming packets std::vector> _incomingPackets; + SemaphoreHandle_t _packetsMutex = nullptr; PacketCallback _packetCb; // KISS constants @@ -63,3 +68,5 @@ private: static constexpr const char* NUS_RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; static constexpr const char* NUS_TX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; }; + +#endif diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index 30373a4..91247bb 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -1,6 +1,8 @@ #include "TCPClientInterface.h" #include "config/Config.h" +#include + TCPClientInterface::TCPClientInterface(const char* host, uint16_t port, const char* name) : RNS::InterfaceImpl(name), _host(host), _port(port) { @@ -18,6 +20,12 @@ TCPClientInterface::TCPClientInterface(const char* host, uint16_t port, const ch TCPClientInterface::~TCPClientInterface() { stop(); + if (_connectState == CS_CONNECTING) { + Serial.printf("[TCP] Waiting for connect task before destroy: %s:%d\n", + _host.c_str(), _port); + waitForConnectTask(); + } + if (_client.connected()) _client.stop(); if (_rxBuffer) { free(_rxBuffer); _rxBuffer = nullptr; } if (_txBuffer) { free(_txBuffer); _txBuffer = nullptr; } if (_wrapBuffer) { free(_wrapBuffer); _wrapBuffer = nullptr; } @@ -31,38 +39,80 @@ bool TCPClientInterface::start() { void TCPClientInterface::stop() { _online = false; + if (_connectState == CS_CONNECTING) { + if (WiFi.status() != WL_CONNECTED) { + Serial.printf("[TCP] Stop deferred while connect task exits for %s:%d\n", + _host.c_str(), _port); + return; + } + waitForConnectTask(); + } else { + _connectTask = nullptr; + } if (_client.connected()) { _client.stop(); Serial.printf("[TCP] Disconnected from %s:%d\n", _host.c_str(), _port); } } +void TCPClientInterface::waitForConnectTask() { + while (_connectState == CS_CONNECTING) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + _connectTask = nullptr; +} + +void TCPClientInterface::connectTaskFn(void* arg) { + auto* self = static_cast(arg); + bool ok = self->_client.connect(self->_host.c_str(), self->_port, TCP_CONNECT_TIMEOUT_MS); + if (ok && !self->_online) { + self->_client.stop(); + ok = false; + } + self->_connectState = ok ? CS_CONNECTED : CS_FAILED; + vTaskDelete(nullptr); +} + void TCPClientInterface::tryConnect() { + if (_connectState == CS_CONNECTING) return; _lastAttempt = millis(); - Serial.printf("[TCP] Connecting to %s:%d...\n", _host.c_str(), _port); - - if (_client.connect(_host.c_str(), _port, TCP_CONNECT_TIMEOUT_MS)) { - // Reset HDLC frame state and hub discovery for new connection - _inFrame = false; - _escaped = false; - _rxPos = 0; - _hubTransportIdKnown = false; - _pendingAnnounces.clear(); - _lastRxTime = millis(); - - // Set TCP write timeout to prevent blocking on half-open sockets - _client.setTimeout(5); // 5 second write timeout - _client.setNoDelay(true); // Disable Nagle — send immediately - - Serial.printf("[TCP] Connected to %s:%d\n", _host.c_str(), _port); - } else { - Serial.printf("[TCP] Failed to connect to %s:%d\n", _host.c_str(), _port); + _connectState = CS_CONNECTING; + Serial.printf("[TCP] Connecting to %s:%d (async)...\n", _host.c_str(), _port); + BaseType_t ok = xTaskCreate(connectTaskFn, "tcpconn", 4096, this, 1, &_connectTask); + if (ok != pdPASS) { + _connectState = CS_IDLE; + _connectTask = nullptr; + Serial.printf("[TCP] Failed to spawn connect task for %s:%d\n", _host.c_str(), _port); } } void TCPClientInterface::loop() { if (!_online) return; + if (_connectState == CS_CONNECTED) { + _connectState = CS_IDLE; + _connectTask = nullptr; + // Reset HDLC frame state and hub discovery for new connection + _inFrame = false; + _escaped = false; + _rxPos = 0; + _hubTransportIdKnown = false; + _lastRxTime = millis(); + + // Set TCP write timeout to prevent blocking on half-open sockets + _client.setTimeout(5); // 5 ms write/read timeout + _client.setNoDelay(true); // Disable Nagle — send immediately + + Serial.printf("[TCP] Connected to %s:%d\n", _host.c_str(), _port); + } else if (_connectState == CS_FAILED) { + _connectState = CS_IDLE; + _connectTask = nullptr; + Serial.printf("[TCP] Failed to connect to %s:%d\n", _host.c_str(), _port); + } + + // WiFiClient is not safe to use from the loop while connectTaskFn owns it. + if (_connectState == CS_CONNECTING) return; + // Auto-reconnect (only if WiFi is connected) if (!_client.connected()) { if (WiFi.status() != WL_CONNECTED) return; @@ -102,15 +152,6 @@ void TCPClientInterface::loop() { char hex[33]; for (int j = 0; j < 16; j++) sprintf(hex + j*2, "%02x", _hubTransportId[j]); Serial.printf("[TCP] Learned hub transport_id: %.8s\n", hex); - - // Flush any announces that were queued before hub ID was known - for (auto& pending : _pendingAnnounces) { - sendFrame(pending.data(), pending.size()); - InterfaceImpl::handle_outgoing(pending); - Serial.printf("[TCP] TX %d bytes (flushed pending announce) to %s:%d\n", - (int)pending.size(), _host.c_str(), _port); - } - _pendingAnnounces.clear(); } } @@ -129,29 +170,38 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { Serial.printf("[TCP] TX BLOCKED (offline) %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); return; } + if (_connectState == CS_CONNECTING) { + Serial.printf("[TCP] TX BLOCKED (connecting) %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); + return; + } if (!_client.connected()) { Serial.printf("[TCP] TX BLOCKED (disconnected) %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); return; } // Wrap Header1 non-announce packets as Header2 for TCP transport - // (mirrors Rust actor.rs:653-678 — hub drops raw Header1 data packets) + // (mirrors Rust actor.rs:653-678 — hub drops raw Header1 data packets). + // Link packets are the exception: LRPROOF/LRRTT/link DATA are addressed + // by link_id and must stay Header1 so the hub's link_table can route them + // back along the link request path. if (_hubTransportIdKnown && data.size() >= 19) { uint8_t flags = data.data()[0]; uint8_t header_type = (flags >> 6) & 0x01; + uint8_t destination_type = (flags >> 2) & 0x03; uint8_t packet_type = flags & 0x03; + bool link_packet = destination_type == 0x03; // Diagnostic: identify packet types going through TCP static const char* pt_names[] = {"DATA", "ANNOUNCE", "LINKREQ", "PROOF"}; - Serial.printf("[TCP-DIAG] send: %d bytes ht=%d pt=%s(%d) to %s:%d\n", - (int)data.size(), header_type, + Serial.printf("[TCP-DIAG] send: %d bytes ht=%d dt=%d pt=%s(%d) to %s:%d\n", + (int)data.size(), header_type, destination_type, (packet_type < 4) ? pt_names[packet_type] : "?", packet_type, _host.c_str(), _port); if (packet_type == 0x03) { Serial.printf("[TCP-DIAG] *** PROOF packet being sent via TCP! ***\n"); } - if (packet_type != 0x01 && packet_type != 0x03) { // Not ANNOUNCE, not PROOF + if (packet_type != 0x01 && !link_packet) { // Not ANNOUNCE or LINK traffic if (header_type == 0) { // Header1 → wrap as Header2 (handles hops==1, hops==0, unknown path) uint8_t new_flags = flags | 0x50; // Set Header2 (bit 6) + Transport (bit 4) @@ -191,18 +241,10 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { } } - // Queue announces until hub transport_id is learned if (!_hubTransportIdKnown) { - uint8_t flags = data.size() >= 1 ? data.data()[0] : 0; - uint8_t packet_type = flags & 0x03; - if (packet_type == 0x01 && _pendingAnnounces.size() < 3) { - _pendingAnnounces.push_back(data); - Serial.printf("[TCP] TX %d bytes (queued announce, hub ID pending) to %s:%d\n", - (int)data.size(), _host.c_str(), _port); - return; - } sendFrame(data.data(), data.size()); - Serial.printf("[TCP] TX %d bytes (no hub ID yet) to %s:%d\n", (int)data.size(), _host.c_str(), _port); + Serial.printf("[TCP] TX %d bytes (hub ID pending) to %s:%d\n", + (int)data.size(), _host.c_str(), _port); } else { // Passthrough: announces, correct Header2 sendFrame(data.data(), data.size()); diff --git a/src/transport/TCPClientInterface.h b/src/transport/TCPClientInterface.h index 5178dcd..660a30b 100644 --- a/src/transport/TCPClientInterface.h +++ b/src/transport/TCPClientInterface.h @@ -3,7 +3,8 @@ #include #include #include -#include +#include +#include class TCPClientInterface : public RNS::InterfaceImpl { public: @@ -18,7 +19,11 @@ public: return "TCPClient[" + _name + "]"; } - bool isConnected() { return _client.connected(); } + bool isConnected() { + if (_connectState == CS_CONNECTING) return false; + return _client.connected(); + } + bool canDestroy() const { return _connectState != CS_CONNECTING; } const String& host() const { return _host; } uint16_t port() const { return _port; } @@ -29,6 +34,19 @@ private: void tryConnect(); void sendFrame(const uint8_t* data, size_t len); int readFrame(); + static void connectTaskFn(void* arg); + void waitForConnectTask(); + + // WiFiClient::connect() blocks. Run it in a one-shot task and let the + // main loop claim the result before touching _client again. + enum ConnectState : uint8_t { + CS_IDLE = 0, + CS_CONNECTING = 1, + CS_CONNECTED = 2, + CS_FAILED = 3, + }; + volatile ConnectState _connectState = CS_IDLE; + TaskHandle_t _connectTask = nullptr; WiFiClient _client; String _host; @@ -49,9 +67,6 @@ private: unsigned long _hubRxCount = 0; unsigned long _txDropCount = 0; - // Pending announces: buffered until hub transport_id is learned - std::vector _pendingAnnounces; - // Persistent HDLC frame reassembly state (survives across loop() calls) bool _inFrame = false; bool _escaped = false; diff --git a/src/transport/WiFiInterface.cpp b/src/transport/WiFiInterface.cpp index b4850eb..0ed153e 100644 --- a/src/transport/WiFiInterface.cpp +++ b/src/transport/WiFiInterface.cpp @@ -66,6 +66,7 @@ void WiFiInterface::stop() { client.stop(); } _clients.clear(); + _clientFrames.clear(); _server.stop(); WiFi.softAPdisconnect(true); } @@ -85,7 +86,10 @@ void WiFiInterface::acceptClients() { Serial.printf("[WIFI] Client rejected (max %d reached)\n", MAX_AP_CLIENTS); return; } + newClient.setNoDelay(true); + newClient.setTimeout(5); _clients.push_back(newClient); + _clientFrames.push_back(FrameState{}); Serial.printf("[WIFI] Client connected (%d total)\n", (int)_clients.size()); } } @@ -95,11 +99,13 @@ void WiFiInterface::readClients() { if (!_clients[i].connected()) { _clients[i].stop(); _clients.erase(_clients.begin() + i); + if (i < (int)_clientFrames.size()) _clientFrames.erase(_clientFrames.begin() + i); Serial.printf("[WIFI] Client disconnected (%d total)\n", (int)_clients.size()); continue; } - int len = readFrame(_clients[i], _rxBuffer, sizeof(_rxBuffer)); + if (i >= (int)_clientFrames.size()) _clientFrames.resize(_clients.size()); + int len = readFrame(_clients[i], _clientFrames[i], _rxBuffer, sizeof(_rxBuffer)); if (len > 0) { RNS::Bytes data(_rxBuffer, len); Serial.printf("[WIFI] RX %d bytes from client\n", len); @@ -201,6 +207,12 @@ std::vector WiFiInterface::scanNetworks(int maxResult } void WiFiInterface::startAsyncScan() { + wifi_mode_t mode = WiFi.getMode(); + if (mode == WIFI_OFF) { + WiFi.mode(WIFI_STA); + } else if (mode == WIFI_AP) { + WiFi.mode(WIFI_AP_STA); + } WiFi.scanDelete(); WiFi.scanNetworks(true, false, false, 300, 0); // async=true Serial.println("[WIFI] Async scan started"); @@ -255,50 +267,47 @@ void WiFiInterface::sendFrame(WiFiClient& client, const uint8_t* data, size_t le } _txBuffer[pos++] = FRAME_START; client.write(_txBuffer, pos); - client.flush(); } -int WiFiInterface::readFrame(WiFiClient& client, uint8_t* buffer, size_t maxLen) { +int WiFiInterface::readFrame(WiFiClient& client, FrameState& state, uint8_t* buffer, size_t maxLen) { if (!client.available()) return 0; - bool inFrame = false; - bool escaped = false; - size_t pos = 0; - - // Tight drain loop: wait up to 10ms for complete frame (AP clients are on LAN) - unsigned long deadline = millis() + 10; - while (pos < maxLen) { - if (!client.available()) { - if (millis() >= deadline) break; - delay(1); - continue; - } - + while (client.available() && state.pos < maxLen) { uint8_t b = client.read(); if (b == FRAME_START) { - if (inFrame && pos > 0) { - return pos; // End of frame + if (state.inFrame && state.pos > 0) { + int len = state.pos; + state.pos = 0; + state.escaped = false; + return len; // End of frame } - inFrame = true; - pos = 0; + state.inFrame = true; + state.escaped = false; + state.pos = 0; continue; } - if (!inFrame) continue; + if (!state.inFrame) continue; if (b == FRAME_ESC) { - escaped = true; + state.escaped = true; continue; } - if (escaped) { - buffer[pos++] = b ^ FRAME_XOR; - escaped = false; + if (state.escaped) { + buffer[state.pos++] = b ^ FRAME_XOR; + state.escaped = false; } else { - buffer[pos++] = b; + buffer[state.pos++] = b; } } + if (state.pos >= maxLen) { + state.inFrame = false; + state.escaped = false; + state.pos = 0; + } + return 0; // Incomplete frame } diff --git a/src/transport/WiFiInterface.h b/src/transport/WiFiInterface.h index e3de2ac..ae9f526 100644 --- a/src/transport/WiFiInterface.h +++ b/src/transport/WiFiInterface.h @@ -54,7 +54,12 @@ private: // HDLC-like framing for TCP stream void sendFrame(WiFiClient& client, const uint8_t* data, size_t len); - int readFrame(WiFiClient& client, uint8_t* buffer, size_t maxLen); + struct FrameState { + bool inFrame = false; + bool escaped = false; + size_t pos = 0; + }; + int readFrame(WiFiClient& client, FrameState& state, uint8_t* buffer, size_t maxLen); String _apSSID; String _apPassword; @@ -64,6 +69,7 @@ private: WiFiServer _server; std::vector _clients; + std::vector _clientFrames; uint8_t _rxBuffer[600]; uint8_t* _txBuffer = nullptr; static constexpr size_t TX_BUFFER_SIZE = 1202; // worst-case: 600*2 + 2 delimiters diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp index 9c06b4c..228f0f8 100644 --- a/src/ui/LvStatusBar.cpp +++ b/src/ui/LvStatusBar.cpp @@ -1,67 +1,88 @@ #include "LvStatusBar.h" #include "Theme.h" +#include "LvTheme.h" #include +#include #include #include "fonts/fonts.h" +namespace { + +constexpr int kTimeW = 50; +constexpr int kBattW = 44; +constexpr int kLinksX = 56; +constexpr int kLinksW = Theme::SCREEN_W - kLinksX - kBattW - 8; + +int normalizedBattery(int pct) { + if (pct < 0) return -1; + if (pct > 100) return 100; + return pct; +} + +} // namespace + 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::DIVIDER), 0); - lv_obj_set_style_border_width(_bar, 1, 0); - lv_obj_set_style_border_side(_bar, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_pad_all(_bar, 0, 0); - lv_obj_set_style_radius(_bar, 0, 0); + lv_obj_add_style(_bar, LvTheme::styleStatusBar(), 0); lv_obj_clear_flag(_bar, LV_OBJ_FLAG_SCROLLABLE); - const lv_font_t* font = &lv_font_ratdeck_12; - - // Left: Time display (hidden until valid time) + // Left: time. Fixed width keeps the center indicators stable. _lblTime = lv_label_create(_bar); - lv_obj_set_style_text_font(_lblTime, font, 0); - lv_obj_set_style_text_color(_lblTime, lv_color_hex(Theme::TEXT_PRIMARY), 0); - lv_label_set_text(_lblTime, ""); + lv_obj_set_size(_lblTime, kTimeW, Theme::STATUS_BAR_H); + lv_label_set_long_mode(_lblTime, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_font(_lblTime, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_align(_lblTime, LV_TEXT_ALIGN_LEFT, 0); + lv_label_set_text(_lblTime, "--:--"); lv_obj_align(_lblTime, LV_ALIGN_LEFT_MID, 4, 0); - // Center: Brand - _lblBrand = lv_label_create(_bar); - lv_obj_set_style_text_font(_lblBrand, font, 0); - lv_obj_set_style_text_color(_lblBrand, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(_lblBrand, "Ratspeak.org"); - lv_obj_align(_lblBrand, LV_ALIGN_CENTER, 0, 0); + // Center: brand/status text. Transport states are shown on Home. + _lblLinks = lv_label_create(_bar); + lv_obj_set_size(_lblLinks, kLinksW, Theme::STATUS_BAR_H); + lv_label_set_long_mode(_lblLinks, LV_LABEL_LONG_CLIP); + lv_label_set_recolor(_lblLinks, true); + lv_obj_set_style_text_font(_lblLinks, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_align(_lblLinks, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(_lblLinks, LV_ALIGN_LEFT_MID, kLinksX, 0); - // Right: Battery % + // Right: battery percentage. _lblBatt = lv_label_create(_bar); + lv_obj_set_size(_lblBatt, kBattW, Theme::STATUS_BAR_H); lv_obj_set_style_text_font(_lblBatt, &lv_font_ratdeck_10, 0); - lv_label_set_text(_lblBatt, ""); + lv_obj_set_style_text_align(_lblBatt, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_long_mode(_lblBatt, LV_LABEL_LONG_CLIP); lv_obj_align(_lblBatt, LV_ALIGN_RIGHT_MID, -4, 0); - // Toast overlay (hidden by default) - _toast = lv_obj_create(parent); + // Toast overlay (hidden by default). It is a child of the bar so boot mode + // and other shell visibility changes affect it with the status bar. + _toast = lv_obj_create(_bar); 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::PRIMARY), 0); - lv_obj_set_style_bg_opa(_toast, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(_toast, 0, 0); - lv_obj_set_style_radius(_toast, 0, 0); + lv_obj_add_style(_toast, LvTheme::styleStatusToast(), 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_width(_lblToast, Theme::SCREEN_W - 12); + lv_label_set_long_mode(_lblToast, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_font(_lblToast, &lv_font_ratdeck_12, 0); lv_obj_set_style_text_color(_lblToast, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_text_align(_lblToast, LV_TEXT_ALIGN_CENTER, 0); lv_obj_center(_lblToast); lv_label_set_text(_lblToast, ""); + + refreshTimeColor(); + refreshIndicators(); + refreshBattery(); } void LvStatusBar::update() { if (_announceFlashEnd > 0 && millis() >= _announceFlashEnd) { _announceFlashEnd = 0; + refreshIndicators(); } - if (_toastEnd > 0 && millis() >= _toastEnd) { + if (_toast && _toastEnd > 0 && millis() >= _toastEnd) { _toastEnd = 0; lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN); } @@ -71,14 +92,22 @@ void LvStatusBar::updateTime() { if (!_lblTime) return; time_t now = time(nullptr); if (now <= 1700000000) { - lv_label_set_text(_lblTime, ""); - _lastHour = -1; _lastMinute = -1; + if (_lastHour != -2) { + lv_label_set_text(_lblTime, "--:--"); + _lastHour = -2; + _lastMinute = -2; + refreshTimeColor(); + } return; } struct tm* local = localtime(&now); if (!local) { - lv_label_set_text(_lblTime, ""); - _lastHour = -1; _lastMinute = -1; + if (_lastHour != -2) { + lv_label_set_text(_lblTime, "--:--"); + _lastHour = -2; + _lastMinute = -2; + refreshTimeColor(); + } return; } if (local->tm_hour == _lastHour && local->tm_min == _lastMinute) return; @@ -93,28 +122,127 @@ void LvStatusBar::updateTime() { snprintf(buf, sizeof(buf), "%d:%02d", h, local->tm_min); } lv_label_set_text(_lblTime, buf); + refreshTimeColor(); } -void LvStatusBar::setGPSFix(bool hasFix) { _gpsFix = hasFix; } +void LvStatusBar::setLoRaOnline(bool online) { + if (_loraOnline == online) return; + _loraOnline = online; + refreshIndicators(); +} + +void LvStatusBar::setBLEActive(bool active) { + if (_bleActive == active) return; + _bleActive = active; + refreshIndicators(); +} + +void LvStatusBar::setBLEEnabled(bool enabled) { + if (_bleEnabled == enabled) return; + _bleEnabled = enabled; + refreshIndicators(); +} + +void LvStatusBar::setWiFiActive(bool active) { + if (_wifiActive == active) return; + _wifiActive = active; + refreshIndicators(); +} + +void LvStatusBar::setWiFiEnabled(bool enabled) { + if (_wifiEnabled == enabled) return; + _wifiEnabled = enabled; + refreshIndicators(); +} + +void LvStatusBar::setTCPConnected(bool connected) { + if (_tcpConnected == connected) return; + _tcpConnected = connected; + refreshIndicators(); +} + +void LvStatusBar::setAutoIfacePeers(int n) { + if (n < -1) n = -1; + if (_autoIfacePeers == n) return; + _autoIfacePeers = n; + refreshIndicators(); +} + +void LvStatusBar::setGPSFix(bool hasFix) { + if (_gpsFix == hasFix) return; + _gpsFix = hasFix; + refreshIndicators(); + refreshTimeColor(); +} void LvStatusBar::setBatteryPercent(int pct) { + pct = normalizedBattery(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::TEXT_PRIMARY; - if (pct <= 15) col = Theme::ERROR_CLR; - else if (pct <= 30) col = Theme::WARNING_CLR; - lv_obj_set_style_text_color(_lblBatt, lv_color_hex(col), 0); - } + refreshBattery(); } -void LvStatusBar::flashAnnounce() { _announceFlashEnd = millis() + 1000; } +void LvStatusBar::flashAnnounce() { + _announceFlashEnd = millis() + 1000; + refreshIndicators(); +} void LvStatusBar::showToast(const char* msg, uint32_t durationMs) { + if (!_toast || !_lblToast) return; lv_label_set_text(_lblToast, msg); _toastEnd = millis() + durationMs; lv_obj_clear_flag(_toast, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_toast); +} + +void LvStatusBar::setUse24Hour(bool use24h) { + if (_use24h == use24h) return; + _use24h = use24h; + _lastHour = -1; + _lastMinute = -1; + updateTime(); +} + +void LvStatusBar::refreshIndicators() { + if (!_lblLinks) return; + + const bool txFlash = _announceFlashEnd > 0 && millis() < _announceFlashEnd; + const uint32_t brandColor = txFlash ? Theme::WARNING_CLR : Theme::ACCENT; + char buf[160]; + snprintf(buf, sizeof(buf), "#%06X Ratspeak.org#", (unsigned int)brandColor); + lv_label_set_text(_lblLinks, buf); + + if (_bar) { + lv_obj_set_style_bg_color(_bar, + lv_color_hex(txFlash ? Theme::STATUS_FLASH : Theme::STATUS_BG), 0); + lv_obj_set_style_border_color(_bar, + lv_color_hex(txFlash ? Theme::PRIMARY : Theme::DIVIDER), 0); + } +} + +void LvStatusBar::refreshBattery() { + if (!_lblBatt) return; + + char buf[8]; + uint32_t col = Theme::TEXT_MUTED; + if (_battPct < 0) { + snprintf(buf, sizeof(buf), "--%%"); + } else { + snprintf(buf, sizeof(buf), "%d%%", _battPct); + col = Theme::TEXT_PRIMARY; + if (_battPct <= 15) col = Theme::ERROR_CLR; + else if (_battPct <= 30) col = Theme::WARNING_CLR; + } + + lv_label_set_text(_lblBatt, buf); + lv_obj_set_style_text_color(_lblBatt, lv_color_hex(col), 0); +} + +void LvStatusBar::refreshTimeColor() { + if (!_lblTime) return; + const bool validTime = _lastHour >= 0; + const uint32_t col = validTime + ? (_gpsFix ? Theme::ACCENT : Theme::TEXT_PRIMARY) + : Theme::TEXT_MUTED; + lv_obj_set_style_text_color(_lblTime, lv_color_hex(col), 0); } diff --git a/src/ui/LvStatusBar.h b/src/ui/LvStatusBar.h index 4d59c35..91cd6d7 100644 --- a/src/ui/LvStatusBar.h +++ b/src/ui/LvStatusBar.h @@ -9,29 +9,33 @@ public: void update(); // Status setters - void setLoRaOnline(bool online) { _loraOnline = online; } - void setBLEActive(bool active) { _bleActive = active; } - void setBLEEnabled(bool enabled) { _bleEnabled = enabled; } - void setWiFiActive(bool active) { _wifiActive = active; } - void setWiFiEnabled(bool enabled) { _wifiEnabled = enabled; } - void setTCPConnected(bool connected) { _tcpConnected = connected; } - // -1 = disabled (hide); 0 = yellow (idle); >0 = green (peers) - void setAutoIfacePeers(int n) { _autoIfacePeers = n; } + void setLoRaOnline(bool online); + void setBLEActive(bool active); + void setBLEEnabled(bool enabled); + void setWiFiActive(bool active); + void setWiFiEnabled(bool enabled); + void setTCPConnected(bool connected); + // -1 = disabled/muted; 0 = yellow (idle); >0 = green (peers) + void setAutoIfacePeers(int n); void setGPSFix(bool hasFix); void setBatteryPercent(int pct); void flashAnnounce(); void showToast(const char* msg, uint32_t durationMs = 1500); // Time display - void setUse24Hour(bool use24h) { _use24h = use24h; } + void setUse24Hour(bool use24h); void updateTime(); // Call at 1 Hz to refresh clock lv_obj_t* obj() { return _bar; } private: + void refreshIndicators(); + void refreshBattery(); + void refreshTimeColor(); + lv_obj_t* _bar = nullptr; lv_obj_t* _lblTime = nullptr; // Left: current time - lv_obj_t* _lblBrand = nullptr; // Center: "Ratspeak.org" + lv_obj_t* _lblLinks = nullptr; // Center: brand/status text lv_obj_t* _lblBatt = nullptr; // Right: battery % lv_obj_t* _toast = nullptr; lv_obj_t* _lblToast = nullptr; diff --git a/src/ui/LvTabBar.cpp b/src/ui/LvTabBar.cpp index 8862ebe..5f7afd7 100644 --- a/src/ui/LvTabBar.cpp +++ b/src/ui/LvTabBar.cpp @@ -6,13 +6,20 @@ constexpr const char* LvTabBar::TAB_NAMES[TAB_COUNT]; +static lv_obj_t* tab_cell_from_target(LvTabBar* bar, lv_obj_t* target) { + while (target && target != bar->obj()) { + lv_obj_t* parent = lv_obj_get_parent(target); + if (parent == bar->obj()) return target; + target = parent; + } + return nullptr; +} + 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); + lv_obj_t* target = tab_cell_from_target(bar, 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))) { + if (target == lv_obj_get_child(bar->obj(), i)) { bar->setActiveTab(i); break; } @@ -23,27 +30,42 @@ 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::DIVIDER), 0); - lv_obj_set_style_border_width(_bar, 1, 0); - lv_obj_set_style_border_side(_bar, LV_BORDER_SIDE_TOP, 0); - lv_obj_set_style_pad_all(_bar, 0, 0); - lv_obj_set_style_radius(_bar, 0, 0); + lv_obj_add_style(_bar, LvTheme::styleTabBar(), 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_ratdeck_12; + lv_obj_set_flex_align(_bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(_bar, 0, 0); for (int i = 0; i < TAB_COUNT; i++) { - _tabs[i] = lv_label_create(_bar); - lv_obj_set_style_text_font(_tabs[i], font, 0); - lv_label_set_recolor(_tabs[i], true); - lv_label_set_text(_tabs[i], TAB_NAMES[i]); - lv_obj_add_flag(_tabs[i], LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(_tabs[i], tab_click_cb, LV_EVENT_CLICKED, this); + _cells[i] = lv_obj_create(_bar); + lv_obj_set_size(_cells[i], Theme::TAB_W, Theme::TAB_BAR_H); + lv_obj_add_style(_cells[i], LvTheme::styleTabCell(), 0); + lv_obj_add_style(_cells[i], LvTheme::styleTabCellActive(), LV_STATE_CHECKED); + lv_obj_clear_flag(_cells[i], LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_cells[i], LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(_cells[i], tab_click_cb, LV_EVENT_CLICKED, this); + + _labels[i] = lv_label_create(_cells[i]); + lv_obj_set_size(_labels[i], Theme::TAB_W - 4, 14); + lv_label_set_long_mode(_labels[i], LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_font(_labels[i], &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_align(_labels[i], LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(_labels[i], TAB_NAMES[i]); + lv_obj_align(_labels[i], LV_ALIGN_CENTER, 0, 2); + + _badges[i] = lv_obj_create(_cells[i]); + lv_obj_set_size(_badges[i], Theme::TAB_BADGE_W, Theme::TAB_BADGE_H); + lv_obj_add_style(_badges[i], LvTheme::styleBadge(), 0); + lv_obj_clear_flag(_badges[i], LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_badges[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_align(_badges[i], LV_ALIGN_TOP_RIGHT, -3, 2); + + _badgeLabels[i] = lv_label_create(_badges[i]); + lv_label_set_long_mode(_badgeLabels[i], LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_font(_badgeLabels[i], &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_align(_badgeLabels[i], LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(_badgeLabels[i]); } refreshTabs(); @@ -63,26 +85,31 @@ void LvTabBar::cycleTab(int direction) { void LvTabBar::setUnreadCount(int tab, int count) { if (tab < 0 || tab >= TAB_COUNT) return; + if (count < 0) count = 0; if (_unread[tab] == count) return; // No change _unread[tab] = count; refreshTab(tab); } void LvTabBar::refreshTab(int idx) { - if (idx < 0 || idx >= TAB_COUNT || !_tabs[idx]) return; + if (idx < 0 || idx >= TAB_COUNT || !_cells[idx] || !_labels[idx]) return; bool active = (idx == _activeTab); - lv_obj_set_style_text_color(_tabs[idx], - lv_color_hex(active ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE), 0); + if (active) lv_obj_add_state(_cells[idx], LV_STATE_CHECKED); + else lv_obj_clear_state(_cells[idx], LV_STATE_CHECKED); - char buf[48]; - if (_unread[idx] > 0) { - // Unread count in badge color using LVGL recolor - snprintf(buf, sizeof(buf), "%s #%06X %d#", - TAB_NAMES[idx], (unsigned int)Theme::BADGE_BG, _unread[idx]); + lv_obj_set_style_text_color(_labels[idx], + lv_color_hex(active ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE), 0); + lv_label_set_text(_labels[idx], TAB_NAMES[idx]); + + if (_badges[idx] && _badgeLabels[idx] && _unread[idx] > 0) { + char buf[4]; + if (_unread[idx] > 9) snprintf(buf, sizeof(buf), "9+"); + else snprintf(buf, sizeof(buf), "%d", _unread[idx]); + lv_label_set_text(_badgeLabels[idx], buf); + lv_obj_clear_flag(_badges[idx], LV_OBJ_FLAG_HIDDEN); } else { - snprintf(buf, sizeof(buf), "%s", TAB_NAMES[idx]); + if (_badges[idx]) lv_obj_add_flag(_badges[idx], LV_OBJ_FLAG_HIDDEN); } - lv_label_set_text(_tabs[idx], buf); } void LvTabBar::refreshTabs() { diff --git a/src/ui/LvTabBar.h b/src/ui/LvTabBar.h index d969913..bb8a014 100644 --- a/src/ui/LvTabBar.h +++ b/src/ui/LvTabBar.h @@ -4,7 +4,17 @@ class LvTabBar { public: - enum Tab { TAB_HOME = 0, TAB_CONTACTS, TAB_MSGS, TAB_NODES, TAB_SETTINGS, TAB_COUNT = 5 }; + enum Tab { + TAB_HOME = 0, + TAB_MSGS = 1, + TAB_CONTACTS = 2, + TAB_NODES = 3, + TAB_SETTINGS = 4, + TAB_CHATS = TAB_MSGS, + TAB_PEERS = TAB_NODES, + TAB_SETUP = TAB_SETTINGS, + TAB_COUNT = 5 + }; void create(lv_obj_t* parent); @@ -24,10 +34,13 @@ private: void refreshTab(int idx); lv_obj_t* _bar = nullptr; - lv_obj_t* _tabs[TAB_COUNT] = {}; + lv_obj_t* _cells[TAB_COUNT] = {}; + lv_obj_t* _labels[TAB_COUNT] = {}; + lv_obj_t* _badges[TAB_COUNT] = {}; + lv_obj_t* _badgeLabels[TAB_COUNT] = {}; int _activeTab = TAB_HOME; int _unread[TAB_COUNT] = {}; TabCallback _tabCb = nullptr; - static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Friends", "Msgs", "Peers", "Settings"}; + static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Chats", "Contacts", "Peers", "Settings"}; }; diff --git a/src/ui/LvTheme.cpp b/src/ui/LvTheme.cpp index fb55bd7..0f4c893 100644 --- a/src/ui/LvTheme.cpp +++ b/src/ui/LvTheme.cpp @@ -30,10 +30,15 @@ static lv_style_t s_sectionHeader; static lv_style_t s_modal; static lv_style_t s_scrollbar; static lv_style_t s_roller; +static lv_style_t s_statusBar; +static lv_style_t s_statusToast; +static lv_style_t s_tabBar; +static lv_style_t s_tabCell; +static lv_style_t s_tabCellActive; +static lv_style_t s_badge; void init(lv_disp_t* disp) { - // Screen background (LV_USE_THEME_DEFAULT is disabled in lv_conf.h, - // so there's no default blue focus outline to worry about) + // Screen background (LV_USE_THEME_DEFAULT is disabled in lv_conf.h). 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); @@ -43,12 +48,15 @@ void init(lv_disp_t* disp) { // Labels lv_style_init(&s_label); lv_style_set_text_color(&s_label, lv_color_hex(Theme::TEXT_PRIMARY)); + lv_style_set_text_font(&s_label, &lv_font_ratdeck_14); lv_style_init(&s_labelMuted); lv_style_set_text_color(&s_labelMuted, lv_color_hex(Theme::TEXT_SECONDARY)); + lv_style_set_text_font(&s_labelMuted, &lv_font_ratdeck_12); lv_style_init(&s_labelAccent); lv_style_set_text_color(&s_labelAccent, lv_color_hex(Theme::ACCENT)); + lv_style_set_text_font(&s_labelAccent, &lv_font_ratdeck_14); // Buttons lv_style_init(&s_btn); @@ -57,8 +65,12 @@ void init(lv_disp_t* disp) { 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::TEXT_PRIMARY)); - lv_style_set_radius(&s_btn, 4); - lv_style_set_pad_all(&s_btn, 8); + lv_style_set_text_font(&s_btn, &lv_font_ratdeck_12); + lv_style_set_radius(&s_btn, 3); + lv_style_set_pad_top(&s_btn, 5); + lv_style_set_pad_bottom(&s_btn, 5); + lv_style_set_pad_left(&s_btn, 8); + lv_style_set_pad_right(&s_btn, 8); lv_style_init(&s_btnPressed); lv_style_set_bg_color(&s_btnPressed, lv_color_hex(Theme::PRIMARY_SUBTLE)); @@ -69,17 +81,20 @@ void init(lv_disp_t* disp) { lv_style_init(&s_btnFocused); lv_style_set_border_color(&s_btnFocused, lv_color_hex(Theme::PRIMARY)); lv_style_set_border_width(&s_btnFocused, 2); + lv_style_set_outline_color(&s_btnFocused, lv_color_hex(Theme::ACCENT)); + lv_style_set_outline_width(&s_btnFocused, 1); + lv_style_set_outline_pad(&s_btnFocused, 0); // 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, 3); + 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, 3); + lv_style_set_radius(&s_barIndicator, 2); // Switch (LV_USE_SWITCH currently 0; kept for future lv_menu rewrite) lv_style_init(&s_switch); @@ -97,7 +112,8 @@ void init(lv_disp_t* disp) { 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::TEXT_PRIMARY)); - lv_style_set_radius(&s_textarea, 4); + lv_style_set_text_font(&s_textarea, &lv_font_ratdeck_14); + lv_style_set_radius(&s_textarea, 3); lv_style_set_pad_all(&s_textarea, 6); lv_style_init(&s_textareaFocused); @@ -111,26 +127,30 @@ void init(lv_disp_t* disp) { 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_set_radius(&s_list, 0); // List items lv_style_init(&s_listBtn); lv_style_set_bg_color(&s_listBtn, lv_color_hex(Theme::BG)); lv_style_set_bg_opa(&s_listBtn, LV_OPA_COVER); lv_style_set_text_color(&s_listBtn, lv_color_hex(Theme::TEXT_PRIMARY)); + lv_style_set_text_font(&s_listBtn, &lv_font_ratdeck_12); lv_style_set_border_color(&s_listBtn, lv_color_hex(Theme::DIVIDER)); lv_style_set_border_width(&s_listBtn, 1); lv_style_set_border_side(&s_listBtn, LV_BORDER_SIDE_BOTTOM); - lv_style_set_pad_all(&s_listBtn, 6); + lv_style_set_pad_top(&s_listBtn, 5); + lv_style_set_pad_bottom(&s_listBtn, 5); lv_style_set_pad_left(&s_listBtn, 8); + lv_style_set_pad_right(&s_listBtn, 8); lv_style_set_radius(&s_listBtn, 0); - // List item focused — left-bar indicator + hover background + // List item focused - left-bar indicator + hover background lv_style_init(&s_listBtnFocused); lv_style_set_bg_color(&s_listBtnFocused, lv_color_hex(Theme::BG_HOVER)); lv_style_set_bg_opa(&s_listBtnFocused, LV_OPA_COVER); lv_style_set_text_color(&s_listBtnFocused, lv_color_hex(Theme::TEXT_PRIMARY)); lv_style_set_border_color(&s_listBtnFocused, lv_color_hex(Theme::PRIMARY)); - lv_style_set_border_width(&s_listBtnFocused, 2); + lv_style_set_border_width(&s_listBtnFocused, 3); lv_style_set_border_side(&s_listBtnFocused, LV_BORDER_SIDE_LEFT); // Dropdown @@ -140,7 +160,8 @@ void init(lv_disp_t* disp) { 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::TEXT_PRIMARY)); - lv_style_set_radius(&s_dropdown, 4); + lv_style_set_text_font(&s_dropdown, &lv_font_ratdeck_12); + lv_style_set_radius(&s_dropdown, 3); lv_style_set_pad_all(&s_dropdown, 4); // Slider @@ -154,7 +175,7 @@ void init(lv_disp_t* disp) { lv_style_set_bg_opa(&s_sectionHeader, LV_OPA_TRANSP); lv_style_set_text_color(&s_sectionHeader, lv_color_hex(Theme::TEXT_SECONDARY)); lv_style_set_text_font(&s_sectionHeader, &lv_font_ratdeck_10); - lv_style_set_text_letter_space(&s_sectionHeader, 1); + lv_style_set_text_letter_space(&s_sectionHeader, 0); lv_style_set_border_color(&s_sectionHeader, lv_color_hex(Theme::DIVIDER)); lv_style_set_border_width(&s_sectionHeader, 1); lv_style_set_border_side(&s_sectionHeader, LV_BORDER_SIDE_BOTTOM); @@ -168,10 +189,10 @@ void init(lv_disp_t* disp) { lv_style_set_bg_opa(&s_modal, LV_OPA_COVER); lv_style_set_border_color(&s_modal, lv_color_hex(Theme::BORDER)); lv_style_set_border_width(&s_modal, 1); - lv_style_set_radius(&s_modal, 6); - lv_style_set_pad_all(&s_modal, 12); + lv_style_set_radius(&s_modal, 4); + lv_style_set_pad_all(&s_modal, 10); lv_style_set_pad_row(&s_modal, 4); - lv_style_set_shadow_width(&s_modal, 8); + lv_style_set_shadow_width(&s_modal, 4); lv_style_set_shadow_color(&s_modal, lv_color_hex(0x000000)); lv_style_set_shadow_opa(&s_modal, LV_OPA_50); @@ -183,7 +204,7 @@ void init(lv_disp_t* disp) { lv_style_set_width(&s_scrollbar, 3); lv_style_set_pad_right(&s_scrollbar, 2); - // Roller — no border so selected row highlight extends edge-to-edge + // Roller - no border so selected row highlight extends edge-to-edge lv_style_init(&s_roller); lv_style_set_bg_color(&s_roller, lv_color_hex(Theme::BG)); lv_style_set_bg_opa(&s_roller, LV_OPA_COVER); @@ -191,10 +212,63 @@ void init(lv_disp_t* disp) { lv_style_set_text_font(&s_roller, &lv_font_ratdeck_14); lv_style_set_border_width(&s_roller, 0); + // Persistent shell bars. + lv_style_init(&s_statusBar); + lv_style_set_bg_color(&s_statusBar, lv_color_hex(Theme::STATUS_BG)); + lv_style_set_bg_opa(&s_statusBar, LV_OPA_COVER); + lv_style_set_border_color(&s_statusBar, lv_color_hex(Theme::DIVIDER)); + lv_style_set_border_width(&s_statusBar, 1); + lv_style_set_border_side(&s_statusBar, LV_BORDER_SIDE_BOTTOM); + lv_style_set_pad_all(&s_statusBar, 0); + lv_style_set_radius(&s_statusBar, 0); + + lv_style_init(&s_statusToast); + lv_style_set_bg_color(&s_statusToast, lv_color_hex(Theme::TOAST_BG)); + lv_style_set_bg_opa(&s_statusToast, LV_OPA_COVER); + lv_style_set_text_color(&s_statusToast, lv_color_hex(Theme::BG)); + lv_style_set_text_font(&s_statusToast, &lv_font_ratdeck_12); + lv_style_set_pad_all(&s_statusToast, 0); + lv_style_set_radius(&s_statusToast, 0); + + lv_style_init(&s_tabBar); + lv_style_set_bg_color(&s_tabBar, lv_color_hex(Theme::TAB_BG)); + lv_style_set_bg_opa(&s_tabBar, LV_OPA_COVER); + lv_style_set_border_color(&s_tabBar, lv_color_hex(Theme::DIVIDER)); + lv_style_set_border_width(&s_tabBar, 1); + lv_style_set_border_side(&s_tabBar, LV_BORDER_SIDE_TOP); + lv_style_set_pad_all(&s_tabBar, 0); + lv_style_set_pad_gap(&s_tabBar, 0); + lv_style_set_radius(&s_tabBar, 0); + + lv_style_init(&s_tabCell); + lv_style_set_bg_color(&s_tabCell, lv_color_hex(Theme::TAB_BG)); + lv_style_set_bg_opa(&s_tabCell, LV_OPA_COVER); + lv_style_set_border_width(&s_tabCell, 0); + lv_style_set_text_color(&s_tabCell, lv_color_hex(Theme::TAB_INACTIVE)); + lv_style_set_text_font(&s_tabCell, &lv_font_ratdeck_10); + lv_style_set_pad_all(&s_tabCell, 0); + lv_style_set_radius(&s_tabCell, 0); + + lv_style_init(&s_tabCellActive); + lv_style_set_bg_color(&s_tabCellActive, lv_color_hex(Theme::TAB_ACTIVE_BG)); + lv_style_set_bg_opa(&s_tabCellActive, LV_OPA_COVER); + lv_style_set_border_color(&s_tabCellActive, lv_color_hex(Theme::PRIMARY)); + lv_style_set_border_width(&s_tabCellActive, 2); + lv_style_set_border_side(&s_tabCellActive, LV_BORDER_SIDE_TOP); + lv_style_set_text_color(&s_tabCellActive, lv_color_hex(Theme::TAB_ACTIVE)); + + lv_style_init(&s_badge); + lv_style_set_bg_color(&s_badge, lv_color_hex(Theme::BADGE_BG)); + lv_style_set_bg_opa(&s_badge, LV_OPA_COVER); + lv_style_set_text_color(&s_badge, lv_color_hex(Theme::BG)); + lv_style_set_text_font(&s_badge, &lv_font_ratdeck_10); + lv_style_set_radius(&s_badge, 3); + lv_style_set_pad_all(&s_badge, 0); + // Apply screen style to default theme lv_obj_add_style(lv_scr_act(), &s_screen, 0); - Serial.println("[LVGL] Ratspeak theme initialized"); + Serial.println("[LVGL] Ratdeck field-console theme initialized"); } // Existing accessors (16) @@ -222,5 +296,11 @@ lv_style_t* styleSectionHeader() { return &s_sectionHeader; } lv_style_t* styleModal() { return &s_modal; } lv_style_t* styleScrollbar() { return &s_scrollbar; } lv_style_t* styleRoller() { return &s_roller; } +lv_style_t* styleStatusBar() { return &s_statusBar; } +lv_style_t* styleStatusToast() { return &s_statusToast; } +lv_style_t* styleTabBar() { return &s_tabBar; } +lv_style_t* styleTabCell() { return &s_tabCell; } +lv_style_t* styleTabCellActive() { return &s_tabCellActive; } +lv_style_t* styleBadge() { return &s_badge; } } // namespace LvTheme diff --git a/src/ui/LvTheme.h b/src/ui/LvTheme.h index 92d19e3..2d38bef 100644 --- a/src/ui/LvTheme.h +++ b/src/ui/LvTheme.h @@ -2,12 +2,12 @@ #include -// Ratspeak LVGL theme — matrix green on black +// Ratdeck LVGL theme: compact field-console styles for LVGL 8.3. namespace LvTheme { void init(lv_disp_t* disp); -// Style accessors — existing (16) +// Style accessors - existing (16) lv_style_t* styleScreen(); lv_style_t* styleLabel(); lv_style_t* styleLabelMuted(); @@ -25,7 +25,7 @@ lv_style_t* styleListBtnFocused(); lv_style_t* styleDropdown(); lv_style_t* styleSlider(); -// Style accessors — new (6) +// Style accessors - new (6) lv_style_t* styleBtnFocused(); lv_style_t* styleTextareaFocused(); lv_style_t* styleSectionHeader(); @@ -33,4 +33,12 @@ lv_style_t* styleModal(); lv_style_t* styleScrollbar(); lv_style_t* styleRoller(); +// Shared shell styles +lv_style_t* styleStatusBar(); +lv_style_t* styleStatusToast(); +lv_style_t* styleTabBar(); +lv_style_t* styleTabCell(); +lv_style_t* styleTabCellActive(); +lv_style_t* styleBadge(); + } // namespace LvTheme diff --git a/src/ui/LxmFaceAvatar.cpp b/src/ui/LxmFaceAvatar.cpp new file mode 100644 index 0000000..6f5e517 --- /dev/null +++ b/src/ui/LxmFaceAvatar.cpp @@ -0,0 +1,181 @@ +#include "ui/LxmFaceAvatar.h" + +#include + +namespace { + +constexpr int kGrid = 8; + +struct LxmColor { + uint16_t h; + double s; + double l; +}; + +struct LxmFaceData { + LxmColor color; + LxmColor bgcolor; + LxmColor spotcolor; + uint8_t grid[kGrid][kGrid] = {}; +}; + +class LxmPrng { +public: + explicit LxmPrng(const String& seed) { + for (size_t i = 0; i < seed.length(); i++) { + int idx = i % 4; + uint32_t v = (uint32_t)_s[idx]; + _s[idx] = (int32_t)((v << 5) - v + (uint8_t)seed.charAt(i)); + } + } + + double next() { + int32_t t = _s[0] ^ (int32_t)((uint32_t)_s[0] << 11); + _s[0] = _s[1]; + _s[1] = _s[2]; + _s[2] = _s[3]; + uint32_t result = (uint32_t)(_s[3] ^ (_s[3] >> 19) ^ t ^ (t >> 8)); + _s[3] = (int32_t)result; + return (double)result / 2147483648.0; + } + +private: + int32_t _s[4] = {}; +}; + +LxmColor createColor(LxmPrng& rng) { + LxmColor c; + c.h = (uint16_t)floor(rng.next() * 360.0); + c.s = rng.next() * 60.0 + 40.0; + c.l = (rng.next() + rng.next() + rng.next() + rng.next()) * 25.0; + return c; +} + +LxmFaceData createFace(const String& seed) { + LxmPrng rng(seed); + LxmFaceData face; + face.color = createColor(rng); + face.bgcolor = createColor(rng); + face.spotcolor = createColor(rng); + + for (int y = 0; y < kGrid; y++) { + for (int x = 0; x < kGrid / 2; x++) { + face.grid[y][x] = (uint8_t)floor(rng.next() * 2.3); + } + for (int x = 0; x < kGrid / 2; x++) { + face.grid[y][kGrid / 2 + x] = face.grid[y][kGrid / 2 - 1 - x]; + } + } + return face; +} + +double hueToRgb(double p, double q, double t) { + if (t < 0.0) t += 1.0; + if (t > 1.0) t -= 1.0; + if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0 / 2.0) return q; + if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + return p; +} + +lv_color_t toLvColor(const LxmColor& color) { + double h = fmod((double)color.h, 360.0) / 360.0; + double s = constrain(color.s, 0.0, 100.0) / 100.0; + double l = constrain(color.l, 0.0, 100.0) / 100.0; + + double r = l; + double g = l; + double b = l; + if (s > 0.0) { + double q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + double p = 2.0 * l - q; + r = hueToRgb(p, q, h + 1.0 / 3.0); + g = hueToRgb(p, q, h); + b = hueToRgb(p, q, h - 1.0 / 3.0); + } + + uint32_t rgb = + ((uint32_t)constrain((int)round(r * 255.0), 0, 255) << 16) | + ((uint32_t)constrain((int)round(g * 255.0), 0, 255) << 8) | + (uint32_t)constrain((int)round(b * 255.0), 0, 255); + return lv_color_hex(rgb); +} + +bool inCircle(int x, int y, int size) { + double center = (double)size / 2.0; + double radius = (double)size / 2.0; + double dx = ((double)x + 0.5) - center; + double dy = ((double)y + 0.5) - center; + return (dx * dx + dy * dy) <= (radius * radius); +} + +} // namespace + +namespace LxmFaceAvatar { + +size_t bufferSize(lv_coord_t size) { + if (size <= 0) return 0; + return lv_img_buf_get_img_size(size, size, LV_IMG_CF_TRUE_COLOR_ALPHA); +} + +View create(lv_obj_t* parent, lv_coord_t x, lv_coord_t y, lv_coord_t size, + void* buffer, uint32_t bg, uint32_t border) { + View view; + view.box = lv_obj_create(parent); + lv_obj_set_pos(view.box, x, y); + lv_obj_set_size(view.box, size, size); + lv_obj_set_style_bg_color(view.box, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(view.box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(view.box, lv_color_hex(border), 0); + lv_obj_set_style_border_width(view.box, 1, 0); + lv_obj_set_style_radius(view.box, size / 2, 0); + lv_obj_set_style_clip_corner(view.box, false, 0); + lv_obj_set_style_pad_all(view.box, 0, 0); + lv_obj_set_style_shadow_width(view.box, 0, 0); + lv_obj_clear_flag(view.box, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + + view.canvas = lv_canvas_create(view.box); + lv_canvas_set_buffer(view.canvas, buffer, size, size, LV_IMG_CF_TRUE_COLOR_ALPHA); + lv_obj_set_size(view.canvas, size, size); + lv_obj_align(view.canvas, LV_ALIGN_CENTER, 0, 0); + lv_img_set_antialias(view.canvas, false); + lv_obj_set_style_bg_opa(view.canvas, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(view.canvas, 0, 0); + lv_obj_set_style_pad_all(view.canvas, 0, 0); + lv_obj_clear_flag(view.canvas, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + + return view; +} + +bool render(lv_obj_t* canvas, const String& seed) { + if (!canvas || seed.isEmpty()) return false; + + lv_img_dsc_t* dsc = lv_canvas_get_img(canvas); + if (!dsc || !dsc->data || dsc->header.w <= 0 || dsc->header.h <= 0) return false; + int size = dsc->header.w < dsc->header.h ? dsc->header.w : dsc->header.h; + if (size <= 0) return false; + + LxmFaceData face = createFace(seed); + lv_color_t colors[3] = { + toLvColor(face.bgcolor), + toLvColor(face.color), + toLvColor(face.spotcolor), + }; + + for (int y = 0; y < size; y++) { + int gy = y * kGrid / size; + if (gy >= kGrid) gy = kGrid - 1; + for (int x = 0; x < size; x++) { + int gx = x * kGrid / size; + if (gx >= kGrid) gx = kGrid - 1; + uint8_t idx = face.grid[gy][gx]; + if (idx > 2) idx = 0; + lv_img_buf_set_px_color(dsc, x, y, colors[idx]); + lv_img_buf_set_px_alpha(dsc, x, y, inCircle(x, y, size) ? LV_OPA_COVER : LV_OPA_TRANSP); + } + } + lv_obj_invalidate(canvas); + return true; +} + +} // namespace LxmFaceAvatar diff --git a/src/ui/LxmFaceAvatar.h b/src/ui/LxmFaceAvatar.h new file mode 100644 index 0000000..4a007a2 --- /dev/null +++ b/src/ui/LxmFaceAvatar.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include +#include + +namespace LxmFaceAvatar { + +struct View { + lv_obj_t* box = nullptr; + lv_obj_t* canvas = nullptr; +}; + +size_t bufferSize(lv_coord_t size); +View create(lv_obj_t* parent, lv_coord_t x, lv_coord_t y, lv_coord_t size, + void* buffer, uint32_t bg, uint32_t border); +bool render(lv_obj_t* canvas, const String& seed); + +} // namespace LxmFaceAvatar diff --git a/src/ui/Theme.h b/src/ui/Theme.h index 9541434..d0a73c4 100644 --- a/src/ui/Theme.h +++ b/src/ui/Theme.h @@ -3,54 +3,59 @@ #include // ============================================================================= -// RatDeck — Design System Constants -// Matrix green palette on pure black +// Ratdeck design constants +// Dark field-console palette tuned for the 320x240 LVGL surface. // ============================================================================= namespace Theme { -// --- Backgrounds (true black base) --- -constexpr uint32_t BG = 0x000000; // Pure black (screen bg) -constexpr uint32_t BG_ELEVATED = 0x0E140E; // Cards, inputs, list items -constexpr uint32_t BG_SURFACE = 0x162016; // Modals, dropdowns, tooltips -constexpr uint32_t BG_HOVER = 0x1E2E1E; // Focus/hover background +// --- Backgrounds --- +constexpr uint32_t BG = 0x05080A; // Screen base +constexpr uint32_t BG_ELEVATED = 0x0C1418; // Buttons, inputs, list items +constexpr uint32_t BG_SURFACE = 0x121D23; // Modals, dropdowns, shell bars +constexpr uint32_t BG_HOVER = 0x183033; // Focus/hover background -// --- Text Hierarchy (cool green-tinted for cohesion with brand) --- -constexpr uint32_t TEXT_PRIMARY = 0xCCE8CC; // Primary text (green-tinted white) -constexpr uint32_t TEXT_SECONDARY = 0x7A9A7A; // Timestamps, metadata (green-tinted gray) -constexpr uint32_t TEXT_MUTED = 0x4A5E4A; // Disabled, placeholder (green-tinted dark) +// --- Text hierarchy --- +constexpr uint32_t TEXT_PRIMARY = 0xE4F3F0; // Primary copy +constexpr uint32_t TEXT_SECONDARY = 0x91A8A5; // Metadata and labels +constexpr uint32_t TEXT_MUTED = 0x526866; // Disabled, placeholders -// --- Brand / Interactive --- -constexpr uint32_t PRIMARY = 0x00CC66; // Emerald green (interactive, focus) -constexpr uint32_t PRIMARY_MUTED = 0x00AA55; // Pressed/active state -constexpr uint32_t PRIMARY_SUBTLE = 0x0A1F12; // Selection backgrounds, subtle highlight -constexpr uint32_t ACCENT = 0x40E880; // Headers, brand text (bright emerald) +// --- Brand / interactive --- +constexpr uint32_t PRIMARY = 0x00E06D; // Signal green +constexpr uint32_t PRIMARY_MUTED = 0x00A853; // Pressed/active state +constexpr uint32_t PRIMARY_SUBTLE = 0x06251A; // Selection backgrounds +constexpr uint32_t ACCENT = 0x4FD7FF; // Console cyan accent // --- Status --- -constexpr uint32_t SUCCESS = 0x00CC66; // Online, delivered, connected (= PRIMARY) -constexpr uint32_t WARNING_CLR = 0xE0B83E; // Queued, pending, caution (golden amber) -constexpr uint32_t ERROR_CLR = 0xF87171; // Failed, error, critical (soft red) +constexpr uint32_t SUCCESS = 0x31E981; // Online, delivered, connected +constexpr uint32_t WARNING_CLR = 0xF0C04F; // Queued, pending, caution +constexpr uint32_t ERROR_CLR = 0xFF5C6C; // Failed, error, critical // --- Structural --- -constexpr uint32_t BORDER = 0x1E2E1E; // Subtle structural borders (= BG_HOVER) -constexpr uint32_t BORDER_ACTIVE = 0x00CC66; // Focus borders (= PRIMARY) -constexpr uint32_t DIVIDER = 0x162016; // Hairline separators (= BG_SURFACE) +constexpr uint32_t BORDER = 0x21383B; // Subtle structural borders +constexpr uint32_t BORDER_ACTIVE = 0x00E06D; // Focus borders +constexpr uint32_t DIVIDER = 0x102329; // Hairline separators // --- Messages --- -constexpr uint32_t MSG_OUT_BG = 0x0A1F12; // Outgoing bubble (= PRIMARY_SUBTLE) -constexpr uint32_t MSG_IN_BG = 0x0A120A; // Incoming bubble +constexpr uint32_t MSG_OUT_BG = 0x082719; // Outgoing bubble +constexpr uint32_t MSG_IN_BG = 0x0B151C; // Incoming bubble -// --- Tab Bar --- -constexpr uint32_t TAB_ACTIVE = 0x00CC66; // Active tab (= PRIMARY) -constexpr uint32_t TAB_INACTIVE = 0x9A908A; // Inactive tabs (warm gray) -constexpr uint32_t BADGE_BG = 0xF87171; // Unread badge (= ERROR_CLR) +// --- Shell --- +constexpr uint32_t STATUS_BG = 0x071014; +constexpr uint32_t STATUS_FLASH = 0x12351F; +constexpr uint32_t TAB_BG = 0x081115; +constexpr uint32_t TAB_ACTIVE_BG = 0x0B241B; +constexpr uint32_t TAB_ACTIVE = 0x00E06D; +constexpr uint32_t TAB_INACTIVE = 0x91A8A5; +constexpr uint32_t BADGE_BG = 0xFF5C6C; +constexpr uint32_t TOAST_BG = 0x00E06D; // --- Layout Metrics --- constexpr int SCREEN_W = 320; constexpr int SCREEN_H = 240; constexpr int STATUS_BAR_H = 20; -constexpr int TAB_BAR_H = 24; +constexpr int TAB_BAR_H = 26; 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; @@ -58,6 +63,8 @@ constexpr int CONTENT_W = SCREEN_W; // --- Tab Bar --- constexpr int TAB_COUNT = 5; constexpr int TAB_W = SCREEN_W / TAB_COUNT; +constexpr int TAB_BADGE_W = 18; +constexpr int TAB_BADGE_H = 10; // --- Spacing (base unit: 4px) --- constexpr int SP_1 = 4; @@ -68,7 +75,7 @@ constexpr int SP_6 = 24; } // namespace Theme -// LVGL color helper — available when LVGL is included +// 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); } diff --git a/src/ui/UIManager.cpp b/src/ui/UIManager.cpp index 03986a7..2500f32 100644 --- a/src/ui/UIManager.cpp +++ b/src/ui/UIManager.cpp @@ -40,6 +40,8 @@ void UIManager::begin() { } void UIManager::setScreen(LvScreen* screen) { + if (_currentLvScreen == screen) return; + // Transition from previous LVGL screen if (_currentLvScreen) { _currentLvScreen->onExit(); diff --git a/src/ui/screens/LvBootScreen.cpp b/src/ui/screens/LvBootScreen.cpp index e6fd200..a0697e5 100644 --- a/src/ui/screens/LvBootScreen.cpp +++ b/src/ui/screens/LvBootScreen.cpp @@ -1,54 +1,54 @@ #include "LvBootScreen.h" #include "ui/Theme.h" #include "ui/LvTheme.h" -#include "config/Config.h" #include "fonts/fonts.h" +namespace { + +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, const lv_font_t* font, + uint32_t color, lv_coord_t width, lv_text_align_t align) { + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_width(lbl, width); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_align(lbl, align, 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_label_set_text(lbl, text); + return lbl; +} + +int progressToPercent(float progress) { + if (progress < 0.0f) return 0; + if (progress > 1.0f) return 100; + return (int)(progress * 100.0f + 0.5f); +} + +} // namespace + 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::ACCENT), 0); - lv_label_set_text(_lblTitle, "RATDECK"); - lv_obj_align(_lblTitle, LV_ALIGN_TOP_MID, 0, 60); + _lblTitle = makeLabel(parent, "RATSPEAK", &lv_font_montserrat_16, + Theme::ACCENT, 240, LV_TEXT_ALIGN_CENTER); + lv_obj_align(_lblTitle, LV_ALIGN_CENTER, 0, -26); - // Version - _lblVersion = lv_label_create(parent); - lv_obj_set_style_text_font(_lblVersion, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_lblVersion, lv_color_hex(Theme::TEXT_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_obj_set_size(_bar, 188, 8); + lv_obj_align(_bar, LV_ALIGN_CENTER, 0, 14); 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_ratdeck_12, 0); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(_lblStatus, "Starting..."); - lv_obj_align(_lblStatus, LV_ALIGN_TOP_MID, 0, 128); } void LvBootScreen::setProgress(float progress, const char* status) { + (void)status; + int percent = progressToPercent(progress); if (_bar) { - lv_bar_set_value(_bar, (int)(progress * 100), LV_ANIM_OFF); - } - if (_lblStatus) { - lv_label_set_text(_lblStatus, status); + lv_bar_set_value(_bar, percent, LV_ANIM_OFF); } // 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 index fab92ad..0bf1e96 100644 --- a/src/ui/screens/LvBootScreen.h +++ b/src/ui/screens/LvBootScreen.h @@ -11,7 +11,5 @@ public: 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/LvContactsScreen.cpp b/src/ui/screens/LvContactsScreen.cpp index bfc102a..e2c5d6f 100644 --- a/src/ui/screens/LvContactsScreen.cpp +++ b/src/ui/screens/LvContactsScreen.cpp @@ -2,27 +2,123 @@ #include "ui/Theme.h" #include "ui/LvTheme.h" #include "ui/LvInput.h" +#include "ui/LxmFaceAvatar.h" #include "ui/UIManager.h" #include "reticulum/AnnounceManager.h" #include +#include +#include #include "fonts/fonts.h" +namespace { + +constexpr int kContactRowH = 38; +constexpr int kContactAvatar = 32; +constexpr int kContactTextX = 48; + +unsigned long nodeAgeMs(const DiscoveredNode& node, unsigned long now) { + if (node.lastSeen == 0 || now < node.lastSeen) return ULONG_MAX; + return now - node.lastSeen; +} + +std::string displayNameFor(const DiscoveredNode& node) { + if (!node.name.empty()) return node.name; + std::string hex = node.hash.toHex(); + return hex.substr(0, 12); +} + +std::string identityLineFor(const DiscoveredNode& node) { + std::string hex = node.hash.toHex(); + return "ID: " + hex; +} + +std::string compactAge(unsigned long ageMs) { + if (ageMs == ULONG_MAX) return "old"; + if (ageMs < 5000) return "now"; + + unsigned long sec = ageMs / 1000; + char buf[12]; + if (sec < 60) { + snprintf(buf, sizeof(buf), "%lus", sec); + } else if (sec < 3600) { + snprintf(buf, sizeof(buf), "%lum", sec / 60); + } else if (sec < 86400) { + snprintf(buf, sizeof(buf), "%luh", sec / 3600); + } else { + snprintf(buf, sizeof(buf), "%lud", sec / 86400); + } + return buf; +} + +std::string contactMetaFor(const DiscoveredNode& node, unsigned long ageMs) { + std::string age = compactAge(ageMs); + if (node.hops > 0 && node.hops < 128) { + char buf[24]; + snprintf(buf, sizeof(buf), "%uH %s", (unsigned)node.hops, age.c_str()); + return buf; + } + return age; +} + +lv_obj_t* createEmptyState(lv_obj_t* parent) { + lv_obj_t* box = lv_obj_create(parent); + lv_obj_set_size(box, 252, 94); + lv_obj_set_style_bg_opa(box, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(box, 0, 0); + lv_obj_set_style_pad_all(box, 0, 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_center(box); + + lv_obj_t* head = lv_obj_create(box); + lv_obj_set_size(head, 18, 18); + lv_obj_set_pos(head, 117, 7); + lv_obj_set_style_radius(head, 9, 0); + lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(head, 2, 0); + lv_obj_set_style_border_color(head, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_pad_all(head, 0, 0); + lv_obj_clear_flag(head, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* shoulders = lv_obj_create(box); + lv_obj_set_size(shoulders, 36, 17); + lv_obj_set_pos(shoulders, 108, 27); + lv_obj_set_style_radius(shoulders, 8, 0); + lv_obj_set_style_bg_opa(shoulders, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(shoulders, 2, 0); + lv_obj_set_style_border_color(shoulders, lv_color_hex(Theme::BORDER_ACTIVE), 0); + lv_obj_set_style_pad_all(shoulders, 0, 0); + lv_obj_clear_flag(shoulders, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* title = lv_label_create(box); + lv_obj_set_style_text_font(title, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(title, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(title, "No trusted contacts"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 49); + + lv_obj_t* hint = lv_label_create(box); + lv_obj_set_style_text_font(hint, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(hint, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(hint, "Saved peers appear here"); + lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 70); + + return box; +} + +} // namespace + void LvContactsScreen::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_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_text(_lblEmpty, "No saved contacts"); - lv_obj_center(_lblEmpty); + _emptyState = createEmptyState(parent); _list = lv_obj_create(parent); lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); lv_obj_add_style(_list, LvTheme::styleList(), 0); lv_obj_set_layout(_list, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); _lastContactCount = -1; rebuildList(); @@ -48,34 +144,46 @@ void LvContactsScreen::rebuildList() { _contactIndices.clear(); lv_obj_clean(_list); + _avatarBuffers.clear(); const auto& nodes = _am->nodes(); for (int i = 0; i < (int)nodes.size(); i++) { if (nodes[i].saved) _contactIndices.push_back(i); } + std::sort(_contactIndices.begin(), _contactIndices.end(), [&nodes](int a, int b) { + std::string an = displayNameFor(nodes[a]); + std::string bn = displayNameFor(nodes[b]); + if (an == bn) return nodes[a].hash.toHex() < nodes[b].hash.toHex(); + return an < bn; + }); int count = (int)_contactIndices.size(); _lastContactCount = count; + _avatarBuffers.reserve(count); if (count == 0) { - lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + if (_emptyState) lv_obj_clear_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); return; } - lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + if (_emptyState) lv_obj_add_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); + unsigned long now = millis(); + for (int i = 0; i < count; i++) { int nodeIdx = _contactIndices[i]; const auto& node = nodes[nodeIdx]; + unsigned long age = nodeAgeMs(node, now); lv_obj_t* row = lv_obj_create(_list); - lv_obj_set_size(row, Theme::CONTENT_W, 32); + lv_obj_set_size(row, Theme::CONTENT_W, kContactRowH); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_border_width(row, 1, 0); lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_pad_all(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_user_data(row, (void*)(intptr_t)i); @@ -94,20 +202,40 @@ void LvContactsScreen::rebuildList() { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); - // Contact name - lv_obj_t* lbl = lv_label_create(row); - lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0); - lv_label_set_text(lbl, node.name.c_str()); - lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); + std::string hashHex = node.hash.toHex(); + _avatarBuffers.emplace_back(LxmFaceAvatar::bufferSize(kContactAvatar)); + auto avatar = LxmFaceAvatar::create(row, 8, 3, kContactAvatar, + _avatarBuffers.back().data(), + Theme::PRIMARY_SUBTLE, Theme::BORDER); + LxmFaceAvatar::render(avatar.canvas, String(hashHex.c_str())); - // Hash suffix (right side, muted) - lv_obj_t* lblHash = lv_label_create(row); - lv_obj_set_style_text_font(lblHash, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(lblHash, lv_color_hex(Theme::TEXT_MUTED), 0); - std::string shortHash = node.hash.toHex().substr(0, 8); - lv_label_set_text(lblHash, shortHash.c_str()); - lv_obj_align(lblHash, LV_ALIGN_RIGHT_MID, -8, 0); + lv_obj_t* nameLbl = lv_label_create(row); + lv_obj_set_style_text_font(nameLbl, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_long_mode(nameLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(nameLbl, Theme::CONTENT_W - kContactTextX - 72); + std::string name = displayNameFor(node); + lv_label_set_text(nameLbl, name.c_str()); + lv_obj_set_pos(nameLbl, kContactTextX, 2); + + lv_obj_t* metaLbl = lv_label_create(row); + lv_obj_set_style_text_font(metaLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(metaLbl, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_obj_set_style_text_align(metaLbl, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_long_mode(metaLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(metaLbl, 64); + std::string meta = contactMetaFor(node, age); + lv_label_set_text(metaLbl, meta.c_str()); + lv_obj_set_pos(metaLbl, Theme::CONTENT_W - 72, 5); + + lv_obj_t* idLbl = lv_label_create(row); + lv_obj_set_style_text_font(idLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(idLbl, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_long_mode(idLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(idLbl, Theme::CONTENT_W - kContactTextX - 8); + std::string identity = identityLineFor(node); + lv_label_set_text(idLbl, identity.c_str()); + lv_obj_set_pos(idLbl, kContactTextX, 22); } // Clear auto-focus if user hasn't started navigating yet @@ -127,7 +255,7 @@ bool LvContactsScreen::handleLongPress() { _deleteIdx = (int)(intptr_t)lv_obj_get_user_data(focused); if (_deleteIdx < 0 || _deleteIdx >= (int)_contactIndices.size()) return false; _confirmDelete = true; - if (_ui) _ui->lvStatusBar().showToast("Delete contact? Enter=Yes Esc=No", 5000); + if (_ui) _ui->lvStatusBar().showToast("Remove? Enter=Remove Esc=Keep", 5000); return true; } @@ -147,7 +275,7 @@ bool LvContactsScreen::handleKey(const KeyEvent& event) { int nodeIdx = _contactIndices[_deleteIdx]; if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { _am->deleteContact(nodeIdx); - if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200); + if (_ui) _ui->lvStatusBar().showToast("Contact removed", 1200); rebuildList(); } } @@ -155,7 +283,7 @@ bool LvContactsScreen::handleKey(const KeyEvent& event) { return true; } _confirmDelete = false; - if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + if (_ui) _ui->lvStatusBar().showToast("Kept contact", 800); return true; } diff --git a/src/ui/screens/LvContactsScreen.h b/src/ui/screens/LvContactsScreen.h index ea1b894..a53bca7 100644 --- a/src/ui/screens/LvContactsScreen.h +++ b/src/ui/screens/LvContactsScreen.h @@ -34,7 +34,8 @@ private: int _deleteIdx = -1; int _lastContactCount = -1; std::vector _contactIndices; + std::vector> _avatarBuffers; lv_obj_t* _list = nullptr; - lv_obj_t* _lblEmpty = nullptr; + lv_obj_t* _emptyState = nullptr; }; diff --git a/src/ui/screens/LvDataCleanScreen.cpp b/src/ui/screens/LvDataCleanScreen.cpp index 5f6366e..df3b333 100644 --- a/src/ui/screens/LvDataCleanScreen.cpp +++ b/src/ui/screens/LvDataCleanScreen.cpp @@ -4,72 +4,132 @@ #include "config/Config.h" #include "fonts/fonts.h" +namespace { + +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, const lv_font_t* font, + uint32_t color, lv_coord_t width, lv_text_align_t align, + lv_label_long_mode_t mode = LV_LABEL_LONG_WRAP) { + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_width(lbl, width); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_align(lbl, align, 0); + lv_label_set_long_mode(lbl, mode); + lv_label_set_text(lbl, text); + return lbl; +} + +lv_obj_t* makeChoice(lv_obj_t* parent, lv_coord_t x, lv_coord_t y) { + lv_obj_t* box = lv_obj_create(parent); + lv_obj_set_pos(box, x, y); + lv_obj_set_size(box, 126, 34); + lv_obj_set_style_bg_color(box, lv_color_hex(Theme::BG_ELEVATED), 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_radius(box, 4, 0); + lv_obj_set_style_pad_all(box, 0, 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(box, LV_OBJ_FLAG_CLICKABLE); + return box; +} + +void styleChoice(lv_obj_t* box, bool selected, bool destructive, bool armed) { + if (!box) return; + uint32_t border = selected ? (destructive && armed ? Theme::ERROR_CLR : Theme::PRIMARY) : Theme::BORDER; + uint32_t bg = selected ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED; + if (destructive && armed) bg = 0x2A0E0E; + lv_obj_set_style_bg_color(box, lv_color_hex(bg), 0); + lv_obj_set_style_border_color(box, lv_color_hex(border), 0); + lv_obj_set_style_border_width(box, selected ? 2 : 1, 0); +} + +} // namespace + void LvDataCleanScreen::createUI(lv_obj_t* parent) { _screen = parent; + _yesBox = nullptr; + _noBox = nullptr; + _yesLabel = nullptr; + _noLabel = nullptr; + _hintLabel = nullptr; + _confirmLabel = nullptr; + _statusLabel = nullptr; 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_bg_opa(parent, LV_OPA_COVER, 0); + _selectedYes = false; + _confirmWipe = false; - // 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); + lv_obj_t* eyebrow = makeLabel(parent, "STORAGE CHECK", &lv_font_ratdeck_10, + Theme::TEXT_SECONDARY, 220, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_align(eyebrow, LV_ALIGN_TOP_MID, 0, 10); - // Subtitle - lv_obj_t* sub = lv_label_create(parent); - lv_obj_set_style_text_font(sub, &lv_font_ratdeck_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); + lv_obj_t* title = makeLabel(parent, "SD DATA FOUND", &lv_font_montserrat_16, + Theme::ACCENT, 260, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 28); - // Message - lv_obj_t* msg = lv_label_create(parent); - lv_obj_set_style_text_font(msg, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(msg, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(msg, "Old data found on SD card."); - lv_obj_align(msg, LV_ALIGN_TOP_MID, 0, 75); + lv_obj_t* msg = makeLabel(parent, + "Ratdeck found older data on the SD card.", + &lv_font_ratdeck_12, Theme::TEXT_PRIMARY, 268, LV_TEXT_ALIGN_CENTER); + lv_obj_align(msg, LV_ALIGN_TOP_MID, 0, 58); - // Prompt - lv_obj_t* prompt = lv_label_create(parent); - lv_obj_set_style_text_font(prompt, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(prompt, "Remove old data and start fresh?"); - lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 95); + lv_obj_t* prompt = makeLabel(parent, + "Keeping data is safest. Erase only for a clean setup.", + &lv_font_ratdeck_12, Theme::TEXT_SECONDARY, 276, LV_TEXT_ALIGN_CENTER); + lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 82); - // Yes label - _yesLabel = lv_label_create(parent); - lv_obj_set_style_text_font(_yesLabel, &lv_font_ratdeck_14, 0); - lv_label_set_text(_yesLabel, "[ Yes ]"); - lv_obj_align(_yesLabel, LV_ALIGN_TOP_MID, -50, 135); + _noBox = makeChoice(parent, 28, 122); + _noLabel = makeLabel(_noBox, "KEEP DATA", &lv_font_ratdeck_12, + Theme::TEXT_PRIMARY, 112, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_center(_noLabel); + lv_obj_add_event_cb(_noBox, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->_selectedYes = false; + self->_confirmWipe = false; + self->updateSelection(); + if (self->_doneCb) self->_doneCb(false); + }, LV_EVENT_CLICKED, this); - // No label - _noLabel = lv_label_create(parent); - lv_obj_set_style_text_font(_noLabel, &lv_font_ratdeck_14, 0); - lv_label_set_text(_noLabel, "[ No ]"); - lv_obj_align(_noLabel, LV_ALIGN_TOP_MID, 50, 135); + _yesBox = makeChoice(parent, 166, 122); + _yesLabel = makeLabel(_yesBox, "ERASE", &lv_font_ratdeck_12, + Theme::TEXT_PRIMARY, 112, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_center(_yesLabel); + lv_obj_add_event_cb(_yesBox, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->_selectedYes = true; + if (!self->_confirmWipe) { + self->_confirmWipe = true; + self->updateSelection(); + return; + } + if (self->_doneCb) self->_doneCb(true); + }, LV_EVENT_CLICKED, this); - updateSelection(); + _confirmLabel = makeLabel(parent, "", &lv_font_ratdeck_12, + Theme::WARNING_CLR, 280, LV_TEXT_ALIGN_CENTER); + lv_obj_align(_confirmLabel, LV_ALIGN_TOP_MID, 0, 164); - // Hint - _hintLabel = lv_label_create(parent); - lv_obj_set_style_text_font(_hintLabel, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_hintLabel, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(_hintLabel, "[] Select [Enter] OK"); - lv_obj_align(_hintLabel, LV_ALIGN_TOP_MID, 0, 165); + _hintLabel = makeLabel(parent, "Left/Right choose Enter continues", + &lv_font_ratdeck_12, Theme::ACCENT, + 286, LV_TEXT_ALIGN_CENTER, LV_LABEL_LONG_DOT); + lv_obj_align(_hintLabel, LV_ALIGN_TOP_MID, 0, 188); - // Status label (hidden until showStatus is called) - _statusLabel = lv_label_create(parent); - lv_obj_set_style_text_font(_statusLabel, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_statusLabel, lv_color_hex(Theme::PRIMARY), 0); - lv_label_set_text(_statusLabel, ""); + _statusLabel = makeLabel(parent, "", &lv_font_ratdeck_12, + Theme::PRIMARY, 286, LV_TEXT_ALIGN_CENTER); lv_obj_align(_statusLabel, LV_ALIGN_TOP_MID, 0, 140); lv_obj_add_flag(_statusLabel, LV_OBJ_FLAG_HIDDEN); + updateSelection(); - // Version - lv_obj_t* ver = lv_label_create(parent); - lv_obj_set_style_text_font(ver, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(ver, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_obj_t* ver = makeLabel(parent, "", &lv_font_ratdeck_10, + Theme::TEXT_MUTED, 120, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); char verBuf[32]; snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING); lv_label_set_text(ver, verBuf); @@ -77,38 +137,64 @@ void LvDataCleanScreen::createUI(lv_obj_t* parent) { } void LvDataCleanScreen::updateSelection() { + styleChoice(_noBox, !_selectedYes, false, false); + styleChoice(_yesBox, _selectedYes, true, _confirmWipe); + if (_selectedYes) { - lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::ACCENT), 0); + lv_obj_set_style_text_color(_yesLabel, + lv_color_hex(_confirmWipe ? Theme::ERROR_CLR : Theme::WARNING_CLR), 0); lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::TEXT_MUTED), 0); + if (_confirmLabel) { + lv_label_set_text(_confirmLabel, + _confirmWipe ? "Erase armed. Press Enter again to wipe." : + "Erase will remove old SD data."); + } + if (_hintLabel) { + lv_label_set_text(_hintLabel, + _confirmWipe ? "Enter confirms erase Left cancels" : + "Enter arms erase Left cancels"); + } } else { lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::TEXT_MUTED), 0); lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::ACCENT), 0); + if (_confirmLabel) lv_label_set_text(_confirmLabel, "Recommended: keep existing data."); + if (_hintLabel) lv_label_set_text(_hintLabel, "Enter keeps data Right selects erase"); } } void LvDataCleanScreen::showStatus(const char* msg) { + if (_yesBox) lv_obj_add_flag(_yesBox, LV_OBJ_FLAG_HIDDEN); + if (_noBox) lv_obj_add_flag(_noBox, LV_OBJ_FLAG_HIDDEN); if (_yesLabel) lv_obj_add_flag(_yesLabel, LV_OBJ_FLAG_HIDDEN); if (_noLabel) lv_obj_add_flag(_noLabel, LV_OBJ_FLAG_HIDDEN); if (_hintLabel) lv_obj_add_flag(_hintLabel, LV_OBJ_FLAG_HIDDEN); + if (_confirmLabel) lv_obj_add_flag(_confirmLabel, LV_OBJ_FLAG_HIDDEN); if (_statusLabel) { - lv_label_set_text(_statusLabel, msg); + lv_label_set_text(_statusLabel, msg ? msg : ""); lv_obj_clear_flag(_statusLabel, LV_OBJ_FLAG_HIDDEN); } lv_timer_handler(); } bool LvDataCleanScreen::handleKey(const KeyEvent& event) { - if (event.left) { - _selectedYes = true; + if (event.left || event.up) { + _selectedYes = false; + _confirmWipe = false; updateSelection(); return true; } - if (event.right) { - _selectedYes = false; + if (event.right || event.down) { + _selectedYes = true; + _confirmWipe = false; updateSelection(); return true; } if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedYes && !_confirmWipe) { + _confirmWipe = true; + updateSelection(); + return true; + } if (_doneCb) _doneCb(_selectedYes); return true; } diff --git a/src/ui/screens/LvDataCleanScreen.h b/src/ui/screens/LvDataCleanScreen.h index 2b035eb..7413106 100644 --- a/src/ui/screens/LvDataCleanScreen.h +++ b/src/ui/screens/LvDataCleanScreen.h @@ -16,11 +16,15 @@ public: void showStatus(const char* msg); private: + lv_obj_t* _yesBox = nullptr; + lv_obj_t* _noBox = nullptr; lv_obj_t* _yesLabel = nullptr; lv_obj_t* _noLabel = nullptr; lv_obj_t* _hintLabel = nullptr; + lv_obj_t* _confirmLabel = nullptr; lv_obj_t* _statusLabel = nullptr; - bool _selectedYes = true; + bool _selectedYes = false; + bool _confirmWipe = false; std::function _doneCb; void updateSelection(); }; diff --git a/src/ui/screens/LvHelpOverlay.cpp b/src/ui/screens/LvHelpOverlay.cpp index 86728b3..3ab377b 100644 --- a/src/ui/screens/LvHelpOverlay.cpp +++ b/src/ui/screens/LvHelpOverlay.cpp @@ -4,38 +4,80 @@ #include #include "fonts/fonts.h" +namespace { + +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, const lv_font_t* font, + uint32_t color, lv_coord_t width, lv_text_align_t align, + lv_label_long_mode_t mode = LV_LABEL_LONG_DOT) { + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_width(lbl, width); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_align(lbl, align, 0); + lv_label_set_long_mode(lbl, mode); + lv_label_set_text(lbl, text); + return lbl; +} + +lv_obj_t* makeRow(lv_obj_t* parent) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, lv_pct(100), 13); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, 6, 0); + lv_obj_set_layout(row, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + return row; +} + +void addHelpRow(lv_obj_t* parent, const char* keys, const char* action) { + lv_obj_t* row = makeRow(parent); + makeLabel(row, keys, &lv_font_ratdeck_10, Theme::ACCENT, 72, LV_TEXT_ALIGN_LEFT); + makeLabel(row, action, &lv_font_ratdeck_10, Theme::TEXT_PRIMARY, 178, LV_TEXT_ALIGN_LEFT); +} + +} // namespace + void LvHelpOverlay::create() { + if (_overlay) return; + _overlay = lv_obj_create(lv_layer_top()); - lv_obj_set_size(_overlay, Theme::CONTENT_W - 40, Theme::CONTENT_H - 20); + lv_obj_set_size(_overlay, 304, 190); lv_obj_center(_overlay); lv_obj_add_style(_overlay, LvTheme::styleModal(), 0); - lv_obj_set_style_pad_row(_overlay, 3, 0); + lv_obj_set_style_pad_all(_overlay, 8, 0); + lv_obj_set_style_pad_row(_overlay, 1, 0); lv_obj_set_layout(_overlay, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_overlay, LV_FLEX_FLOW_COLUMN); + lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(_overlay, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self) self->hide(); + }, LV_EVENT_CLICKED, this); - const lv_font_t* font = &lv_font_ratdeck_12; + lv_obj_t* header = makeRow(_overlay); + lv_obj_set_height(header, 16); + makeLabel(header, "HELP", &lv_font_ratdeck_12, Theme::ACCENT, 70, LV_TEXT_ALIGN_LEFT); + makeLabel(header, "Ratspeak.org", &lv_font_ratdeck_10, + Theme::TEXT_SECONDARY, 180, LV_TEXT_ALIGN_RIGHT); - 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); - }; + addHelpRow(_overlay, "Trackball", "Move selection"); + addHelpRow(_overlay, "Enter", "Open or confirm"); + addHelpRow(_overlay, ", /", "Previous or next tab"); + addHelpRow(_overlay, "Esc", "Back"); + addHelpRow(_overlay, "Ctrl+M", "Messages"); + addHelpRow(_overlay, "Ctrl+N", "New message"); + addHelpRow(_overlay, "Ctrl+S", "Settings"); + addHelpRow(_overlay, "Ctrl+A", "Announce"); + addHelpRow(_overlay, "Ctrl+D/I/T/R", "Diagnostics tools"); - mkLabel("HOTKEYS", Theme::ACCENT); - mkLabel("Ctrl+H Help", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+M Messages", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+N New Message", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+S Settings", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+A Announce", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+D Diagnostics", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+I AutoIface dump", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+T Radio Test", Theme::TEXT_PRIMARY); - mkLabel("Ctrl+R RSSI Monitor", Theme::TEXT_PRIMARY); - mkLabel(", / Prev/Next Tab", Theme::TEXT_PRIMARY); - mkLabel("Esc Back", Theme::TEXT_PRIMARY); - mkLabel("", Theme::TEXT_MUTED); - mkLabel("Any key to close", Theme::TEXT_MUTED); + lv_obj_t* footer = makeLabel(_overlay, "Any key or tap closes", + &lv_font_ratdeck_10, Theme::TEXT_MUTED, + 260, LV_TEXT_ALIGN_CENTER); + lv_obj_set_style_pad_top(footer, 2, 0); lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); } @@ -56,6 +98,7 @@ void LvHelpOverlay::toggle() { } bool LvHelpOverlay::handleKey(const KeyEvent& event) { + (void)event; if (_visible) { hide(); return true; diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp index fc71476..a919d30 100644 --- a/src/ui/screens/LvHomeScreen.cpp +++ b/src/ui/screens/LvHomeScreen.cpp @@ -2,6 +2,7 @@ #include "ui/Theme.h" #include "ui/LvTheme.h" #include "ui/LvInput.h" +#include "ui/LxmFaceAvatar.h" #include "reticulum/ReticulumManager.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" @@ -12,50 +13,211 @@ #include #include "fonts/fonts.h" +namespace { + +constexpr lv_coord_t kPad = 6; +constexpr lv_coord_t kPanelW = Theme::CONTENT_W - (kPad * 2); +constexpr lv_coord_t kIdentityY = 4; +constexpr lv_coord_t kIdentityH = 58; +constexpr lv_coord_t kChipY = 68; +constexpr lv_coord_t kChipH = 36; +constexpr lv_coord_t kStatY = 108; +constexpr lv_coord_t kStatH = 38; +constexpr lv_coord_t kFooterY = 152; +constexpr lv_coord_t kFooterH = 38; +constexpr lv_coord_t kCellW = 98; +constexpr lv_coord_t kGap = 7; +constexpr int kAvatarSize = 40; + +lv_obj_t* makePanel(lv_obj_t* parent, lv_coord_t x, lv_coord_t y, lv_coord_t w, + lv_coord_t h, uint32_t bg, uint32_t border) { + lv_obj_t* obj = lv_obj_create(parent); + lv_obj_set_pos(obj, x, y); + lv_obj_set_size(obj, w, h); + lv_obj_set_style_bg_color(obj, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(obj, lv_color_hex(border), 0); + lv_obj_set_style_border_width(obj, 1, 0); + lv_obj_set_style_radius(obj, 4, 0); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_shadow_width(obj, 0, 0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + return obj; +} + +lv_obj_t* makeLabel(lv_obj_t* parent, lv_coord_t x, lv_coord_t y, lv_coord_t w, + const lv_font_t* font, uint32_t color, const char* text, + lv_text_align_t align = LV_TEXT_ALIGN_LEFT) { + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_pos(lbl, x, y); + lv_obj_set_width(lbl, w); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_align(lbl, align, 0); + lv_label_set_text(lbl, text); + return lbl; +} + +lv_obj_t* makeCaption(lv_obj_t* parent, const char* text) { + lv_obj_t* lbl = makeLabel(parent, 7, 4, 84, &lv_font_ratdeck_10, + Theme::TEXT_MUTED, text); + lv_obj_set_style_text_letter_space(lbl, 1, 0); + return lbl; +} + +lv_obj_t* makeChip(lv_obj_t* parent, lv_coord_t x, const char* title, + lv_obj_t** valueLabel) { + lv_obj_t* chip = makePanel(parent, x, kChipY, kCellW, kChipH, + Theme::BG_ELEVATED, Theme::BORDER); + makeCaption(chip, title); + *valueLabel = makeLabel(chip, 7, 18, 84, &lv_font_ratdeck_12, + Theme::TEXT_SECONDARY, "..."); + return chip; +} + +lv_obj_t* makeStat(lv_obj_t* parent, lv_coord_t x, const char* title, + lv_obj_t** valueLabel) { + lv_obj_t* stat = makePanel(parent, x, kStatY, kCellW, kStatH, + Theme::BG_ELEVATED, Theme::BORDER); + makeCaption(stat, title); + *valueLabel = makeLabel(stat, 7, 17, 84, &lv_font_ratdeck_14, + Theme::TEXT_PRIMARY, "0"); + return stat; +} + +void setPanelTone(lv_obj_t* obj, uint32_t bg, uint32_t border) { + if (!obj) return; + lv_obj_set_style_bg_color(obj, lv_color_hex(bg), 0); + lv_obj_set_style_border_color(obj, lv_color_hex(border), 0); +} + +void setChipState(lv_obj_t* chip, lv_obj_t* label, const char* text, bool active, + bool warning = false) { + uint32_t color = Theme::TEXT_MUTED; + uint32_t bg = Theme::BG_ELEVATED; + uint32_t border = Theme::BORDER; + + if (active) { + color = Theme::SUCCESS; + bg = Theme::PRIMARY_SUBTLE; + border = Theme::PRIMARY; + } else if (warning) { + color = Theme::WARNING_CLR; + border = Theme::WARNING_CLR; + } + + setPanelTone(chip, bg, border); + if (label) { + lv_label_set_text(label, text); + lv_obj_set_style_text_color(label, lv_color_hex(color), 0); + } +} + +void formatAge(unsigned long elapsedMs, char* out, size_t len) { + unsigned long sec = elapsedMs / 1000; + if (sec < 60) { + snprintf(out, len, "%lus ago", sec); + } else if (sec < 3600) { + snprintf(out, len, "%lum ago", sec / 60); + } else { + snprintf(out, len, "%luh ago", sec / 3600); + } +} + +} // namespace + 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, 8, 0); - lv_obj_set_style_pad_row(parent, 6, 0); + lv_obj_set_layout(parent, 0); + 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_set_style_pad_all(parent, 0, 0); + lv_obj_set_style_border_width(parent, 0, 0); + lv_obj_set_style_radius(parent, 0, 0); lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - // Name — large, accent color - _lblName = lv_label_create(parent); - lv_obj_set_style_text_font(_lblName, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblName, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(_lblName, "..."); + _identityPanel = makePanel(parent, kPad, kIdentityY, kPanelW, kIdentityH, + Theme::BG_ELEVATED, Theme::BORDER); - // ID hash — smaller, muted - _lblId = lv_label_create(parent); - lv_obj_set_style_text_font(_lblId, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(_lblId, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_text(_lblId, "ID: ..."); + lv_obj_t* rail = lv_obj_create(_identityPanel); + lv_obj_set_pos(rail, 0, 0); + lv_obj_set_size(rail, 3, kIdentityH); + lv_obj_set_style_bg_color(rail, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_bg_opa(rail, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(rail, 0, 0); + lv_obj_set_style_radius(rail, 0, 0); + lv_obj_clear_flag(rail, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); - // Transport indicators row - _lblStatus = lv_label_create(parent); - lv_obj_set_style_text_font(_lblStatus, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(_lblStatus, "Status: ..."); - lv_obj_set_style_pad_top(_lblStatus, 4, 0); + _avatarBuffer.assign(LxmFaceAvatar::bufferSize(kAvatarSize), 0); + auto avatar = LxmFaceAvatar::create(_identityPanel, 11, 9, kAvatarSize, + _avatarBuffer.data(), + Theme::PRIMARY_SUBTLE, Theme::BORDER_ACTIVE); + _avatarBox = avatar.box; + _avatarCanvas = avatar.canvas; + _avatarSeed = ""; - // Online nodes - _lblNodes = lv_label_create(parent); - lv_obj_set_style_text_font(_lblNodes, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(_lblNodes, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(_lblNodes, "Online Nodes: ..."); + _lblConsoleTitle = makeLabel(_identityPanel, 58, 6, 116, + &lv_font_ratdeck_10, Theme::ACCENT, + "IDENTITY:"); + lv_obj_set_style_text_letter_space(_lblConsoleTitle, 1, 0); + + _lblStatus = makeLabel(_identityPanel, 184, 6, 114, &lv_font_ratdeck_10, + Theme::TEXT_SECONDARY, "OFFLINE", + LV_TEXT_ALIGN_RIGHT); + lv_obj_set_style_text_letter_space(_lblStatus, 1, 0); + + _lblName = makeLabel(_identityPanel, 58, 21, 240, &lv_font_ratdeck_14, + Theme::TEXT_PRIMARY, "..."); + lv_label_set_long_mode(_lblName, LV_LABEL_LONG_DOT); + + _lblId = makeLabel(_identityPanel, 58, 41, 136, &lv_font_ratdeck_10, + Theme::TEXT_SECONDARY, "LXMF ----"); + _lblIdentity = makeLabel(_identityPanel, 198, 41, 100, + &lv_font_ratdeck_10, Theme::TEXT_MUTED, + "ID ----", LV_TEXT_ALIGN_RIGHT); + + _chipLora = makeChip(parent, kPad, "LORA", &_lblLoraState); + _chipTcp = makeChip(parent, kPad + kCellW + kGap, "TCP", &_lblTcpState); + _chipWifi = makeChip(parent, kPad + (kCellW + kGap) * 2, "WIFI", &_lblWifiState); + + _statNodes = makeStat(parent, kPad, "PEERS", &_lblNodes); + _statPaths = makeStat(parent, kPad + kCellW + kGap, "AUDIO", &_lblPaths); + lv_obj_add_flag(_statPaths, LV_OBJ_FLAG_CLICKABLE); + lv_group_add_obj(LvInput::group(), _statPaths); + lv_obj_add_event_cb(_statPaths, [](lv_event_t* e) { + auto* self = (LvHomeScreen*)lv_event_get_user_data(e); + self->toggleAudio(); + }, LV_EVENT_CLICKED, this); + + _statLinks = makeStat(parent, kPad + (kCellW + kGap) * 2, "GPS", &_lblLinks); + + lv_obj_t* footer = makePanel(parent, kPad, kFooterY, 198, kFooterH, + Theme::BG_ELEVATED, Theme::BORDER); + _lblSummary = makeLabel(footer, 8, 6, 182, &lv_font_ratdeck_12, + Theme::TEXT_SECONDARY, "Waiting for transport"); + lv_label_set_long_mode(_lblSummary, LV_LABEL_LONG_DOT); + _lblLastAnnounce = makeLabel(footer, 8, 23, 182, &lv_font_ratdeck_10, + Theme::TEXT_MUTED, "Announced: never"); + lv_label_set_long_mode(_lblLastAnnounce, LV_LABEL_LONG_CLIP); - // Announce button — compact, understated _btnAnnounce = lv_btn_create(parent); - lv_obj_set_size(_btnAnnounce, 90, 24); + lv_obj_set_pos(_btnAnnounce, 210, kFooterY); + lv_obj_set_size(_btnAnnounce, 104, kFooterH); lv_obj_add_style(_btnAnnounce, LvTheme::styleBtn(), 0); lv_obj_add_style(_btnAnnounce, LvTheme::styleBtnFocused(), LV_STATE_FOCUSED); lv_obj_add_style(_btnAnnounce, LvTheme::styleBtnPressed(), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(_btnAnnounce, lv_color_hex(Theme::PRIMARY_SUBTLE), 0); + lv_obj_set_style_border_color(_btnAnnounce, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_shadow_width(_btnAnnounce, 0, 0); + lv_obj_set_style_radius(_btnAnnounce, 4, 0); + lv_obj_set_style_pad_all(_btnAnnounce, 0, 0); - lv_obj_t* btnLbl = lv_label_create(_btnAnnounce); - lv_obj_set_style_text_font(btnLbl, &lv_font_ratdeck_10, 0); - lv_label_set_text(btnLbl, "Announce"); - lv_obj_center(btnLbl); + _lblAnnounceAction = lv_label_create(_btnAnnounce); + lv_obj_set_style_text_font(_lblAnnounceAction, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(_lblAnnounceAction, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(_lblAnnounceAction, "ANNOUNCE"); + lv_obj_center(_lblAnnounceAction); lv_group_add_obj(LvInput::group(), _btnAnnounce); lv_obj_add_event_cb(_btnAnnounce, [](lv_event_t* e) { @@ -63,87 +225,173 @@ void LvHomeScreen::createUI(lv_obj_t* parent) { if (self->_announceCb) self->_announceCb(); }, LV_EVENT_CLICKED, this); - // Force refresh on new UI + _lastRefreshMs = 0; _lastUptime = ULONG_MAX; _lastHeap = UINT32_MAX; refreshUI(); } void LvHomeScreen::onEnter() { + _lastRefreshMs = 0; _lastUptime = ULONG_MAX; _lastHeap = UINT32_MAX; + _avatarSeed = ""; refreshUI(); } void LvHomeScreen::refreshUI() { if (!_lblName) return; - unsigned long upMins = millis() / 60000; + unsigned long now = millis(); + bool force = (_lastUptime == ULONG_MAX || _lastHeap == UINT32_MAX); + if (!force && (now - _lastRefreshMs) < 1000) return; + _lastRefreshMs = now; + + unsigned long upMins = now / 60000; uint32_t heap = ESP.getFreeHeap() / 1024; - if (upMins == _lastUptime && heap == _lastHeap) return; _lastUptime = upMins; _lastHeap = heap; - // Name + String displayName; if (_cfg && !_cfg->settings().displayName.isEmpty()) { - lv_label_set_text(_lblName, _cfg->settings().displayName.c_str()); + displayName = _cfg->settings().displayName; } else if (_rns) { String dh = _rns->destinationHashHex(); - String fallback = "Ratspeak.org-" + dh.substring(0, 3); - lv_label_set_text(_lblName, fallback.c_str()); + displayName = "Ratspeak.org-" + dh.substring(0, 3); } else { - lv_label_set_text(_lblName, "---"); + displayName = "Ratdeck"; } + lv_label_set_text(_lblName, displayName.c_str()); + + String avatarSeed = displayName; - // ID (LXMF destination hash, 12 chars) if (_rns) { String dh = _rns->destinationHashHex(); + if (!dh.isEmpty() && dh != "unknown") avatarSeed = dh; if (dh.length() > 12) dh = dh.substring(0, 12); - lv_label_set_text_fmt(_lblId, "LXMF ID: %s", dh.c_str()); - } else { - lv_label_set_text(_lblId, "LXMF ID: ---"); - } + lv_label_set_text_fmt(_lblId, "LXMF %s", dh.c_str()); + + String ih = _rns->identityHash(); + if (ih.length() > 14) ih = ih.substring(0, 14); + lv_label_set_text_fmt(_lblIdentity, "ID %s", ih.c_str()); + } else { + lv_label_set_text(_lblId, "LXMF ----"); + lv_label_set_text(_lblIdentity, "ID ----"); + } + renderAvatar(avatarSeed); - // Status with transport indicators bool loraUp = _radioOnline && _radio && _radio->isRadioOnline(); bool tcpUp = false; + int tcpConfigured = 0; if (_tcpClients) { for (auto* tcp : *_tcpClients) { + if (tcp) tcpConfigured++; if (tcp && tcp->isConnected()) { tcpUp = true; break; } } } bool wifiUp = WiFi.status() == WL_CONNECTED; + bool transportActive = _rns && _rns->isTransportActive(); + bool reachable = loraUp || tcpUp; - if (loraUp && tcpUp) { - lv_label_set_text(_lblStatus, "LoRa + TCP"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); - } else if (loraUp && wifiUp) { - lv_label_set_text(_lblStatus, "LoRa + WiFi"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); - } else if (loraUp) { - lv_label_set_text(_lblStatus, "LoRa"); - lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); - } else if (tcpUp) { - lv_label_set_text(_lblStatus, "TCP"); + setChipState(_chipLora, _lblLoraState, loraUp ? "ON" : "OFF", loraUp); + setChipState(_chipTcp, _lblTcpState, tcpUp ? "ON" : "OFF", + tcpUp, tcpConfigured > 0 && wifiUp); + setChipState(_chipWifi, _lblWifiState, wifiUp ? "ON" : "OFF", wifiUp); + + if (reachable) { + lv_label_set_text(_lblStatus, "ONLINE"); lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::SUCCESS), 0); + setPanelTone(_identityPanel, Theme::BG_ELEVATED, Theme::PRIMARY); + } else if (wifiUp || transportActive) { + lv_label_set_text(_lblStatus, wifiUp ? "WIFI ONLY" : "ROUTING"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::WARNING_CLR), 0); + setPanelTone(_identityPanel, Theme::BG_ELEVATED, Theme::WARNING_CLR); } else { - lv_label_set_text(_lblStatus, "Offline"); + lv_label_set_text(_lblStatus, "OFFLINE"); lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::ERROR_CLR), 0); + setPanelTone(_identityPanel, Theme::BG_ELEVATED, Theme::BORDER); } - // Online Nodes (30 min window) + int online = 0; if (_am) { - int online = _am->nodesOnlineSince(1800000); - lv_label_set_text_fmt(_lblNodes, "Online Nodes: %d", online); + online = _am->nodesOnlineSince(1800000); + } + lv_label_set_text_fmt(_lblNodes, "%d", online); + lv_obj_set_style_text_color(_lblNodes, lv_color_hex( + online > 0 ? Theme::ACCENT : Theme::TEXT_SECONDARY), 0); + setPanelTone(_statNodes, online > 0 ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, + online > 0 ? Theme::PRIMARY : Theme::BORDER); + + bool audioOn = !_cfg || _cfg->settings().audioEnabled; + lv_label_set_text(_lblPaths, audioOn ? "ON" : "OFF"); + lv_obj_set_style_text_color(_lblPaths, lv_color_hex( + audioOn ? Theme::SUCCESS : Theme::TEXT_MUTED), 0); + setPanelTone(_statPaths, audioOn ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, + audioOn ? Theme::PRIMARY : Theme::BORDER); + +#if HAS_GPS + bool gpsOn = _cfg && (_cfg->settings().gpsTimeEnabled || _cfg->settings().gpsLocationEnabled); +#else + bool gpsOn = false; +#endif + lv_label_set_text(_lblLinks, gpsOn ? "ON" : "OFF"); + lv_obj_set_style_text_color(_lblLinks, lv_color_hex( + gpsOn ? Theme::SUCCESS : Theme::TEXT_MUTED), 0); + setPanelTone(_statLinks, gpsOn ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, + gpsOn ? Theme::PRIMARY : Theme::BORDER); + + if (!_rns) { + lv_label_set_text(_lblSummary, "Identity unavailable"); + lv_obj_set_style_text_color(_lblSummary, lv_color_hex(Theme::WARNING_CLR), 0); + } else if (!reachable && transportActive) { + lv_label_set_text(_lblSummary, "Transport active, no link"); + lv_obj_set_style_text_color(_lblSummary, lv_color_hex(Theme::WARNING_CLR), 0); + } else if (!reachable) { + lv_label_set_text(_lblSummary, "No active transport"); + lv_obj_set_style_text_color(_lblSummary, lv_color_hex(Theme::ERROR_CLR), 0); + } else if (online == 0) { + lv_label_set_text(_lblSummary, "Listening for peers"); + lv_obj_set_style_text_color(_lblSummary, lv_color_hex(Theme::TEXT_SECONDARY), 0); + } else if (online == 1) { + lv_label_set_text(_lblSummary, "1 peer heard in 30m"); + lv_obj_set_style_text_color(_lblSummary, lv_color_hex(Theme::ACCENT), 0); } else { - lv_label_set_text(_lblNodes, "Online Nodes: 0"); + lv_label_set_text_fmt(_lblSummary, "%d peers heard in 30m", online); + lv_obj_set_style_text_color(_lblSummary, lv_color_hex(Theme::ACCENT), 0); + } + + if (_rns && _rns->lastAnnounceTime() > 0) { + char age[16]; + formatAge(now - _rns->lastAnnounceTime(), age, sizeof(age)); + lv_label_set_text_fmt(_lblLastAnnounce, "Announced: %s", age); + } else { + lv_label_set_text(_lblLastAnnounce, "Announced: never"); } } bool LvHomeScreen::handleKey(const KeyEvent& event) { if (event.enter || event.character == '\n' || event.character == '\r') { + lv_obj_t* focused = lv_group_get_focused(LvInput::group()); + if (focused == _statPaths) { + toggleAudio(); + return true; + } if (_announceCb) _announceCb(); return true; } return false; } + +void LvHomeScreen::toggleAudio() { + if (_audioToggleCb) _audioToggleCb(); + _lastRefreshMs = 0; + _lastUptime = ULONG_MAX; + refreshUI(); +} + +void LvHomeScreen::renderAvatar(const String& seed) { + if (seed.isEmpty() || seed == _avatarSeed || !_avatarCanvas) return; + if (LxmFaceAvatar::render(_avatarCanvas, seed)) { + _avatarSeed = seed; + } +} diff --git a/src/ui/screens/LvHomeScreen.h b/src/ui/screens/LvHomeScreen.h index 1f889fd..e5e8777 100644 --- a/src/ui/screens/LvHomeScreen.h +++ b/src/ui/screens/LvHomeScreen.h @@ -1,6 +1,7 @@ #pragma once #include "ui/UIManager.h" +#include #include #include @@ -26,6 +27,7 @@ public: void setRadioOnline(bool online) { _radioOnline = online; } void setTCPClients(std::vector* clients) { _tcpClients = clients; } void setAnnounceCallback(std::function cb) { _announceCb = cb; } + void setAudioToggleCallback(std::function cb) { _audioToggleCb = cb; } const char* title() const override { return "Home"; } @@ -38,12 +40,39 @@ private: std::vector* _tcpClients = nullptr; bool _radioOnline = false; std::function _announceCb; + std::function _audioToggleCb; + unsigned long _lastRefreshMs = 0; unsigned long _lastUptime = 0; uint32_t _lastHeap = 0; + String _avatarSeed; + std::vector _avatarBuffer; + lv_obj_t* _identityPanel = nullptr; + lv_obj_t* _avatarBox = nullptr; + lv_obj_t* _avatarCanvas = nullptr; + lv_obj_t* _chipLora = nullptr; + lv_obj_t* _chipTcp = nullptr; + lv_obj_t* _chipWifi = nullptr; + lv_obj_t* _statNodes = nullptr; + lv_obj_t* _statPaths = nullptr; + lv_obj_t* _statLinks = nullptr; + + lv_obj_t* _lblConsoleTitle = nullptr; lv_obj_t* _lblName = nullptr; lv_obj_t* _lblId = nullptr; + lv_obj_t* _lblIdentity = nullptr; lv_obj_t* _lblStatus = nullptr; + lv_obj_t* _lblLoraState = nullptr; + lv_obj_t* _lblTcpState = nullptr; + lv_obj_t* _lblWifiState = nullptr; lv_obj_t* _lblNodes = nullptr; + lv_obj_t* _lblPaths = nullptr; + lv_obj_t* _lblLinks = nullptr; + lv_obj_t* _lblSummary = nullptr; + lv_obj_t* _lblLastAnnounce = nullptr; lv_obj_t* _btnAnnounce = nullptr; + lv_obj_t* _lblAnnounceAction = nullptr; + + void toggleAudio(); + void renderAvatar(const String& seed); }; diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index 80d0ad8..57468b6 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -6,8 +6,81 @@ #include "reticulum/AnnounceManager.h" #include #include +#include #include "fonts/fonts.h" +namespace { + +constexpr int kHeaderH = 36; +constexpr int kInputH = 31; +constexpr int kComposerButtonW = 44; +constexpr int kBubbleMaxW = Theme::CONTENT_W * 3 / 4; +constexpr unsigned long kFreshNodeMs = 10UL * 60UL * 1000UL; +constexpr const char* kComposerPlaceholder = "Message..."; + +bool isPendingStatus(LXMFStatus status) { + return status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING; +} + +uint32_t bubbleBorderColor(const LXMFMessage& msg) { + if (msg.incoming) return Theme::DIVIDER; + if (msg.status == LXMFStatus::FAILED) return Theme::ERROR_CLR; + if (isPendingStatus(msg.status)) return Theme::WARNING_CLR; + if (msg.status == LXMFStatus::DELIVERED) return Theme::PRIMARY_MUTED; + return Theme::BORDER; +} + +uint32_t bubbleBorderColor(LXMFStatus status) { + if (status == LXMFStatus::FAILED) return Theme::ERROR_CLR; + if (isPendingStatus(status)) return Theme::WARNING_CLR; + if (status == LXMFStatus::DELIVERED) return Theme::PRIMARY_MUTED; + return Theme::BORDER; +} + +bool formatClock(double ts, char* out, size_t outLen) { + if (!out || outLen == 0 || ts <= 1700000000) return false; + time_t t = (time_t)ts; + struct tm* tm = localtime(&t); + if (!tm) return false; + snprintf(out, outLen, "%02d:%02d", tm->tm_hour, tm->tm_min); + return true; +} + +int textWidthForBubble(const std::string& content) { + size_t longest = 0; + size_t current = 0; + for (char ch : content) { + if (ch == '\n' || ch == '\r') { + if (current > longest) longest = current; + current = 0; + } else { + current++; + } + } + if (current > longest) longest = current; + if (content.size() > 34 || longest > 28) return kBubbleMaxW - 18; + int width = (int)longest * 7 + 12; + if (width < 54) width = 54; + int maxW = kBubbleMaxW - 18; + if (width > maxW) width = maxW; + return width; +} + +std::string shortHex(const std::string& peerHex) { + if (peerHex.size() <= 12) return peerHex; + return peerHex.substr(0, 6) + ".." + peerHex.substr(peerHex.size() - 4); +} + +void makeTransparent(lv_obj_t* obj) { + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); +} + +} // namespace + std::string LvMessageView::getPeerName() { if (_am) { std::string name = _am->lookupName(_peerHex); @@ -16,6 +89,63 @@ std::string LvMessageView::getPeerName() { return _peerHex.substr(0, 12); } +void LvMessageView::updateHeader() { + if (!_lblHeader) return; + + std::string name = getPeerName(); + lv_label_set_text(_lblHeader, name.c_str()); + + const DiscoveredNode* node = _am ? _am->findNodeByHex(_peerHex) : nullptr; + const char* identity = "unknown"; + const char* state = "UNKNOWN"; + uint32_t stateColor = Theme::TEXT_MUTED; + char meta[72]; + + if (node) { + bool fresh = node->lastSeen != 0 && (millis() - node->lastSeen) <= kFreshNodeMs; + identity = node->saved ? "friend" : "heard"; + state = fresh ? "ONLINE" : (node->saved ? "FRIEND" : "STALE"); + stateColor = fresh ? Theme::SUCCESS : (node->saved ? Theme::PRIMARY : Theme::TEXT_SECONDARY); + if (node->rssi != 0 || node->snr != 0.0f) { + snprintf(meta, sizeof(meta), "%s | %s | %ddBm %.1fdB %uH", + identity, shortHex(_peerHex).c_str(), node->rssi, node->snr, (unsigned)node->hops); + } else if (node->hops > 0) { + snprintf(meta, sizeof(meta), "%s | %s | %uH", + identity, shortHex(_peerHex).c_str(), (unsigned)node->hops); + } else { + snprintf(meta, sizeof(meta), "%s | %s", identity, shortHex(_peerHex).c_str()); + } + } else { + snprintf(meta, sizeof(meta), "%s | %s", identity, shortHex(_peerHex).c_str()); + } + + if (_lblHeaderMeta) { + lv_label_set_text(_lblHeaderMeta, meta); + } + if (_lblHeaderState) { + lv_label_set_text(_lblHeaderState, state); + lv_obj_set_style_text_color(_lblHeaderState, lv_color_hex(stateColor), 0); + } +} + +void LvMessageView::updateComposerState() { + if (!_btnSend) return; + bool hasText = !_inputText.empty(); + lv_obj_set_style_border_color(_btnSend, lv_color_hex(hasText ? Theme::PRIMARY : Theme::BORDER), 0); + lv_obj_set_style_bg_color(_btnSend, lv_color_hex(hasText ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED), 0); + if (_textarea) { + lv_obj_set_style_border_color(_textarea, lv_color_hex(hasText ? Theme::PRIMARY_MUTED : Theme::BORDER), 0); + } + refreshComposerPlaceholder(); +} + +void LvMessageView::refreshComposerPlaceholder() { + if (!_textarea) return; + bool focused = lv_obj_has_state(_textarea, LV_STATE_FOCUSED); + lv_textarea_set_placeholder_text(_textarea, + (_inputText.empty() && !focused) ? kComposerPlaceholder : ""); +} + void LvMessageView::createUI(lv_obj_t* parent) { _screen = parent; lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE); @@ -28,13 +158,11 @@ void LvMessageView::createUI(lv_obj_t* parent) { lv_obj_set_style_pad_row(parent, 0, 0); const lv_font_t* font = &lv_font_ratdeck_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_size(_header, lv_pct(100), kHeaderH); + lv_obj_set_style_bg_color(_header, lv_color_hex(Theme::BG_ELEVATED), 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); @@ -43,10 +171,31 @@ void LvMessageView::createUI(lv_obj_t* parent) { lv_obj_set_style_radius(_header, 0, 0); lv_obj_clear_flag(_header, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* backLbl = lv_label_create(_header); + lv_obj_set_style_text_font(backLbl, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(backLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(backLbl, "<"); + lv_obj_align(backLbl, LV_ALIGN_LEFT_MID, 6, 0); + _lblHeader = lv_label_create(_header); lv_obj_set_style_text_font(_lblHeader, &lv_font_ratdeck_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); + lv_label_set_long_mode(_lblHeader, LV_LABEL_LONG_DOT); + lv_obj_set_width(_lblHeader, 188); + lv_obj_set_pos(_lblHeader, 22, 3); + + _lblHeaderMeta = lv_label_create(_header); + lv_obj_set_style_text_font(_lblHeaderMeta, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(_lblHeaderMeta, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_long_mode(_lblHeaderMeta, LV_LABEL_LONG_DOT); + lv_obj_set_width(_lblHeaderMeta, 214); + lv_obj_set_pos(_lblHeaderMeta, 22, 21); + + _lblHeaderState = lv_label_create(_header); + lv_obj_set_style_text_font(_lblHeaderState, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(_lblHeaderState, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(_lblHeaderState, "UNKNOWN"); + lv_obj_align(_lblHeaderState, LV_ALIGN_TOP_RIGHT, -8, 8); // Make header tappable for back navigation lv_obj_add_flag(_header, LV_OBJ_FLAG_CLICKABLE); @@ -62,8 +211,8 @@ void LvMessageView::createUI(lv_obj_t* parent) { 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_pad_all(_msgScroll, 6, 0); + lv_obj_set_style_pad_row(_msgScroll, 7, 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); @@ -71,8 +220,8 @@ void LvMessageView::createUI(lv_obj_t* parent) { // 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_size(_inputRow, lv_pct(100), kInputH); + lv_obj_set_style_bg_color(_inputRow, lv_color_hex(Theme::BG_ELEVATED), 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); @@ -82,37 +231,71 @@ void LvMessageView::createUI(lv_obj_t* parent) { 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_set_size(_textarea, Theme::CONTENT_W - kComposerButtonW - 12, 23); 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_textarea_set_max_length(_textarea, MAX_COMPOSER_CHARS); + lv_textarea_set_placeholder_text(_textarea, kComposerPlaceholder); lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0); - lv_obj_set_style_border_width(_textarea, 0, 0); + lv_obj_add_style(_textarea, LvTheme::styleTextareaFocused(), LV_STATE_FOCUSED); + lv_obj_set_style_border_width(_textarea, 1, 0); lv_obj_set_style_text_font(_textarea, font, 0); lv_obj_set_style_pad_all(_textarea, 2, 0); + lv_obj_add_event_cb(_textarea, [](lv_event_t* e) { + auto* self = (LvMessageView*)lv_event_get_user_data(e); + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED || code == LV_EVENT_PRESSED) { + lv_textarea_set_placeholder_text(self->_textarea, ""); + } else if (code == LV_EVENT_DEFOCUSED) { + self->refreshComposerPlaceholder(); + } + }, LV_EVENT_ALL, this); _btnSend = lv_btn_create(_inputRow); - lv_obj_set_size(_btnSend, 40, 22); + lv_obj_set_size(_btnSend, kComposerButtonW, 23); lv_obj_align(_btnSend, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_add_style(_btnSend, LvTheme::styleBtn(), 0); lv_obj_set_style_pad_all(_btnSend, 0, 0); lv_obj_t* sendLbl = lv_label_create(_btnSend); lv_obj_set_style_text_font(sendLbl, &lv_font_ratdeck_10, 0); - lv_label_set_text(sendLbl, "Send"); + lv_obj_set_style_text_color(sendLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(sendLbl, "SEND"); lv_obj_center(sendLbl); lv_obj_add_event_cb(_btnSend, [](lv_event_t* e) { auto* self = (LvMessageView*)lv_event_get_user_data(e); - self->sendCurrentMessage(); + if (self->_suppressNextSendClick) { + self->_suppressNextSendClick = false; + return; + } + self->sendCurrentMessage(false); }, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(_btnSend, [](lv_event_t* e) { + auto* self = (LvMessageView*)lv_event_get_user_data(e); + self->_suppressNextSendClick = true; + self->showSendModeMenu(); + }, LV_EVENT_LONG_PRESSED, this); + + updateHeader(); + updateComposerState(); } void LvMessageView::destroyUI() { + hideSendModeMenu(); _header = nullptr; _lblHeader = nullptr; + _lblHeaderMeta = nullptr; + _lblHeaderState = nullptr; _msgScroll = nullptr; _inputRow = nullptr; _textarea = nullptr; _btnSend = nullptr; + _statusLabels.clear(); + _textLabels.clear(); + _bubbleBoxes.clear(); + for (int i = 0; i < 3; i++) { + _sendRows[i] = nullptr; + _sendLabels[i] = nullptr; + } LvScreen::destroyUI(); } @@ -121,12 +304,15 @@ void LvMessageView::onEnter() { _lxmf->markRead(_peerHex); // Update unread badge on Messages tab if (_ui) _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount()); - // Register status callback — partial update without full rebuild + // Register status callback - partial update without full rebuild std::string peer = _peerHex; - _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double ts, LXMFStatus newStatus) { + _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double ts, uint32_t savedCounter, LXMFStatus newStatus) { if (peerHex != peer) return; - for (int i = _cachedMsgs.size() - 1; i >= 0; i--) { - if (!_cachedMsgs[i].incoming && fabs(_cachedMsgs[i].timestamp - ts) < 1.0) { + for (int i = (int)_cachedMsgs.size() - 1; i >= 0; i--) { + bool sameMessage = savedCounter > 0 + ? _cachedMsgs[i].savedCounter == savedCounter + : std::fabs(_cachedMsgs[i].timestamp - ts) < 1.0; + if (!_cachedMsgs[i].incoming && sameMessage) { _cachedMsgs[i].status = newStatus; updateMessageStatus(i, newStatus); return; @@ -137,23 +323,26 @@ void LvMessageView::onEnter() { _lastMsgCount = -1; _lastRefreshMs = 0; _inputText.clear(); + hideSendModeMenu(); - 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, ""); + refreshComposerPlaceholder(); } + updateHeader(); + updateComposerState(); _cachedMsgs.clear(); // Force fresh load rebuildMessages(); } void LvMessageView::onExit() { if (_lxmf) _lxmf->setStatusCallback(nullptr); + hideSendModeMenu(); _inputText.clear(); _cachedMsgs.clear(); + _statusLabels.clear(); + _textLabels.clear(); + _bubbleBoxes.clear(); } void LvMessageView::refreshUI() { @@ -161,6 +350,7 @@ void LvMessageView::refreshUI() { unsigned long now = millis(); if (now - _lastRefreshMs < REFRESH_INTERVAL_MS) return; _lastRefreshMs = now; + updateHeader(); // Only reload from disk when message count changes (new messages arrive) auto* summary = _lxmf->getConversationSummary(_peerHex); @@ -169,17 +359,21 @@ void LvMessageView::refreshUI() { auto newMsgs = _lxmf->getMessages(_peerHex); if (newMsgs.size() != _cachedMsgs.size()) { if (newMsgs.size() > _cachedMsgs.size()) { - // Incremental append — only create widgets for new messages + // Incremental append - only create widgets for new messages size_t oldCount = _cachedMsgs.size(); _cachedMsgs = std::move(newMsgs); _lastMsgCount = (int)_cachedMsgs.size(); _lastRefreshMs = millis(); - for (size_t i = oldCount; i < _cachedMsgs.size(); i++) { - appendMessage(_cachedMsgs[i]); + if (oldCount == 0) { + rebuildMessages(); + } else { + for (size_t i = oldCount; i < _cachedMsgs.size(); i++) { + appendMessage(_cachedMsgs[i]); + } } lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF); } else { - // Count decreased (deletion?) — full rebuild + // Count decreased (deletion?) - full rebuild _cachedMsgs = std::move(newMsgs); _lastMsgCount = (int)_cachedMsgs.size(); rebuildMessages(); @@ -194,28 +388,31 @@ void LvMessageView::appendMessage(const LXMFMessage& msg) { if (!_msgScroll) return; const lv_font_t* font = &lv_font_ratdeck_12; - int maxBubbleW = Theme::CONTENT_W * 3 / 4; + int textW = textWidthForBubble(msg.content); + if (!msg.incoming && textW < 96) textW = 96; + int boxW = textW + 16; + + lv_obj_set_layout(_msgScroll, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_msgScroll, LV_FLEX_FLOW_COLUMN); - // Bubble container. Reserve pad_bottom for the timestamp row so the - // bubble's allocated height in the flex scroll includes it — without - // this, the time renders into the row gap and looks like it's hanging - // outside the bubble. 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_pad_bottom(bubble, 14, 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); + makeTransparent(bubble); - // 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_width(box, boxW); lv_obj_set_height(box, LV_SIZE_CONTENT); + lv_obj_set_layout(box, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_left(box, 7, 0); + lv_obj_set_style_pad_right(box, 7, 0); + lv_obj_set_style_pad_top(box, 5, 0); + lv_obj_set_style_pad_bottom(box, 5, 0); + lv_obj_set_style_pad_row(box, 3, 0); + lv_obj_set_style_radius(box, 6, 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_border_color(box, lv_color_hex(bubbleBorderColor(msg)), 0); lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); if (msg.incoming) { @@ -227,7 +424,7 @@ void LvMessageView::appendMessage(const LXMFMessage& msg) { } lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); - // Message text color — incoming is plain text, outgoing reflects delivery status + // Message text color - incoming is plain text, outgoing reflects delivery status uint32_t textColor = Theme::TEXT_PRIMARY; // incoming default if (!msg.incoming) { switch (msg.status) { @@ -247,46 +444,51 @@ void LvMessageView::appendMessage(const LXMFMessage& msg) { lv_obj_set_style_text_font(lbl, font, 0); lv_obj_set_style_text_color(lbl, lv_color_hex(textColor), 0); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); - lv_obj_set_width(lbl, maxBubbleW - 14); + lv_obj_set_width(lbl, textW); lv_label_set_text(lbl, msg.content.c_str()); - // Status indicator for outgoing (tracked for partial updates). - // Glyph + colour come from applyStatusGlyph so single-check vs - // double-check rendering stays in one place. - if (!msg.incoming) { - lv_obj_t* statusLbl = lv_label_create(box); - lv_obj_set_style_text_font(statusLbl, &lv_font_montserrat_12, 0); - lv_obj_align(statusLbl, LV_ALIGN_BOTTOM_RIGHT, 0, 1); - applyStatusGlyph(statusLbl, msg.status); - _statusLabels.push_back(statusLbl); - _textLabels.push_back(lbl); - } else { - _statusLabels.push_back(nullptr); - _textLabels.push_back(nullptr); - } + char timeBuf[8] = {0}; + bool hasTime = formatClock(msg.timestamp, timeBuf, sizeof(timeBuf)); + bool needsMeta = hasTime || !msg.incoming; + lv_obj_t* statusLbl = nullptr; + if (needsMeta) { + lv_obj_t* meta = lv_obj_create(box); + lv_obj_set_size(meta, textW, 12); + makeTransparent(meta); - // Timestamp below bubble - if (msg.timestamp > 1700000000) { - 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); + if (hasTime) { + lv_obj_t* timeLbl = lv_label_create(meta); lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::TEXT_MUTED), 0); lv_label_set_text(timeLbl, timeBuf); - if (msg.incoming) { - lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_LEFT, 2, 1); - } else { - lv_obj_align_to(timeLbl, box, LV_ALIGN_OUT_BOTTOM_RIGHT, -2, 1); - } + lv_obj_align(timeLbl, LV_ALIGN_LEFT_MID, 0, 0); + } + + if (!msg.incoming) { + statusLbl = lv_label_create(meta); + lv_obj_set_style_text_font(statusLbl, &lv_font_ratdeck_10, 0); + applyStatusGlyph(statusLbl, msg.status); + lv_obj_align(statusLbl, LV_ALIGN_RIGHT_MID, 0, 0); } } + + if (!msg.incoming) { + _statusLabels.push_back(statusLbl); + _textLabels.push_back(lbl); + _bubbleBoxes.push_back(box); + } else { + _statusLabels.push_back(nullptr); + _textLabels.push_back(nullptr); + _bubbleBoxes.push_back(nullptr); + } + + lv_obj_update_layout(box); + lv_obj_set_height(bubble, lv_obj_get_height(box)); } void LvMessageView::rebuildMessages() { if (!_lxmf || !_msgScroll) return; + unsigned long startMs = millis(); // Only load from disk if _cachedMsgs is empty (first call or after send) if (_cachedMsgs.empty()) { @@ -297,6 +499,44 @@ void LvMessageView::rebuildMessages() { lv_obj_clean(_msgScroll); _statusLabels.clear(); _textLabels.clear(); + _bubbleBoxes.clear(); + + if (_cachedMsgs.empty()) { + lv_obj_set_layout(_msgScroll, 0); + + lv_obj_t* empty = lv_obj_create(_msgScroll); + lv_obj_set_size(empty, 264, 88); + lv_obj_center(empty); + lv_obj_set_style_bg_color(empty, lv_color_hex(Theme::BG_ELEVATED), 0); + lv_obj_set_style_bg_opa(empty, LV_OPA_70, 0); + lv_obj_set_style_border_color(empty, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(empty, 1, 0); + lv_obj_set_style_radius(empty, 6, 0); + lv_obj_set_style_pad_all(empty, 0, 0); + lv_obj_clear_flag(empty, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* icon = lv_label_create(empty); + lv_obj_set_style_text_font(icon, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(icon, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(icon, LV_SYMBOL_ENVELOPE); + lv_obj_align(icon, LV_ALIGN_TOP_MID, 0, 13); + + lv_obj_t* title = lv_label_create(empty); + lv_obj_set_style_text_font(title, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(title, lv_color_hex(Theme::TEXT_PRIMARY), 0); + lv_label_set_text(title, "No messages yet"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 32); + + lv_obj_t* sub = lv_label_create(empty); + lv_obj_set_style_text_font(sub, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(sub, "Thread is quiet"); + lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 54); + return; + } + + lv_obj_set_layout(_msgScroll, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_msgScroll, LV_FLEX_FLOW_COLUMN); for (const auto& msg : _cachedMsgs) { appendMessage(msg); @@ -304,15 +544,25 @@ void LvMessageView::rebuildMessages() { // Auto-scroll to bottom lv_obj_scroll_to_y(_msgScroll, LV_COORD_MAX, LV_ANIM_OFF); + + unsigned long elapsed = millis() - startMs; + if (elapsed > 40) { + Serial.printf("[PERF] Chat rebuild: %d msgs in %lums\n", + (int)_cachedMsgs.size(), (unsigned long)elapsed); + } } void LvMessageView::updateMessageStatus(int msgIdx, LXMFStatus status) { if (msgIdx < 0 || msgIdx >= (int)_statusLabels.size()) return; lv_obj_t* statusLbl = _statusLabels[msgIdx]; lv_obj_t* textLbl = _textLabels[msgIdx]; + lv_obj_t* bubbleBox = msgIdx < (int)_bubbleBoxes.size() ? _bubbleBoxes[msgIdx] : nullptr; if (!statusLbl) return; // Incoming message, no status label applyStatusGlyph(statusLbl, status); + if (bubbleBox) { + lv_obj_set_style_border_color(bubbleBox, lv_color_hex(bubbleBorderColor(status)), 0); + } // Update text color to match status if (textLbl) { @@ -332,21 +582,27 @@ void LvMessageView::applyStatusGlyph(lv_obj_t* lbl, LXMFStatus status) { uint32_t color; switch (status) { case LXMFStatus::DELIVERED: - glyph = LV_SYMBOL_OK LV_SYMBOL_OK; // Double check + glyph = "sent"; color = Theme::SUCCESS; break; case LXMFStatus::SENT: - glyph = LV_SYMBOL_OK; // Single check + glyph = "sent"; color = Theme::TEXT_MUTED; break; case LXMFStatus::FAILED: - glyph = LV_SYMBOL_WARNING; + glyph = "failed"; color = Theme::ERROR_CLR; break; - case LXMFStatus::QUEUED: case LXMFStatus::SENDING: + glyph = "sending"; + color = Theme::WARNING_CLR; + break; + case LXMFStatus::QUEUED: + glyph = "queued"; + color = Theme::WARNING_CLR; + break; default: - glyph = LV_SYMBOL_REFRESH; // In-flight + glyph = "draft"; color = Theme::TEXT_MUTED; break; } @@ -354,20 +610,54 @@ void LvMessageView::applyStatusGlyph(lv_obj_t* lbl, LXMFStatus status) { lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); } -void LvMessageView::sendCurrentMessage() { +void LvMessageView::sendCurrentMessage(bool viaLink) { if (!_lxmf || _peerHex.empty() || _inputText.empty()) return; + if (_inputText.size() > MAX_COMPOSER_CHARS) { + if (_ui) _ui->lvStatusBar().showToast("Message too long", 1500); + return; + } RNS::Bytes destHash; destHash.assignHex(_peerHex.c_str()); - _lxmf->sendMessage(destHash, _inputText.c_str()); + bool queued = viaLink + ? _lxmf->sendMessageViaLink(destHash, _inputText.c_str()) + : _lxmf->sendMessage(destHash, _inputText.c_str()); + if (!queued) { + if (_ui) _ui->lvStatusBar().showToast("Message queue full", 1500); + return; + } + if (viaLink && _ui) _ui->lvStatusBar().showToast("Link send queued", 1200); _inputText.clear(); if (_textarea) lv_textarea_set_text(_textarea, ""); + updateComposerState(); _cachedMsgs.clear(); // Force fresh load in rebuildMessages rebuildMessages(); } bool LvMessageView::handleKey(const KeyEvent& event) { + if (_sendOverlay) { + if (event.character == 0x1B || event.del || event.character == 0x08) { + hideSendModeMenu(); + return true; + } + if (event.up || event.left) { + _sendMenuIdx = (_sendMenuIdx + 2) % 3; + updateSendModeMenu(); + return true; + } + if (event.down || event.right || event.tab) { + _sendMenuIdx = (_sendMenuIdx + 1) % 3; + updateSendModeMenu(); + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + chooseSendMode(_sendMenuIdx); + return true; + } + return true; + } + if (event.character == 0x1B) { if (_onBack) _onBack(); return true; @@ -377,6 +667,7 @@ bool LvMessageView::handleKey(const KeyEvent& event) { if (!_inputText.empty()) { _inputText.pop_back(); if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str()); + updateComposerState(); } else { if (_onBack) _onBack(); } @@ -384,7 +675,7 @@ bool LvMessageView::handleKey(const KeyEvent& event) { } if (event.enter || event.character == '\n' || event.character == '\r') { - sendCurrentMessage(); + sendCurrentMessage(false); return true; } @@ -400,11 +691,108 @@ bool LvMessageView::handleKey(const KeyEvent& event) { return true; } + // Keep horizontal trackball movement and Tab inside the chat view so + // composing or reading a thread does not accidentally cycle global tabs. + if (event.left || event.right || event.tab) { + return true; + } + if (event.character >= 0x20 && event.character < 0x7F) { + if (_inputText.size() >= MAX_COMPOSER_CHARS) { + if (_ui) _ui->lvStatusBar().showToast("Message too long", 900); + return true; + } _inputText += (char)event.character; if (_textarea) lv_textarea_set_text(_textarea, _inputText.c_str()); + updateComposerState(); return true; } return false; } + +bool LvMessageView::handleLongPress() { + if (_inputText.empty()) return false; + showSendModeMenu(); + return true; +} + +void LvMessageView::showSendModeMenu() { + if (_inputText.empty()) return; + hideSendModeMenu(); + _sendMenuIdx = 1; + + _sendOverlay = lv_obj_create(lv_layer_top()); + lv_obj_set_size(_sendOverlay, 244, 118); + lv_obj_center(_sendOverlay); + lv_obj_add_style(_sendOverlay, LvTheme::styleModal(), 0); + lv_obj_set_style_pad_all(_sendOverlay, 8, 0); + lv_obj_clear_flag(_sendOverlay, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* title = lv_label_create(_sendOverlay); + lv_obj_set_style_text_font(title, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(title, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(title, "Send mode"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + static const char* labels[3] = {"Send normally", "Send as link", "Cancel"}; + for (int i = 0; i < 3; i++) { + lv_obj_t* row = lv_obj_create(_sendOverlay); + lv_obj_set_size(row, 220, 24); + lv_obj_set_pos(row, 12, 24 + i * 28); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_radius(row, 4, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_user_data(row, (void*)(intptr_t)i); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + auto* self = (LvMessageView*)lv_event_get_user_data(e); + int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); + self->chooseSendMode(idx); + }, LV_EVENT_CLICKED, this); + + _sendLabels[i] = lv_label_create(row); + lv_obj_set_style_text_font(_sendLabels[i], &lv_font_ratdeck_12, 0); + lv_label_set_text(_sendLabels[i], labels[i]); + lv_obj_center(_sendLabels[i]); + _sendRows[i] = row; + } + + updateSendModeMenu(); +} + +void LvMessageView::hideSendModeMenu() { + if (_sendOverlay) { + lv_obj_del_async(_sendOverlay); + _sendOverlay = nullptr; + } + for (int i = 0; i < 3; i++) { + _sendRows[i] = nullptr; + _sendLabels[i] = nullptr; + } +} + +void LvMessageView::updateSendModeMenu() { + for (int i = 0; i < 3; i++) { + if (!_sendRows[i] || !_sendLabels[i]) continue; + bool selected = i == _sendMenuIdx; + lv_obj_set_style_bg_color(_sendRows[i], + lv_color_hex(selected ? Theme::PRIMARY_SUBTLE : Theme::BG_SURFACE), 0); + lv_obj_set_style_border_color(_sendRows[i], + lv_color_hex(selected ? Theme::BORDER_ACTIVE : Theme::BORDER), 0); + lv_obj_set_style_text_color(_sendLabels[i], + lv_color_hex(selected ? Theme::ACCENT : Theme::TEXT_SECONDARY), 0); + } +} + +void LvMessageView::chooseSendMode(int idx) { + bool viaLink = idx == 1; + if (idx == 2) { + hideSendModeMenu(); + return; + } + hideSendModeMenu(); + sendCurrentMessage(viaLink); +} diff --git a/src/ui/screens/LvMessageView.h b/src/ui/screens/LvMessageView.h index 6663f56..502a8e6 100644 --- a/src/ui/screens/LvMessageView.h +++ b/src/ui/screens/LvMessageView.h @@ -19,6 +19,7 @@ public: void onEnter() override; void onExit() override; bool handleKey(const KeyEvent& event) override; + bool handleLongPress() override; void setPeerHex(const std::string& hex) { _peerHex = hex; } void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } @@ -29,10 +30,17 @@ public: const char* title() const override { return "Chat"; } private: - void sendCurrentMessage(); + void sendCurrentMessage(bool viaLink = false); void rebuildMessages(); void appendMessage(const LXMFMessage& msg); std::string getPeerName(); + void updateHeader(); + void updateComposerState(); + void refreshComposerPlaceholder(); + void showSendModeMenu(); + void hideSendModeMenu(); + void updateSendModeMenu(); + void chooseSendMode(int idx); LXMFManager* _lxmf = nullptr; AnnounceManager* _am = nullptr; @@ -50,14 +58,23 @@ private: // LVGL widgets lv_obj_t* _header = nullptr; lv_obj_t* _lblHeader = nullptr; + lv_obj_t* _lblHeaderMeta = nullptr; + lv_obj_t* _lblHeaderState = nullptr; lv_obj_t* _msgScroll = nullptr; lv_obj_t* _inputRow = nullptr; lv_obj_t* _textarea = nullptr; lv_obj_t* _btnSend = nullptr; + lv_obj_t* _sendOverlay = nullptr; + lv_obj_t* _sendRows[3] = {}; + lv_obj_t* _sendLabels[3] = {}; + int _sendMenuIdx = 0; + bool _suppressNextSendClick = false; // Per-message status labels for partial updates (avoids full rebuild) std::vector _statusLabels; std::vector _textLabels; + std::vector _bubbleBoxes; static constexpr unsigned long REFRESH_INTERVAL_MS = 2000; // Check for new messages every 2s + static constexpr size_t MAX_COMPOSER_CHARS = 120; }; diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index 32e9c76..8fd67aa 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -3,6 +3,7 @@ #include "ui/LvTheme.h" #include "ui/LvInput.h" #include "ui/UIManager.h" +#include "ui/LxmFaceAvatar.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" #include "storage/MessageStore.h" @@ -11,53 +12,153 @@ #include #include "fonts/fonts.h" +namespace { + +constexpr int kRowH = 58; +constexpr int kRailW = 5; +constexpr int kChatAvatar = 32; +constexpr int kChatTextX = 54; +bool isPendingStatus(LXMFStatus status) { + return status == LXMFStatus::QUEUED || status == LXMFStatus::SENDING; +} + +uint32_t statusColor(LXMFStatus status) { + switch (status) { + case LXMFStatus::DELIVERED: return Theme::SUCCESS; + case LXMFStatus::SENT: return Theme::TEXT_MUTED; + case LXMFStatus::FAILED: return Theme::ERROR_CLR; + case LXMFStatus::QUEUED: + case LXMFStatus::SENDING: return Theme::WARNING_CLR; + default: return Theme::TEXT_MUTED; + } +} + +const char* statusLabel(LXMFStatus status) { + switch (status) { + case LXMFStatus::DELIVERED: return "SENT"; + case LXMFStatus::SENT: return "SENT"; + case LXMFStatus::FAILED: return "FAILED"; + case LXMFStatus::SENDING: return "SENDING"; + case LXMFStatus::QUEUED: return "QUEUED"; + default: return ""; + } +} + +std::string shortText(const std::string& text, size_t maxLen) { + if (text.size() <= maxLen) return text; + if (maxLen <= 3) return text.substr(0, maxLen); + return text.substr(0, maxLen - 3) + "..."; +} + +std::string displayNameForPeer(AnnounceManager* am, const std::string& peerHex) { + if (am) { + std::string peerName = am->lookupName(peerHex); + if (!peerName.empty()) return shortText(peerName, 18); + } + return shortText(peerHex, 12); +} + +bool formatClock(double ts, char* out, size_t outLen) { + if (!out || outLen == 0 || ts <= 1700000000) return false; + time_t t = (time_t)ts; + struct tm* tm = localtime(&t); + if (!tm) return false; + snprintf(out, outLen, "%02d:%02d", tm->tm_hour, tm->tm_min); + return true; +} + +} // namespace + 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_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_text(_lblEmpty, "No conversations"); + _lblEmpty = lv_obj_create(parent); + lv_obj_set_size(_lblEmpty, 272, 104); + lv_obj_set_style_bg_color(_lblEmpty, lv_color_hex(Theme::BG_ELEVATED), 0); + lv_obj_set_style_bg_opa(_lblEmpty, LV_OPA_80, 0); + lv_obj_set_style_border_color(_lblEmpty, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(_lblEmpty, 1, 0); + lv_obj_set_style_radius(_lblEmpty, 6, 0); + lv_obj_set_style_pad_all(_lblEmpty, 0, 0); + lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_SCROLLABLE); lv_obj_center(_lblEmpty); + lv_obj_t* emptyIcon = lv_label_create(_lblEmpty); + lv_obj_set_style_text_font(emptyIcon, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(emptyIcon, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(emptyIcon, LV_SYMBOL_ENVELOPE); + lv_obj_align(emptyIcon, LV_ALIGN_TOP_MID, 0, 15); + + lv_obj_t* emptyTitle = lv_label_create(_lblEmpty); + lv_obj_set_style_text_font(emptyTitle, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(emptyTitle, lv_color_hex(Theme::TEXT_PRIMARY), 0); + lv_label_set_text(emptyTitle, "No conversations"); + lv_obj_align(emptyTitle, LV_ALIGN_TOP_MID, 0, 34); + + lv_obj_t* emptySub = lv_label_create(_lblEmpty); + lv_obj_set_style_text_font(emptySub, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(emptySub, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(emptySub, "LXMF inbox is clear"); + lv_obj_align(emptySub, LV_ALIGN_TOP_MID, 0, 56); + + lv_obj_t* emptyLine = lv_obj_create(_lblEmpty); + lv_obj_set_size(emptyLine, 160, 1); + lv_obj_set_style_bg_color(emptyLine, lv_color_hex(Theme::DIVIDER), 0); + lv_obj_set_style_bg_opa(emptyLine, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(emptyLine, 0, 0); + lv_obj_align(emptyLine, LV_ALIGN_BOTTOM_MID, 0, -18); + _list = lv_obj_create(parent); lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); lv_obj_add_style(_list, LvTheme::styleList(), 0); + lv_obj_add_style(_list, LvTheme::styleScrollbar(), LV_PART_SCROLLBAR); 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; _focusActive = false; + hideActionMenu(); rebuildList(); } +void LvMessagesScreen::onExit() { + hideActionMenu(); +} + void LvMessagesScreen::refreshUI() { if (!_lxmf) return; + if (_lpState != LP_NONE) return; int count = (int)_lxmf->conversations().size(); int unread = _lxmf->unreadCount(); - if (count != _lastConvCount || unread != _lastUnreadTotal) { + int queued = _lxmf->queuedCount(); + uint32_t revision = _lxmf->storeRevision(); + if (count != _lastConvCount || unread != _lastUnreadTotal || + queued != _lastQueuedCount || revision != _lastStoreRevision) { rebuildList(); } } void LvMessagesScreen::rebuildList() { if (!_lxmf || !_list) return; + unsigned long startMs = millis(); const auto& convs = _lxmf->conversations(); int count = (int)convs.size(); _lastConvCount = count; _lastUnreadTotal = _lxmf->unreadCount(); + _lastQueuedCount = _lxmf->queuedCount(); + _lastStoreRevision = _lxmf->storeRevision(); _sortedPeers.clear(); _sortedConvs.clear(); lv_obj_clean(_list); + _avatarBuffers.clear(); if (count == 0) { lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); @@ -70,6 +171,7 @@ void LvMessagesScreen::rebuildList() { // Build sorted conversation info _sortedConvs.reserve(count); + _avatarBuffers.reserve(count); for (int i = 0; i < count; i++) { ConvInfo ci; ci.peerHex = convs[i]; @@ -77,11 +179,29 @@ void LvMessagesScreen::rebuildList() { if (s) { ci.lastTs = s->lastTimestamp; ci.preview = s->lastPreview; - ci.hasUnread = s->unreadCount > 0; + ci.lastIncoming = s->lastIncoming; + ci.unreadCount = s->unreadCount; + ci.totalCount = s->totalCount; + ci.hasUnread = ci.unreadCount > 0; + ci.hasOutgoing = s->hasOutgoing; + ci.hasPending = s->hasPending; + ci.hasFailed = s->hasFailed; + ci.lastOutgoingStatus = s->lastOutgoingStatus; } - std::string peerName; - if (_am) peerName = _am->lookupName(ci.peerHex); - ci.displayName = !peerName.empty() ? peerName.substr(0, 15) : ci.peerHex.substr(0, 12); + ci.displayName = displayNameForPeer(_am, ci.peerHex); + + if (_am) { + const DiscoveredNode* node = _am->findNodeByHex(ci.peerHex); + if (node) { + ci.knownNode = true; + ci.savedNode = node->saved; + ci.rssi = node->rssi; + ci.snr = node->snr; + ci.hops = node->hops; + ci.lastSeen = node->lastSeen; + } + } + _sortedConvs.push_back(ci); } @@ -99,9 +219,11 @@ void LvMessagesScreen::rebuildList() { const auto& ci = _sortedConvs[i]; lv_obj_t* row = lv_obj_create(_list); - lv_obj_set_size(row, Theme::CONTENT_W, 46); + lv_obj_set_size(row, Theme::CONTENT_W, kRowH); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_bg_color(row, lv_color_hex(ci.hasUnread ? Theme::PRIMARY_SUBTLE : Theme::BG), 0); lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_border_width(row, 1, 0); lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); @@ -111,62 +233,117 @@ void LvMessagesScreen::rebuildList() { lv_obj_add_event_cb(row, [](lv_event_t* e) { auto* self = (LvMessagesScreen*)lv_event_get_user_data(e); + if (self->_lpState != LP_NONE) return; int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); if (idx < (int)self->_sortedPeers.size() && self->_onOpen) { self->_onOpen(self->_sortedPeers[idx]); } }, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + auto* self = (LvMessagesScreen*)lv_event_get_user_data(e); + int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); + self->showActionMenu(idx); + }, LV_EVENT_LONG_PRESSED, this); lv_group_add_obj(LvInput::group(), row); lv_obj_add_event_cb(row, [](lv_event_t* e) { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); - int leftPad = 14; + uint32_t railColor = Theme::BORDER; + if (ci.hasFailed) railColor = Theme::ERROR_CLR; + else if (ci.hasPending) railColor = Theme::WARNING_CLR; + else if (ci.hasUnread) railColor = Theme::PRIMARY; + else if (ci.savedNode) railColor = Theme::TEXT_SECONDARY; + else if (ci.knownNode) railColor = Theme::TEXT_MUTED; - // Unread dot - if (ci.hasUnread) { - lv_obj_t* dot = lv_obj_create(row); - lv_obj_set_size(dot, 6, 6); - lv_obj_set_style_radius(dot, 3, 0); - lv_obj_set_style_bg_color(dot, lv_color_hex(Theme::PRIMARY), 0); - lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(dot, 0, 0); - lv_obj_set_style_pad_all(dot, 0, 0); - lv_obj_set_pos(dot, 4, 7); - } + lv_obj_t* rail = lv_obj_create(row); + lv_obj_set_size(rail, kRailW, kRowH); + lv_obj_set_pos(rail, 0, 0); + lv_obj_set_style_bg_color(rail, lv_color_hex(railColor), 0); + lv_obj_set_style_bg_opa(rail, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(rail, 0, 0); + lv_obj_set_style_radius(rail, 0, 0); + lv_obj_set_style_pad_all(rail, 0, 0); + lv_obj_clear_flag(rail, LV_OBJ_FLAG_SCROLLABLE); + + _avatarBuffers.emplace_back(LxmFaceAvatar::bufferSize(kChatAvatar)); + auto avatar = LxmFaceAvatar::create(row, 13, 13, kChatAvatar, + _avatarBuffers.back().data(), + Theme::PRIMARY_SUBTLE, + ci.hasUnread ? Theme::PRIMARY : Theme::BORDER); + LxmFaceAvatar::render(avatar.canvas, String(ci.peerHex.c_str())); + + int leftPad = kChatTextX; // Name (top-left, first line) lv_obj_t* nameLbl = lv_label_create(row); lv_obj_set_style_text_font(nameLbl, nameFont, 0); - lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_text_color(nameLbl, lv_color_hex(ci.hasUnread ? Theme::ACCENT : Theme::TEXT_PRIMARY), 0); + lv_label_set_long_mode(nameLbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(nameLbl, Theme::CONTENT_W - leftPad - 70); lv_label_set_text(nameLbl, ci.displayName.c_str()); - lv_obj_set_pos(nameLbl, leftPad, 1); + lv_obj_set_pos(nameLbl, leftPad, 6); // Time (top-right) - if (ci.lastTs > 1700000000) { - time_t t = (time_t)ci.lastTs; - 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(row); - lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_text(timeLbl, timeBuf); - lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 3); - } + char timeBuf[8]; + if (formatClock(ci.lastTs, timeBuf, sizeof(timeBuf))) { + lv_obj_t* timeLbl = lv_label_create(row); + lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(timeLbl, lv_color_hex(ci.hasUnread ? Theme::PRIMARY : Theme::TEXT_MUTED), 0); + lv_label_set_text(timeLbl, timeBuf); + lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -8, 7); } // Preview (second line, below name) - if (!ci.preview.empty()) { - lv_obj_t* prevLbl = lv_label_create(row); - lv_obj_set_style_text_font(prevLbl, smallFont, 0); - lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_long_mode(prevLbl, LV_LABEL_LONG_CLIP); - lv_obj_set_width(prevLbl, Theme::CONTENT_W - leftPad - 8); - lv_label_set_text(prevLbl, ci.preview.c_str()); - lv_obj_set_pos(prevLbl, leftPad, 20); + lv_obj_t* prevLbl = lv_label_create(row); + lv_obj_set_style_text_font(prevLbl, smallFont, 0); + uint32_t previewColor = Theme::TEXT_SECONDARY; + if (ci.hasFailed) previewColor = Theme::ERROR_CLR; + else if (ci.hasPending) previewColor = Theme::WARNING_CLR; + else if (ci.hasUnread) previewColor = Theme::TEXT_PRIMARY; + lv_obj_set_style_text_color(prevLbl, lv_color_hex(previewColor), 0); + lv_label_set_long_mode(prevLbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(prevLbl, Theme::CONTENT_W - leftPad - 76); + lv_label_set_text(prevLbl, ci.preview.empty() ? "No preview" : ci.preview.c_str()); + lv_obj_set_pos(prevLbl, leftPad, 28); + + char chipBuf[16] = {0}; + uint32_t chipColor = Theme::TEXT_MUTED; + if (ci.hasFailed) { + snprintf(chipBuf, sizeof(chipBuf), "FAILED"); + chipColor = Theme::ERROR_CLR; + } else if (ci.hasPending) { + const char* pending = statusLabel(ci.lastOutgoingStatus); + snprintf(chipBuf, sizeof(chipBuf), "%s", pending[0] ? pending : "QUEUED"); + chipColor = Theme::WARNING_CLR; + } else if (ci.hasUnread) { + if (ci.unreadCount > 9) snprintf(chipBuf, sizeof(chipBuf), "NEW 9+"); + else snprintf(chipBuf, sizeof(chipBuf), "NEW %d", ci.unreadCount); + chipColor = Theme::PRIMARY; + } else if (!ci.lastIncoming && ci.hasOutgoing && + (ci.lastOutgoingStatus == LXMFStatus::SENT || ci.lastOutgoingStatus == LXMFStatus::DELIVERED)) { + snprintf(chipBuf, sizeof(chipBuf), "%s", statusLabel(ci.lastOutgoingStatus)); + chipColor = statusColor(ci.lastOutgoingStatus); + } + + if (chipBuf[0] != '\0') { + lv_obj_t* chip = lv_obj_create(row); + lv_obj_set_size(chip, 58, 15); + lv_obj_align(chip, LV_ALIGN_BOTTOM_RIGHT, -7, -8); + lv_obj_set_style_bg_color(chip, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_80, 0); + lv_obj_set_style_border_color(chip, lv_color_hex(chipColor), 0); + lv_obj_set_style_border_width(chip, 1, 0); + lv_obj_set_style_radius(chip, 4, 0); + lv_obj_set_style_pad_all(chip, 0, 0); + lv_obj_clear_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* chipLbl = lv_label_create(chip); + lv_obj_set_style_text_font(chipLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(chipLbl, lv_color_hex(chipColor), 0); + lv_label_set_text(chipLbl, chipBuf); + lv_obj_center(chipLbl); } } @@ -175,6 +352,12 @@ void LvMessagesScreen::rebuildList() { lv_obj_t* focused = lv_group_get_focused(LvInput::group()); if (focused) lv_obj_clear_state(focused, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); } + + unsigned long elapsed = millis() - startMs; + if (elapsed > 25) { + Serial.printf("[PERF] Chats rebuild: %d convs in %lums\n", + count, (unsigned long)elapsed); + } } int LvMessagesScreen::getFocusedPeerIdx() const { @@ -183,6 +366,127 @@ int LvMessagesScreen::getFocusedPeerIdx() const { return (int)(intptr_t)lv_obj_get_user_data(focused); } +void LvMessagesScreen::hideActionMenu() { + if (_actionOverlay) { + lv_obj_del_async(_actionOverlay); + _actionOverlay = nullptr; + } + for (int i = 0; i < 3; i++) { + _actionRows[i] = nullptr; + _actionLabels[i] = nullptr; + } + _lpState = LP_NONE; + _lpPeerIdx = -1; + _menuIdx = 0; + _menuCount = 0; +} + +void LvMessagesScreen::rebuildActionOverlay(const char* title, const char* const* labels, int count) { + if (_actionOverlay) { + lv_obj_del_async(_actionOverlay); + _actionOverlay = nullptr; + } + for (int i = 0; i < 3; i++) { + _actionRows[i] = nullptr; + _actionLabels[i] = nullptr; + } + + _menuCount = count; + _actionOverlay = lv_obj_create(lv_layer_top()); + lv_obj_set_size(_actionOverlay, 244, 118); + lv_obj_center(_actionOverlay); + lv_obj_add_style(_actionOverlay, LvTheme::styleModal(), 0); + lv_obj_set_style_pad_all(_actionOverlay, 8, 0); + lv_obj_clear_flag(_actionOverlay, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* titleLbl = lv_label_create(_actionOverlay); + lv_obj_set_style_text_font(titleLbl, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_long_mode(titleLbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(titleLbl, 220); + lv_label_set_text(titleLbl, title); + lv_obj_align(titleLbl, LV_ALIGN_TOP_MID, 0, 0); + + for (int i = 0; i < count && i < 3; i++) { + lv_obj_t* row = lv_obj_create(_actionOverlay); + lv_obj_set_size(row, 220, 24); + lv_obj_set_pos(row, 4, 24 + i * 28); + lv_obj_add_style(row, LvTheme::styleListBtn(), 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_bg_color(row, lv_color_hex(i == _menuIdx ? Theme::BG_HOVER : Theme::BG), 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_user_data(row, (void*)(intptr_t)i); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + auto* self = (LvMessagesScreen*)lv_event_get_user_data(e); + int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); + if (!self) return; + self->_menuIdx = idx; + KeyEvent enter = {}; + enter.enter = true; + self->handleKey(enter); + }, LV_EVENT_CLICKED, this); + + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(lbl, 200); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(lbl, labels[i]); + lv_obj_center(lbl); + _actionRows[i] = row; + _actionLabels[i] = lbl; + } +} + +void LvMessagesScreen::showActionMenu(int peerIdx) { + if (!_lxmf || peerIdx < 0 || peerIdx >= (int)_sortedPeers.size()) return; + _focusActive = true; + _lpPeerIdx = peerIdx; + _lpState = LP_MENU; + _menuIdx = 0; + static const char* labels[] = {"Add Friend", "Delete Chat", "Cancel"}; + rebuildActionOverlay("CHAT ACTION", labels, 3); +} + +void LvMessagesScreen::showDeleteConfirm() { + _lpState = LP_CONFIRM_DELETE; + _menuIdx = 0; + static const char* labels[] = {"Delete Chat", "Cancel"}; + rebuildActionOverlay("DELETE CHAT?", labels, 2); +} + +void LvMessagesScreen::addFocusedPeerToContacts() { + if (!_am || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return; + const auto& peerHex = _sortedPeers[_lpPeerIdx]; + const DiscoveredNode* existing = _am->findNodeByHex(peerHex); + if (existing && !existing->saved) { + auto& node = const_cast(*existing); + node.saved = true; + _am->saveContacts(); + if (_ui) _ui->lvStatusBar().showToast("Added to friends", 1200); + } else if (!existing) { + _am->addManualContact(peerHex, ""); + if (_ui) _ui->lvStatusBar().showToast("Added to friends", 1200); + } else if (_ui) { + _ui->lvStatusBar().showToast("Already a friend", 1200); + } +} + +void LvMessagesScreen::deleteFocusedConversation() { + if (!_lxmf || _lpPeerIdx < 0 || _lpPeerIdx >= (int)_sortedPeers.size()) return; + const auto& peerHex = _sortedPeers[_lpPeerIdx]; + _lxmf->markRead(peerHex); + _lxmf->deleteConversation(peerHex); + if (_ui) { + _ui->lvStatusBar().showToast("Chat deleted", 1200); + _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount()); + } + _lastConvCount = -1; + rebuildList(); +} + bool LvMessagesScreen::handleLongPress() { if (!_lxmf) return false; // Only consume long-press once the user has actively navigated into @@ -190,10 +494,7 @@ bool LvMessagesScreen::handleLongPress() { if (!_focusActive) return false; int idx = getFocusedPeerIdx(); if (idx < 0 || idx >= (int)_sortedPeers.size()) return false; - _lpPeerIdx = idx; - _lpState = LP_MENU; - _menuIdx = 0; - if (_ui) _ui->lvStatusBar().showToast("Up/Down: Add Friend | Delete | Cancel", 5000); + showActionMenu(idx); return true; } @@ -210,40 +511,25 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) { // Long-press menu mode if (_lpState == LP_MENU) { if (event.up || event.down) { - _menuIdx = (_menuIdx + (event.down ? 1 : -1) + 3) % 3; - const char* labels[] = {">> Add Friend <<", ">> Delete Chat <<", ">> Cancel <<"}; - if (_ui) _ui->lvStatusBar().showToast(labels[_menuIdx], 5000); + _menuIdx = (_menuIdx + (event.down ? 1 : -1) + _menuCount) % _menuCount; + static const char* labels[] = {"Add Friend", "Delete Chat", "Cancel"}; + rebuildActionOverlay("CHAT ACTION", labels, 3); return true; } if (event.enter || event.character == '\n' || event.character == '\r') { - if (_menuIdx == 0 && _lpPeerIdx < (int)_sortedPeers.size()) { - const auto& peerHex = _sortedPeers[_lpPeerIdx]; - if (_am) { - const DiscoveredNode* existing = _am->findNodeByHex(peerHex); - if (existing && !existing->saved) { - auto& node = const_cast(*existing); - node.saved = true; - _am->saveContacts(); - if (_ui) _ui->lvStatusBar().showToast("Added to friends!", 1200); - } else if (!existing) { - _am->addManualContact(peerHex, ""); - if (_ui) _ui->lvStatusBar().showToast("Added to friends!", 1200); - } else { - if (_ui) _ui->lvStatusBar().showToast("Already a friend", 1200); - } - } - } else if (_menuIdx == 1 && _lpPeerIdx < (int)_sortedPeers.size()) { - _lpState = LP_CONFIRM_DELETE; - if (_ui) _ui->lvStatusBar().showToast("Delete chat? Enter=Yes Esc=No", 5000); + if (_menuIdx == 0) { + addFocusedPeerToContacts(); + } else if (_menuIdx == 1) { + showDeleteConfirm(); return true; } else { if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); } - _lpState = LP_NONE; + hideActionMenu(); return true; } if (event.del || event.character == 8 || event.character == 0x1B) { - _lpState = LP_NONE; + hideActionMenu(); if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); return true; } @@ -252,24 +538,22 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) { // Confirm delete mode if (_lpState == LP_CONFIRM_DELETE) { - if (event.enter || event.character == '\n' || event.character == '\r') { - if (_lpPeerIdx < (int)_sortedPeers.size()) { - const auto& peerHex = _sortedPeers[_lpPeerIdx]; - _lxmf->markRead(peerHex); - extern MessageStore messageStore; - messageStore.deleteConversation(peerHex); - messageStore.refreshConversations(); - if (_ui) { - _ui->lvStatusBar().showToast("Chat deleted", 1200); - _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount()); - } - _lastConvCount = -1; - rebuildList(); - } - _lpState = LP_NONE; + if (event.up || event.down) { + _menuIdx = (_menuIdx + 1) % _menuCount; + static const char* labels[] = {"Delete Chat", "Cancel"}; + rebuildActionOverlay("DELETE CHAT?", labels, 2); return true; } - _lpState = LP_NONE; + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_menuIdx == 0) { + deleteFocusedConversation(); + } else if (_ui) { + _ui->lvStatusBar().showToast("Cancelled", 800); + } + hideActionMenu(); + return true; + } + hideActionMenu(); if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); return true; } diff --git a/src/ui/screens/LvMessagesScreen.h b/src/ui/screens/LvMessagesScreen.h index 8f6c869..8cb5c00 100644 --- a/src/ui/screens/LvMessagesScreen.h +++ b/src/ui/screens/LvMessagesScreen.h @@ -1,6 +1,7 @@ #pragma once #include "ui/UIManager.h" +#include "reticulum/LXMFMessage.h" #include #include #include @@ -15,6 +16,7 @@ public: void createUI(lv_obj_t* parent) override; void refreshUI() override; void onEnter() override; + void onExit() override; bool handleKey(const KeyEvent& event) override; void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } @@ -28,6 +30,12 @@ public: private: void rebuildList(); int getFocusedPeerIdx() const; + void showActionMenu(int peerIdx); + void showDeleteConfirm(); + void hideActionMenu(); + void rebuildActionOverlay(const char* title, const char* const* labels, int count); + void addFocusedPeerToContacts(); + void deleteFocusedConversation(); LXMFManager* _lxmf = nullptr; AnnounceManager* _am = nullptr; @@ -35,15 +43,22 @@ private: OpenCallback _onOpen; int _lastConvCount = -1; int _lastUnreadTotal = 0; + int _lastQueuedCount = 0; + uint32_t _lastStoreRevision = 0; bool _focusActive = false; std::vector _sortedPeers; enum LongPressState { LP_NONE, LP_MENU, LP_CONFIRM_DELETE }; LongPressState _lpState = LP_NONE; int _lpPeerIdx = -1; int _menuIdx = 0; + int _menuCount = 0; lv_obj_t* _list = nullptr; lv_obj_t* _lblEmpty = nullptr; + lv_obj_t* _actionOverlay = nullptr; + lv_obj_t* _actionRows[3] = {}; + lv_obj_t* _actionLabels[3] = {}; + std::vector> _avatarBuffers; // Cached sorted conversation data struct ConvInfo { @@ -51,7 +66,20 @@ private: double lastTs = 0; std::string preview; std::string displayName; + bool lastIncoming = false; bool hasUnread = false; + int unreadCount = 0; + int totalCount = 0; + bool hasOutgoing = false; + bool hasPending = false; + bool hasFailed = false; + LXMFStatus lastOutgoingStatus = LXMFStatus::DRAFT; + bool knownNode = false; + bool savedNode = false; + int rssi = 0; + float snr = 0; + uint8_t hops = 0; + unsigned long lastSeen = 0; }; std::vector _sortedConvs; }; diff --git a/src/ui/screens/LvNameInputScreen.cpp b/src/ui/screens/LvNameInputScreen.cpp index a5a51cf..2de2778 100644 --- a/src/ui/screens/LvNameInputScreen.cpp +++ b/src/ui/screens/LvNameInputScreen.cpp @@ -5,36 +5,86 @@ #include "config/Config.h" #include "fonts/fonts.h" +namespace { + +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, const lv_font_t* font, + uint32_t color, lv_coord_t width, lv_text_align_t align, + lv_label_long_mode_t mode = LV_LABEL_LONG_WRAP) { + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_width(lbl, width); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_align(lbl, align, 0); + lv_label_set_long_mode(lbl, mode); + lv_label_set_text(lbl, text); + return lbl; +} + +lv_obj_t* makeLine(lv_obj_t* parent, lv_coord_t y) { + lv_obj_t* line = lv_obj_create(parent); + lv_obj_set_pos(line, 24, y); + lv_obj_set_size(line, Theme::SCREEN_W - 48, 1); + lv_obj_set_style_bg_color(line, lv_color_hex(Theme::DIVIDER), 0); + lv_obj_set_style_bg_opa(line, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(line, 0, 0); + lv_obj_set_style_pad_all(line, 0, 0); + lv_obj_clear_flag(line, LV_OBJ_FLAG_SCROLLABLE); + return line; +} + +lv_obj_t* makeActionButton(lv_obj_t* parent, const char* text, lv_coord_t y) { + lv_obj_t* btn = lv_btn_create(parent); + lv_obj_set_size(btn, 168, 34); + lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, y); + lv_obj_add_style(btn, LvTheme::styleBtn(), 0); + lv_obj_add_style(btn, LvTheme::styleBtnFocused(), LV_STATE_FOCUSED); + lv_obj_add_style(btn, LvTheme::styleBtnPressed(), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(btn, lv_color_hex(Theme::PRIMARY_SUBTLE), 0); + lv_obj_set_style_border_color(btn, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + lv_obj_set_style_radius(btn, 4, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + + lv_obj_t* lbl = lv_label_create(btn); + lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(lbl, 150); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(lbl, text); + lv_obj_center(lbl); + return btn; +} + +} // namespace + void LvNameInputScreen::createUI(lv_obj_t* parent) { _screen = parent; + _textarea = nullptr; + _doneButton = nullptr; 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_bg_opa(parent, LV_OPA_COVER, 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); + lv_obj_t* eyebrow = makeLabel(parent, "FIRST BOOT", &lv_font_ratdeck_10, + Theme::TEXT_SECONDARY, 220, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_align(eyebrow, LV_ALIGN_TOP_MID, 0, 13); - // Subtitle - lv_obj_t* sub = lv_label_create(parent); - lv_obj_set_style_text_font(sub, &lv_font_ratdeck_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); + lv_obj_t* title = makeLabel(parent, "DISPLAY NAME", &lv_font_montserrat_16, + Theme::ACCENT, 260, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 30); + makeLine(parent, 58); - // Prompt - lv_obj_t* prompt = lv_label_create(parent); - lv_obj_set_style_text_font(prompt, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(prompt, "Enter your display name:"); + lv_obj_t* prompt = makeLabel(parent, + "Choose the name other operators see on announces.", + &lv_font_ratdeck_12, Theme::TEXT_PRIMARY, 250, LV_TEXT_ALIGN_CENTER); 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_obj_set_size(_textarea, 252, 36); + lv_obj_align(_textarea, LV_ALIGN_TOP_MID, 0, 108); lv_textarea_set_max_length(_textarea, MAX_NAME_LEN); lv_textarea_set_one_line(_textarea, true); lv_textarea_set_placeholder_text(_textarea, "Optional"); @@ -42,34 +92,44 @@ void LvNameInputScreen::createUI(lv_obj_t* parent) { lv_obj_add_style(_textarea, LvTheme::styleTextareaFocused(), LV_STATE_FOCUSED); lv_obj_set_style_text_font(_textarea, &lv_font_ratdeck_14, 0); lv_group_add_obj(LvInput::group(), _textarea); + lv_group_focus_obj(_textarea); - // Hint - lv_obj_t* hint = lv_label_create(parent); - lv_obj_set_style_text_font(hint, &lv_font_ratdeck_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); + lv_obj_t* note = makeLabel(parent, + "Leave blank to generate a Ratdeck name from this identity.", + &lv_font_ratdeck_12, Theme::TEXT_SECONDARY, 260, LV_TEXT_ALIGN_CENTER); + lv_obj_align(note, LV_ALIGN_TOP_MID, 0, 154); - // Version - lv_obj_t* ver = lv_label_create(parent); - lv_obj_set_style_text_font(ver, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(ver, lv_color_hex(Theme::TEXT_MUTED), 0); + _doneButton = makeActionButton(parent, "DONE", 184); + lv_obj_add_event_cb(_doneButton, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->submit(false); + }, LV_EVENT_CLICKED, this); + + lv_obj_t* ver = makeLabel(parent, "", &lv_font_ratdeck_10, + Theme::TEXT_MUTED, 120, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); 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); } +void LvNameInputScreen::submit(bool enforceEnterGuard) { + if (!_textarea) return; + if (enforceEnterGuard && millis() - _enterTime < ENTER_GUARD_MS) return; + + const char* text = lv_textarea_get_text(_textarea); + if (_doneCb) { + _doneCb(String(text && strlen(text) > 0 ? text : "")); + } +} + bool LvNameInputScreen::handleKey(const KeyEvent& event) { if (!_textarea) return false; if (event.enter || event.character == '\n' || event.character == '\r') { - // Guard: ignore Enter for first 600ms to prevent stale keypress from factory reset - if (millis() - _enterTime < ENTER_GUARD_MS) return true; - const char* text = lv_textarea_get_text(_textarea); - if (_doneCb) { - _doneCb(String(text && strlen(text) > 0 ? text : "")); - } + submit(true); return true; } if (event.del || event.character == 8) { diff --git a/src/ui/screens/LvNameInputScreen.h b/src/ui/screens/LvNameInputScreen.h index 7019dfd..2afd9b5 100644 --- a/src/ui/screens/LvNameInputScreen.h +++ b/src/ui/screens/LvNameInputScreen.h @@ -16,7 +16,10 @@ public: private: lv_obj_t* _textarea = nullptr; + lv_obj_t* _doneButton = nullptr; std::function _doneCb; unsigned long _enterTime = 0; static constexpr unsigned long ENTER_GUARD_MS = 600; // Ignore Enter for 600ms after screen appears + + void submit(bool enforceEnterGuard); }; diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index 7cfd3d7..ca5a365 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -7,46 +7,161 @@ #include "config/UserConfig.h" #include #include +#include #include "fonts/fonts.h" +namespace { + +constexpr int kPeerRowH = 36; +constexpr int kPeerHeaderH = 20; +unsigned long nodeAgeMs(const DiscoveredNode& node, unsigned long now) { + if (node.lastSeen == 0 || now < node.lastSeen) return ULONG_MAX; + return now - node.lastSeen; +} + +std::string displayNameFor(const DiscoveredNode& node) { + if (!node.name.empty()) return node.name; + std::string hex = node.hash.toHex(); + return hex.substr(0, 12); +} + +std::string identityLineFor(const DiscoveredNode& node) { + std::string hex = node.hash.toHex(); + return "ID: " + hex; +} + +std::string compactAge(unsigned long ageMs) { + if (ageMs == ULONG_MAX) return "old"; + if (ageMs < 5000) return "now"; + + unsigned long sec = ageMs / 1000; + char buf[12]; + if (sec < 60) { + snprintf(buf, sizeof(buf), "%lus", sec); + } else if (sec < 3600) { + snprintf(buf, sizeof(buf), "%lum", sec / 60); + } else if (sec < 86400) { + snprintf(buf, sizeof(buf), "%luh", sec / 3600); + } else { + snprintf(buf, sizeof(buf), "%lud", sec / 86400); + } + return buf; +} + +std::string hopTextFor(const DiscoveredNode& node) { + if (node.hops > 0 && node.hops < 128) { + char buf[12]; + snprintf(buf, sizeof(buf), "%uhop", (unsigned)node.hops); + return buf; + } + return ""; +} + +std::string peerMetaFor(const DiscoveredNode& node, unsigned long ageMs, bool devMode) { + std::string meta = hopTextFor(node); + if (!meta.empty()) meta += " "; + meta += compactAge(ageMs); + if (devMode && node.rssi != 0) { + char buf[14]; + snprintf(buf, sizeof(buf), " %ddB", node.rssi); + meta += buf; + } + return meta; +} + +lv_obj_t* createEmptyState(lv_obj_t* parent) { + lv_obj_t* box = lv_obj_create(parent); + lv_obj_set_size(box, 252, 94); + lv_obj_set_style_bg_opa(box, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(box, 0, 0); + lv_obj_set_style_pad_all(box, 0, 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_center(box); + + for (int i = 0; i < 4; i++) { + lv_obj_t* pip = lv_obj_create(box); + lv_obj_set_size(pip, 6, 6); + lv_obj_set_pos(pip, 104 + i * 13, 14); + lv_obj_set_style_radius(pip, 3, 0); + lv_obj_set_style_bg_color(pip, lv_color_hex(i == 0 ? Theme::TEXT_MUTED : Theme::BORDER), 0); + lv_obj_set_style_bg_opa(pip, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pip, 0, 0); + lv_obj_set_style_pad_all(pip, 0, 0); + lv_obj_clear_flag(pip, LV_OBJ_FLAG_SCROLLABLE); + } + + lv_obj_t* title = lv_label_create(box); + lv_obj_set_style_text_font(title, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(title, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(title, "No peers heard"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 34); + + lv_obj_t* hint = lv_label_create(box); + lv_obj_set_style_text_font(hint, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(hint, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(hint, "Listening for announces"); + lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 55); + + return box; +} + +} // namespace + 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); - _lblEmpty = lv_label_create(parent); - lv_obj_set_style_text_font(_lblEmpty, &lv_font_ratdeck_14, 0); - lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_text(_lblEmpty, "No nodes discovered"); - lv_obj_center(_lblEmpty); + _emptyState = createEmptyState(parent); _list = lv_obj_create(parent); lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); lv_obj_add_style(_list, LvTheme::styleList(), 0); lv_obj_set_layout(_list, LV_LAYOUT_FLEX); lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); rebuildList(); // --- Action modal overlay (on top layer, centered) --- _overlay = lv_obj_create(lv_layer_top()); - lv_obj_set_size(_overlay, 200, 110); - lv_obj_set_pos(_overlay, (Theme::SCREEN_W - 200) / 2, Theme::STATUS_BAR_H + (Theme::CONTENT_H - 110) / 2); + lv_obj_set_size(_overlay, 260, 158); + lv_obj_set_pos(_overlay, (Theme::SCREEN_W - 260) / 2, Theme::STATUS_BAR_H + (Theme::CONTENT_H - 158) / 2); lv_obj_add_style(_overlay, LvTheme::styleModal(), 0); - lv_obj_set_style_pad_row(_overlay, 4, 0); - lv_obj_set_layout(_overlay, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(_overlay, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(_overlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(_overlay, 0, 0); lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(_overlay, LV_OBJ_FLAG_HIDDEN); - const char* menuText[] = {"Add Contact", "Message", "Back"}; + _overlayTitle = lv_label_create(_overlay); + lv_obj_set_style_text_font(_overlayTitle, &lv_font_ratdeck_14, 0); + lv_obj_set_style_text_color(_overlayTitle, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_long_mode(_overlayTitle, LV_LABEL_LONG_CLIP); + lv_obj_set_width(_overlayTitle, 236); + lv_obj_set_pos(_overlayTitle, 12, 9); + + _overlayMeta = lv_label_create(_overlay); + lv_obj_set_style_text_font(_overlayMeta, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(_overlayMeta, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_long_mode(_overlayMeta, LV_LABEL_LONG_CLIP); + lv_obj_set_width(_overlayMeta, 236); + lv_obj_set_pos(_overlayMeta, 12, 29); + + _overlayReach = lv_label_create(_overlay); + lv_obj_set_style_text_font(_overlayReach, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(_overlayReach, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_long_mode(_overlayReach, LV_LABEL_LONG_CLIP); + lv_obj_set_width(_overlayReach, 236); + lv_obj_set_pos(_overlayReach, 12, 43); + + const char* menuText[] = {"Save Contact", "Message", "Close"}; for (int i = 0; i < 3; i++) { lv_obj_t* btn = lv_obj_create(_overlay); - lv_obj_set_size(btn, 176, 28); + lv_obj_set_size(btn, 236, 24); + lv_obj_set_pos(btn, 12, 63 + i * 27); lv_obj_set_style_bg_color(btn, lv_color_hex(Theme::BG_SURFACE), 0); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_set_style_border_color(btn, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(btn, 1, 0); lv_obj_set_style_pad_all(btn, 0, 0); lv_obj_set_style_radius(btn, 4, 0); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); @@ -72,38 +187,41 @@ void LvNodesScreen::createUI(lv_obj_t* parent) { // Nickname input widgets _nicknameBox = lv_obj_create(_overlay); - lv_obj_set_size(_nicknameBox, 176, LV_SIZE_CONTENT); + lv_obj_set_size(_nicknameBox, 236, 86); + lv_obj_set_pos(_nicknameBox, 12, 63); lv_obj_set_style_bg_opa(_nicknameBox, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(_nicknameBox, 0, 0); lv_obj_set_style_pad_all(_nicknameBox, 0, 0); - lv_obj_set_style_pad_row(_nicknameBox, 2, 0); - lv_obj_set_layout(_nicknameBox, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(_nicknameBox, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(_nicknameBox, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(_nicknameBox, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); lv_obj_t* nickTitle = lv_label_create(_nicknameBox); - lv_obj_set_style_text_font(nickTitle, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(nickTitle, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(nickTitle, "Enter nickname:"); + lv_obj_set_style_text_font(nickTitle, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(nickTitle, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(nickTitle, "Contact name"); + lv_obj_set_pos(nickTitle, 0, 0); _nicknameLbl = lv_label_create(_nicknameBox); lv_obj_set_style_text_font(_nicknameLbl, &lv_font_ratdeck_14, 0); lv_obj_set_style_text_color(_nicknameLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_long_mode(_nicknameLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(_nicknameLbl, 232); lv_label_set_text(_nicknameLbl, "_"); + lv_obj_set_pos(_nicknameLbl, 0, 22); _nicknameHint = lv_label_create(_nicknameBox); lv_obj_set_style_text_font(_nicknameHint, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(_nicknameHint, lv_color_hex(Theme::TEXT_MUTED), 0); - lv_label_set_text(_nicknameHint, "Enter=Save Esc=Cancel"); + lv_label_set_text(_nicknameHint, "Enter saves / Esc keeps"); + lv_obj_set_pos(_nicknameHint, 0, 52); } void LvNodesScreen::destroyUI() { if (_overlay) { lv_obj_del(_overlay); _overlay = nullptr; } for (int i = 0; i < 3; i++) { _menuBtns[i] = nullptr; _menuLabels[i] = nullptr; } + _overlayTitle = nullptr; _overlayMeta = nullptr; _overlayReach = nullptr; _nicknameBox = nullptr; _nicknameLbl = nullptr; _nicknameHint = nullptr; - _list = nullptr; _lblEmpty = nullptr; + _list = nullptr; _emptyState = nullptr; LvScreen::destroyUI(); } @@ -111,6 +229,8 @@ void LvNodesScreen::onEnter() { _lastNodeCount = -1; _lastContactCount = -1; _focusActive = false; + _confirmDelete = false; + hideOverlay(); rebuildList(); } @@ -124,9 +244,7 @@ void LvNodesScreen::refreshUI() { int contactDelta = abs(contacts - _lastContactCount); if (countDelta > 0 || contactDelta > 0) { _lastRebuild = now; - if (countDelta > 3 || contactDelta > 0) { - rebuildList(); - } + rebuildList(); } } @@ -141,6 +259,7 @@ void LvNodesScreen::rebuildList() { const auto& nodes = _am->nodes(); int count = (int)nodes.size(); _lastNodeCount = count; + unsigned long now = millis(); for (int i = 0; i < count; i++) { if (nodes[i].saved) _sortedContactIndices.push_back(i); @@ -148,41 +267,57 @@ void LvNodesScreen::rebuildList() { } _lastContactCount = (int)_sortedContactIndices.size(); + std::sort(_sortedContactIndices.begin(), _sortedContactIndices.end(), [&nodes, now](int a, int b) { + unsigned long ageA = nodeAgeMs(nodes[a], now); + unsigned long ageB = nodeAgeMs(nodes[b], now); + if (ageA != ageB) return ageA < ageB; + std::string nameA = displayNameFor(nodes[a]); + std::string nameB = displayNameFor(nodes[b]); + if (nameA == nameB) return nodes[a].hash.toHex() < nodes[b].hash.toHex(); + return nameA < nameB; + }); + std::sort(_sortedOnlineIndices.begin(), _sortedOnlineIndices.end(), [&nodes](int a, int b) { return nodes[a].lastSeen > nodes[b].lastSeen; }); if (_sortedContactIndices.empty() && _sortedOnlineIndices.empty()) { - lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + if (_emptyState) lv_obj_clear_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); return; } - lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + if (_emptyState) lv_obj_add_flag(_emptyState, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); bool devMode = _cfg && _cfg->settings().devMode; auto addHeader = [&](const char* text) { lv_obj_t* hdr = lv_obj_create(_list); - lv_obj_set_size(hdr, Theme::CONTENT_W, 22); + lv_obj_set_size(hdr, Theme::CONTENT_W, kPeerHeaderH); lv_obj_add_style(hdr, LvTheme::styleSectionHeader(), 0); lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_set_style_pad_all(hdr, 0, 0); lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); lv_obj_t* lbl = lv_label_create(hdr); lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); lv_label_set_text(lbl, text); - lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_set_pos(lbl, 8, 5); }; auto addNodeRow = [&](int nodeIdx) { const auto& node = nodes[nodeIdx]; + unsigned long age = nodeAgeMs(node, now); lv_obj_t* row = lv_obj_create(_list); - lv_obj_set_size(row, Theme::CONTENT_W, 26); + lv_obj_set_size(row, Theme::CONTENT_W, kPeerRowH); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_pad_all(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_user_data(row, (void*)(intptr_t)nodeIdx); @@ -200,51 +335,47 @@ void LvNodesScreen::rebuildList() { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); - // Name + hash - std::string truncName = node.name.substr(0, devMode ? 12 : 15); - std::string displayHash = node.hash.toHex().substr(0, 12); - char buf[64]; - snprintf(buf, sizeof(buf), "%s [%s]", truncName.c_str(), displayHash.c_str()); lv_obj_t* nameLbl = lv_label_create(row); lv_obj_set_style_text_font(nameLbl, &lv_font_ratdeck_12, 0); lv_obj_set_style_text_color(nameLbl, lv_color_hex( node.saved ? Theme::ACCENT : Theme::PRIMARY), 0); - lv_label_set_text(nameLbl, buf); - lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 8, 0); + lv_label_set_long_mode(nameLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(nameLbl, Theme::CONTENT_W - 132); + std::string name = displayNameFor(node); + lv_label_set_text(nameLbl, name.c_str()); + lv_obj_set_pos(nameLbl, 8, 3); - // Info (hops + age) - unsigned long ageSec = (millis() - node.lastSeen) / 1000; - char infoBuf[32]; - if (node.hops > 0 && node.hops < 128) { - if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%dhop %lus", node.hops, ageSec); - else snprintf(infoBuf, sizeof(infoBuf), "%dhop %lum", node.hops, ageSec / 60); - } else { - if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%lus", ageSec); - else snprintf(infoBuf, sizeof(infoBuf), "%lum", ageSec / 60); - } - if (devMode && node.rssi != 0) { - char rssiBuf[16]; - snprintf(rssiBuf, sizeof(rssiBuf), " %ddB", node.rssi); - strncat(infoBuf, rssiBuf, sizeof(infoBuf) - strlen(infoBuf) - 1); - } lv_obj_t* infoLbl = lv_label_create(row); lv_obj_set_style_text_font(infoLbl, &lv_font_ratdeck_10, 0); lv_obj_set_style_text_color(infoLbl, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(infoLbl, infoBuf); - lv_obj_align(infoLbl, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_text_align(infoLbl, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_long_mode(infoLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(infoLbl, 116); + std::string meta = peerMetaFor(node, age, devMode); + lv_label_set_text(infoLbl, meta.c_str()); + lv_obj_set_pos(infoLbl, Theme::CONTENT_W - 124, 5); + + lv_obj_t* idLbl = lv_label_create(row); + lv_obj_set_style_text_font(idLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(idLbl, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_long_mode(idLbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(idLbl, Theme::CONTENT_W - 16); + std::string identity = identityLineFor(node); + lv_label_set_text(idLbl, identity.c_str()); + lv_obj_set_pos(idLbl, 8, 20); }; // Build list: Contacts section, then Online section if (!_sortedContactIndices.empty()) { char hdrBuf[32]; - snprintf(hdrBuf, sizeof(hdrBuf), "Contacts (%d)", (int)_sortedContactIndices.size()); + snprintf(hdrBuf, sizeof(hdrBuf), "TRUSTED CONTACTS (%d)", (int)_sortedContactIndices.size()); addHeader(hdrBuf); for (int idx : _sortedContactIndices) addNodeRow(idx); } { char hdrBuf[32]; - snprintf(hdrBuf, sizeof(hdrBuf), "Online (%d)", (int)_sortedOnlineIndices.size()); + snprintf(hdrBuf, sizeof(hdrBuf), "RECENT PEERS (%d)", (int)_sortedOnlineIndices.size()); addHeader(hdrBuf); for (int idx : _sortedOnlineIndices) addNodeRow(idx); } @@ -270,6 +401,32 @@ int LvNodesScreen::getFocusedNodeIdx() const { // --- Action modal helpers --- +void LvNodesScreen::updateOverlayDetails(const char* title) { + if (!_overlayTitle || !_overlayMeta || !_overlayReach) return; + + if (!_am || _actionNodeIdx < 0 || _actionNodeIdx >= (int)_am->nodes().size()) { + lv_label_set_text(_overlayTitle, title ? title : "Peer"); + lv_label_set_text(_overlayMeta, "ID: unavailable"); + lv_label_set_text(_overlayReach, "No route data"); + return; + } + + const auto& node = _am->nodes()[_actionNodeIdx]; + unsigned long age = nodeAgeMs(node, millis()); + bool devMode = _cfg && _cfg->settings().devMode; + + std::string heading = title ? title : displayNameFor(node); + std::string identity = identityLineFor(node); + std::string details = node.saved ? "Saved contact / " : "Peer / "; + details += peerMetaFor(node, age, devMode); + + lv_obj_set_style_text_color(_overlayTitle, lv_color_hex(node.saved ? Theme::ACCENT : Theme::PRIMARY), 0); + lv_obj_set_style_text_color(_overlayReach, lv_color_hex(Theme::TEXT_SECONDARY), 0); + lv_label_set_text(_overlayTitle, heading.c_str()); + lv_label_set_text(_overlayMeta, identity.c_str()); + lv_label_set_text(_overlayReach, details.c_str()); +} + void LvNodesScreen::showActionMenu(int nodeIdx) { _actionNodeIdx = nodeIdx; _menuIdx = 0; @@ -278,8 +435,11 @@ void LvNodesScreen::showActionMenu(int nodeIdx) { if (_overlay) { if (_am && nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { bool isSaved = _am->nodes()[nodeIdx].saved; - lv_label_set_text(_menuLabels[0], isSaved ? "Edit Name" : "Add Contact"); + lv_label_set_text(_menuLabels[0], isSaved ? "Edit Name" : "Save Contact"); + lv_label_set_text(_menuLabels[1], "Message"); + lv_label_set_text(_menuLabels[2], "Close"); } + updateOverlayDetails(nullptr); for (int i = 0; i < 3; i++) lv_obj_clear_flag(_menuBtns[i], LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_overlay, LV_OBJ_FLAG_HIDDEN); @@ -299,6 +459,7 @@ void LvNodesScreen::showNicknameInput() { if (_am && _actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { _nicknameText = String(_am->nodes()[_actionNodeIdx].name.c_str()); } + updateOverlayDetails("Set contact name"); for (int i = 0; i < 3; i++) lv_obj_add_flag(_menuBtns[i], LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_nicknameBox, LV_OBJ_FLAG_HIDDEN); updateNicknameDisplay(); @@ -308,10 +469,11 @@ void LvNodesScreen::updateMenuSelection() { for (int i = 0; i < 3; i++) { bool sel = (i == _menuIdx); lv_obj_set_style_text_color(_menuLabels[i], lv_color_hex( - sel ? Theme::TEXT_PRIMARY : Theme::TEXT_SECONDARY), 0); + sel ? Theme::ACCENT : Theme::TEXT_SECONDARY), 0); lv_obj_set_style_bg_color(_menuBtns[i], lv_color_hex( - sel ? Theme::BG_HOVER : Theme::BG_SURFACE), 0); + sel ? Theme::PRIMARY_SUBTLE : Theme::BG_SURFACE), 0); lv_obj_set_style_bg_opa(_menuBtns[i], LV_OPA_COVER, 0); + lv_obj_set_style_border_color(_menuBtns[i], lv_color_hex(sel ? Theme::BORDER_ACTIVE : Theme::BORDER), 0); } } @@ -333,7 +495,7 @@ bool LvNodesScreen::handleLongPress() { if (node.saved) { _confirmDelete = true; _actionNodeIdx = nodeIdx; - if (_ui) _ui->lvStatusBar().showToast("Remove friend? Enter=Yes Esc=No", 5000); + if (_ui) _ui->lvStatusBar().showToast("Remove? Enter=Remove Esc=Keep", 5000); } else { showActionMenu(nodeIdx); } @@ -366,7 +528,7 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { node.name = finalName.c_str(); node.saved = true; _am->saveContacts(); - if (_ui) _ui->lvStatusBar().showToast("Contact saved!", 1200); + if (_ui) _ui->lvStatusBar().showToast("Contact saved", 1200); hideOverlay(); rebuildList(); } else { @@ -427,14 +589,14 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) { if (event.enter || event.character == '\n' || event.character == '\r') { if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) { _am->deleteContact(_actionNodeIdx); - if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200); + if (_ui) _ui->lvStatusBar().showToast("Contact removed", 1200); rebuildList(); } _confirmDelete = false; return true; } _confirmDelete = false; - if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + if (_ui) _ui->lvStatusBar().showToast("Kept contact", 800); return true; } diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h index 2dff387..d212e73 100644 --- a/src/ui/screens/LvNodesScreen.h +++ b/src/ui/screens/LvNodesScreen.h @@ -24,7 +24,7 @@ public: void setUserConfig(UserConfig* cfg) { _cfg = cfg; } bool handleLongPress() override; - const char* title() const override { return "Nodes"; } + const char* title() const override { return "Peers"; } private: void rebuildList(); @@ -37,6 +37,7 @@ private: void showNicknameInput(); void updateMenuSelection(); void updateNicknameDisplay(); + void updateOverlayDetails(const char* title); AnnounceManager* _am = nullptr; class UIManager* _ui = nullptr; @@ -53,6 +54,9 @@ private: // Overlay widgets lv_obj_t* _overlay = nullptr; + lv_obj_t* _overlayTitle = nullptr; + lv_obj_t* _overlayMeta = nullptr; + lv_obj_t* _overlayReach = nullptr; lv_obj_t* _menuLabels[3] = {}; lv_obj_t* _menuBtns[3] = {}; lv_obj_t* _nicknameBox = nullptr; @@ -69,5 +73,5 @@ private: static constexpr unsigned long REBUILD_INTERVAL_MS = 5000; lv_obj_t* _list = nullptr; - lv_obj_t* _lblEmpty = nullptr; + lv_obj_t* _emptyState = nullptr; }; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 6ec376d..08453b3 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -18,7 +18,6 @@ #include #include #include -#include #include "fonts/fonts.h" struct RadioPresetLv { @@ -37,12 +36,102 @@ static const RadioPresetLv LV_PRESETS[] = { }; static constexpr int LV_NUM_PRESETS = 8; +namespace { + +bool labelEq(const char* a, const char* b) { + return a && b && strcmp(a, b) == 0; +} + +const char* onOff(bool enabled) { + return enabled ? "ON" : "OFF"; +} + +const char* wifiModeLabel(RatWiFiMode mode) { + switch (mode) { + case RAT_WIFI_AP: return "Hotspot"; + case RAT_WIFI_STA: return "Client"; + case RAT_WIFI_OFF: + default: return "Off"; + } +} + +String maskedValue(const String& value) { + if (value.isEmpty()) return String(""); + int len = constrain((int)value.length(), 4, 12); + String masked; + masked.reserve(len); + for (int i = 0; i < len; i++) masked += '*'; + return masked; +} + +bool isWiFiSSIDLabel(const char* label) { + return labelEq(label, "WiFi SSID"); +} + +bool isWiFiPasswordLabel(const char* label) { + return labelEq(label, "WiFi Password"); +} + +size_t selectedWiFiSlot(const UserSettings& s) { + return s.wifiSTASelected < WIFI_STA_MAX_NETWORKS ? s.wifiSTASelected : 0; +} + +void ensureWiFiSlot(UserSettings& s, size_t slot) { + while (s.wifiSTANetworks.size() <= slot && s.wifiSTANetworks.size() < WIFI_STA_MAX_NETWORKS) { + s.wifiSTANetworks.push_back({}); + } +} + +String wifiProfileValue(const UserSettings& s, size_t slot) { + String label = String(slot + 1); + label += " "; + if (slot < s.wifiSTANetworks.size() && !s.wifiSTANetworks[slot].ssid.isEmpty()) { + label += s.wifiSTANetworks[slot].ssid; + } else { + label += "empty"; + } + return label; +} + +String selectedWiFiSSID(const UserSettings& s) { + size_t slot = selectedWiFiSlot(s); + if (slot >= s.wifiSTANetworks.size()) return String(""); + return s.wifiSTANetworks[slot].ssid; +} + +void clipLabel(lv_obj_t* lbl, int width) { + lv_obj_set_width(lbl, width); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); +} + +bool extractReleaseTag(const String& payload, char* out, size_t outLen) { + if (!out || outLen == 0) return false; + out[0] = '\0'; + int key = payload.indexOf("\"tag_name\""); + if (key < 0) return false; + int colon = payload.indexOf(':', key); + if (colon < 0) return false; + int start = payload.indexOf('"', colon + 1); + if (start < 0) return false; + int end = payload.indexOf('"', start + 1); + if (end <= start + 1) return false; + + String tag = payload.substring(start + 1, end); + if (tag.startsWith("v") || tag.startsWith("V")) tag.remove(0, 1); + tag.trim(); + if (tag.isEmpty()) return false; + strlcpy(out, tag.c_str(), outLen); + return out[0] != '\0'; +} + +} // namespace + int LvSettingsScreen::detectPreset() const { if (!_cfg) return -1; auto& s = _cfg->settings(); // Check if frequency matches the current region's default uint32_t regionFreq = REGION_FREQ[constrain(s.radioRegion, 0, REGION_COUNT - 1)]; - if (s.loraFrequency != regionFreq) return -1; // Custom frequency → no preset match + if (s.loraFrequency != regionFreq) return -1; // Custom frequency: no preset match 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) @@ -82,7 +171,194 @@ void LvSettingsScreen::skipToNextEditable(int dir) { _selectedIdx = start; } -// buildItems() — identical logic to SettingsScreen::buildItems() +bool LvSettingsScreen::settingNeedsReboot(const SettingItem& item) const { + if (!_cfg) return false; + auto& s = _cfg->settings(); + if (labelEq(item.label, "WiFi Mode")) return s.wifiMode != _rebootSnap.wifiMode; + if (labelEq(item.label, "Active WiFi")) return s.wifiSTASelected != _rebootSnap.wifiSTASelected; + if (isWiFiSSIDLabel(item.label) || isWiFiPasswordLabel(item.label)) return rebootSettingsChanged(); + if (labelEq(item.label, "WiFi Scan") || labelEq(item.label, "Forget WiFi")) return rebootSettingsChanged(); + if (labelEq(item.label, "LAN Discovery")) return s.autoIfaceEnabled != _rebootSnap.autoIfaceEnabled; + if (labelEq(item.label, "SD Message Store")) return s.sdStorageEnabled != _rebootSnap.sdStorageEnabled; + return false; +} + +bool LvSettingsScreen::categoryNeedsReboot(int catIdx) const { + if (catIdx < 0 || catIdx >= (int)_categories.size()) return false; + return (labelEq(_categories[catIdx].name, "Interfaces") + || labelEq(_categories[catIdx].name, "Storage & Maintenance")) + && rebootSettingsChanged(); +} + +bool LvSettingsScreen::confirmableAction(const SettingItem& item) const { + return labelEq(item.label, "Developer Radio Controls") + || labelEq(item.label, "Format SD Card") + || labelEq(item.label, "Erase Ratdeck SD Data") + || labelEq(item.label, "Erase Device"); +} + +bool LvSettingsScreen::armedAction(const SettingItem& item) const { + return (_confirmingInitSD && labelEq(item.label, "Format SD Card")) || + (_confirmingWipeSD && labelEq(item.label, "Erase Ratdeck SD Data")) || + (_confirmingReset && labelEq(item.label, "Erase Device")) || + (_confirmingDevMode && labelEq(item.label, "Developer Radio Controls")); +} + +bool LvSettingsScreen::destructiveAction(const SettingItem& item) const { + return labelEq(item.label, "Format SD Card") + || labelEq(item.label, "Erase Ratdeck SD Data") + || labelEq(item.label, "Erase Device"); +} + +const char* LvSettingsScreen::confirmationTitle() const { + if (_confirmingInitSD) return "ARMED: FORMAT SD CARD"; + if (_confirmingWipeSD) return "ARMED: ERASE SD DATA"; + if (_confirmingReset) return "ARMED: ERASE DEVICE"; + if (_confirmingDevMode) return "ARMED: UNLOCK RF CONTROLS"; + return nullptr; +} + +const char* LvSettingsScreen::confirmationDetail() const { + if (_confirmingInitSD) return "Hold trackball to format. Esc cancels."; + if (_confirmingWipeSD) return "Hold trackball to erase SD data. Esc cancels."; + if (_confirmingReset) return "Hold trackball to erase device. Esc cancels."; + if (_confirmingDevMode) return "Hold trackball to unlock. Esc cancels."; + return nullptr; +} + +bool LvSettingsScreen::hasPendingConfirmation() const { + return _confirmingInitSD || _confirmingWipeSD || _confirmingReset || _confirmingDevMode; +} + +void LvSettingsScreen::clearConfirmations() { + _confirmingInitSD = false; + _confirmingWipeSD = false; + _confirmingReset = false; + _confirmingDevMode = false; +} + +void LvSettingsScreen::runFormatSD() { + _confirmingInitSD = false; + if (!_sd || !_sd->isReady()) { + if (_ui) _ui->lvStatusBar().showToast("No SD card", 1200); + rebuildItemList(); + return; + } + if (_ui) _ui->lvStatusBar().showToast("Formatting SD card...", 2000); + bool ok = _sd->formatForRatdeck(); + if (_ui) _ui->lvStatusBar().showToast(ok ? "SD card formatted" : "SD format failed", 1500); + rebuildItemList(); +} + +void LvSettingsScreen::runWipeSD() { + _confirmingWipeSD = false; + if (!_sd || !_sd->isReady()) { + if (_ui) _ui->lvStatusBar().showToast("No SD card", 1200); + rebuildItemList(); + return; + } + if (_ui) _ui->lvStatusBar().showToast("Erasing Ratdeck SD data...", 2000); + bool ok = _sd->wipeRatdeck(); + if (_ui) _ui->lvStatusBar().showToast(ok ? "SD data erased" : "SD erase failed", 1500); + rebuildItemList(); +} + +void LvSettingsScreen::runFactoryReset() { + _confirmingReset = false; + if (_ui) _ui->lvStatusBar().showToast("Erasing device...", 3000); + if (_sd && _sd->isReady()) _sd->wipeRatdeck(); + if (_flash) _flash->format(); + nvs_flash_erase(); + delay(1500); // Long enough for key state to clear before reboot + ESP.restart(); +} + +void LvSettingsScreen::runEnableDevMode() { + _confirmingDevMode = false; + if (!_cfg) { + rebuildItemList(); + return; + } + _cfg->settings().devMode = true; + applyAndSave(); + buildItems(); + enterCategory(_categoryIdx); + if (_ui) _ui->lvStatusBar().showToast("Developer radio controls unlocked", 1500); +} + +void LvSettingsScreen::startFirmwareCheck() { + if (WiFi.status() != WL_CONNECTED) { + if (_ui) _ui->lvStatusBar().showToast("Connect WiFi to check firmware", 2500); + return; + } + if (_fwCheckState == FirmwareCheckState::RUNNING) { + if (_ui) _ui->lvStatusBar().showToast("Firmware check already running", 1200); + return; + } + + _fwCheckVersion[0] = '\0'; + _fwCheckState = FirmwareCheckState::RUNNING; + if (xTaskCreatePinnedToCore( + firmwareCheckTask, + "fw-check", + 6144, + this, + 1, + &_fwCheckTask, + 0) != pdPASS) { + _fwCheckTask = nullptr; + _fwCheckState = FirmwareCheckState::FAILED; + } else if (_ui) { + _ui->lvStatusBar().showToast("Checking firmware release...", 1200); + } +} + +void LvSettingsScreen::firmwareCheckTask(void* arg) { + auto* self = static_cast(arg); + if (!self) { + vTaskDelete(nullptr); + return; + } + + FirmwareCheckState result = FirmwareCheckState::FAILED; + char version[sizeof(self->_fwCheckVersion)] = {}; + + HTTPClient http; + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setTimeout(5000); + if (http.begin("https://api.github.com/repos/ratspeak/ratdeck/releases/latest")) { + http.addHeader("Accept", "application/vnd.github.v3+json"); + int httpCode = http.GET(); + if (httpCode == 200) { + WiFiClient* stream = http.getStreamPtr(); + String payload; + payload.reserve(1536); + unsigned long start = millis(); + while (stream && http.connected() && millis() - start < 5000 && payload.length() < 2048) { + while (stream->available() && payload.length() < 2048) { + payload += (char)stream->read(); + if (payload.indexOf("\"tag_name\"") >= 0 && payload.indexOf('\n', payload.indexOf("\"tag_name\"")) >= 0) { + break; + } + } + if (extractReleaseTag(payload, version, sizeof(version))) break; + delay(10); + } + if (extractReleaseTag(payload, version, sizeof(version))) { + result = strcmp(version, RATDECK_VERSION_STRING) > 0 + ? FirmwareCheckState::AVAILABLE + : FirmwareCheckState::CURRENT; + } + } + } + http.end(); + + if (version[0]) strlcpy(self->_fwCheckVersion, version, sizeof(self->_fwCheckVersion)); + self->_fwCheckState = result; + self->_fwCheckTask = nullptr; + vTaskDelete(nullptr); +} + void LvSettingsScreen::buildItems() { _items.clear(); _categories.clear(); @@ -90,15 +366,15 @@ void LvSettingsScreen::buildItems() { auto& s = _cfg->settings(); int idx = 0; - // Device + // Identity & Device int devStart = idx; - _items.push_back({"Version", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"Firmware", SettingType::READONLY, nullptr, nullptr, [](int) { return String(RATDECK_VERSION_STRING); }}); idx++; - _items.push_back({"LXMF Addr", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"LXMF Address", SettingType::READONLY, nullptr, nullptr, [this](int) { return _destinationHash.length() > 0 ? _destinationHash : String("unknown"); }}); idx++; - _items.push_back({"Identity", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"Identity Hash", SettingType::READONLY, nullptr, nullptr, [this](int) { return _identityHash; }}); idx++; { @@ -119,7 +395,7 @@ void LvSettingsScreen::buildItems() { } if (_idMgr && _idMgr->count() > 0) { SettingItem idSwitch; - idSwitch.label = "Active Identity"; + idSwitch.label = "Identity Slot"; idSwitch.type = SettingType::ENUM_CHOICE; idSwitch.getter = [this]() { return _idMgr->activeIndex(); }; idSwitch.setter = [this](int v) { @@ -134,12 +410,12 @@ void LvSettingsScreen::buildItems() { if (_cfg) { _cfg->settings().displayName = _idMgr->getDisplayName(v); } - if (_ui) _ui->lvStatusBar().showToast("Identity switched! Rebooting...", 2000); + if (_ui) _ui->lvStatusBar().showToast("Identity switched; rebooting", 2000); applyAndSave(); delay(1000); ESP.restart(); } else { - if (_ui) _ui->lvStatusBar().showToast("Switch failed!", 1500); + if (_ui) _ui->lvStatusBar().showToast("Identity switch failed", 1500); } }; idSwitch.minVal = 0; @@ -161,15 +437,15 @@ void LvSettingsScreen::buildItems() { } { SettingItem newId; - newId.label = "New Identity"; + newId.label = "Create 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; } + if (_idMgr->count() >= 8) { if (_ui) _ui->lvStatusBar().showToast("Identity slots full", 1200); return; } int idx = _idMgr->createIdentity(s.displayName); if (idx >= 0) { - if (_ui) _ui->lvStatusBar().showToast("Identity created!", 1200); + if (_ui) _ui->lvStatusBar().showToast("Identity created", 1200); buildItems(); rebuildCategoryList(); } @@ -177,85 +453,61 @@ void LvSettingsScreen::buildItems() { _items.push_back(newId); idx++; } - _items.push_back({"Announce Interval", SettingType::INTEGER, + _items.push_back({"Auto Announce", SettingType::INTEGER, [&s]() { return s.announceInterval; }, [&s](int v) { s.announceInterval = v; }, - [](int v) { return String(v) + "m"; }, 5, 60 * 6, 5}); // 5m - 6h + [](int v) { return String(v) + "m"; }, 30, 60 * 6, 5}); // 30m - 6h idx++; - { - SettingItem devModeItem; - devModeItem.label = "Developer Mode"; - devModeItem.type = SettingType::ACTION; - devModeItem.formatter = [&s](int) { return s.devMode ? String("ON") : String("OFF"); }; - devModeItem.action = [this, &s]() { - if (s.devMode) { - // Already on — just turn it off - s.devMode = false; - _confirmingDevMode = false; - applyAndSave(); - buildItems(); - enterCategory(_categoryIdx); - if (_ui) _ui->lvStatusBar().showToast("Developer mode OFF", 1200); - return; - } - if (!_confirmingDevMode) { - _confirmingDevMode = true; - if (_ui) _ui->lvStatusBar().showToast("WARNING: KNOW YOUR LAWS! Enter=Enable", 5000); - rebuildItemList(); - return; - } - // Confirmed - _confirmingDevMode = false; - s.devMode = true; - applyAndSave(); - buildItems(); - enterCategory(_categoryIdx); - if (_ui) _ui->lvStatusBar().showToast("Developer mode ON", 1200); - }; - _items.push_back(devModeItem); - idx++; - } - _categories.push_back({"Device", devStart, idx - devStart, - [&s]() { return s.displayName.isEmpty() ? String("(unnamed)") : s.displayName; }}); + _categories.push_back({"Identity & Device", devStart, idx - devStart, + [&s]() -> String { + String name = s.displayName.isEmpty() ? String("Unnamed device") : s.displayName; + if (s.devMode) name += " / Dev Enabled"; + return name; + }}); - // Display & Input + // Screen & Input int dispStart = idx; - _items.push_back({"Display Brightness", SettingType::INTEGER, + _items.push_back({"Screen 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({"Display Dim Timeout", SettingType::INTEGER, + _items.push_back({"Dim After", 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({"Display Off Timeout", SettingType::INTEGER, + _items.push_back({"Display Off After", 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({"Keyboard Backlight Brightness", SettingType::INTEGER, + _items.push_back({"Key Backlight", SettingType::INTEGER, [&s]() { return s.keyboardBrightness; }, [&s](int v) { s.keyboardBrightness = v; }, - [](int v) { return String(v) + "%"; }, 5, 100, 5}); + [](int v) { return v <= 0 ? String("OFF") : String(v) + "%"; }, 0, 100, 5}); idx++; - _items.push_back({"Keyboard Backlight Auto-ON", SettingType::TOGGLE, + _items.push_back({"Keys Wake Light", SettingType::TOGGLE, [&s]() { return s.keyboardAutoOn ? 1 : 0; }, [&s](int v) { s.keyboardAutoOn = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); idx++; - _items.push_back({"Keyboard Backlight Auto-OFF", SettingType::TOGGLE, + _items.push_back({"Keys Auto-Off", SettingType::TOGGLE, [&s]() { return s.keyboardAutoOff ? 1 : 0; }, [&s](int v) { s.keyboardAutoOff = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); 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) + "%"; }}); + _categories.push_back({"Screen & Input", dispStart, idx - dispStart, + [&s]() -> String { + String summary = String("Screen ") + String(s.brightness); + summary += "% / Keys "; + summary += s.keyboardBrightness == 0 ? String("OFF") : String(s.keyboardBrightness) + "%"; + return summary; + }}); - // Radio + // LoRa link int radioStart = idx; { - // Region picker — always visible + // Region picker - always visible SettingItem regionItem; regionItem.label = "Region"; regionItem.type = SettingType::ENUM_CHOICE; @@ -270,7 +522,7 @@ void LvSettingsScreen::buildItems() { idx++; SettingItem presetItem; - presetItem.label = "Preset"; + presetItem.label = "Link 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); }; @@ -282,7 +534,38 @@ void LvSettingsScreen::buildItems() { _items.push_back(presetItem); idx++; } - // Custom radio parameters — only visible in Developer Mode + { + SettingItem devModeItem; + devModeItem.label = "Developer Radio Controls"; + devModeItem.type = SettingType::ACTION; + devModeItem.formatter = [this, &s](int) { + if (_confirmingDevMode) return String("Hold"); + return s.devMode ? String("Unlocked") : String("Locked"); + }; + devModeItem.action = [this, &s]() { + if (s.devMode) { + s.devMode = false; + _confirmingDevMode = false; + clearConfirmations(); + applyAndSave(); + buildItems(); + enterCategory(_categoryIdx); + if (_ui) _ui->lvStatusBar().showToast("Developer radio controls locked", 1500); + return; + } + if (!_confirmingDevMode) { + clearConfirmations(); + _confirmingDevMode = true; + if (_ui) _ui->lvStatusBar().showToast("RF controls armed. Hold to unlock", 5000); + rebuildItemList(); + return; + } + if (_ui) _ui->lvStatusBar().showToast("Hold trackball to unlock RF controls", 2500); + }; + _items.push_back(devModeItem); + idx++; + } + // Custom radio parameters - only visible in Developer Mode if (s.devMode) { _items.push_back({"Frequency", SettingType::INTEGER, [&s]() { return (int)(s.loraFrequency); }, @@ -336,29 +619,46 @@ void LvSettingsScreen::buildItems() { [](int v) { return String(v); }, 6, 65, 1}); idx++; } - _categories.push_back({"Radio", radioStart, idx - radioStart, + _categories.push_back({"LoRa", radioStart, idx - radioStart, [this]() { int p = detectPreset(); auto& s = _cfg->settings(); String label = (p >= 0) ? String(LV_PRESETS[p].name) : String("Custom"); label += " "; label += REGION_LABELS[constrain(s.radioRegion, 0, REGION_COUNT - 1)]; + if (s.devMode) label += " / Dev"; return label; }}); - // Network + // Interfaces 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"}}); + nullptr, 0, 2, 1, {"Off", "Hotspot", "Client"}}); + idx++; + _items.push_back({"Active WiFi", SettingType::INTEGER, + [&s]() { return (int)selectedWiFiSlot(s) + 1; }, + [&s](int v) { s.wifiSTASelected = (uint8_t)constrain(v - 1, 0, (int)WIFI_STA_MAX_NETWORKS - 1); }, + [&s](int v) { return wifiProfileValue(s, constrain(v - 1, 0, (int)WIFI_STA_MAX_NETWORKS - 1)); }, + 1, (int)WIFI_STA_MAX_NETWORKS, 1}); 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.textGetter = [&s]() { + size_t slot = selectedWiFiSlot(s); + return slot < s.wifiSTANetworks.size() ? s.wifiSTANetworks[slot].ssid : String(""); + }; + ssidItem.textSetter = [&s](const String& v) { + size_t slot = selectedWiFiSlot(s); + ensureWiFiSlot(s, slot); + if (slot >= s.wifiSTANetworks.size()) return; + bool changed = s.wifiSTANetworks[slot].ssid != v; + s.wifiSTANetworks[slot].ssid = v; + if (changed || v.isEmpty()) s.wifiSTANetworks[slot].password = ""; + }; ssidItem.maxTextLen = 32; _items.push_back(ssidItem); idx++; @@ -367,15 +667,56 @@ void LvSettingsScreen::buildItems() { 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; + passItem.textGetter = [&s]() { + size_t slot = selectedWiFiSlot(s); + return slot < s.wifiSTANetworks.size() ? s.wifiSTANetworks[slot].password : String(""); + }; + passItem.textSetter = [&s](const String& v) { + size_t slot = selectedWiFiSlot(s); + ensureWiFiSlot(s, slot); + if (slot < s.wifiSTANetworks.size()) s.wifiSTANetworks[slot].password = v; + }; + passItem.maxTextLen = 64; _items.push_back(passItem); idx++; } + { + SettingItem scanItem; + scanItem.label = "WiFi Scan"; + scanItem.type = SettingType::ACTION; + scanItem.formatter = [](int) { return String("[Enter]"); }; + scanItem.action = [this, &s]() { + _wifiTargetSlot = selectedWiFiSlot(s); + showWifiPicker(); + }; + _items.push_back(scanItem); + idx++; + } + { + SettingItem forgetItem; + forgetItem.label = "Forget WiFi"; + forgetItem.type = SettingType::ACTION; + forgetItem.formatter = [&s](int) { + String ssid = selectedWiFiSSID(s); + return ssid.isEmpty() ? String("Empty") : String("[Enter]"); + }; + forgetItem.action = [this, &s]() { + size_t slot = selectedWiFiSlot(s); + if (slot >= s.wifiSTANetworks.size() || s.wifiSTANetworks[slot].ssid.isEmpty()) { + if (_ui) _ui->lvStatusBar().showToast("WiFi profile already empty", 1200); + return; + } + s.wifiSTANetworks[slot].ssid = ""; + s.wifiSTANetworks[slot].password = ""; + applyAndSave(); + if (_ui) _ui->lvStatusBar().showToast("WiFi profile cleared", 1200); + }; + _items.push_back(forgetItem); + idx++; + } { SettingItem tcpPreset; - tcpPreset.label = "TCP Server"; + tcpPreset.label = "TCP Relay"; tcpPreset.type = SettingType::ENUM_CHOICE; tcpPreset.getter = [&s]() { for (auto& ep : s.tcpConnections) { if (ep.host == "rns.ratspeak.org") return 1; } @@ -397,7 +738,7 @@ void LvSettingsScreen::buildItems() { } { SettingItem tcpHost; - tcpHost.label = "TCP Host"; + tcpHost.label = "Relay Host"; tcpHost.type = SettingType::TEXT_INPUT; tcpHost.textGetter = [&s]() { return s.tcpConnections.empty() ? String("") : s.tcpConnections[0].host; }; tcpHost.textSetter = [&s](const String& v) { @@ -410,7 +751,7 @@ void LvSettingsScreen::buildItems() { _items.push_back(tcpHost); idx++; } - _items.push_back({"TCP Port", SettingType::INTEGER, + _items.push_back({"Relay Port", SettingType::INTEGER, [&s]() { return s.tcpConnections.empty() ? TCP_DEFAULT_PORT : (int)s.tcpConnections[0].port; }, [&s](int v) { if (s.tcpConnections.empty()) { @@ -420,34 +761,37 @@ void LvSettingsScreen::buildItems() { }, [](int v) { return String(v); }, 1, 65535, 1}); 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++; - _items.push_back({"Auto-discover LAN", SettingType::TOGGLE, + _items.push_back({"LAN Discovery", SettingType::TOGGLE, [&s]() { return s.autoIfaceEnabled ? 1 : 0; }, [&s](int v) { s.autoIfaceEnabled = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); idx++; - _categories.push_back({"Network", netStart, idx - netStart, - [&s]() { - const char* modes[] = {"OFF", "AP", "STA"}; - return String(modes[constrain((int)s.wifiMode, 0, 2)]); + _categories.push_back({"Interfaces", netStart, idx - netStart, + [this, &s]() { + if (rebootSettingsChanged()) return String("Saved - reboot to apply"); + String summary = wifiModeLabel(s.wifiMode); + if (s.wifiMode == RAT_WIFI_STA) { + String ssid = WiFi.status() == WL_CONNECTED ? WiFi.SSID() : selectedWiFiSSID(s); + summary += ": "; + summary += ssid.isEmpty() ? String("No profile") : ssid; + } + if (!s.tcpConnections.empty()) summary += " + TCP"; + if (s.autoIfaceEnabled) summary += " + LAN"; + return summary; }}); - // GPS & Time + // Time & Location int gpsStart = idx; #if HAS_GPS - _items.push_back({"GPS Time", SettingType::TOGGLE, + _items.push_back({"GPS Time Sync", SettingType::TOGGLE, [&s]() { return s.gpsTimeEnabled ? 1 : 0; }, [&s](int v) { s.gpsTimeEnabled = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); idx++; _items.push_back({"GPS Location", SettingType::TOGGLE, [&s]() { return s.gpsLocationEnabled ? 1 : 0; }, [&s](int v) { s.gpsLocationEnabled = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); idx++; #endif _items.push_back({"Timezone", SettingType::INTEGER, @@ -459,44 +803,49 @@ void LvSettingsScreen::buildItems() { }, 0, TIMEZONE_COUNT - 1, 1}); idx++; - _items.push_back({"24h Time", SettingType::TOGGLE, + _items.push_back({"24-Hour Clock", SettingType::TOGGLE, [&s]() { return s.use24HourTime ? 1 : 0; }, [&s](int v) { s.use24HourTime = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); idx++; - _categories.push_back({"GPS/Time", gpsStart, idx - gpsStart, + _categories.push_back({"Time & Location", gpsStart, idx - gpsStart, [&s]() { if (s.timezoneIdx < TIMEZONE_COUNT) return String(TIMEZONE_TABLE[s.timezoneIdx].label); return String("Not set"); }}); - // Audio + // Alerts & Audio int audioStart = idx; - _items.push_back({"Audio", SettingType::TOGGLE, + _items.push_back({"Audio Alerts", SettingType::TOGGLE, [&s]() { return s.audioEnabled ? 1 : 0; }, [&s](int v) { s.audioEnabled = (v != 0); }, - [](int v) { return v ? String("ON") : String("OFF"); }}); + [](int v) { return String(onOff(v != 0)); }}); 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"); }}); + _categories.push_back({"Alerts & Audio", audioStart, idx - audioStart, + [&s]() -> String { + if (!s.audioEnabled) return String("Muted"); + String summary = String("Volume ") + String(s.audioVolume); + summary += "%"; + return summary; + }}); - // Info (diagnostics moved from Home screen) + // Diagnostics int infoStart = idx; - _items.push_back({"Transport", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"RNS Transport", SettingType::READONLY, nullptr, nullptr, [this](int) { return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); }}); idx++; - _items.push_back({"Paths", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"Known Paths", SettingType::READONLY, nullptr, nullptr, [this](int) { return _rns ? String((int)_rns->pathCount()) : String("0"); }}); idx++; - _items.push_back({"Links", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"Live Links", SettingType::READONLY, nullptr, nullptr, [this](int) { return _rns ? String((int)_rns->linkCount()) : String("0"); }}); idx++; - _items.push_back({"Radio", SettingType::READONLY, nullptr, nullptr, + _items.push_back({"LoRa Driver", SettingType::READONLY, nullptr, nullptr, [this](int) { if (_radio && _radio->isRadioOnline()) { char buf[32]; @@ -526,10 +875,16 @@ void LvSettingsScreen::buildItems() { return String(m) + "m"; }}); idx++; - _categories.push_back({"Info", infoStart, idx - infoStart, - [this]() { return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); }}); + _categories.push_back({"Diagnostics", infoStart, idx - infoStart, + [this]() -> String { + if (!_rns || !_rns->isTransportActive()) return String("Transport offline"); + String summary = String("Paths ") + String((int)_rns->pathCount()); + summary += " / Links "; + summary += String((int)_rns->linkCount()); + return summary; + }}); - // System + // Storage & Maintenance int sysStart = idx; _items.push_back({"Free Heap", SettingType::READONLY, nullptr, nullptr, [](int) { return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB"; }}); @@ -550,6 +905,11 @@ void LvSettingsScreen::buildItems() { _items.push_back({"SD Card", SettingType::READONLY, nullptr, nullptr, [this](int) { return _sd && _sd->isReady() ? String("Ready") : String("Not Found"); }}); idx++; + _items.push_back({"SD Message Store", SettingType::TOGGLE, + [&s]() { return s.sdStorageEnabled ? 1 : 0; }, + [&s](int v) { s.sdStorageEnabled = (v != 0); }, + [](int v) { return String(onOff(v != 0)); }}); + idx++; { SettingItem announceItem; announceItem.label = "Send Announce"; @@ -559,7 +919,7 @@ void LvSettingsScreen::buildItems() { if (_rns && _cfg) { RNS::Bytes appData = encodeAnnounceName(_cfg->settings().displayName); _rns->announce(appData); - if (_ui) { _ui->lvStatusBar().flashAnnounce(); _ui->lvStatusBar().showToast("Announce sent!"); } + if (_ui) { _ui->lvStatusBar().flashAnnounce(); _ui->lvStatusBar().showToast("Announce sent"); } } else { if (_ui) _ui->lvStatusBar().showToast("RNS not ready"); } @@ -569,58 +929,69 @@ void LvSettingsScreen::buildItems() { } { SettingItem initSD; - initSD.label = "Init SD Card"; + initSD.label = "Format SD Card"; initSD.type = SettingType::ACTION; - initSD.formatter = [this](int) { return (_sd && _sd->isReady()) ? String("[Enter]") : String("No Card"); }; + initSD.formatter = [this](int) { + if (!_sd || !_sd->isReady()) return String("No Card"); + return _confirmingInitSD ? String("Hold") : String("Arm"); + }; 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->formatForRatdeck(); - if (_ui) _ui->lvStatusBar().showToast(ok ? "SD initialized!" : "SD init failed!", 1500); + if (!_confirmingInitSD) { + clearConfirmations(); + _confirmingInitSD = true; + if (_ui) _ui->lvStatusBar().showToast("Format SD armed. Hold to confirm", 5000); + rebuildItemList(); + return; + } + if (_ui) _ui->lvStatusBar().showToast("Hold trackball to format SD", 2500); }; _items.push_back(initSD); idx++; } { SettingItem wipeSD; - wipeSD.label = "Wipe SD Data"; + wipeSD.label = "Erase Ratdeck SD Data"; wipeSD.type = SettingType::ACTION; - wipeSD.formatter = [this](int) { return (_sd && _sd->isReady()) ? String("[Enter]") : String("No Card"); }; + wipeSD.formatter = [this](int) { + if (!_sd || !_sd->isReady()) return String("No Card"); + return _confirmingWipeSD ? String("Hold") : String("Arm"); + }; 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->wipeRatdeck(); - if (_ui) _ui->lvStatusBar().showToast(ok ? "SD wiped & reinit!" : "Wipe failed!", 1500); + if (!_confirmingWipeSD) { + clearConfirmations(); + _confirmingWipeSD = true; + if (_ui) _ui->lvStatusBar().showToast("SD erase armed. Hold to confirm", 5000); + rebuildItemList(); + return; + } + if (_ui) _ui->lvStatusBar().showToast("Hold trackball to erase SD data", 2500); }; _items.push_back(wipeSD); idx++; } { SettingItem factoryReset; - factoryReset.label = "Factory Reset"; + factoryReset.label = "Erase Device"; factoryReset.type = SettingType::ACTION; - factoryReset.formatter = [this](int) { return _confirmingReset ? String("[Confirm?]") : String("[Enter]"); }; + factoryReset.formatter = [this](int) { return _confirmingReset ? String("Hold") : String("Arm"); }; factoryReset.action = [this]() { if (!_confirmingReset) { + clearConfirmations(); _confirmingReset = true; - if (_ui) _ui->lvStatusBar().showToast("Press again to confirm!", 2000); + if (_ui) _ui->lvStatusBar().showToast("Device erase armed. Hold to confirm", 5000); rebuildItemList(); return; } - _confirmingReset = false; - if (_ui) _ui->lvStatusBar().showToast("Factory resetting...", 3000); - if (_sd && _sd->isReady()) _sd->wipeRatdeck(); - if (_flash) _flash->format(); - nvs_flash_erase(); - delay(1500); // Long enough for key state to clear before reboot - ESP.restart(); + if (_ui) _ui->lvStatusBar().showToast("Hold trackball to erase device", 2500); }; _items.push_back(factoryReset); idx++; } { SettingItem rebootItem; - rebootItem.label = "Reboot Device"; + rebootItem.label = "Reboot Now"; rebootItem.type = SettingType::ACTION; rebootItem.formatter = [](int) { return String("[Enter]"); }; rebootItem.action = [this]() { @@ -633,63 +1004,23 @@ void LvSettingsScreen::buildItems() { } { SettingItem updateCheck; - updateCheck.label = "Check for Updates"; + updateCheck.label = "Check Firmware"; updateCheck.type = SettingType::ACTION; - updateCheck.formatter = [](int) { return String("[Enter]"); }; + updateCheck.formatter = [this](int) { + return _fwCheckState == FirmwareCheckState::RUNNING ? String("Checking") : String("[Enter]"); + }; updateCheck.action = [this]() { - if (WiFi.status() != WL_CONNECTED) { - if (_ui) _ui->lvStatusBar().showToast("Connect to WiFi to check!", 2000); - return; - } - if (_ui) _ui->lvStatusBar().showToast("Checking for updates...", 3000); - - HTTPClient http; - http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - http.setTimeout(8000); - http.begin("https://api.github.com/repos/ratspeak/ratdeck/releases/latest"); - http.addHeader("Accept", "application/vnd.github.v3+json"); - int httpCode = http.GET(); - - if (httpCode != 200) { - http.end(); - if (_ui) _ui->lvStatusBar().showToast("Couldn't fetch data!", 2000); - return; - } - - String payload = http.getString(); - http.end(); - - JsonDocument doc; - if (deserializeJson(doc, payload)) { - if (_ui) _ui->lvStatusBar().showToast("Couldn't fetch data!", 2000); - return; - } - - const char* tagName = doc["tag_name"] | ""; - // Strip leading 'v' if present - const char* remoteVer = tagName; - if (remoteVer[0] == 'v' || remoteVer[0] == 'V') remoteVer++; - - if (strlen(remoteVer) == 0) { - if (_ui) _ui->lvStatusBar().showToast("Couldn't fetch data!", 2000); - return; - } - - // Compare versions (simple string compare works for semver) - int cmp = strcmp(remoteVer, RATDECK_VERSION_STRING); - if (cmp > 0) { - char msg[64]; - snprintf(msg, sizeof(msg), "v%s available! Flash at ratspeak.org", remoteVer); - if (_ui) _ui->lvStatusBar().showToast(msg, 5000); - } else { - if (_ui) _ui->lvStatusBar().showToast("You're up to date!", 2000); - } + startFirmwareCheck(); + rebuildItemList(); }; _items.push_back(updateCheck); idx++; } - _categories.push_back({"System", sysStart, idx - sysStart, - [](){ return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB free"; }}); + _categories.push_back({"Storage & Maintenance", sysStart, idx - sysStart, + [this](){ + if (_rebootNeeded) return String("Reboot pending"); + return (_sd && _sd->isReady()) ? String("SD ready") : String("Flash only"); + }}); } void LvSettingsScreen::createUI(lv_obj_t* parent) { @@ -721,12 +1052,51 @@ void LvSettingsScreen::onEnter() { _selectedIdx = 0; _editing = false; _textEditing = false; - _confirmingReset = false; - _confirmingDevMode = false; + _wifiScanActive = false; + _wifiTargetSlot = 0; + clearConfirmations(); _kbBrightness = _cfg ? _cfg->settings().keyboardBrightness : 0; rebuildCategoryList(); } +void LvSettingsScreen::refreshUI() { + FirmwareCheckState fwState = _fwCheckState; + if (fwState != FirmwareCheckState::IDLE && fwState != FirmwareCheckState::RUNNING) { + if (_ui) { + if (fwState == FirmwareCheckState::AVAILABLE) { + char msg[64]; + snprintf(msg, sizeof(msg), "v%s available at ratspeak.org", _fwCheckVersion); + _ui->lvStatusBar().showToast(msg, 5000); + } else if (fwState == FirmwareCheckState::CURRENT) { + _ui->lvStatusBar().showToast("Firmware is current", 2000); + } else { + _ui->lvStatusBar().showToast("Couldn't fetch firmware", 2000); + } + } + _fwCheckState = FirmwareCheckState::IDLE; + if (_view == SettingsView::ITEM_LIST) rebuildItemList(); + } + + if (_view != SettingsView::WIFI_PICKER || !_wifiScanActive) return; + if (!WiFiInterface::isScanComplete()) return; + + _wifiScanActive = false; + _wifiResults = WiFiInterface::getScanResults(); + if (_wifiResults.empty() && _ui) { + _ui->lvStatusBar().showToast("No networks found", 1500); + } + rebuildWifiList(); +} + +void LvSettingsScreen::showWifiPicker() { + _wifiResults.clear(); + _wifiPickerIdx = 0; + _wifiScanActive = true; + WiFiInterface::startAsyncScan(); + _view = SettingsView::WIFI_PICKER; + rebuildWifiList(); +} + void LvSettingsScreen::rebuildCategoryList() { if (!_scrollContainer) return; _rowObjs.clear(); @@ -736,8 +1106,9 @@ void LvSettingsScreen::rebuildCategoryList() { // 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_size(titleRow, Theme::CONTENT_W, 26); + lv_obj_set_style_bg_color(titleRow, lv_color_hex(Theme::BG_ELEVATED), 0); + lv_obj_set_style_bg_opa(titleRow, LV_OPA_COVER, 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); @@ -747,17 +1118,30 @@ void LvSettingsScreen::rebuildCategoryList() { 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); + lv_label_set_text(titleLbl, "SETTINGS DECK"); + lv_obj_align(titleLbl, LV_ALIGN_LEFT_MID, 8, 0); + + if (_rebootNeeded) { + lv_obj_t* pendingLbl = lv_label_create(titleRow); + lv_obj_set_style_text_font(pendingLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(pendingLbl, lv_color_hex(Theme::WARNING_CLR), 0); + lv_label_set_text(pendingLbl, "REBOOT PENDING"); + lv_obj_align(pendingLbl, LV_ALIGN_RIGHT_MID, -8, 0); + } for (int i = 0; i < (int)_categories.size(); i++) { auto& cat = _categories[i]; bool selected = (i == _categoryIdx); + bool pending = categoryNeedsReboot(i); lv_obj_t* row = lv_obj_create(_scrollContainer); - lv_obj_set_size(row, Theme::CONTENT_W, 38); + lv_obj_set_size(row, Theme::CONTENT_W, 40); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); + lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::BG_HOVER : Theme::BG), 0); + lv_obj_set_style_border_color(row, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::BORDER), 0); + lv_obj_set_style_border_width(row, pending ? 2 : 1, 0); + lv_obj_set_style_border_side(row, pending ? (lv_border_side_t)(LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_BOTTOM) : LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_pad_all(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); @@ -773,20 +1157,28 @@ void LvSettingsScreen::rebuildCategoryList() { lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); }, LV_EVENT_FOCUSED, nullptr); - // Category name + count - char buf[48]; - snprintf(buf, sizeof(buf), "%s (%d)", cat.name, cat.count); + // Category name 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(Theme::TEXT_PRIMARY), 0); - lv_label_set_text(nameLbl, buf); + lv_obj_set_style_text_color(nameLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_PRIMARY), 0); + clipLabel(nameLbl, Theme::CONTENT_W - 72); + lv_label_set_text(nameLbl, cat.name); lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 12, 4); + char countBuf[8]; + snprintf(countBuf, sizeof(countBuf), "%d", cat.count); + lv_obj_t* countLbl = lv_label_create(row); + lv_obj_set_style_text_font(countLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(countLbl, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(countLbl, countBuf); + lv_obj_align(countLbl, LV_ALIGN_TOP_RIGHT, -24, 5); + // Summary if (cat.summary) { lv_obj_t* sumLbl = lv_label_create(row); lv_obj_set_style_text_font(sumLbl, &lv_font_ratdeck_10, 0); - lv_obj_set_style_text_color(sumLbl, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_obj_set_style_text_color(sumLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_MUTED), 0); + clipLabel(sumLbl, Theme::CONTENT_W - 44); lv_label_set_text(sumLbl, cat.summary().c_str()); lv_obj_align(sumLbl, LV_ALIGN_BOTTOM_LEFT, 20, -4); } @@ -794,8 +1186,8 @@ void LvSettingsScreen::rebuildCategoryList() { // 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::TEXT_MUTED), 0); - lv_label_set_text(arrow, ">"); + lv_obj_set_style_text_color(arrow, lv_color_hex(pending ? Theme::WARNING_CLR : (selected ? Theme::ACCENT : Theme::TEXT_MUTED)), 0); + lv_label_set_text(arrow, pending ? "!" : ">"); lv_obj_align(arrow, LV_ALIGN_RIGHT_MID, -8, 0); _rowObjs.push_back(row); @@ -817,8 +1209,9 @@ void LvSettingsScreen::rebuildItemList() { // 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_size(headerRow, Theme::CONTENT_W, 24); + lv_obj_set_style_bg_color(headerRow, lv_color_hex(Theme::BG_ELEVATED), 0); + lv_obj_set_style_bg_opa(headerRow, LV_OPA_COVER, 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); @@ -836,20 +1229,87 @@ void LvSettingsScreen::rebuildItemList() { 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); + clipLabel(headerLbl, Theme::CONTENT_W - 128); lv_label_set_text(headerLbl, headerBuf); - lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 8, 0); + + if (_rebootNeeded) { + lv_obj_t* pendingLbl = lv_label_create(headerRow); + lv_obj_set_style_text_font(pendingLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(pendingLbl, lv_color_hex(Theme::WARNING_CLR), 0); + lv_label_set_text(pendingLbl, "REBOOT NEEDED"); + lv_obj_align(pendingLbl, LV_ALIGN_RIGHT_MID, -8, 0); + } + + if (_rebootNeeded) { + lv_obj_t* notice = lv_obj_create(_scrollContainer); + lv_obj_set_size(notice, Theme::CONTENT_W, 20); + lv_obj_set_style_bg_color(notice, lv_color_hex(Theme::PRIMARY_SUBTLE), 0); + lv_obj_set_style_bg_opa(notice, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(notice, lv_color_hex(Theme::WARNING_CLR), 0); + lv_obj_set_style_border_width(notice, 1, 0); + lv_obj_set_style_border_side(notice, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(notice, 0, 0); + lv_obj_set_style_radius(notice, 0, 0); + lv_obj_clear_flag(notice, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* noticeLbl = lv_label_create(notice); + lv_obj_set_style_text_font(noticeLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(noticeLbl, lv_color_hex(Theme::WARNING_CLR), 0); + clipLabel(noticeLbl, Theme::CONTENT_W - 16); + lv_label_set_text(noticeLbl, "Saved interface config is pending reboot"); + lv_obj_align(noticeLbl, LV_ALIGN_LEFT_MID, 8, 0); + } + + const char* confirmTitle = confirmationTitle(); + const char* confirmDetail = confirmationDetail(); + if (confirmTitle && confirmDetail) { + uint32_t confirmColor = _confirmingDevMode ? Theme::WARNING_CLR : Theme::ERROR_CLR; + lv_obj_t* confirm = lv_obj_create(_scrollContainer); + lv_obj_set_size(confirm, Theme::CONTENT_W, 34); + lv_obj_set_style_bg_color(confirm, lv_color_hex(Theme::BG_SURFACE), 0); + lv_obj_set_style_bg_opa(confirm, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(confirm, lv_color_hex(confirmColor), 0); + lv_obj_set_style_border_width(confirm, 2, 0); + lv_obj_set_style_border_side(confirm, (lv_border_side_t)(LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_BOTTOM), 0); + lv_obj_set_style_pad_all(confirm, 0, 0); + lv_obj_set_style_radius(confirm, 0, 0); + lv_obj_clear_flag(confirm, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* confirmTitleLbl = lv_label_create(confirm); + lv_obj_set_style_text_font(confirmTitleLbl, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(confirmTitleLbl, lv_color_hex(confirmColor), 0); + clipLabel(confirmTitleLbl, Theme::CONTENT_W - 16); + lv_label_set_text(confirmTitleLbl, confirmTitle); + lv_obj_align(confirmTitleLbl, LV_ALIGN_TOP_LEFT, 8, 3); + + lv_obj_t* confirmDetailLbl = lv_label_create(confirm); + lv_obj_set_style_text_font(confirmDetailLbl, &lv_font_ratdeck_10, 0); + lv_obj_set_style_text_color(confirmDetailLbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); + clipLabel(confirmDetailLbl, Theme::CONTENT_W - 16); + lv_label_set_text(confirmDetailLbl, confirmDetail); + lv_obj_align(confirmDetailLbl, LV_ALIGN_BOTTOM_LEFT, 8, -3); + } for (int i = _catRangeStart; i < _catRangeEnd; i++) { const auto& item = _items[i]; bool selected = (i == _selectedIdx); bool editable = isEditable(i); + bool rebootPending = settingNeedsReboot(item); + bool armed = armedAction(item); + bool destructive = destructiveAction(item); + uint32_t armedColor = destructive ? Theme::ERROR_CLR : Theme::WARNING_CLR; lv_obj_t* row = lv_obj_create(_scrollContainer); - lv_obj_set_size(row, Theme::CONTENT_W, 26); + lv_obj_set_size(row, Theme::CONTENT_W, 28); lv_obj_add_style(row, LvTheme::styleListBtn(), 0); if (editable) { lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); } + lv_obj_set_style_bg_color(row, lv_color_hex(armed ? Theme::BG_SURFACE : (selected && editable ? Theme::BG_HOVER : Theme::BG)), 0); + lv_obj_set_style_border_color(row, lv_color_hex(armed ? armedColor : (rebootPending ? Theme::WARNING_CLR : Theme::BORDER)), 0); + lv_obj_set_style_border_width(row, rebootPending || armed ? 2 : 1, 0); + lv_obj_set_style_border_side(row, rebootPending || armed ? (lv_border_side_t)(LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_BOTTOM) : LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_pad_all(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); if (editable) { @@ -878,11 +1338,16 @@ void LvSettingsScreen::rebuildItemList() { // 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( + uint32_t nameColor = + armed ? armedColor : + rebootPending ? Theme::WARNING_CLR : + destructive ? Theme::ERROR_CLR : item.type == SettingType::ACTION ? Theme::TEXT_PRIMARY : - item.type == SettingType::READONLY ? Theme::TEXT_MUTED : Theme::TEXT_SECONDARY), 0); + item.type == SettingType::READONLY ? Theme::TEXT_MUTED : Theme::TEXT_SECONDARY; + lv_obj_set_style_text_color(nameLbl, lv_color_hex(nameColor), 0); + clipLabel(nameLbl, Theme::CONTENT_W - 136); lv_label_set_text(nameLbl, item.label); - lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 8, 0); // Value String valStr; @@ -912,7 +1377,7 @@ void LvSettingsScreen::rebuildItemList() { break; case SettingType::TEXT_INPUT: { String v = item.textGetter ? item.textGetter() : ""; - valStr = v.isEmpty() ? "(not set)" : v; + valStr = v.isEmpty() ? "(not set)" : (isWiFiPasswordLabel(item.label) ? maskedValue(v) : v); valColor = v.isEmpty() ? Theme::TEXT_MUTED : Theme::PRIMARY; break; } @@ -924,7 +1389,7 @@ void LvSettingsScreen::rebuildItemList() { break; case SettingType::ACTION: valStr = item.formatter ? item.formatter(0) : ""; - valColor = Theme::TEXT_MUTED; + valColor = destructive ? Theme::ERROR_CLR : Theme::TEXT_MUTED; break; default: { int v = item.getter ? item.getter() : 0; @@ -934,12 +1399,20 @@ void LvSettingsScreen::rebuildItemList() { } } + if (armed) { + valColor = armedColor; + } else if (rebootPending) { + valColor = Theme::WARNING_CLR; + } + 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_obj_set_style_text_align(valLbl, LV_TEXT_ALIGN_RIGHT, 0); + clipLabel(valLbl, 124); lv_label_set_text(valLbl, valStr.c_str()); - lv_obj_align(valLbl, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_align(valLbl, LV_ALIGN_RIGHT_MID, -8, 0); // Cache value label for the actively edited item (in-place updates) if (i == _selectedIdx && (_textEditing || _freqEditing || _editing)) { _editValueLbl = valLbl; @@ -956,6 +1429,33 @@ void LvSettingsScreen::rebuildItemList() { } } +void LvSettingsScreen::selectWifiResult(int resultIdx) { + if (!_cfg || resultIdx < 0 || resultIdx >= (int)_wifiResults.size()) return; + + auto& net = _wifiResults[resultIdx]; + auto& nets = _cfg->settings().wifiSTANetworks; + while (nets.size() <= _wifiTargetSlot && nets.size() < WIFI_STA_MAX_NETWORKS) { + nets.push_back({}); + } + if (_wifiTargetSlot >= WIFI_STA_MAX_NETWORKS || _wifiTargetSlot >= nets.size()) { + if (_ui) _ui->lvStatusBar().showToast("Network slots full", 1500); + return; + } + + for (size_t i = 0; i < nets.size(); i++) { + if (i != _wifiTargetSlot && !nets[i].ssid.isEmpty() && nets[i].ssid == net.ssid) { + if (_ui) _ui->lvStatusBar().showToast("Already saved", 1200); + return; + } + } + + bool sameSSID = nets[_wifiTargetSlot].ssid == net.ssid; + nets[_wifiTargetSlot].ssid = net.ssid; + if (!sameSSID) nets[_wifiTargetSlot].password = ""; + _cfg->settings().wifiSTASelected = (uint8_t)_wifiTargetSlot; + applyAndSave(); +} + void LvSettingsScreen::rebuildWifiList() { if (!_scrollContainer) return; _rowObjs.clear(); @@ -976,25 +1476,28 @@ void LvSettingsScreen::rebuildWifiList() { 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"); + char header[48]; + snprintf(header, sizeof(header), "< Scan for profile %u", (unsigned)(_wifiTargetSlot + 1)); + lv_label_set_text(headerLbl, header); 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::TEXT_MUTED), 0); - lv_label_set_text(emptyLbl, "No networks found"); - return; - } - // Make header tappable to go back lv_obj_add_flag(headerRow, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(headerRow, [](lv_event_t* e) { auto* self = (LvSettingsScreen*)lv_event_get_user_data(e); + self->_wifiScanActive = false; self->_view = SettingsView::ITEM_LIST; self->rebuildItemList(); }, LV_EVENT_CLICKED, this); + 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::TEXT_MUTED), 0); + lv_label_set_text(emptyLbl, _wifiScanActive ? "Scanning..." : "No networks found"); + return; + } + for (int i = 0; i < (int)_wifiResults.size(); i++) { auto& net = _wifiResults[i]; @@ -1011,10 +1514,8 @@ void LvSettingsScreen::rebuildWifiList() { lv_obj_add_event_cb(row, [](lv_event_t* e) { auto* self = (LvSettingsScreen*)lv_event_get_user_data(e); int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); - if (idx < (int)self->_wifiResults.size()) { - auto& net = self->_wifiResults[idx]; - if (self->_cfg) { self->_cfg->settings().wifiSTASSID = net.ssid; self->applyAndSave(); } - } + self->selectWifiResult(idx); + self->_wifiScanActive = false; self->_view = SettingsView::ITEM_LIST; self->rebuildItemList(); }, LV_EVENT_CLICKED, this); @@ -1047,18 +1548,20 @@ void LvSettingsScreen::rebuildWifiList() { void LvSettingsScreen::updateCategorySelection(int oldIdx, int newIdx) { if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) { + bool pending = categoryNeedsReboot(oldIdx); 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::TEXT_PRIMARY), 0); + if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_PRIMARY), 0); lv_obj_t* arrow = lv_obj_get_child(_rowObjs[oldIdx], -1); - if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(Theme::TEXT_MUTED), 0); + if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_MUTED), 0); } if (newIdx >= 0 && newIdx < (int)_rowObjs.size()) { + bool pending = categoryNeedsReboot(newIdx); lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::BG_HOVER), 0); lv_obj_t* nameLbl = lv_obj_get_child(_rowObjs[newIdx], 0); - if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); + if (nameLbl) lv_obj_set_style_text_color(nameLbl, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::TEXT_PRIMARY), 0); lv_obj_t* arrow = lv_obj_get_child(_rowObjs[newIdx], -1); - if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(Theme::PRIMARY), 0); + if (arrow) lv_obj_set_style_text_color(arrow, lv_color_hex(pending ? Theme::WARNING_CLR : Theme::PRIMARY), 0); lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF); } } @@ -1069,9 +1572,12 @@ void LvSettingsScreen::updateItemSelection(int oldIdx, int newIdx) { int newRow = newIdx - _catRangeStart; auto setItemRowBg = [&](int row, bool selected) { if (row < 0 || row >= (int)_rowObjs.size()) return; - bool editable = isEditable(row + _catRangeStart); + int itemIdx = row + _catRangeStart; + bool editable = isEditable(itemIdx); + const auto& item = _items[itemIdx]; + bool armed = armedAction(item); lv_obj_set_style_bg_color(_rowObjs[row], lv_color_hex( - (selected && editable) ? Theme::BG_HOVER : Theme::BG), 0); + armed ? Theme::BG_SURFACE : ((selected && editable) ? Theme::BG_HOVER : Theme::BG)), 0); }; setItemRowBg(oldRow, false); setItemRowBg(newRow, true); @@ -1112,8 +1618,7 @@ void LvSettingsScreen::exitToCategories() { _view = SettingsView::CATEGORY_LIST; _editing = false; _textEditing = false; - _confirmingReset = false; - _confirmingDevMode = false; + clearConfirmations(); rebuildCategoryList(); } @@ -1134,14 +1639,14 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { case SettingsView::ITEM_LIST: { if (_items.empty()) return false; - // Text edit mode — in-place label updates for responsiveness + // Text edit mode - in-place label updates for responsiveness if (_textEditing) { auto& item = _items[_selectedIdx]; - auto updateEditLabel = [this]() { + auto updateEditLabel = [this, &item]() { if (_editValueLbl) { String display = _editText + "_"; lv_label_set_text(_editValueLbl, display.c_str()); - lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -8, 0); } }; if (event.enter || event.character == '\n' || event.character == '\r') { @@ -1163,13 +1668,13 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { return true; } - // Frequency digit-cursor edit mode — in-place label updates + // Frequency digit-cursor edit mode - in-place label updates if (_freqEditing) { auto updateFreqLabel = [this]() { if (_editValueLbl) { String display = String("< ") + freqFormatWithCursor() + " >"; lv_label_set_text(_editValueLbl, display.c_str()); - lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_align(_editValueLbl, LV_ALIGN_RIGHT_MID, -8, 0); } }; if (event.left) { @@ -1257,8 +1762,20 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { return true; } - // Browse mode — LVGL focus group handles up/down + // Browse mode - LVGL focus group handles up/down + if (hasPendingConfirmation() && (event.up || event.down || event.left || event.right || event.tab)) { + clearConfirmations(); + rebuildItemList(); + if (_ui) _ui->lvStatusBar().showToast("Confirmation cancelled", 1000); + return true; + } if (event.del || event.character == 8 || event.character == 0x1B) { + if (hasPendingConfirmation()) { + clearConfirmations(); + rebuildItemList(); + if (_ui) _ui->lvStatusBar().showToast("Confirmation cancelled", 1000); + return true; + } exitToCategories(); return true; } if (event.enter || event.character == '\n' || event.character == '\r') { @@ -1268,30 +1785,22 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { if (!isEditable(_selectedIdx)) return true; auto& item = _items[_selectedIdx]; if (item.type == SettingType::ACTION) { + if (!confirmableAction(item)) clearConfirmations(); if (item.action) item.action(); - rebuildItemList(); + if (_view == SettingsView::ITEM_LIST) 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; - } + clearConfirmations(); _textEditing = true; _editText = item.textGetter ? item.textGetter() : ""; rebuildItemList(); } else if (item.type == SettingType::TOGGLE) { + clearConfirmations(); int val = item.getter ? item.getter() : 0; if (item.setter) item.setter(val ? 0 : 1); applyAndSave(); rebuildItemList(); } else if (strcmp(item.label, "Frequency") == 0 && item.type == SettingType::INTEGER) { + clearConfirmations(); // Radio-style digit cursor editor for frequency _editing = true; _editValue = item.getter ? item.getter() : 0; @@ -1301,6 +1810,7 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { _freqEditing = true; rebuildItemList(); } else { + clearConfirmations(); _editing = true; _numericTyping = false; _editValue = item.getter ? item.getter() : 0; @@ -1314,19 +1824,21 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { case SettingsView::WIFI_PICKER: { // LVGL handles up/down navigation, click handler handles selection if (event.enter || event.character == '\n' || event.character == '\r') { + if (_wifiScanActive) return true; lv_obj_t* focused = lv_group_get_focused(LvInput::group()); if (focused) { int idx = (int)(intptr_t)lv_obj_get_user_data(focused); if (idx < (int)_wifiResults.size()) { - auto& net = _wifiResults[idx]; - if (_cfg) { _cfg->settings().wifiSTASSID = net.ssid; applyAndSave(); } + selectWifiResult(idx); } } + _wifiScanActive = false; _view = SettingsView::ITEM_LIST; rebuildItemList(); return true; } if (event.del || event.character == 8 || event.character == 0x1B) { + _wifiScanActive = false; _view = SettingsView::ITEM_LIST; rebuildItemList(); return true; @@ -1337,25 +1849,63 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { return false; } +bool LvSettingsScreen::handleLongPress() { + if (_view != SettingsView::ITEM_LIST || !hasPendingConfirmation()) return false; + + int focusedIdx = _selectedIdx; + lv_obj_t* focused = lv_group_get_focused(LvInput::group()); + if (focused) { + int candidate = (int)(intptr_t)lv_obj_get_user_data(focused); + if (candidate >= 0 && candidate < (int)_items.size()) focusedIdx = candidate; + } + if (focusedIdx < 0 || focusedIdx >= (int)_items.size()) return false; + + const auto& item = _items[focusedIdx]; + if (_confirmingInitSD && labelEq(item.label, "Format SD Card")) { + runFormatSD(); + return true; + } + if (_confirmingWipeSD && labelEq(item.label, "Erase Ratdeck SD Data")) { + runWipeSD(); + return true; + } + if (_confirmingReset && labelEq(item.label, "Erase Device")) { + runFactoryReset(); + return true; + } + if (_confirmingDevMode && labelEq(item.label, "Developer Radio Controls")) { + runEnableDevMode(); + return true; + } + + if (_ui) _ui->lvStatusBar().showToast("Select armed action, then hold", 2000); + return true; +} + 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.wifiSTANetworks = s.wifiSTANetworks; + _rebootSnap.wifiSTASelected = s.wifiSTASelected; _rebootSnap.autoIfaceEnabled = s.autoIfaceEnabled; + _rebootSnap.sdStorageEnabled = s.sdStorageEnabled; _gpsSnapEnabled = s.gpsTimeEnabled; } 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.autoIfaceEnabled != _rebootSnap.autoIfaceEnabled; + if (s.wifiMode != _rebootSnap.wifiMode) return true; + if (s.wifiSTASelected != _rebootSnap.wifiSTASelected) return true; + if (s.autoIfaceEnabled != _rebootSnap.autoIfaceEnabled) return true; + if (s.sdStorageEnabled != _rebootSnap.sdStorageEnabled) return true; + if (s.wifiSTANetworks.size() != _rebootSnap.wifiSTANetworks.size()) return true; + for (size_t i = 0; i < s.wifiSTANetworks.size(); i++) { + if (s.wifiSTANetworks[i].ssid != _rebootSnap.wifiSTANetworks[i].ssid) return true; + if (s.wifiSTANetworks[i].password != _rebootSnap.wifiSTANetworks[i].password) return true; + } + return false; } void LvSettingsScreen::snapshotTCPSettings() { @@ -1461,15 +2011,15 @@ void LvSettingsScreen::applyAndSave() { // Check if reboot-required settings changed (show toast only on first detection) bool wasRebootNeeded = _rebootNeeded; - if (rebootSettingsChanged()) { - _rebootNeeded = true; - } + _rebootNeeded = rebootSettingsChanged(); if (_ui) { if (_rebootNeeded && !wasRebootNeeded) { - _ui->lvStatusBar().showToast("WiFi changes apply on reboot", 2500); + _ui->lvStatusBar().showToast("Interface changes saved; reboot to apply", 3000); + } else if (!_rebootNeeded && wasRebootNeeded) { + _ui->lvStatusBar().showToast("Pending reboot cleared", 1500); } else if (tcpChanged) { - _ui->lvStatusBar().showToast("TCP Updated", 1200); + _ui->lvStatusBar().showToast("TCP relay updated", 1200); } else { _ui->lvStatusBar().showToast(saved ? "Saved" : "Applied", 800); } diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index 60e3a30..165a3d7 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include class FlashStore; class SDStore; class SX1262; @@ -58,7 +60,9 @@ class LvSettingsScreen : public LvScreen { public: void createUI(lv_obj_t* parent) override; void onEnter() override; + void refreshUI() override; bool handleKey(const KeyEvent& event) override; + bool handleLongPress() override; void setUserConfig(UserConfig* cfg) { _cfg = cfg; } void setFlashStore(FlashStore* fs) { _flash = fs; } @@ -93,12 +97,28 @@ private: void rebuildCategoryList(); void rebuildItemList(); void rebuildWifiList(); + void selectWifiResult(int resultIdx); void enterCategory(int catIdx); void exitToCategories(); void updateCategorySelection(int oldIdx, int newIdx); void updateItemSelection(int oldIdx, int newIdx); void updateWifiSelection(int oldIdx, int newIdx); + bool settingNeedsReboot(const SettingItem& item) const; + bool categoryNeedsReboot(int catIdx) const; + bool confirmableAction(const SettingItem& item) const; + bool armedAction(const SettingItem& item) const; + bool destructiveAction(const SettingItem& item) const; + const char* confirmationTitle() const; + const char* confirmationDetail() const; + bool hasPendingConfirmation() const; + void clearConfirmations(); + void runFormatSD(); + void runWipeSD(); + void runFactoryReset(); + void runEnableDevMode(); + void startFirmwareCheck(); + static void firmwareCheckTask(void* arg); UserConfig* _cfg = nullptr; FlashStore* _flash = nullptr; @@ -132,9 +152,22 @@ private: bool _numericTyping = false; // true once user types digits in INTEGER edit bool _textEditing = false; String _editText; + bool _confirmingInitSD = false; + bool _confirmingWipeSD = false; bool _confirmingReset = false; bool _confirmingDevMode = false; + enum class FirmwareCheckState : uint8_t { + IDLE, + RUNNING, + CURRENT, + AVAILABLE, + FAILED + }; + volatile FirmwareCheckState _fwCheckState = FirmwareCheckState::IDLE; + TaskHandle_t _fwCheckTask = nullptr; + char _fwCheckVersion[24] = {}; + // Frequency digit-cursor editor (radio-style) bool _freqEditing = false; int _freqCursor = 0; // 0-8, active digit position @@ -147,15 +180,17 @@ private: // WiFi picker std::vector _wifiResults; int _wifiPickerIdx = 0; + size_t _wifiTargetSlot = 0; + bool _wifiScanActive = false; // Reboot-required tracking bool _rebootNeeded = false; struct RebootSnapshot { RatWiFiMode wifiMode; - String wifiSTASSID; - String wifiSTAPassword; - bool bleEnabled; + std::vector wifiSTANetworks; + uint8_t wifiSTASelected; bool autoIfaceEnabled; + bool sdStorageEnabled; }; RebootSnapshot _rebootSnap; void snapshotRebootSettings(); diff --git a/src/ui/screens/LvTimezoneScreen.cpp b/src/ui/screens/LvTimezoneScreen.cpp index f9ccd81..546e2b2 100644 --- a/src/ui/screens/LvTimezoneScreen.cpp +++ b/src/ui/screens/LvTimezoneScreen.cpp @@ -5,99 +5,182 @@ #include "fonts/fonts.h" #include +namespace { + +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, const lv_font_t* font, + uint32_t color, lv_coord_t width, lv_text_align_t align, + lv_label_long_mode_t mode = LV_LABEL_LONG_WRAP) { + lv_obj_t* lbl = lv_label_create(parent); + lv_obj_set_width(lbl, width); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_align(lbl, align, 0); + lv_label_set_long_mode(lbl, mode); + lv_label_set_text(lbl, text); + return lbl; +} + +void stylePanel(lv_obj_t* panel) { + lv_obj_set_style_bg_color(panel, lv_color_hex(Theme::BG_ELEVATED), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(Theme::BORDER_ACTIVE), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_radius(panel, 4, 0); + lv_obj_set_style_pad_all(panel, 4, 0); + lv_obj_set_style_clip_corner(panel, true, 0); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); +} + +int clampTimezoneIndex(int idx) { + if (idx < 0 || idx >= TIMEZONE_COUNT) return 6; + return idx; +} + +lv_obj_t* makeActionButton(lv_obj_t* parent, const char* text, lv_coord_t x, + lv_coord_t y, lv_coord_t w, lv_coord_t h, + bool primary) { + lv_obj_t* btn = lv_btn_create(parent); + lv_obj_set_pos(btn, x, y); + lv_obj_set_size(btn, w, h); + lv_obj_add_style(btn, LvTheme::styleBtn(), 0); + lv_obj_add_style(btn, LvTheme::styleBtnFocused(), LV_STATE_FOCUSED); + lv_obj_add_style(btn, LvTheme::styleBtnPressed(), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(btn, + lv_color_hex(primary ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED), 0); + lv_obj_set_style_border_color(btn, + lv_color_hex(primary ? Theme::PRIMARY : Theme::BORDER), 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + lv_obj_set_style_radius(btn, 4, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + + lv_obj_t* lbl = lv_label_create(btn); + lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_12, 0); + lv_obj_set_style_text_color(lbl, + lv_color_hex(primary ? Theme::ACCENT : Theme::TEXT_PRIMARY), 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(lbl, w - 10); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(lbl, text); + lv_obj_center(lbl); + return btn; +} + +} // namespace + void LvTimezoneScreen::createUI(lv_obj_t* parent) { _screen = parent; + _roller = nullptr; + _upButton = nullptr; + _downButton = nullptr; + _doneButton = nullptr; 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_bg_opa(parent, LV_OPA_COVER, 0); + _selectedIdx = clampTimezoneIndex(_selectedIdx); - // Title - lv_obj_t* title = lv_label_create(parent); - lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(title, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(title, "SELECT TIMEZONE"); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8); + lv_obj_t* eyebrow = makeLabel(parent, "REGION SETUP", &lv_font_ratdeck_10, + Theme::TEXT_SECONDARY, 220, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_align(eyebrow, LV_ALIGN_TOP_MID, 0, 8); - // Subtitle - lv_obj_t* sub = lv_label_create(parent); - lv_obj_set_style_text_font(sub, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(sub, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(sub, "Choose your nearest city:"); - lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 30); + lv_obj_t* title = makeLabel(parent, "TIMEZONE", &lv_font_montserrat_16, + Theme::ACCENT, 260, LV_TEXT_ALIGN_CENTER, + LV_LABEL_LONG_DOT); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 24); + + lv_obj_t* sub = makeLabel(parent, "Pick the nearest city for time and radio-region hints.", + &lv_font_ratdeck_12, Theme::TEXT_PRIMARY, + 280, LV_TEXT_ALIGN_CENTER); + lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 48); // Build roller options string: "City UTC+X\nCity UTC-Y\n..." static char opts[640]; int pos = 0; for (int i = 0; i < TIMEZONE_COUNT; i++) { if (i > 0 && pos < (int)sizeof(opts) - 1) opts[pos++] = '\n'; - pos += snprintf(opts + pos, sizeof(opts) - pos, "%-14s UTC%+d", + pos += snprintf(opts + pos, sizeof(opts) - pos, "%-13s UTC%+d", TIMEZONE_TABLE[i].label, TIMEZONE_TABLE[i].baseOffset); } - // Card container — green-bordered panel holding the roller lv_obj_t* card = lv_obj_create(parent); - lv_obj_set_width(card, Theme::CONTENT_W - 20); - lv_obj_set_style_bg_color(card, lv_color_hex(Theme::BG), 0); - lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(card, lv_color_hex(Theme::PRIMARY), 0); - lv_obj_set_style_border_width(card, 1, 0); - lv_obj_set_style_radius(card, 4, 0); - lv_obj_set_style_pad_left(card, 6, 0); - lv_obj_set_style_pad_right(card, 6, 0); - lv_obj_set_style_pad_top(card, 0, 0); - lv_obj_set_style_pad_bottom(card, 0, 0); - lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_clip_corner(card, true, 0); - lv_obj_align_to(card, sub, LV_ALIGN_OUT_BOTTOM_MID, 0, 6); + lv_obj_set_size(card, 288, 104); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 76); + stylePanel(card); - // Roller inside the card — borderless, fills the card _roller = lv_roller_create(card); lv_roller_set_options(_roller, opts, LV_ROLLER_MODE_NORMAL); - lv_roller_set_visible_row_count(_roller, 7); - lv_obj_set_width(_roller, lv_pct(100)); - lv_obj_center(_roller); + lv_roller_set_visible_row_count(_roller, 3); + lv_obj_set_size(_roller, 278, 94); + lv_obj_set_pos(_roller, 4, 4); lv_roller_set_selected(_roller, _selectedIdx, LV_ANIM_OFF); - // Style the roller — no border, highlight goes edge-to-edge within card lv_obj_add_style(_roller, LvTheme::styleRoller(), LV_PART_MAIN); lv_obj_set_style_text_font(_roller, &lv_font_ratdeck_14, LV_PART_MAIN); - // Selected row highlight + lv_obj_set_style_text_align(_roller, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_bg_color(_roller, lv_color_hex(Theme::BG_HOVER), LV_PART_SELECTED); lv_obj_set_style_bg_opa(_roller, LV_OPA_COVER, LV_PART_SELECTED); - lv_obj_set_style_text_color(_roller, lv_color_hex(Theme::PRIMARY), LV_PART_SELECTED); + lv_obj_set_style_text_color(_roller, lv_color_hex(Theme::ACCENT), LV_PART_SELECTED); lv_obj_set_style_text_font(_roller, &lv_font_ratdeck_14, LV_PART_SELECTED); - - // Size the card to fit the roller - lv_obj_update_layout(parent); - lv_obj_set_height(card, lv_obj_get_height(_roller)); + lv_obj_set_style_text_align(_roller, LV_TEXT_ALIGN_CENTER, LV_PART_SELECTED); // Add to focus group for trackball navigation lv_group_add_obj(LvInput::group(), _roller); + lv_group_focus_obj(_roller); - // Hint — positioned below roller - lv_obj_t* hint = lv_label_create(parent); - lv_obj_set_style_text_font(hint, &lv_font_ratdeck_12, 0); - lv_obj_set_style_text_color(hint, lv_color_hex(Theme::TEXT_SECONDARY), 0); - lv_label_set_text(hint, "[Enter] Select"); - lv_obj_align_to(hint, card, LV_ALIGN_OUT_BOTTOM_MID, 0, 6); + _upButton = makeActionButton(parent, "UP", 22, 194, 74, 34, false); + lv_obj_add_event_cb(_upButton, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->stepSelection(-1); + }, LV_EVENT_CLICKED, this); + + _doneButton = makeActionButton(parent, "DONE", 106, 194, 108, 34, true); + lv_obj_add_event_cb(_doneButton, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->submit(false); + }, LV_EVENT_CLICKED, this); + + _downButton = makeActionButton(parent, "DOWN", 224, 194, 74, 34, false); + lv_obj_add_event_cb(_downButton, [](lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->stepSelection(1); + }, LV_EVENT_CLICKED, this); +} + +void LvTimezoneScreen::stepSelection(int delta) { + if (!_roller) return; + + int sel = lv_roller_get_selected(_roller); + sel += delta; + if (sel < 0) sel = 0; + if (sel >= TIMEZONE_COUNT) sel = TIMEZONE_COUNT - 1; + lv_roller_set_selected(_roller, sel, LV_ANIM_ON); +} + +void LvTimezoneScreen::submit(bool enforceEnterGuard) { + if (!_roller) return; + if (enforceEnterGuard && millis() - _enterTime < ENTER_GUARD_MS) return; + + _selectedIdx = lv_roller_get_selected(_roller); + if (_doneCb) _doneCb(_selectedIdx); } bool LvTimezoneScreen::handleKey(const KeyEvent& event) { + if (!_roller) return true; if (event.enter || event.character == '\n' || event.character == '\r') { - if (millis() - _enterTime < ENTER_GUARD_MS) return true; - _selectedIdx = lv_roller_get_selected(_roller); - if (_doneCb) _doneCb(_selectedIdx); + submit(true); return true; } // Trackball maps to PREV/NEXT (focus group nav), but roller needs // explicit selection changes since it's the only focusable widget if (event.up) { - uint16_t sel = lv_roller_get_selected(_roller); - if (sel > 0) lv_roller_set_selected(_roller, sel - 1, LV_ANIM_ON); + stepSelection(-1); return true; } if (event.down) { - uint16_t sel = lv_roller_get_selected(_roller); - if (sel < TIMEZONE_COUNT - 1) lv_roller_set_selected(_roller, sel + 1, LV_ANIM_ON); + stepSelection(1); return true; } return true; // Consume all keys on this screen diff --git a/src/ui/screens/LvTimezoneScreen.h b/src/ui/screens/LvTimezoneScreen.h index 963df8a..887e758 100644 --- a/src/ui/screens/LvTimezoneScreen.h +++ b/src/ui/screens/LvTimezoneScreen.h @@ -57,4 +57,10 @@ private: static constexpr unsigned long ENTER_GUARD_MS = 600; lv_obj_t* _roller = nullptr; + lv_obj_t* _upButton = nullptr; + lv_obj_t* _downButton = nullptr; + lv_obj_t* _doneButton = nullptr; + + void stepSelection(int delta); + void submit(bool enforceEnterGuard); };