mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-05-19 22:05:11 +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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user