diff --git a/.gitignore b/.gitignore index 50631d890..350197cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ cmake-* compile_commands.json .venv/ venv/ +examples/simple_secure_chat_ui/UI_PLAN.md diff --git a/examples/simple_secure_chat_ui/main.cpp b/examples/simple_secure_chat_ui/main.cpp index 29ef05417..fbf77b74e 100644 --- a/examples/simple_secure_chat_ui/main.cpp +++ b/examples/simple_secure_chat_ui/main.cpp @@ -21,13 +21,13 @@ #define FIRMWARE_VER_TEXT "v2 (build: 4 Feb 2025)" #ifndef LORA_FREQ - #define LORA_FREQ 915.0 + #define LORA_FREQ 869.525 #endif #ifndef LORA_BW #define LORA_BW 250 #endif #ifndef LORA_SF - #define LORA_SF 10 + #define LORA_SF 11 #endif #ifndef LORA_CR #define LORA_CR 5 @@ -59,6 +59,7 @@ #include "../include/uiManager.h" #include "../include/uiTasks.h" #include "uiTouch.h" +#include "messageStore.h" #define TAG "main" @@ -232,6 +233,95 @@ static uint32_t _atoi(const char* sp) { /* -------------------------------------------------------------------------------------- */ +static void msgstore_dm_path(char* out, size_t len, const uint8_t* pub_key) { + snprintf(out, len, "/m_%02x%02x%02x%02x.log", + pub_key[0], pub_key[1], pub_key[2], pub_key[3]); +} + +static const char* MSGSTORE_PUBLIC_PATH = "/m_public.log"; + +static void msgstore_rotate_if_needed(const char* path) { +#if defined(ESP32) + if (!SPIFFS.exists(path)) return; + File f = SPIFFS.open(path, "r"); + if (!f) return; + size_t sz = f.size(); + f.close(); + if (sz > MSGSTORE_MAX_BYTES) SPIFFS.remove(path); +#endif +} + +void msgstore_append_dm(const uint8_t* pub_key, uint32_t ts, bool sent, const char* text) { +#if defined(ESP32) + char path[24]; + msgstore_dm_path(path, sizeof(path), pub_key); + msgstore_rotate_if_needed(path); + File f = SPIFFS.open(path, FILE_APPEND); + if (!f) return; + f.printf("%u|%c|%s\n", (unsigned)ts, sent ? '>' : '<', text ? text : ""); + f.close(); +#endif +} + +void msgstore_append_public(uint32_t ts, const char* sender, bool sent, const char* text) { +#if defined(ESP32) + msgstore_rotate_if_needed(MSGSTORE_PUBLIC_PATH); + File f = SPIFFS.open(MSGSTORE_PUBLIC_PATH, FILE_APPEND); + if (!f) return; + f.printf("%u|%s|%c|%s\n", (unsigned)ts, + sender ? sender : "?", sent ? '>' : '<', text ? text : ""); + f.close(); +#endif +} + +void msgstore_load_dm(const uint8_t* pub_key) { +#if defined(ESP32) + char path[24]; + msgstore_dm_path(path, sizeof(path), pub_key); + if (!SPIFFS.exists(path)) return; + File f = SPIFFS.open(path, "r"); + if (!f) return; + while (f.available()) { + String line = f.readStringUntil('\n'); + int p1 = line.indexOf('|'); + int p2 = line.indexOf('|', p1 + 1); + if (p1 < 1 || p2 < p1 + 2) continue; + uint32_t ts = (uint32_t) line.substring(0, p1).toInt(); + char dir = line.charAt(p1 + 1); + String text = line.substring(p2 + 1); + char time_buf[16]; + format_time(ts, time_buf, sizeof(time_buf)); + uiManager->addPrivateChatBubble(time_buf, text.c_str(), dir == '>'); + } + f.close(); +#endif +} + +void msgstore_load_public() { +#if defined(ESP32) + if (!SPIFFS.exists(MSGSTORE_PUBLIC_PATH)) return; + File f = SPIFFS.open(MSGSTORE_PUBLIC_PATH, "r"); + if (!f) return; + while (f.available()) { + String line = f.readStringUntil('\n'); + int p1 = line.indexOf('|'); + int p2 = line.indexOf('|', p1 + 1); + int p3 = line.indexOf('|', p2 + 1); + if (p1 < 1 || p2 < p1 + 2 || p3 < p2 + 2) continue; + uint32_t ts = (uint32_t) line.substring(0, p1).toInt(); + String sender = line.substring(p1 + 1, p2); + char dir = line.charAt(p2 + 1); + String text = line.substring(p3 + 1); + char time_buf[16]; + format_time(ts, time_buf, sizeof(time_buf)); + uiManager->addChatBubble(time_buf, sender.c_str(), text.c_str(), dir == '>'); + } + f.close(); +#endif +} + +/* -------------------------------------------------------------------------------------- */ + struct NodePrefs { // persisted to file float airtime_factor; char node_name[32]; @@ -405,7 +495,8 @@ protected: Serial.printf(" Got ACK! (round trip: %d millis)\n", _ms->getMillis() - last_msg_sent); // NOTE: the same ACK can be received multiple times! expected_ack_crc = 0; // reset our expected hash, now that we have received ACK - return NULL; // TODO: really should return ContactInfo pointer + if (uiManager) uiManager->setSendStatus(1); + return NULL; // TODO: really should return ContactInfo pointer } //uint32_t crc; @@ -425,9 +516,8 @@ protected: char time_buf[16]; format_time(sender_timestamp, time_buf, sizeof(time_buf)); - bool is_self = (strcmp(from.name, _prefs.node_name) == 0); - - uiManager->addPrivateChatBubble(time_buf, text, is_self); + uiManager->routeIncomingDM(from.id.pub_key, from.name, time_buf, text); + msgstore_append_dm(from.id.pub_key, sender_timestamp, false, text); } void onCommandDataRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) override { @@ -463,6 +553,7 @@ protected: bool is_self = (strcmp(sender, _prefs.node_name) == 0); uiManager->addChatBubble(time_buf, sender, msg, is_self); + msgstore_append_public(timestamp, sender, is_self, msg); } uint8_t onContactRequest(const ContactInfo& contact, uint32_t sender_timestamp, const uint8_t* data, uint8_t len, uint8_t* reply) override { @@ -486,6 +577,7 @@ protected: void onSendTimeout() override { Serial.println(" ERROR: timed out, no ACK."); + if (uiManager) uiManager->setSendStatus(2); } public: @@ -875,6 +967,23 @@ void initializeMesh() { the_mesh.showWelcome(); + uiManager->populateSettings(the_mesh.getNodeName(), + the_mesh.getFreqPref(), + the_mesh.getTxPowerPref(), + the_mesh.getFirmwareVer(), + the_mesh.getBuildDate()); + + char pk_hex[17]; + mesh::Utils::toHex(pk_hex, the_mesh.self_id.pub_key, 8); + pk_hex[16] = 0; + uiManager->populateHome(the_mesh.getNodeName(), + pk_hex, + the_mesh.getNumContacts(), + the_mesh.getFreqPref()); + uiManager->setMyNodeName(the_mesh.getNodeName()); + + msgstore_load_public(); + Serial.println("[mesh] Sending self-advert..."); the_mesh.sendSelfAdvert(1200); Serial.println("[mesh] Advert queued (1200ms delay)"); diff --git a/examples/simple_secure_chat_ui/messageStore.h b/examples/simple_secure_chat_ui/messageStore.h new file mode 100644 index 000000000..ae264fc9b --- /dev/null +++ b/examples/simple_secure_chat_ui/messageStore.h @@ -0,0 +1,15 @@ +#pragma once +#include + +// Append-only per-contact / public chat persistence on SPIFFS. +// Each DM contact gets a file keyed by the first 4 bytes of the public key. +// When a file exceeds MSGSTORE_MAX_BYTES it is dropped (oldest history lost). + +#define MSGSTORE_MAX_BYTES 16384 + +void msgstore_append_dm(const uint8_t* pub_key, uint32_t ts, bool sent, const char* text); +void msgstore_append_public(uint32_t ts, const char* sender, bool sent, const char* text); + +// Replays stored history into the UI (calls uiManager->addPrivateChatBubble / addChatBubble). +void msgstore_load_dm(const uint8_t* pub_key); +void msgstore_load_public(); diff --git a/examples/simple_secure_chat_ui/uiManager.cpp b/examples/simple_secure_chat_ui/uiManager.cpp index a3361860a..c0fedc076 100644 --- a/examples/simple_secure_chat_ui/uiManager.cpp +++ b/examples/simple_secure_chat_ui/uiManager.cpp @@ -5,6 +5,7 @@ #include "uiVars.h" #include "uiManager.h" +#include "messageStore.h" #include "../src/fonts/fonts.h" @@ -23,7 +24,39 @@ const char *UIManager::months[12] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", #define TAG "UIManager" -//extern void handleCommand(char *msg); +extern void handleCommand(char *msg); + +namespace { +struct UIContactInfo { + ContactInfo info; + lv_obj_t* badge = nullptr; + lv_obj_t* badge_label = nullptr; + uint32_t unread = 0; +}; + +UIContactInfo* findContactByPubKey(lv_obj_t* list, const uint8_t* pub_key) { + if (!list) return nullptr; + uint32_t cnt = lv_obj_get_child_cnt(list); + for (uint32_t i = 0; i < cnt; i++) { + lv_obj_t* row = lv_obj_get_child(list, i); + UIContactInfo* uic = (UIContactInfo*) lv_obj_get_user_data(row); + if (uic && memcmp(uic->info.id.pub_key, pub_key, 32) == 0) return uic; + } + return nullptr; +} + +void updateBadge(UIContactInfo* uic) { + if (!uic || !uic->badge || !uic->badge_label) return; + if (uic->unread == 0) { + lv_obj_add_flag(uic->badge, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(uic->badge, LV_OBJ_FLAG_HIDDEN); + char buf[8]; + snprintf(buf, sizeof(buf), "%u", (unsigned)uic->unread); + lv_label_set_text(uic->badge_label, buf); + } +} +} // namespace UIManager::UIManager() { @@ -351,9 +384,35 @@ static void onContactClick(lv_event_t *e) void UIManager::handleContactClick(lv_event_t *e) { lv_obj_t *row = lv_event_get_target(e); - ContactInfo *c = (ContactInfo*) lv_obj_get_user_data(row); + UIContactInfo *uic = (UIContactInfo*) lv_obj_get_user_data(row); + if (!uic) return; + ContactInfo* c = &uic->info; Serial.printf("Clicked: %s\n", c->name); + + strncpy(currentContactName, c->name, sizeof(currentContactName) - 1); + currentContactName[sizeof(currentContactName) - 1] = 0; + memcpy(currentContactPubKey, c->id.pub_key, sizeof(currentContactPubKey)); + hasCurrentContact = true; + + if (ui_ContactName) { + lv_label_set_text(ui_ContactName, currentContactName); + } + + if (ui_ContactMessages) { + lv_obj_clean(ui_ContactMessages); + } + + uic->unread = 0; + updateBadge(uic); + + setSendStatus(-1); + + char cmd[64]; + snprintf(cmd, sizeof(cmd), "to %s", currentContactName); + handleCommand(cmd); + + msgstore_load_dm(currentContactPubKey); } void UIManager::addContactToUI(ContactInfo c) @@ -382,8 +441,9 @@ void UIManager::addContactToUI(ContactInfo c) lv_obj_set_style_border_color(btn, lv_color_hex(0x222222), 0); lv_obj_set_style_bg_color(btn, lv_color_hex(0x111111), LV_STATE_PRESSED); - // Store ContactInfo - auto* store = new ContactInfo(c); + // Store wrapper (ContactInfo + badge state) + auto* store = new UIContactInfo(); + store->info = c; lv_obj_set_user_data(btn, store); lv_obj_add_event_cb(btn, onContactClick, LV_EVENT_CLICKED, this); @@ -459,6 +519,31 @@ void UIManager::addContactToUI(ContactInfo c) .textColor(0x888888) .wrap(false); + // ============================ + // Unread badge (top-right corner of row) + // ============================ + lv_obj_t* badge = LvObj(btn) + .size(22, 22) + .position(ROW_W - 26, 4) + .radius(LV_RADIUS_CIRCLE) + .bgColor(0xE53935) + .border(0) + .scrollable(false) + .raw(); + lv_obj_add_flag(badge, LV_OBJ_FLAG_HIDDEN); + LvObj(badge, true).clickable(false); + + lv_obj_t* badge_label = LvLabel(badge) + .text("0") + .font(&lv_font_arial_14) + .textColor(0xFFFFFF) + .align(LV_ALIGN_CENTER) + .raw(); + LvObj(badge_label, true).clickable(false); + + store->badge = badge; + store->badge_label = badge_label; + // ============================ // Disable child clicks // ============================ @@ -470,16 +555,16 @@ void UIManager::onShowKeyboard() { LvKeyboard(ui_Keyboard, true).show(true); LvObj(ui_DimOverlay, true).clickable(true); - LvObj(ui_ChannelInput, true).positionY(channelInputBaseKeybOnY); - LvObj(ui_SendBtn, true).positionY(channelInputBaseKeybOnY); + if (activeInput) LvObj(activeInput, true).positionY(channelInputBaseKeybOnY); + if (activeSendBtn) LvObj(activeSendBtn, true).positionY(channelInputBaseKeybOnY); } void UIManager::onHideKeyboard() { LvKeyboard(ui_Keyboard, true).show(false); LvObj(ui_DimOverlay, true).clickable(false); - LvObj(ui_ChannelInput, true).positionY(channelInputBaseY); - LvObj(ui_SendBtn, true).positionY(channelInputBaseY); + if (activeInput) LvObj(activeInput, true).positionY(activeInputBaseY); + if (activeSendBtn) LvObj(activeSendBtn, true).positionY(activeInputBaseY); } static void s_onRestartClick(lv_event_t *e) @@ -487,6 +572,18 @@ static void s_onRestartClick(lv_event_t *e) ESP.restart(); } +static void s_onAdvertiseClick(lv_event_t *e) +{ + UIManager *self = (UIManager*) lv_event_get_user_data(e); + if(self) self->onAdvertiseClick(e); +} + +void UIManager::onAdvertiseClick(lv_event_t* e) +{ + char cmd[16] = "advert"; + handleCommand(cmd); +} + static void s_onChannelInputFocus(lv_event_t *e) { UIManager *self = (UIManager*) lv_event_get_user_data(e); @@ -498,10 +595,73 @@ void UIManager::onChannelInputFocus(lv_event_t* e) lv_obj_t* ta = lv_event_get_target(e); if(!ui_Keyboard || !ta) return; + activeInput = ui_ChannelInput; + activeSendBtn = ui_SendBtn; + activeInputBaseY = channelInputBaseY; + lv_keyboard_set_textarea(ui_Keyboard, ta); onShowKeyboard(); } +static void s_onContactInputFocus(lv_event_t *e) +{ + UIManager *self = (UIManager*) lv_event_get_user_data(e); + if(self) self->onContactInputFocus(e); +} + +void UIManager::onContactInputFocus(lv_event_t* e) +{ + lv_obj_t* ta = lv_event_get_target(e); + if(!ui_Keyboard || !ta) return; + + activeInput = ui_ContactInput; + activeSendBtn = ui_ContactSendBtn; + activeInputBaseY = channelInputBaseY; + + lv_keyboard_set_textarea(ui_Keyboard, ta); + onShowKeyboard(); +} + +static void s_onContactSendClick(lv_event_t *e) +{ + UIManager *self = (UIManager*) lv_event_get_user_data(e); + if(self) self->onContactSendClick(e); +} + +void UIManager::onContactSendClick(lv_event_t* e) +{ + if (!hasCurrentContact) { + Serial.println("[ui] no contact selected — DM ignored"); + return; + } + + const char* msg = lv_textarea_get_text(ui_ContactInput); + if (msg == nullptr || msg[0] == '\0') return; + + char msgCopy[200]; + strncpy(msgCopy, msg, sizeof(msgCopy) - 1); + msgCopy[sizeof(msgCopy) - 1] = 0; + + lv_textarea_set_text(ui_ContactInput, ""); + + char cmd[256]; + snprintf(cmd, sizeof(cmd), "send %s", msgCopy); + handleCommand(cmd); + + char time_buf[16]; + time_t now = time(NULL); + struct tm t; + localtime_r(&now, &t); + snprintf(time_buf, sizeof(time_buf), "%02d:%02d", t.tm_hour, t.tm_min); + + addPrivateChatBubble(time_buf, msgCopy, true); + msgstore_append_dm(currentContactPubKey, (uint32_t)now, true, msgCopy); + + setSendStatus(0); + + onHideKeyboard(); +} + static void s_onSendClick(lv_event_t *e) { UIManager *self = (UIManager*) lv_event_get_user_data(e); @@ -522,7 +682,7 @@ void UIManager::onSendClick(lv_event_t* e) lv_textarea_set_text(ui_ChannelInput, ""); snprintf(fullMessage, sizeof(fullMessage), "public %s", msgCopy); - //handleCommand(fullMessage); + handleCommand(fullMessage); char time_buf[16]; time_t now = time(NULL); @@ -530,11 +690,175 @@ void UIManager::onSendClick(lv_event_t* e) localtime_r(&now, &t); sprintf(time_buf, "%02d:%02d:%02d\n", t.tm_hour, t.tm_min, t.tm_sec); - addChatBubble(time_buf, "Me", msgCopy, true); + const char* sender = (myNodeName[0] != 0) ? myNodeName : "Me"; + addChatBubble(time_buf, sender, msgCopy, true); + msgstore_append_public((uint32_t)now, sender, true, msgCopy); onHideKeyboard(); } +void UIManager::setMyNodeName(const char* name) { + if (!name) { myNodeName[0] = 0; return; } + strncpy(myNodeName, name, sizeof(myNodeName) - 1); + myNodeName[sizeof(myNodeName) - 1] = 0; +} + +void UIManager::routeIncomingDM(const uint8_t* from_pub_key, const char* from_name, + const char* time_str, const char* text) +{ + if (hasCurrentContact && memcmp(from_pub_key, currentContactPubKey, 32) == 0) { + addPrivateChatBubble(time_str, text, false); + return; + } + + UIContactInfo* uic = findContactByPubKey(ui_Contacts, from_pub_key); + if (uic) { + uic->unread++; + updateBadge(uic); + } +} + +void UIManager::setSendStatus(int state) +{ + if (!ui_ContactStatus) return; + + switch (state) { + case 0: + lv_label_set_text(ui_ContactStatus, + #if defined(LANG_GR) + "Αποστολή..."); + #else + "Sending..."); + #endif + lv_obj_set_style_text_color(ui_ContactStatus, lv_color_hex(0xFFC107), 0); + break; + case 1: + lv_label_set_text(ui_ContactStatus, LV_SYMBOL_OK + #if defined(LANG_GR) + " Παραδόθηκε"); + #else + " Delivered"); + #endif + lv_obj_set_style_text_color(ui_ContactStatus, lv_color_hex(0x4CAF50), 0); + break; + case 2: + lv_label_set_text(ui_ContactStatus, LV_SYMBOL_CLOSE + #if defined(LANG_GR) + " Δεν έγινε ack"); + #else + " No ack"); + #endif + lv_obj_set_style_text_color(ui_ContactStatus, lv_color_hex(0xE53935), 0); + break; + default: + lv_label_set_text(ui_ContactStatus, ""); + break; + } +} + +static void s_onSettingsInputFocus(lv_event_t *e) +{ + UIManager *self = (UIManager*) lv_event_get_user_data(e); + if(self) self->onSettingsInputFocus(e); +} + +void UIManager::onSettingsInputFocus(lv_event_t* e) +{ + lv_obj_t* ta = lv_event_get_target(e); + if(!ui_Keyboard || !ta) return; + + // Settings inputs do not slide up — keyboard floats over the form. + activeInput = nullptr; + activeSendBtn = nullptr; + + lv_keyboard_set_textarea(ui_Keyboard, ta); + LvKeyboard(ui_Keyboard, true).show(true); + LvObj(ui_DimOverlay, true).clickable(true); +} + +static void s_onSettingsSaveClick(lv_event_t *e) +{ + UIManager *self = (UIManager*) lv_event_get_user_data(e); + if(self) self->onSettingsSaveClick(e); +} + +void UIManager::onSettingsSaveClick(lv_event_t* e) +{ + char cmd[96]; + + const char* name = ui_SettingsName ? lv_textarea_get_text(ui_SettingsName) : ""; + const char* freq = ui_SettingsFreq ? lv_textarea_get_text(ui_SettingsFreq) : ""; + const char* tx = ui_SettingsTx ? lv_textarea_get_text(ui_SettingsTx) : ""; + + if (name && name[0]) { + snprintf(cmd, sizeof(cmd), "set name %s", name); + handleCommand(cmd); + } + if (freq && freq[0]) { + snprintf(cmd, sizeof(cmd), "set freq %s", freq); + handleCommand(cmd); + } + if (tx && tx[0]) { + snprintf(cmd, sizeof(cmd), "set tx %s", tx); + handleCommand(cmd); + } + + if (ui_SettingsStatus) { + #if defined(LANG_GR) + lv_label_set_text(ui_SettingsStatus, "Αποθηκεύτηκε. Επανεκκίνηση..."); + #else + lv_label_set_text(ui_SettingsStatus, "Saved. Restarting..."); + #endif + } + + onHideKeyboard(); + + Serial.flush(); + delay(800); + ESP.restart(); +} + +void UIManager::populateSettings(const char* name, float freq, uint8_t tx_power, + const char* fw_ver, const char* build_date) +{ + if (ui_SettingsName && name) lv_textarea_set_text(ui_SettingsName, name); + + if (ui_SettingsFreq) { + char buf[16]; + snprintf(buf, sizeof(buf), "%.3f", freq); + lv_textarea_set_text(ui_SettingsFreq, buf); + } + + if (ui_SettingsTx) { + char buf[8]; + snprintf(buf, sizeof(buf), "%u", (unsigned)tx_power); + lv_textarea_set_text(ui_SettingsTx, buf); + } + + if (ui_SettingsFw && fw_ver && build_date) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s (%s)", fw_ver, build_date); + lv_label_set_text(ui_SettingsFw, buf); + } +} + +void UIManager::populateHome(const char* name, const char* pub_key_hex, + int contact_count, float freq) +{ + if (ui_HomeNodeName && name) lv_label_set_text(ui_HomeNodeName, name); + if (ui_HomePubKey && pub_key_hex) lv_label_set_text(ui_HomePubKey, pub_key_hex); + + if (ui_HomeInfo) { + char buf[64]; + #if defined(LANG_GR) + snprintf(buf, sizeof(buf), "%.3f MHz · %d επαφές", freq, contact_count); + #else + snprintf(buf, sizeof(buf), "%.3f MHz · %d contacts", freq, contact_count); + #endif + lv_label_set_text(ui_HomeInfo, buf); + } +} + static void s_onKeyboardEvent(lv_event_t *e) { UIManager *self = (UIManager*) lv_event_get_user_data(e); @@ -549,8 +873,8 @@ void UIManager::onKeyboardEvent(lv_event_t* e) { LvKeyboard(ui_Keyboard, true).show(false); - LvObj(ui_ChannelInput, true).positionY(channelInputBaseY); - LvObj(ui_SendBtn, true).positionY(channelInputBaseY); + if (activeInput) LvObj(activeInput, true).positionY(activeInputBaseY); + if (activeSendBtn) LvObj(activeSendBtn, true).positionY(activeInputBaseY); LvObj(ui_DimOverlay, true) .bgOpa(0) @@ -623,6 +947,36 @@ void UIManager::ui_Screen1_screen_init(void) lv_obj_clear_flag(ui_TabPageChannels, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(ui_TabPageSettings, LV_OBJ_FLAG_SCROLLABLE); + // Node name (large) + ui_HomeNodeName = LvLabel(ui_TabPageHome) + .text("...") + .width(LV_SIZE_CONTENT) + .height(LV_SIZE_CONTENT) + .font(&lv_font_arial_26) + .textColor(0xFFFFFF) + .align(LV_ALIGN_CENTER) + .position(0, -200); + + // Public key (short hex) + ui_HomePubKey = LvLabel(ui_TabPageHome) + .text("") + .width(LV_SIZE_CONTENT) + .height(LV_SIZE_CONTENT) + .font(&lv_font_arial_18) + .textColor(0x888888) + .align(LV_ALIGN_CENTER) + .position(0, -170); + + // Freq + contacts count + ui_HomeInfo = LvLabel(ui_TabPageHome) + .text("") + .width(LV_SIZE_CONTENT) + .height(LV_SIZE_CONTENT) + .font(&lv_font_arial_20) + .textColor(0xAAAAAA) + .align(LV_ALIGN_CENTER) + .position(0, -140); + ui_ValueDate = LvLabel(ui_TabPageHome) .text("--- --/--/----") .width(LV_SIZE_CONTENT) @@ -631,8 +985,7 @@ void UIManager::ui_Screen1_screen_init(void) .textColor(0xFFFFFF) .opa(255) .align(LV_ALIGN_CENTER) - .position(0, -165); - + .position(0, -85); ui_ValueTime = LvLabel(ui_TabPageHome) .text("--:--") @@ -642,12 +995,32 @@ void UIManager::ui_Screen1_screen_init(void) .textColor(0xFFFFFF) .opa(255) .align(LV_ALIGN_CENTER) - .position(0, -100); + .position(0, -25); + + // Advertise button + ui_AdvertiseBtn = LvButton(ui_TabPageHome) + .size(220, 56) + .align(LV_ALIGN_CENTER) + .position(0, 55) + .bgColor(0x1565C0) + .onClick(s_onAdvertiseClick, this) + .raw(); + + lv_obj_t* ui_AdvertiseLabel = LvLabel(ui_AdvertiseBtn) + #if defined(LANG_GR) + .text(LV_SYMBOL_WIFI " Διαφήμιση") + #else + .text(LV_SYMBOL_WIFI " Advertise") + #endif + .font(&lv_font_arial_22) + .textColor(0xFFFFFF) + .raw(); + lv_obj_center(ui_AdvertiseLabel); lv_obj_t* ui_RestartBtn = LvButton(ui_TabPageHome) - .size(200, 56) + .size(220, 56) .align(LV_ALIGN_CENTER) - .position(0, 60) + .position(0, 130) .bgColor(0xC62828) .onClick(s_onRestartClick, nullptr) .raw(); @@ -679,11 +1052,20 @@ void UIManager::ui_Screen1_screen_init(void) lv_obj_set_style_bg_opa(ui_Contacts, LV_OPA_TRANSP, LV_PART_ITEMS); lv_obj_set_style_border_width(ui_Contacts, 0, LV_PART_ITEMS); + ui_ContactName = LvLabel(ui_TabPageContacts) + .text("") + .width(300) + .height(LV_SIZE_CONTENT) + .font(&lv_font_arial_22) + .textColor(0xFFFFFF) + .align(LV_ALIGN_CENTER) + .position(80, -195); + ui_ContactMessages = LvList(ui_TabPageContacts) .width(310) - .height(400) + .height(290) .align(LV_ALIGN_CENTER) - .position(80, 0) + .position(80, -40) .transparent() .raw(); @@ -695,6 +1077,49 @@ void UIManager::ui_Screen1_screen_init(void) //lv_obj_set_scrollbar_mode(ui_ContactMessages, LV_SCROLLBAR_MODE_OFF); lv_obj_set_style_bg_opa(ui_ContactMessages, LV_OPA_TRANSP, LV_PART_ITEMS); lv_obj_set_style_border_width(ui_ContactMessages, 0, LV_PART_ITEMS); + + ui_ContactInput = LvTextArea(ui_TabPageContacts) + .size(230, 40) + .align(LV_ALIGN_CENTER) + .position(40, channelInputBaseY) + .oneLine(true) + #if defined(LANG_EN) + .placeholder("Write message...") + #elif defined(LANG_GR) + .placeholder("Γράψε μήνυμα...") + #endif + .font(&lv_font_arial_20) + .bgColor(0x111111) + .textColor(0xFFFFFF) + .borderColor(0x444444) + .borderWidth(1) + .radius(6) + .onFocus(s_onContactInputFocus, this) + .raw(); + + ui_ContactSendBtn = LvButton(ui_TabPageContacts) + .size(70, 42) + .align(LV_ALIGN_CENTER) + .position(195, channelInputBaseY) + .bgColor(0x3A7AFE) + .onClick(s_onContactSendClick, this) + .raw(); + + ui_ContactSendLabel = LvLabel(ui_ContactSendBtn) + #if defined(LANG_EN) + .text("Send") + #elif defined(LANG_GR) + .text("Αποστολή") + #endif + .font(&lv_font_arial_18); + lv_obj_center(ui_ContactSendLabel); + + ui_ContactStatus = LvLabel(ui_TabPageContacts) + .text("") + .font(&lv_font_arial_14) + .textColor(0xAAAAAA) + .align(LV_ALIGN_CENTER) + .position(80, channelInputBaseY + 30); // LvObj(ui_TabPageContacts) // .size(2, 400) @@ -788,6 +1213,124 @@ void UIManager::ui_Screen1_screen_init(void) .font(&lv_font_arial_18); lv_obj_center(iu_SendLabel); + // ---- Settings tab ---- + const int FIELD_W = 380; + const int LABEL_FONT_SIZE_HACK = 0; // placeholder + + // Device name + LvLabel(ui_TabPageSettings) + #if defined(LANG_GR) + .text("Όνομα συσκευής") + #else + .text("Device name") + #endif + .font(&lv_font_arial_20) + .textColor(0xCCCCCC) + .align(LV_ALIGN_CENTER) + .position(0, -200); + + ui_SettingsName = LvTextArea(ui_TabPageSettings) + .size(FIELD_W, 40) + .align(LV_ALIGN_CENTER) + .position(0, -170) + .oneLine(true) + .font(&lv_font_arial_20) + .bgColor(0x111111) + .textColor(0xFFFFFF) + .borderColor(0x444444) + .borderWidth(1) + .radius(6) + .onFocus(s_onSettingsInputFocus, this) + .raw(); + + // Frequency + LvLabel(ui_TabPageSettings) + #if defined(LANG_GR) + .text("Συχνότητα LoRa (MHz)") + #else + .text("LoRa frequency (MHz)") + #endif + .font(&lv_font_arial_20) + .textColor(0xCCCCCC) + .align(LV_ALIGN_CENTER) + .position(0, -120); + + ui_SettingsFreq = LvTextArea(ui_TabPageSettings) + .size(FIELD_W, 40) + .align(LV_ALIGN_CENTER) + .position(0, -90) + .oneLine(true) + .font(&lv_font_arial_20) + .bgColor(0x111111) + .textColor(0xFFFFFF) + .borderColor(0x444444) + .borderWidth(1) + .radius(6) + .onFocus(s_onSettingsInputFocus, this) + .raw(); + + // TX power + LvLabel(ui_TabPageSettings) + #if defined(LANG_GR) + .text("Ισχύς εκπομπής (dBm)") + #else + .text("TX power (dBm)") + #endif + .font(&lv_font_arial_20) + .textColor(0xCCCCCC) + .align(LV_ALIGN_CENTER) + .position(0, -40); + + ui_SettingsTx = LvTextArea(ui_TabPageSettings) + .size(FIELD_W, 40) + .align(LV_ALIGN_CENTER) + .position(0, -10) + .oneLine(true) + .font(&lv_font_arial_20) + .bgColor(0x111111) + .textColor(0xFFFFFF) + .borderColor(0x444444) + .borderWidth(1) + .radius(6) + .onFocus(s_onSettingsInputFocus, this) + .raw(); + + // Firmware (read-only) + ui_SettingsFw = LvLabel(ui_TabPageSettings) + .text("...") + .font(&lv_font_arial_18) + .textColor(0x888888) + .align(LV_ALIGN_CENTER) + .position(0, 40); + + // Save button + ui_SettingsSaveBtn = LvButton(ui_TabPageSettings) + .size(220, 50) + .align(LV_ALIGN_CENTER) + .position(0, 90) + .bgColor(0x2E7D32) + .onClick(s_onSettingsSaveClick, this) + .raw(); + + lv_obj_t* saveLabel = LvLabel(ui_SettingsSaveBtn) + #if defined(LANG_GR) + .text(LV_SYMBOL_SAVE " Αποθήκευση & Επανεκκίνηση") + #else + .text(LV_SYMBOL_SAVE " Save & Restart") + #endif + .font(&lv_font_arial_18) + .textColor(0xFFFFFF) + .raw(); + lv_obj_center(saveLabel); + + // Status label + ui_SettingsStatus = LvLabel(ui_TabPageSettings) + .text("") + .font(&lv_font_arial_18) + .textColor(0xFFC107) + .align(LV_ALIGN_CENTER) + .position(0, 145); + ui_Keyboard = LvKeyboard(lv_layer_top()) .size(480, 200) .align(LV_ALIGN_BOTTOM_MID) diff --git a/include/uiManager.h b/include/uiManager.h index b4ea2dae1..a0368066f 100644 --- a/include/uiManager.h +++ b/include/uiManager.h @@ -66,15 +66,59 @@ class UIManager { lv_obj_t* iu_SendLabel; lv_obj_t* ui_ChannelDivider; + lv_obj_t* ui_ContactName = nullptr; + lv_obj_t* ui_ContactInput = nullptr; + lv_obj_t* ui_ContactSendBtn = nullptr; + lv_obj_t* ui_ContactSendLabel = nullptr; + char currentContactName[32] = {0}; + uint8_t currentContactPubKey[32] = {0}; + bool hasCurrentContact = false; + char myNodeName[32] = {0}; + + lv_obj_t* ui_ContactStatus = nullptr; + + lv_obj_t* ui_HomeNodeName = nullptr; + lv_obj_t* ui_HomePubKey = nullptr; + lv_obj_t* ui_HomeInfo = nullptr; + lv_obj_t* ui_AdvertiseBtn = nullptr; + + lv_obj_t* ui_SettingsName = nullptr; + lv_obj_t* ui_SettingsFreq = nullptr; + lv_obj_t* ui_SettingsTx = nullptr; + lv_obj_t* ui_SettingsFw = nullptr; + lv_obj_t* ui_SettingsSaveBtn = nullptr; + lv_obj_t* ui_SettingsStatus = nullptr; + + // Whichever input/send button last got focus — used by keyboard show/hide + lv_obj_t* activeInput = nullptr; + lv_obj_t* activeSendBtn = nullptr; + int activeInputBaseY = 0; + public: UIManager(); void onChannelInputFocus(lv_event_t* e); + void onContactInputFocus(lv_event_t* e); + void onContactSendClick(lv_event_t* e); + void onSettingsInputFocus(lv_event_t* e); + void onSettingsSaveClick(lv_event_t* e); + void onAdvertiseClick(lv_event_t* e); void onDimOverlayClick(lv_event_t* e); void onSendClick(lv_event_t* e); void onKeyboardEvent(lv_event_t* e); void scroll_begin_event(lv_event_t* e); + void populateSettings(const char* name, float freq, uint8_t tx_power, + const char* fw_ver, const char* build_date); + void populateHome(const char* name, const char* pub_key_hex, + int contact_count, float freq); + void setMyNodeName(const char* name); + + // Phase 6 + void routeIncomingDM(const uint8_t* from_pub_key, const char* from_name, + const char* time_str, const char* text); + void setSendStatus(int state); // 0=sending, 1=delivered, 2=failed, -1=clear + void updateDateTime(const struct tm timeinfo); void updateInfo(const char *str, uint32_t color); void clearDateTime(); diff --git a/variants/elecrow_espnow/platformio.ini b/variants/elecrow_espnow/platformio.ini index ede9766b9..6578fd840 100644 --- a/variants/elecrow_espnow/platformio.ini +++ b/variants/elecrow_espnow/platformio.ini @@ -7,6 +7,10 @@ board = esp32-s3-devkitc-1-myboard build_flags = ${esp32_base.build_flags} -I variants/Elecrow_espnow + -D LORA_FREQ=869.525 + -D LORA_BW=250.0 + -D LORA_SF=11 + -D LORA_CR=5 -D PIN_BOARD_SDA=-1 -D PIN_BOARD_SCL=-1 -D PIN_USER_BTN=0