diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1b53e28..6c14718 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.8" +#define FIRMWARE_VERSION "Meck v0.8.1" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 59d1705..3e9ae3f 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -9,6 +9,7 @@ #include "TCA8418Keyboard.h" #include #include "TextReaderScreen.h" + #include "ContactsScreen.h" extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -639,11 +640,18 @@ void handleKeyboardInput() { ui_task.gotoTextReader(); break; + case 'n': + case 'N': + // Open contacts list + Serial.println("Opening contacts"); + ui_task.gotoContactsScreen(); + break; + case 'w': case 'W': // Navigate up/previous (scroll on channel screen) - if (ui_task.isOnChannelScreen()) { - ui_task.injectKey('w'); // Pass directly for channel switching + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + ui_task.injectKey('w'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Previous"); ui_task.injectKey(0xF2); // KEY_PREV @@ -653,8 +661,8 @@ void handleKeyboardInput() { case 's': case 'S': // Navigate down/next (scroll on channel screen) - if (ui_task.isOnChannelScreen()) { - ui_task.injectKey('s'); // Pass directly for channel switching + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + ui_task.injectKey('s'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Next"); ui_task.injectKey(0xF1); // KEY_NEXT @@ -664,8 +672,8 @@ void handleKeyboardInput() { case 'a': case 'A': // Navigate left or switch channel (on channel screen) - if (ui_task.isOnChannelScreen()) { - ui_task.injectKey('a'); // Pass directly for channel switching + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + ui_task.injectKey('a'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Previous"); ui_task.injectKey(0xF2); // KEY_PREV @@ -675,8 +683,8 @@ void handleKeyboardInput() { case 'd': case 'D': // Navigate right or switch channel (on channel screen) - if (ui_task.isOnChannelScreen()) { - ui_task.injectKey('d'); // Pass directly for channel switching + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + ui_task.injectKey('d'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Next"); ui_task.injectKey(0xF1); // KEY_NEXT diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h new file mode 100644 index 0000000..533ed5e --- /dev/null +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -0,0 +1,324 @@ +#pragma once + +#include +#include +#include + +// Forward declarations +class UITask; +class MyMesh; +extern MyMesh the_mesh; + +class ContactsScreen : public UIScreen { +public: + // Filter modes for contact type + enum FilterMode { + FILTER_ALL = 0, + FILTER_CHAT, // Companions / Chat nodes + FILTER_REPEATER, + FILTER_ROOM, // Room servers + FILTER_SENSOR, + FILTER_COUNT // keep last + }; + +private: + UITask* _task; + mesh::RTCClock* _rtc; + + int _scrollPos; // Index into filtered list (top visible row) + FilterMode _filter; // Current filter mode + + // Cached filtered contact indices for efficient scrolling + // We rebuild this on filter change or when entering the screen + static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag + uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table + uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting + int _filteredCount; // how many contacts match current filter + bool _cacheValid; + + // How many rows fit on screen (computed during render) + int _rowsPerPage; + + // --- helpers --- + + static const char* filterLabel(FilterMode f) { + switch (f) { + case FILTER_ALL: return "All"; + case FILTER_CHAT: return "Chat"; + case FILTER_REPEATER: return "Rptr"; + case FILTER_ROOM: return "Room"; + case FILTER_SENSOR: return "Sens"; + default: return "?"; + } + } + + static char typeChar(uint8_t adv_type) { + switch (adv_type) { + case ADV_TYPE_CHAT: return 'C'; + case ADV_TYPE_REPEATER: return 'R'; + case ADV_TYPE_ROOM: return 'S'; // Server + default: return '?'; + } + } + + bool matchesFilter(uint8_t adv_type) const { + switch (_filter) { + case FILTER_ALL: return true; + case FILTER_CHAT: return adv_type == ADV_TYPE_CHAT; + case FILTER_REPEATER: return adv_type == ADV_TYPE_REPEATER; + case FILTER_ROOM: return adv_type == ADV_TYPE_ROOM; + case FILTER_SENSOR: return (adv_type != ADV_TYPE_CHAT && + adv_type != ADV_TYPE_REPEATER && + adv_type != ADV_TYPE_ROOM); + default: return true; + } + } + + void rebuildCache() { + _filteredCount = 0; + uint32_t numContacts = the_mesh.getNumContacts(); + ContactInfo contact; + for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_VISIBLE; i++) { + if (the_mesh.getContactByIdx(i, contact)) { + if (matchesFilter(contact.type)) { + _filteredIdx[_filteredCount] = (uint16_t)i; + _filteredTs[_filteredCount] = contact.last_advert_timestamp; + _filteredCount++; + } + } + } + // Sort by last_advert_timestamp descending (most recently seen first) + // Simple insertion sort — fine for up to 400 entries on ESP32 + for (int i = 1; i < _filteredCount; i++) { + uint16_t tmpIdx = _filteredIdx[i]; + uint32_t tmpTs = _filteredTs[i]; + int j = i - 1; + while (j >= 0 && _filteredTs[j] < tmpTs) { + _filteredIdx[j + 1] = _filteredIdx[j]; + _filteredTs[j + 1] = _filteredTs[j]; + j--; + } + _filteredIdx[j + 1] = tmpIdx; + _filteredTs[j + 1] = tmpTs; + } + _cacheValid = true; + // Clamp scroll position + if (_scrollPos >= _filteredCount) { + _scrollPos = (_filteredCount > 0) ? _filteredCount - 1 : 0; + } + } + + // Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "??" + static void formatAge(char* buf, size_t bufLen, uint32_t now, uint32_t timestamp) { + if (timestamp == 0) { + strncpy(buf, "--", bufLen); + return; + } + int secs = (int)(now - timestamp); + if (secs < 0) secs = 0; + if (secs < 60) { + snprintf(buf, bufLen, "%ds", secs); + } else if (secs < 3600) { + snprintf(buf, bufLen, "%dm", secs / 60); + } else if (secs < 86400) { + snprintf(buf, bufLen, "%dh", secs / 3600); + } else { + snprintf(buf, bufLen, "%dd", secs / 86400); + } + } + +public: + ContactsScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL), + _filteredCount(0), _cacheValid(false), _rowsPerPage(5) {} + + void invalidateCache() { _cacheValid = false; } + + void resetScroll() { + _scrollPos = 0; + _cacheValid = false; + } + + FilterMode getFilter() const { return _filter; } + + // Get the raw contact table index for the currently highlighted item + // Returns -1 if no valid selection + int getSelectedContactIdx() const { + if (_filteredCount == 0) return -1; + return _filteredIdx[_scrollPos]; + } + + int render(DisplayDriver& display) override { + if (!_cacheValid) rebuildCache(); + + char tmp[48]; + + // === Header === + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter)); + display.print(tmp); + + // Count on right + snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts()); + display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); + display.print(tmp); + + // Divider + display.drawRect(0, 11, display.width(), 1); + + // === Body - contact rows === + display.setTextSize(0); // tiny font for compact rows + int lineHeight = 9; // 8px font + 1px gap + int headerHeight = 14; + int footerHeight = 14; + int maxY = display.height() - footerHeight; + int y = headerHeight; + + uint32_t now = _rtc->getCurrentTime(); + int rowsDrawn = 0; + + if (_filteredCount == 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + display.print("No contacts"); + display.setCursor(0, y + lineHeight); + display.print("A/D: Change filter"); + } else { + // Center visible window around selected item (TextReaderScreen pattern) + int maxVisible = (maxY - headerHeight) / lineHeight; + if (maxVisible < 3) maxVisible = 3; + int startIdx = max(0, min(_scrollPos - maxVisible / 2, + _filteredCount - maxVisible)); + int endIdx = min(_filteredCount, startIdx + maxVisible); + + for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) { + ContactInfo contact; + if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue; + + bool selected = (i == _scrollPos); + + // Highlight: fill LIGHT rect first, then draw DARK text on top + if (selected) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y + 5, display.width(), lineHeight); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + // Set cursor AFTER fillRect so text draws on top of highlight + display.setCursor(0, y); + + // Prefix: "> " for selected, type char + space for others + char prefix[4]; + if (selected) { + snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type)); + } else { + snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type)); + } + display.print(prefix); + + // Contact name (truncated to fit) + char filteredName[32]; + display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName)); + + // Reserve space for hops + age on right side + char hopStr[6]; + if (contact.out_path_len == 0xFF || contact.out_path_len == 0) { + strcpy(hopStr, "D"); // direct + } else { + snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len); + } + + char ageStr[6]; + formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp); + + // Build right-side string: "hops age" + char rightStr[14]; + snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr); + int rightWidth = display.getTextWidth(rightStr) + 2; + + // Name region: after prefix + small gap, before right info + int nameX = display.getTextWidth(prefix) + 2; + int nameMaxW = display.width() - nameX - rightWidth - 2; + display.drawTextEllipsized(nameX, y, nameMaxW, filteredName); + + // Right-aligned: hops + age + display.setCursor(display.width() - rightWidth, y); + display.print(rightStr); + + y += lineHeight; + rowsDrawn++; + } + _rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1; + } + + display.setTextSize(1); // restore for footer + + // === Footer === + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + + // Left: Q:Back + display.setCursor(0, footerY); + display.print("Q:Back"); + + // Center: A/D:Filter + const char* mid = "A/D:Filtr"; + display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); + display.print(mid); + + // Right: W/S:Scroll + const char* right = "W/S:Scrll"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + + return 5000; // e-ink: next render after 5s + } + + bool handleInput(char c) override { + // W - scroll up (previous contact) + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_scrollPos > 0) { + _scrollPos--; + return true; + } + } + + // S - scroll down (next contact) + if (c == 's' || c == 'S' || c == 0xF1) { + if (_scrollPos < _filteredCount - 1) { + _scrollPos++; + return true; + } + } + + // A - previous filter + if (c == 'a' || c == 'A') { + _filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT); + _scrollPos = 0; + _cacheValid = false; + return true; + } + + // D - next filter + if (c == 'd' || c == 'D') { + _filter = (FilterMode)(((int)_filter + 1) % FILTER_COUNT); + _scrollPos = 0; + _cacheValid = false; + return true; + } + + // Enter - select contact (future: open RepeaterAdmin for repeaters) + if (c == 13 || c == KEY_ENTER) { + // TODO Phase 3: if selected contact is a repeater, open RepeaterAdminScreen + // For now, just acknowledge the selection + return true; + } + + return false; + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index e2e8460..8e1f0c3 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -31,6 +31,7 @@ #include "icons.h" #include "ChannelScreen.h" +#include "ContactsScreen.h" #include "TextReaderScreen.h" class SplashScreen : public UIScreen { @@ -605,6 +606,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); channel_screen = new ChannelScreen(this, &rtc_clock); + contacts_screen = new ContactsScreen(this, &rtc_clock); text_reader = new TextReaderScreen(this); setCurrScreen(splash); } @@ -998,6 +1000,16 @@ void UITask::gotoChannelScreen() { _next_refresh = 100; } +void UITask::gotoContactsScreen() { + ((ContactsScreen *) contacts_screen)->resetScroll(); + setCurrScreen(contacts_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + void UITask::gotoTextReader() { TextReaderScreen* reader = (TextReaderScreen*)text_reader; if (_display != NULL) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5241cda..374fb9c 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -52,6 +52,7 @@ class UITask : public AbstractUITask { UIScreen* home; UIScreen* msg_preview; UIScreen* channel_screen; // Channel message history screen + UIScreen* contacts_screen; // Contacts list screen UIScreen* text_reader; // *** NEW: Text reader screen *** UIScreen* curr; @@ -76,6 +77,7 @@ public: void gotoHomeScreen() { setCurrScreen(home); } void gotoChannelScreen(); // Navigate to channel message screen + void gotoContactsScreen(); // Navigate to contacts list void gotoTextReader(); // *** NEW: Navigate to text reader *** void showAlert(const char* text, int duration_millis) override; void forceRefresh() override { _next_refresh = 100; } @@ -83,6 +85,7 @@ public: bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; bool isOnChannelScreen() const { return curr == channel_screen; } + bool isOnContactsScreen() const { return curr == contacts_screen; } bool isOnTextReader() const { return curr == text_reader; } // *** NEW *** uint8_t getChannelScreenViewIdx() const; @@ -100,6 +103,7 @@ public: UIScreen* getCurrentScreen() const { return curr; } UIScreen* getMsgPreviewScreen() const { return msg_preview; } UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW *** + UIScreen* getContactsScreen() const { return contacts_screen; } // from AbstractUITask void msgRead(int msgcount) override;