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