From bee8e7c3dd39010500f4e81e22f4cd3d1cdfc267 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 24 May 2026 23:30:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Favourites=20phase=202=20=E2=80=94?= =?UTF-8?q?=20pin=20from=20Contact=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth item to the QuickMsg Contact options popup that toggles between "Pin to dial" (when the contact isn't pinned) and "Unpin (slot N)" (when it is). Pinning opens a six-slot picker submenu labeled "Slot N: " or "Slot N: empty"; the chosen slot stores the 6-byte pub_key prefix in NodePrefs::favourite_contacts and savePrefs commits to flash with a brief confirmation alert. New UITask helpers backing the flow: - findFavouriteSlot(pub_key) — returns 0..5 if pinned, -1 otherwise - isFavouriteSlotEmpty(slot) - setFavouriteSlot(slot, pub_key) / clearFavouriteSlot(slot) PopupMenu reuse: a single _ctx_menu instance hosts both the Contact options menu and the slot picker, gated by _pin_picker_active so input routes correctly. Reset on screen reset() and on any non-NONE menu result. Slot labels live in a member buffer (6×22 B) because PopupMenu stores the string pointers verbatim. Co-Authored-By: Claude Sonnet 4.6 --- FEATURES.md | 4 +- .../companion_radio/ui-new/QuickMsgScreen.h | 64 ++++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 29 +++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index ef8a1081..07889f24 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -22,8 +22,8 @@ Implementation: ### 🚧 Favourites dial Phase 1 ✅ (storage + read-only render + grid nav) -Phase 2 📋 (pin from Contact options menu in QuickMsg) -Phase 3 📋 (pin from empty slot via mini-picker: favourites first, then recent) +Phase 2 ✅ (pin from Contact options menu in QuickMsg + slot picker submenu) +Phase 3 📋 (pin from empty slot on the grid via mini-picker: favourites first, then recent) A 2×3 grid (six slots) of pinned contacts on its own home page, between Clock and Messages. Joystick picks a tile, Enter opens the existing DM conversation or sends a pre-set quick reply. diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index a89b8cfd..c151d375 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -46,6 +46,9 @@ class QuickMsgScreen : public UIScreen { bool _ctx_dirty; char _ctx_notif_item[22]; char _ctx_melody_item[20]; + char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)" + char _pin_slot_labels[NodePrefs::FAVOURITES_COUNT][22]; // per-slot picker labels + bool _pin_picker_active; // true while the slot-picker submenu is open char _reply_prefix[36]; // "@[nick] " built when reply is triggered bool _reply_mode; // true while composing a reply (prefix is prepended) @@ -400,7 +403,7 @@ public: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), - _ctx_dirty(false), _reply_mode(false) { + _ctx_dirty(false), _pin_picker_active(false), _reply_mode(false) { memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -465,6 +468,7 @@ public: _ctx_menu.active = false; _ctx_dirty = false; + _pin_picker_active = false; _unread_at_entry = 0; _viewing_max_seen = 0; } @@ -909,6 +913,22 @@ public: // Context menu consumes all input while open if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); + if (_pin_picker_active) { + // Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot. + if (res == PopupMenu::SELECTED && _num_contacts > 0) { + ContactInfo ci; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { + int slot = _ctx_menu.selectedIndex(); + _task->setFavouriteSlot(slot, ci.id.pub_key); + the_mesh.savePrefs(); + char alert[24]; + snprintf(alert, sizeof(alert), "Pinned to slot %d", slot + 1); + _task->showAlert(alert, 800); + } + } + if (res != PopupMenu::NONE) _pin_picker_active = false; + return true; + } if (res == PopupMenu::SELECTED && _num_contacts > 0) { ContactInfo ci; if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { @@ -918,10 +938,46 @@ public: uint8_t nstate = dmNotifState(ci.id.pub_key); setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); _ctx_dirty = true; - } else { + } else if (_ctx_menu.selectedIndex() == 2) { uint8_t slot = dmMelodySlot(ci.id.pub_key); setDmMelody(ci.id.pub_key, (slot + 1) % 3); _ctx_dirty = true; + } else if (_ctx_menu.selectedIndex() == 3) { + // Pin / Unpin + int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key); + if (pinned_slot >= 0) { + _task->clearFavouriteSlot(pinned_slot); + the_mesh.savePrefs(); + char alert[24]; + snprintf(alert, sizeof(alert), "Unpinned (slot %d)", pinned_slot + 1); + _task->showAlert(alert, 800); + } else { + // Open slot picker. Each label shows "Slot N: " or "Slot N: empty". + for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) { + if (_task->isFavouriteSlotEmpty(s)) { + snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: empty", s + 1); + } else { + // Resolve current occupant name by walking contacts. + char nm[16] = "?"; + NodePrefs* p = _task->getNodePrefs(); + if (p) { + const uint8_t* pfx = p->favourite_contacts[s]; + for (int idx = 0; ; idx++) { + ContactInfo c2; + if (!the_mesh.getContactByIdx(idx, c2)) break; + if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + DisplayDriver::translateUTF8Static(nm, c2.name, sizeof(nm)); + break; + } + } + } + snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: %s", s + 1, nm); + } + } + _ctx_menu.begin("Pick slot", 3); + for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) _ctx_menu.addItem(_pin_slot_labels[s]); + _pin_picker_active = true; + } } } } @@ -960,10 +1016,14 @@ public: { static const char* ML[] = { "global", "M1", "M2" }; snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[dmMelodySlot(ci.id.pub_key)]); } + int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key); + if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1); + else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); _ctx_menu.begin("Contact options", 3); _ctx_menu.addItem("Mark as read"); _ctx_menu.addItem(_ctx_notif_item); _ctx_menu.addItem(_ctx_melody_item); + _ctx_menu.addItem(_ctx_pin_item); _ctx_dirty = false; return true; } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a7b894d2..aef1c4f1 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -139,6 +139,35 @@ public: void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); } bool hasDisplay() const { return _display != NULL; } DisplayDriver* getDisplay() const { return _display; } + + // Favourites dial helpers. Slot index 0..FAVOURITES_COUNT-1. + int findFavouriteSlot(const uint8_t* pub_key) const { + if (!_node_prefs || !pub_key) return -1; + for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) { + if (memcmp(_node_prefs->favourite_contacts[i], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + // All-zero prefix is "empty" — never matches a real key. + bool any = false; + for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) + if (_node_prefs->favourite_contacts[i][b]) { any = true; break; } + if (any) return i; + } + } + return -1; + } + bool isFavouriteSlotEmpty(int slot) const { + if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return true; + for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) + if (_node_prefs->favourite_contacts[slot][b]) return false; + return true; + } + void setFavouriteSlot(int slot, const uint8_t* pub_key) { + if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT || !pub_key) return; + memcpy(_node_prefs->favourite_contacts[slot], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); + } + void clearFavouriteSlot(int slot) { + if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return; + memset(_node_prefs->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN); + } bool isButtonPressed() const; bool isBuzzerQuiet() {