From 21c02c711499b72fcc7d361b87e48fd7e670cb6b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 24 May 2026 23:00:27 +0200 Subject: [PATCH] feat(ui): Favourites home page (phase 1: storage + read-only grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New home page rendering 6 pinned-contact slots. Grid transposes to orientation: landscape → 3×2, portrait → 2×3. Joystick claims UP/DOWN and inner LEFT/RIGHT for grid nav; edge LEFT/RIGHT fall through to page navigation. Selected tile inverts via drawSelectionRow, filled tile shows contact name + unread badge, empty tile shows "+". Storage: - NodePrefs::favourite_contacts[6][6] (6-byte pub_key prefix per slot, all-zero = empty; collision probability with a real key is 2^-48) - HomePageBit gains HPB_FAVOURITES = 11 (HPB_COUNT = 12), with a new HP_FAVOURITES mask bit so the page is toggleable in Settings - New PAGE_ORDER_LEN constant decouples the persisted array length (11) from the page-type count; loops over page_order now use it, value-range checks still use HPB_COUNT - homePageBit returns 0 for HPB_SETTINGS / HPB_QUICK_MSG explicitly rather than by bit-index range, so HPB_FAVOURITES (bit 11) is correctly treated as toggleable - Default order inserts FAVOURITES after CLOCK; SHUTDOWN drops to the fallback "missing pages" append (still reachable, lands at end) Schema sentinel bumped to 0xC0DE0002. DataStore::loadPrefsInt and savePrefs read/write the new field. Older saves leave the field zero-initialised (all slots empty), which is the natural starting state. Phase 2 (Pin from Contact options) and Phase 3 (Pin from empty-slot mini picker) wire interactions; this commit handles render + nav only. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 3 + examples/companion_radio/NodePrefs.h | 62 +++++++----- .../companion_radio/ui-new/SettingsScreen.h | 50 +++++----- examples/companion_radio/ui-new/UITask.cpp | 99 ++++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 1 + 5 files changed, 166 insertions(+), 49 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 5e71af8d..813476cc 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -304,6 +304,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no _prefs.page_order_set = NodePrefs::PAGE_ORDER_MAGIC; } + rd(_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts)); + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() already zero-inits any fields not present, // so we just log it — next savePrefs writes the current sentinel. @@ -394,6 +396,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation)); file.write((uint8_t *)&_prefs.eink_full_refresh_every, sizeof(_prefs.eink_full_refresh_every)); file.write((uint8_t *)&_prefs.page_order_set, sizeof(_prefs.page_order_set)); + file.write((uint8_t *)_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 6d17c2ed..280939e9 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -92,49 +92,63 @@ struct NodePrefs { // persisted to file uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default static const uint8_t PAGE_ORDER_MAGIC = 0xA5; + // Favourites dial: 6 pinned contacts, stored as first 6 bytes of pub_key per slot. + // All-zero = empty slot (probability a real pub_key starts with 6 zero bytes is 2^-48). + // Layout transposes between landscape (3×2) and portrait (2×3). + static const uint8_t FAVOURITES_COUNT = 6; + static const uint8_t FAVOURITE_PREFIX_LEN = 6; + uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; + // Tail sentinel written at the end of /new_prefs. Bump the low byte when // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0001; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0002; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage // and SettingsScreen::homePageBitIndex route their per-enum lookups through these. enum HomePageBit : uint8_t { - HPB_CLOCK = 0, - HPB_RECENT = 1, - HPB_RADIO = 2, - HPB_BLUETOOTH = 3, - HPB_ADVERT = 4, - HPB_GPS = 5, - HPB_SENSORS = 6, - HPB_TOOLS = 7, - HPB_SHUTDOWN = 8, - HPB_SETTINGS = 9, - HPB_QUICK_MSG = 10, - HPB_COUNT = 11, + HPB_CLOCK = 0, + HPB_RECENT = 1, + HPB_RADIO = 2, + HPB_BLUETOOTH = 3, + HPB_ADVERT = 4, + HPB_GPS = 5, + HPB_SENSORS = 6, + HPB_TOOLS = 7, + HPB_SHUTDOWN = 8, + HPB_SETTINGS = 9, + HPB_QUICK_MSG = 10, + HPB_FAVOURITES = 11, + HPB_COUNT = 12, }; + // Length of the persisted page_order[] array. Stable across firmware versions + // (don't grow without a schema migration). When HPB_COUNT exceeds this, extra + // visible pages are appended at navigation time via buildVisibleOrder fallback. + static const uint8_t PAGE_ORDER_LEN = 11; + // Bitmasks for home_pages_mask (bit=1 → page visible; 0 field = all visible). // SETTINGS and QUICK_MSG have no mask bit — they're always visible. - static const uint16_t HP_CLOCK = 1 << HPB_CLOCK; - static const uint16_t HP_RECENT = 1 << HPB_RECENT; - static const uint16_t HP_RADIO = 1 << HPB_RADIO; - static const uint16_t HP_BLUETOOTH = 1 << HPB_BLUETOOTH; - static const uint16_t HP_ADVERT = 1 << HPB_ADVERT; - static const uint16_t HP_GPS = 1 << HPB_GPS; - static const uint16_t HP_SENSORS = 1 << HPB_SENSORS; - static const uint16_t HP_TOOLS = 1 << HPB_TOOLS; - static const uint16_t HP_SHUTDOWN = 1 << HPB_SHUTDOWN; - static const uint16_t HP_ALL = 0x01FF; + static const uint16_t HP_CLOCK = 1 << HPB_CLOCK; + static const uint16_t HP_RECENT = 1 << HPB_RECENT; + static const uint16_t HP_RADIO = 1 << HPB_RADIO; + static const uint16_t HP_BLUETOOTH = 1 << HPB_BLUETOOTH; + static const uint16_t HP_ADVERT = 1 << HPB_ADVERT; + static const uint16_t HP_GPS = 1 << HPB_GPS; + static const uint16_t HP_SENSORS = 1 << HPB_SENSORS; + static const uint16_t HP_TOOLS = 1 << HPB_TOOLS; + static const uint16_t HP_SHUTDOWN = 1 << HPB_SHUTDOWN; + static const uint16_t HP_FAVOURITES = 1 << HPB_FAVOURITES; + static const uint16_t HP_ALL = 0x01FF | HP_FAVOURITES; // Label for home page by bit-index; returns "" for out-of-range. // Array indices match HomePageBit values. static const char* homePageLabel(uint8_t bit) { static const char* labels[HPB_COUNT] = { "Clock", "Recent", "Radio", "Bluetooth", "Advert", - "GPS", "Sensors", "Tools", "Shutdown", "Settings", "Messages" + "GPS", "Sensors", "Tools", "Shutdown", "Settings", "Messages", "Favourites" }; return (bit < HPB_COUNT) ? labels[bit] : ""; } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 623dbd9b..802cb656 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -40,7 +40,7 @@ class SettingsScreen : public UIScreen { CH_MELODY, // Home pages section SECTION_HOME_PAGES, - HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, + HOME_CLOCK, HOME_FAVOURITES, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, #if ENV_INCLUDE_GPS == 1 HOME_GPS, #endif @@ -164,9 +164,10 @@ class SettingsScreen : public UIScreen { } bool isHomePage(int item) const { - return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || - item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || - item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG + return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || + item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || + item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG || + item == HOME_FAVOURITES #if ENV_INCLUDE_GPS == 1 || item == HOME_GPS #endif @@ -178,9 +179,10 @@ class SettingsScreen : public UIScreen { uint16_t homePageBit(int item) const { int bit = homePageBitIndex(item); - // Bits 0..HPB_SHUTDOWN have mask bits in home_pages_mask; SETTINGS and - // QUICK_MSG are always visible (no mask bit) and return 0. - return (bit >= 0 && bit < NodePrefs::HPB_SETTINGS) ? (uint16_t)(1 << bit) : 0; + // SETTINGS and QUICK_MSG are always visible (no mask bit). All other pages + // — including FAVOURITES — toggle via home_pages_mask. + if (bit < 0 || bit == NodePrefs::HPB_SETTINGS || bit == NodePrefs::HPB_QUICK_MSG) return 0; + return (uint16_t)(1 << bit); } const char* homePageLabel(int item) const { @@ -204,6 +206,7 @@ class SettingsScreen : public UIScreen { // Bit-index values are defined once in NodePrefs::HomePageBit. int homePageBitIndex(int item) const { if (item == HOME_CLOCK) return NodePrefs::HPB_CLOCK; + if (item == HOME_FAVOURITES) return NodePrefs::HPB_FAVOURITES; if (item == HOME_RECENT) return NodePrefs::HPB_RECENT; if (item == HOME_RADIO) return NodePrefs::HPB_RADIO; if (item == HOME_BT) return NodePrefs::HPB_BLUETOOTH; @@ -226,7 +229,7 @@ class SettingsScreen : public UIScreen { if (!p || p->page_order_set != NodePrefs::PAGE_ORDER_MAGIC) return 0; int bit = homePageBitIndex(item); if (bit < 0) return 0; - for (int i = 0; i < NodePrefs::HPB_COUNT; i++) { + for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) { uint8_t v = p->page_order[i]; if (v < 1 || v > NodePrefs::HPB_COUNT) break; if ((int)(v - 1) == bit) return i + 1; @@ -248,24 +251,27 @@ class SettingsScreen : public UIScreen { // CLOCK missing — reset and fall through to full re-init. memset(p->page_order, 0, sizeof(p->page_order)); } - // Default: CLOCK RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS TOOLS MESSAGES SHUTDOWN + // Default: CLOCK FAVOURITES RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS TOOLS MESSAGES + // SHUTDOWN is omitted from the explicit list (PAGE_ORDER_LEN = 11 leaves room for the + // common case GPS+SENSORS+all-others) and appended by buildVisibleOrder's missing-page + // fallback at the end of the navigation sequence. int j = 0; - p->page_order[j++] = 0 + 1; // CLOCK - p->page_order[j++] = 1 + 1; // RECENT - p->page_order[j++] = 2 + 1; // RADIO - p->page_order[j++] = 3 + 1; // BLUETOOTH - p->page_order[j++] = 4 + 1; // ADVERT + p->page_order[j++] = NodePrefs::HPB_CLOCK + 1; + p->page_order[j++] = NodePrefs::HPB_FAVOURITES + 1; + p->page_order[j++] = NodePrefs::HPB_RECENT + 1; + p->page_order[j++] = NodePrefs::HPB_RADIO + 1; + p->page_order[j++] = NodePrefs::HPB_BLUETOOTH + 1; + p->page_order[j++] = NodePrefs::HPB_ADVERT + 1; #if ENV_INCLUDE_GPS == 1 - p->page_order[j++] = 5 + 1; // GPS + p->page_order[j++] = NodePrefs::HPB_GPS + 1; #endif #if UI_SENSORS_PAGE == 1 - p->page_order[j++] = 6 + 1; // SENSORS + p->page_order[j++] = NodePrefs::HPB_SENSORS + 1; #endif - p->page_order[j++] = 9 + 1; // SETTINGS - p->page_order[j++] = 7 + 1; // TOOLS - p->page_order[j++] = 10 + 1; // MESSAGES (QUICK_MSG) - p->page_order[j++] = 8 + 1; // SHUTDOWN - while (j < NodePrefs::HPB_COUNT) p->page_order[j++] = 0; + p->page_order[j++] = NodePrefs::HPB_SETTINGS + 1; + p->page_order[j++] = NodePrefs::HPB_TOOLS + 1; + p->page_order[j++] = NodePrefs::HPB_QUICK_MSG + 1; + while (j < NodePrefs::PAGE_ORDER_LEN) p->page_order[j++] = 0; p->page_order_set = NodePrefs::PAGE_ORDER_MAGIC; } @@ -275,7 +281,7 @@ class SettingsScreen : public UIScreen { int bit = homePageBitIndex(item); if (bit < 0) return; int cur = -1, total = 0; - for (int i = 0; i < NodePrefs::HPB_COUNT; i++) { + for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) { uint8_t v = p->page_order[i]; if (v < 1 || v > NodePrefs::HPB_COUNT) break; if ((int)(v - 1) == bit) cur = i; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 425d7bb1..adc948d4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -122,6 +122,7 @@ static const int QUICK_MSGS_MAX = 10; class HomeScreen : public UIScreen { enum HomePage { CLOCK, + FAVOURITES, RECENT, RADIO, BLUETOOTH, @@ -139,6 +140,9 @@ class HomeScreen : public UIScreen { Count // keep as last }; + // Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1). + uint8_t _fav_sel = 0; + UITask* _task; mesh::RTCClock* _rtc; SensorManager* _sensors; @@ -148,7 +152,8 @@ class HomeScreen : public UIScreen { AdvertPath recent[UI_RECENT_LIST_SIZE]; int pageBit(int page) const { - if (page == CLOCK) return NodePrefs::HPB_CLOCK; + if (page == CLOCK) return NodePrefs::HPB_CLOCK; + if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES; if (page == RECENT) return NodePrefs::HPB_RECENT; if (page == RADIO) return NodePrefs::HPB_RADIO; if (page == BLUETOOTH) return NodePrefs::HPB_BLUETOOTH; @@ -168,7 +173,8 @@ class HomeScreen : public UIScreen { // Returns -1 if the page is not compiled in. int bitToPage(int bit) const { switch (bit) { - case NodePrefs::HPB_CLOCK: return CLOCK; + case NodePrefs::HPB_CLOCK: return CLOCK; + case NodePrefs::HPB_FAVOURITES: return FAVOURITES; case NodePrefs::HPB_RECENT: return RECENT; case NodePrefs::HPB_RADIO: return RADIO; case NodePrefs::HPB_BLUETOOTH: return BLUETOOTH; @@ -200,7 +206,7 @@ class HomeScreen : public UIScreen { int n = 0; bool custom = _node_prefs && _node_prefs->page_order_set == NodePrefs::PAGE_ORDER_MAGIC; if (custom) { - for (int i = 0; i < NodePrefs::HPB_COUNT; i++) { + for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) { uint8_t v = _node_prefs->page_order[i]; if (v < 1 || v > NodePrefs::HPB_COUNT) break; int pg = bitToPage(v - 1); @@ -738,6 +744,72 @@ public: display.drawTextCentered(display.width() / 2, content_y + step, badge); } display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); + } else if (_page == HomePage::FAVOURITES) { + // Grid of pinned contacts. Layout transposes to current orientation: + // landscape → 3×2, portrait → 2×3. Selected tile inverts via drawSelectionRow. + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 0, "FAVOURITES"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + const int cols = display.isLandscape() ? 3 : 2; + const int rows = NodePrefs::FAVOURITES_COUNT / cols; + const int grid_y = display.headerH() + display.sepH(); + const int grid_h = display.height() - grid_y; + const int cell_w = display.width() / cols; + const int cell_h = grid_h / rows; + const int lh = display.getLineHeight(); + + if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0; + + for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) { + int row = i / cols; + int col = i % cols; + int cx = col * cell_w; + int cy = grid_y + row * cell_h; + bool sel = (i == _fav_sel); + display.drawSelectionRow(cx, cy, cell_w - 1, cell_h - 1, sel); + + // Empty slot → all-zero prefix. Real keys collide with this with probability 2^-48. + const uint8_t* prefix = _node_prefs ? _node_prefs->favourite_contacts[i] : nullptr; + bool filled = false; + if (prefix) { + for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) + if (prefix[b] != 0) { filled = true; break; } + } + + if (filled) { + ContactInfo ci; + bool found = false; + for (int idx = 0; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + ci = c; found = true; break; + } + } + char name[24]; + if (found) display.translateUTF8ToBlocks(name, ci.name, sizeof(name)); + else strncpy(name, "(gone)", sizeof(name) - 1), name[sizeof(name) - 1] = '\0'; + int name_y = cy + (cell_h - lh) / 2; + display.drawTextEllipsized(cx + 2, name_y, cell_w - 4, name); + + if (found) { + uint8_t unread = _task->getDMUnread(ci.id.pub_key); + if (unread > 0) { + char badge[5]; + snprintf(badge, sizeof(badge), "%d", unread); + int bw = display.getTextWidth(badge); + display.setCursor(cx + cell_w - bw - 2, cy + 1); + display.print(badge); + } + } + } else { + int plus_y = cy + (cell_h - lh) / 2; + display.drawTextCentered(cx + cell_w / 2, plus_y, "+"); + } + if (sel) display.setColor(DisplayDriver::LIGHT); + } } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -773,6 +845,27 @@ public: } bool handleInput(char c) override { + // Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the + // left column and RIGHT at the right column fall through to page nav so the + // user can still leave the page sideways. + if (_page == HomePage::FAVOURITES) { + DisplayDriver* d = _task->getDisplay(); + const int cols = (d && d->isLandscape()) ? 3 : 2; + const int rows = NodePrefs::FAVOURITES_COUNT / cols; + int col = _fav_sel % cols; + int row = _fav_sel / cols; + if ((c == KEY_LEFT || c == KEY_PREV) && col > 0) { _fav_sel--; return true; } + if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; } + if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; } + if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; } + if (c == KEY_ENTER) { + // Phase 2/3 wires this: open DM if filled, picker if empty. Consume for now. + _task->showAlert("Pin from contact options", 1000); + return true; + } + // Edge LEFT/RIGHT and unhandled keys fall through to page nav below. + } + if (c == KEY_LEFT || c == KEY_PREV) { _page = navPage(_page, -1); return true; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 8fe0f740..a7b894d2 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -138,6 +138,7 @@ public: } void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); } bool hasDisplay() const { return _display != NULL; } + DisplayDriver* getDisplay() const { return _display; } bool isButtonPressed() const; bool isBuzzerQuiet() {